Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
620 changes: 413 additions & 207 deletions apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts

Large diffs are not rendered by default.

569 changes: 569 additions & 0 deletions apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions apps/browser-demos/pages/kandelo/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,15 @@ export const PRESET_LIBRARY: Preset[] = [
bootCommand: ["bash", "-l", "-i"],
estimatedUrlBytes: 612,
},
{
id: "omarchy",
title: "Omarchy desktop",
summary: "The Omarchy desktop on Kandelo: the tiling compositor with the shell Omarchy is made of. Unmodified Waybar — the real GTK3 status bar, on wlr-layer-shell, its hyprland modules reading the compositor's Hyprland IPC sockets — reserves the top strip (workspaces, focused window, clock) and the windows tile beneath it. CTRL+Space opens klauncher, a keyboard-exclusive overlay launcher reading /usr/share/kandelo/apps; type to filter and Enter to launch. CTRL+SHIFT+Space cycles six themes (Tokyo Night, Catppuccin, Gruvbox, Nord, Everforest, Rosé Pine), each with its own Omarchy background image, recolouring the borders, gaps, wallpaper, bar and launcher live from one palette file. CTRL+Return spawns a terminal, CTRL+K a clock, CTRL+P a paint canvas, CTRL+W closes, CTRL+1..9 switch workspaces — every bind mirrored on SUPER for real Hyprland.",
base: SHELL_BASE,
packages: ["bash@local", "coreutils@local"],
accent: "#7aa2f7",
glyph: "O",
bootCommand: ["bash", "-l", "-i"],
estimatedUrlBytes: 612,
},
];
110 changes: 110 additions & 0 deletions apps/browser-demos/test/kandelo-hidpi.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { expect, test, type Page } from "@playwright/test";

const appUrl = (path: string): string => {
const baseUrl = process.env.KANDELO_TEST_BASE_URL;
return baseUrl ? new URL(path, baseUrl).href : path;
};

async function gotoOrSkip(page: Page, path: string) {
await page.goto(appUrl(path), { waitUntil: "domcontentloaded" });
await page.waitForTimeout(2_000);
if (await page.locator("vite-error-overlay").count()) {
test.skip(true, "Required binary not built - Vite import error");
}
}

const syslog = (page: Page) => page.evaluate(() => document.body.innerText);

/**
* The DRM mode is sized in device pixels, so a dpr-2 pane gets a mode twice
* the CSS box. Nothing in that mode tells the compositor which of the two it
* is looking at, so the page passes WLC_SCALE and the compositor divides the
* mode by it to get the logical grid clients lay out in. Get either half
* wrong and the desktop is silently rendered at the wrong resolution: the
* aspect-derived mode this replaced pinned every pane to 1080 lines, which
* upscaled a Retina desktop and is invisible at dpr 1 — so this file runs at
* dpr 2 on purpose.
*/
test.use({ deviceScaleFactor: 2 });

test("the desktop renders at the pane's device resolution on a dpr-2 display", async ({ page }) => {
test.setTimeout(300_000);

await gotoOrSkip(page, "/?demo=omarchy");
await page.getByText("Internals", { exact: true }).first().click();
await expect.poll(() => syslog(page), { timeout: 180_000 })
.toMatch(/omarchy desktop ready/);

const sizes = await page.evaluate(() => {
const c = document.querySelector<HTMLCanvasElement>(
".kmachine-primary-slot:not(.is-hidden) canvas",
);
const rect = c!.getBoundingClientRect();
return {
dpr: window.devicePixelRatio,
cssW: Math.round(rect.width),
cssH: Math.round(rect.height),
bufW: c!.width,
bufH: c!.height,
};
});

expect(sizes.dpr, "this spec is meaningless at dpr 1").toBe(2);

// The scanout is the pane's device-pixel box, not a fixed 1080 lines. Even
// alignment and the [640,3840]x[480,2160] clamp are the only slack.
expect(sizes.bufW, "scanout width is not the pane's device width")
.toBe(Math.min(3840, Math.max(640, (sizes.cssW * 2) & ~1)));
expect(sizes.bufH, "scanout height is not the pane's device height")
.toBe(Math.min(2160, Math.max(480, (sizes.cssH * 2) & ~1)));

const text = await syslog(page);

// The compositor took the scale, and its logical grid is the mode halved —
// so a client still lays out against roughly the CSS box, not the device one.
expect(text, "compositor did not take the output scale").toMatch(/WLC_SCALE 2/);
const up = text.match(/COMPOSITOR_UP w=(\d+) h=(\d+)/);
expect(up, "compositor never reported its logical grid").not.toBeNull();
expect(Number(up![1])).toBe(sizes.bufW / 2);
expect(Number(up![2])).toBe(sizes.bufH / 2);

// The wallpaper is staged at the source's own resolution, not the fixed
// 960x540 the compositor used to magnify past 2x on a HiDPI pane. It keeps
// the asset's aspect rather than the mode's, because the compositor
// centre-crops: the stager bakes the image into the VFS before the mode
// exists and so cannot match it.
const wallpaper = text.match(/WALLPAPER image w=(\d+) h=(\d+)/);
expect(wallpaper, "the compositor never loaded an image wallpaper").not.toBeNull();
const wallpaperW = Number(wallpaper![1]);
const wallpaperH = Number(wallpaper![2]);
expect({ w: wallpaperW, h: wallpaperH },
"the wallpaper is not staged at the boot theme's own resolution")
.toEqual({ w: 2580, h: 1080 });

// The bar is laid out in logical units, so its exclusive zone must not
// shrink with the scale — that shrinking is what made it unreadable.
await expect.poll(() => syslog(page), { timeout: 120_000 })
.toMatch(/LAYER ns=waybar layer=2 x=0 y=0 w=\d+ h=\d+/);
const layer = (await syslog(page)).match(/LAYER ns=waybar layer=2 x=0 y=0 w=(\d+) h=(\d+)/);
expect(Number(layer![1]), "the bar does not span the logical width")
.toBe(sizes.bufW / 2);
expect(Number(layer![2]), "the bar is not its configured logical height").toBe(26);

// A theme switch raises a mako toast, and a toast makes its scale decision
// once: mako sizes its buffer from the scale of the output wl_surface.enter
// names, before it draws. So the compositor sends the enter when the surface
// takes its layer-shell role. Sent at map, the first frame is already drawn
// at scale 1 — the toast reads soft while the desktop around it is sharp.
await page.getByText("Demo", { exact: true }).first().click();
await page.locator("body").click({ position: { x: 5, y: 5 } });
await page.keyboard.down("Control");
await page.keyboard.down("Shift");
await page.keyboard.press("Space");
await page.keyboard.up("Shift");
await page.keyboard.up("Control");
await page.getByText("Internals", { exact: true }).first().click();
await expect.poll(() => syslog(page), { timeout: 60_000 })
.toMatch(/BUFFER_SCALE app=notifications scale=2 /);
expect(await syslog(page), "the toast drew a frame at scale 1 before that")
.not.toMatch(/BUFFER_SCALE app=notifications scale=1 /);
});
2 changes: 1 addition & 1 deletion apps/browser-demos/test/kandelo-hyprland.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async function gotoOrSkip(page: Page, path: string) {
}

async function openSurface(page: Page, label: string) {
const btn = page.locator("button.kmachine-switch-btn", { hasText: label });
const btn = page.locator("button.kdock-item", { hasText: label });
await btn.waitFor({ state: "visible", timeout: 30_000 });
await btn.click();
}
Expand Down
114 changes: 114 additions & 0 deletions apps/browser-demos/test/kandelo-kms-context-loss.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { expect, test, type Page } from "@playwright/test";

const appUrl = (path: string): string => {
const baseUrl = process.env.KANDELO_TEST_BASE_URL;
return baseUrl ? new URL(path, baseUrl).href : path;
};

async function gotoOrSkip(page: Page, path: string) {
await page.goto(appUrl(path), { waitUntil: "domcontentloaded" });
await page.waitForTimeout(2_000);
if (await page.locator("vite-error-overlay").count()) {
test.skip(true, "Required binary not built - Vite import error");
}
}

// `chrome.gpuBenchmarking.crashGpuProcess` is the only way to lose a real
// WebGL context on demand. The flag replaces the config's launchOptions
// for this file only; the dropped browserLaunchEnv strip matters to
// WebKitGTK, not Chromium.
test.use({ launchOptions: { args: ["--enable-gpu-benchmarking"] } });

/** The Modeset status chip's fields out of the rendered page text:
* "1918×1080 · 349 FLIPS · 115295MS · WEBGL2". */
async function chip(page: Page): Promise<{ flips: number; renderer: string }> {
const text = await page.evaluate(() => document.body.innerText);
const m = text.match(/(\d+)\s*flips\s*·[^·]*·\s*(webgl2-gl|webgl2|2d)/i);
return m
? { flips: Number(m[1]), renderer: m[2].toLowerCase() }
: { flips: -1, renderer: "" };
}

const canvasLocator = (page: Page) =>
page.locator(".kmachine-primary-slot:not(.is-hidden) canvas").first();

/** The desktop repaints continuously (wlclock animates ~10 fps), so two
* captures spaced apart must differ while the presenter is alive. */
async function canvasAdvances(page: Page): Promise<boolean> {
const canvas = canvasLocator(page);
const before = await canvas.screenshot();
await page.waitForTimeout(1_500);
const after = await canvas.screenshot();
return !after.equals(before);
}

/**
* A lost WebGL context silently no-ops every GL call: without loss
* handling the vblank pump keeps "presenting", the kernel-side FLIPS
* counter keeps advancing, and the canvas freezes on the last composited
* frame with no error anywhere — the exact field failure on the desktop
* demos. The kernel-worker presenter must stand down on `webglcontextlost`
* (cancelling it to opt into restoration) and rebuild on
* `webglcontextrestored`. Crashing the GPU process loses every context in
* the browser, so a desktop that keeps animating afterwards proves the
* rebuild path end to end.
*/
test("the webgl2 scanout presenter survives a GPU process crash", async ({ page }) => {
test.setTimeout(300_000);

await gotoOrSkip(page, "/?demo=wayland");

await expect(canvasLocator(page)).toBeVisible({ timeout: 60_000 });
await expect
.poll(async () => (await chip(page)).renderer, { timeout: 180_000 })
.toMatch(/webgl2/);
expect(await canvasAdvances(page), "desktop not animating before the crash")
.toBe(true);

await page.evaluate(() =>
(window as unknown as {
chrome: { gpuBenchmarking: { crashGpuProcess: () => void } };
}).chrome.gpuBenchmarking.crashGpuProcess(),
);

// Restoration is async (GPU process restart + webglcontextrestored).
// The rebuilt presenter must repaint and the desktop must animate again;
// a presenter without loss handling freezes here while FLIPS advance.
await expect
.poll(() => canvasAdvances(page), {
timeout: 60_000,
intervals: [2_000, 5_000],
})
.toBe(true);
await expect
.poll(async () => (await chip(page)).renderer, { timeout: 30_000 })
.toMatch(/webgl2/);

const settled = await chip(page);
await page.waitForTimeout(2_000);
expect(
(await chip(page)).flips,
"kernel-side page flips stopped after the GPU crash",
).toBeGreaterThan(settled.flips);

// Second crash: the desktop now runs the OTHER presenter. A GPU-path
// boot puts wlcompositor's GL session on the canvas (webgl2-gl), and
// the first crash degrades it to CPU compositing behind the pump's
// webgl2-scanout presenter — so one crash per phase covers both the
// program-owned release chain and the pump presenter's own
// loss/rebuild path, whichever the boot happened to start on.
await page.evaluate(() =>
(window as unknown as {
chrome: { gpuBenchmarking: { crashGpuProcess: () => void } };
}).chrome.gpuBenchmarking.crashGpuProcess(),
);
await expect
.poll(() => canvasAdvances(page), {
timeout: 60_000,
intervals: [2_000, 5_000],
})
.toBe(true);
await expect
.poll(async () => (await chip(page)).renderer, { timeout: 30_000 })
.toMatch(/webgl2/);
});
Loading