Skip to content
Merged
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
101 changes: 101 additions & 0 deletions packages/shell/src/main/apps/app-icon-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
AppIconMiss,
appIconMissStatus,
isAppIconHit,
resolveAppIconInBundle,
} from "./app-icon-resolver";

const BUNDLE = join("/vaults", "v1", "apps", "studio.northbound.client-pulse", "1.0.0");

/** Bundle IO stub: a manifest object plus the files that exist on disk. */
const io = (manifest: unknown, files: readonly string[] = []) => ({
readManifest: async () => JSON.stringify(manifest),
fileExists: async (path: string) => files.includes(path),
});

describe("resolveAppIconInBundle", () => {
it("resolves a declared icon inside the bundle", async () => {
const resolved = await resolveAppIconInBundle(
BUNDLE,
io({ id: "x", icon: "icon.svg" }, [join(BUNDLE, "icon.svg")]),
);
expect(isAppIconHit(resolved)).toBe(true);
expect(resolved).toEqual({ path: join(BUNDLE, "icon.svg") });
});

it("resolves an icon in a subdirectory", async () => {
const resolved = await resolveAppIconInBundle(
BUNDLE,
io({ icon: "assets/icon.png" }, [join(BUNDLE, "assets", "icon.png")]),
);
expect(resolved).toEqual({ path: join(BUNDLE, "assets", "icon.png") });
});

// POLISH-LAY-8 — the sideloaded Client Pulse / Hello case: no `icon` key at
// all. Not an error; the caller draws initials.
it("reports NoIcon for a manifest with no icon key", async () => {
expect(await resolveAppIconInBundle(BUNDLE, io({ id: "x" }))).toEqual({
miss: AppIconMiss.NoIcon,
});
});

it("reports NoIcon for an empty or non-string icon", async () => {
expect(await resolveAppIconInBundle(BUNDLE, io({ icon: "" }))).toEqual({
miss: AppIconMiss.NoIcon,
});
expect(await resolveAppIconInBundle(BUNDLE, io({ icon: 7 }))).toEqual({
miss: AppIconMiss.NoIcon,
});
});

it("reports NoIcon for a declared icon that isn't on disk", async () => {
// Never hand the caller a path it will fetch as a missing `file://`
// (ERR_UNEXPECTED in the renderer) — the app simply has no artwork.
expect(await resolveAppIconInBundle(BUNDLE, io({ icon: "icon.svg" }, []))).toEqual({
miss: AppIconMiss.NoIcon,
});
});

it("refuses a path that escapes the bundle", async () => {
expect(
await resolveAppIconInBundle(BUNDLE, io({ icon: "../../secrets.png" }, ["/vaults/secrets.png"])),
).toEqual({ miss: AppIconMiss.NoIcon });
});

it("contains an absolute-looking icon path inside the bundle", async () => {
// `join` treats a leading slash as relative, so `/etc/passwd` can only
// ever address `<bundle>/etc/passwd` — and that doesn't exist.
expect(
await resolveAppIconInBundle(BUNDLE, io({ icon: "/etc/passwd" }, ["/etc/passwd"])),
).toEqual({ miss: AppIconMiss.NoIcon });
});

it("reports Unresolved when the app has no active bundle", async () => {
expect(await resolveAppIconInBundle(null)).toEqual({ miss: AppIconMiss.Unresolved });
});

it("reports Unresolved when the manifest is missing or unparseable", async () => {
expect(
await resolveAppIconInBundle(BUNDLE, {
readManifest: async () => {
throw new Error("ENOENT");
},
}),
).toEqual({ miss: AppIconMiss.Unresolved });
expect(await resolveAppIconInBundle(BUNDLE, { readManifest: async () => "{ not json" })).toEqual({
miss: AppIconMiss.Unresolved,
});
});
});

describe("appIconMissStatus", () => {
it("answers 204 for a declared-no-icon app so no console 404 is logged", () => {
expect(appIconMissStatus(AppIconMiss.NoIcon)).toBe(204);
});

it("keeps 404 meaningful for an id that resolves to nothing installed", () => {
expect(appIconMissStatus(AppIconMiss.Unresolved)).toBe(404);
});
});
88 changes: 88 additions & 0 deletions packages/shell/src/main/apps/app-icon-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* `brainstorm://app-icon/<appId>` resolution — which file (if any) backs an
* app's icon, and WHY there isn't one.
*
* The distinction is the point. An installed app that declares no `icon` is a
* normal, expected state: every icon surface already draws a deterministic
* initials tile for it. Answering that with a 404 turned "this app has no
* artwork" into a console error on every surface that renders the app, every
* boot — four per run for two icon-less sideloaded apps, and the only console
* errors in the VID-build-apps capture (POLISH-LAY-8). It answers 204 instead.
* A 404 stays reserved for an id that resolves to nothing installed, which IS
* worth surfacing.
*
* Path containment (the icon must stay inside the app's own bundle) is part of
* the resolution, not the caller's job.
*/

import { readFile, stat } from "node:fs/promises";
import { join, normalize, sep } from "node:path";

export enum AppIconMiss {
/** No session, no active record for this id, or an unreadable manifest — a
* real miss the caller should see. */
Unresolved = "unresolved",
/** Installed, but the manifest declares no in-bundle icon. Not an error. */
NoIcon = "no-icon",
}

export type AppIconResolution = { path: string } | { miss: AppIconMiss };

export function isAppIconHit(resolution: AppIconResolution): resolution is { path: string } {
return "path" in resolution;
}

/** HTTP status for a miss — 204 (no content) for a declared-no-icon app so the
* caller's initials fallback runs quietly; 404 for an unresolvable id. */
export function appIconMissStatus(miss: AppIconMiss): number {
return miss === AppIconMiss.NoIcon ? 204 : 404;
}

export type AppIconResolverIo = {
/** Read `<bundleDir>/manifest.json`. */
readManifest: (dir: string) => Promise<string>;
/** Does the resolved icon file exist on disk? */
fileExists: (path: string) => Promise<boolean>;
};

const DEFAULT_IO: AppIconResolverIo = {
readManifest: (dir) => readFile(join(dir, "manifest.json"), "utf8"),
fileExists: (path) =>
stat(path).then(
() => true,
() => false,
),
};

/** Resolve the icon file inside an installed app's bundle. `bundleDir` is
* `null` when no active record exists for the id. `io` is injectable for
* tests.
*
* A declared icon that isn't actually on disk resolves to `NoIcon`, not to a
* path: letting the caller `net.fetch` a missing `file://` surfaces as an
* `ERR_UNEXPECTED` in the renderer rather than a clean status (the same trap
* the emoji handler already guards). Either way the app has no usable
* artwork, which is exactly what `NoIcon` means. */
export async function resolveAppIconInBundle(
bundleDir: string | null,
io: Partial<AppIconResolverIo> = {},
): Promise<AppIconResolution> {
if (!bundleDir) return { miss: AppIconMiss.Unresolved };
const { readManifest, fileExists } = { ...DEFAULT_IO, ...io };
let manifest: { icon?: unknown };
try {
manifest = JSON.parse(await readManifest(bundleDir)) as { icon?: unknown };
} catch {
return { miss: AppIconMiss.Unresolved };
}
if (typeof manifest.icon !== "string" || manifest.icon.length === 0) {
return { miss: AppIconMiss.NoIcon };
}
if (manifest.icon.includes("..")) return { miss: AppIconMiss.NoIcon };
const target = normalize(join(bundleDir, manifest.icon));
if (!target.startsWith(bundleDir + sep) && target !== bundleDir) {
return { miss: AppIconMiss.NoIcon };
}
if (!(await fileExists(target))) return { miss: AppIconMiss.NoIcon };
return { path: target };
}
86 changes: 86 additions & 0 deletions packages/shell/src/main/dashboard/dashboard-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
import { OsHandoffConsent } from "@brainstorm-os/sdk-types";
import { ThemeName } from "@brainstorm-os/tokens";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
ICON_FOOTPRINT_H,
ICON_FOOTPRINT_W,
footprintsOverlap,
} from "../../shared/dashboard-icon-grid";
import { placeDashboardIcon } from "../dev/seed-demo-apps";
import { YDocStore } from "../storage/ydoc-store";
import { DASHBOARD_DOC_ID, DashboardStore, applyLegacyMigration } from "./dashboard-store";
Expand Down Expand Up @@ -665,6 +670,87 @@ describe("DashboardStore — shell prefs (locale / regional / chrome / notificat
await store.close();
});

// POLISH-LAY-6 — the install path, end to end over the real store. Main
// used to place onto a fictional 12-column grid of 1×1 cells while the
// renderer stores 8px units and paints an ICON_FOOTPRINT_W × _H box per
// icon, so a newly installed app landed ON TOP of Notes.
it("places an installed app's icon clear of every existing icon's footprint", async () => {
const store = await DashboardStore.open(yStore);
// The seeded fleet as captured on the real dashboard.
const seeded = [
{ id: "io.brainstorm.notes", x: 0, y: 0 },
{ id: "io.brainstorm.files", x: 11, y: 0 },
{ id: "io.brainstorm.database", x: 22, y: 0 },
{ id: "io.brainstorm.books", x: 0, y: 14 },
];
for (const app of seeded) {
store.upsertIcon(`icon_${app.id}_demo`, {
x: app.x,
y: app.y,
kind: "app",
target: app.id,
label: app.id,
});
}

expect(placeDashboardIcon(store, "studio.northbound.client-pulse", "Client Pulse")).toBe(true);
const placed = store.snapshot().icons["icon_studio.northbound.client-pulse_demo"];
expect(placed).toBeDefined();
if (!placed) throw new Error("unreachable");
for (const app of seeded) {
expect(footprintsOverlap({ col: placed.x, row: placed.y }, { col: app.x, row: app.y })).toBe(
false,
);
}

// A second install stacks on neither the fleet nor the first newcomer.
expect(placeDashboardIcon(store, "io.brainstorm.hello", "Hello")).toBe(true);
const second = store.snapshot().icons["icon_io.brainstorm.hello_demo"];
if (!second) throw new Error("unreachable");
for (const other of [...seeded.map((a) => ({ x: a.x, y: a.y })), placed]) {
expect(footprintsOverlap({ col: second.x, row: second.y }, { col: other.x, row: other.y })).toBe(
false,
);
}
await store.close();
});

it("wraps an install onto the next footprint row once the first row is full", async () => {
const store = await DashboardStore.open(yStore);
// Fill the first footprint row wall to wall for a 1440px-wide surface
// (≈ 16 slots at ICON_FOOTPRINT_W); the placer keeps stepping across
// (the grid is unbounded) but never overlaps.
for (let i = 0; i < 16; i++) {
store.upsertIcon(`icon_seed${i}_demo`, {
x: i * ICON_FOOTPRINT_W,
y: 0,
kind: "app",
target: `seed${i}`,
label: `Seed ${i}`,
});
}
// One icon parked on the second footprint row's origin: the newcomer
// must clear that too.
store.upsertIcon("icon_seedRow2_demo", {
x: 0,
y: ICON_FOOTPRINT_H,
kind: "app",
target: "seedRow2",
label: "Row 2",
});

expect(placeDashboardIcon(store, "io.brainstorm.newcomer", "Newcomer")).toBe(true);
const placed = store.snapshot().icons["icon_io.brainstorm.newcomer_demo"];
if (!placed) throw new Error("unreachable");
for (const icon of Object.values(store.snapshot().icons)) {
if (icon.target === "io.brainstorm.newcomer") continue;
expect(footprintsOverlap({ col: placed.x, row: placed.y }, { col: icon.x, row: icon.y })).toBe(
false,
);
}
await store.close();
});

it("isWithinDnd handles same-day and wrap-past-midnight windows", () => {
const at = (h: number, m: number) => new Date(2026, 0, 1, h, m);
const wrap = { enabled: true, start: "22:00", end: "08:00" };
Expand Down
85 changes: 70 additions & 15 deletions packages/shell/src/main/dashboard/grid-placement.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,87 @@
import { describe, expect, it } from "vitest";
import { GRID_COLS, firstFreeCell, occupiedCells } from "./grid-placement";
import {
ICON_FOOTPRINT_H,
ICON_FOOTPRINT_W,
footprintsOverlap,
} from "../../shared/dashboard-icon-grid";
import { firstFreeCell, occupiedCells } from "./grid-placement";

describe("occupiedCells", () => {
it("collects only integer-cell records (legacy pixel records occupy nothing)", () => {
const taken = occupiedCells({
a: { x: 0, y: 0 },
b: { x: 3, y: 1 },
pixel: { x: 12.5, y: 40.2 }, // legacy float — ignored
});
expect([...taken].sort()).toEqual(["0:0", "3:1"]);
it("keeps every record — a dropped record is one the placer would land on", () => {
expect(
occupiedCells({
a: { x: 0, y: 0 },
b: { x: 11, y: 0 },
fractional: { x: 12.5, y: 40.2 },
}),
).toEqual([
{ col: 0, row: 0 },
{ col: 11, row: 0 },
{ col: 12, row: 40 },
]);
});

it("clamps negatives to the origin", () => {
expect(occupiedCells({ a: { x: -4, y: -1 } })).toEqual([{ col: 0, row: 0 }]);
});

it("skips non-finite records", () => {
expect(occupiedCells({ a: { x: Number.NaN, y: 0 } })).toEqual([]);
});

it("is empty for no icons", () => {
expect(occupiedCells({}).size).toBe(0);
expect(occupiedCells({}).length).toBe(0);
});
});

describe("firstFreeCell", () => {
it("returns 0:0 on an empty grid", () => {
expect(firstFreeCell(new Set())).toEqual({ col: 0, row: 0 });
expect(firstFreeCell([])).toEqual({ col: 0, row: 0 });
});

it("steps a whole icon footprint, not one grid unit (POLISH-LAY-6)", () => {
// The regression: with Notes at 0,0 the OLD 12-column 1×1-cell scan
// answered {col:1,row:0} — inside Notes' 11×14-unit box — so the newly
// installed app's tile and label printed on top of it.
const cell = firstFreeCell([{ col: 0, row: 0 }]);
expect(cell).toEqual({ col: ICON_FOOTPRINT_W, row: 0 });
expect(footprintsOverlap(cell, { col: 0, row: 0 })).toBe(false);
});

it("never overlaps ANY icon of a populated seeded fleet", () => {
// The real seeded layout the dry-run captured: one icon every
// ICON_FOOTPRINT_W across, wrapping every ICON_FOOTPRINT_H down.
const fleet = [
{ col: 0, row: 0 },
{ col: ICON_FOOTPRINT_W, row: 0 },
{ col: 2 * ICON_FOOTPRINT_W, row: 0 },
{ col: 3 * ICON_FOOTPRINT_W, row: 0 },
{ col: 0, row: ICON_FOOTPRINT_H },
];
const cell = firstFreeCell(fleet);
for (const icon of fleet) expect(footprintsOverlap(cell, icon)).toBe(false);
});

it("wraps to the next footprint row when the first row is full", () => {
const row0 = Array.from({ length: 6 }, (_, c) => ({ col: c * ICON_FOOTPRINT_W, row: 0 }));
// Six columns is not the whole (unbounded) row, so the placer keeps
// going across before it wraps.
expect(firstFreeCell(row0)).toEqual({ col: 6 * ICON_FOOTPRINT_W, row: 0 });
});

it("scans row-major, skipping taken cells", () => {
expect(firstFreeCell(new Set(["0:0", "1:0"]))).toEqual({ col: 2, row: 0 });
it("places clear of an icon the user dragged off the slot lattice", () => {
// Free drags snap to the 8px grid, not to install slots — a dragged
// icon at 3,3 still blocks the 0,0 slot it overlaps.
const dragged = { col: 3, row: 3 };
const cell = firstFreeCell([dragged]);
expect(footprintsOverlap(cell, dragged)).toBe(false);
});

it("wraps to the next row when the first is full", () => {
const fullRow0 = new Set(Array.from({ length: GRID_COLS }, (_, c) => `${c}:0`));
expect(firstFreeCell(fullRow0)).toEqual({ col: 0, row: 1 });
it("fills a hole left by an uninstalled app before extending the grid", () => {
const fleet = [
{ col: 0, row: 0 },
{ col: 2 * ICON_FOOTPRINT_W, row: 0 },
];
expect(firstFreeCell(fleet)).toEqual({ col: ICON_FOOTPRINT_W, row: 0 });
});
});
Loading
Loading