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
121 changes: 121 additions & 0 deletions app/desktop/src/hosts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {
findHostByOrigin,
hostInfos,
loadHosts,
moveHost,
normalizeOrigin,
removeHost,
resolveActiveHost,
saveHosts,
setActiveHost,
setHostAccentColor,
setHostLastPath,
} from "./hosts";

Expand Down Expand Up @@ -362,3 +364,122 @@ test("loadHosts keeps a pre-remote file unchanged (schema still version 1)", ()
assert.equal(loaded.hosts.length, 1);
assert.equal("remote" in loaded.hosts[0], false);
});

test("loadHosts keeps a string accentColor and drops a wrong-typed one (schema still version 1)", () => {
const dir = tmpDataDir();
const stored = {
version: 1,
activeId: "a",
hosts: [
{ id: "a", name: "bad", url: "http://h:1", accentColor: 42 },
{ id: "b", name: "good", url: "http://h:2", accentColor: "#8b7ff0" },
{ id: "c", name: "plain", url: "http://h:3" },
],
};
writeFileSync(join(dir, "hosts.json"), JSON.stringify(stored), "utf8");
const loaded = loadHosts(dir);
assert.equal(loaded.version, 1);
assert.equal(loaded.hosts.length, 3);
assert.equal("accentColor" in loaded.hosts[0], false);
assert.equal(loaded.hosts[1].accentColor, "#8b7ff0");
assert.equal("accentColor" in loaded.hosts[2], false);
});

test("setHostAccentColor sets, overwrites, and round-trips through load", () => {
const dir = tmpDataDir();
const a = addHost(dir, "a", "http://a:1");
const b = addHost(dir, "b", "http://b:2");
assert.equal(a.ok && b.ok, true);
if (!a.ok || !b.ok) return;

setHostAccentColor(dir, a.host.id, "#8b7ff0");
const next = setHostAccentColor(dir, a.host.id, "#4a4468");
assert.equal(next.hosts[0].accentColor, "#4a4468");
// Only the target entry is patched.
assert.equal("accentColor" in next.hosts[1], false);
assert.deepEqual(loadHosts(dir), next);
});

test("setHostAccentColor with an unknown id writes nothing", () => {
const dir = tmpDataDir();
const a = addHost(dir, "a", "http://a:1");
assert.equal(a.ok, true);
if (!a.ok) return;

const before = readFileSync(join(dir, "hosts.json"), "utf8");
const result = setHostAccentColor(dir, "nope", "#8b7ff0");
assert.deepEqual(result, a.list);
assert.equal(readFileSync(join(dir, "hosts.json"), "utf8"), before);
});

test("setHostAccentColor with an unchanged value writes nothing", () => {
const dir = tmpDataDir();
const a = addHost(dir, "a", "http://a:1");
assert.equal(a.ok, true);
if (!a.ok) return;

const first = setHostAccentColor(dir, a.host.id, "#8b7ff0");
const before = readFileSync(join(dir, "hosts.json"), "utf8");
const again = setHostAccentColor(dir, a.host.id, "#8b7ff0");
assert.deepEqual(again, first);
assert.equal(readFileSync(join(dir, "hosts.json"), "utf8"), before);
});

test("moveHost reorders by id and leaves activeId untouched", () => {
const dir = tmpDataDir();
const a = addHost(dir, "a", "http://a:1");
assert.equal(a.ok, true);
if (!a.ok) return;
const b = addHost(dir, "b", "http://b:2");
const c = addHost(dir, "c", "http://c:3");
assert.equal(b.ok && c.ok, true);
if (!b.ok || !c.ok) return;

const next = moveHost(dir, c.host.id, 0);
assert.deepEqual(
next.hosts.map((h) => h.id),
[c.host.id, a.host.id, b.host.id],
);
assert.equal(next.activeId, c.host.id); // c was active (added last)
assert.deepEqual(loadHosts(dir), next);
});

test("moveHost clamps an out-of-range target index", () => {
const dir = tmpDataDir();
const a = addHost(dir, "a", "http://a:1");
const b = addHost(dir, "b", "http://b:2");
const c = addHost(dir, "c", "http://c:3");
assert.equal(a.ok && b.ok && c.ok, true);
if (!a.ok || !b.ok || !c.ok) return;

const next = moveHost(dir, a.host.id, 5);
assert.deepEqual(
next.hosts.map((h) => h.id),
[b.host.id, c.host.id, a.host.id],
);
});

test("moveHost with an unknown id or a same-index move writes nothing", () => {
const dir = tmpDataDir();
const a = addHost(dir, "a", "http://a:1");
const b = addHost(dir, "b", "http://b:2");
assert.equal(a.ok && b.ok, true);
if (!a.ok || !b.ok) return;

const before = readFileSync(join(dir, "hosts.json"), "utf8");
assert.deepEqual(moveHost(dir, "nope", 0).hosts, b.list.hosts);
assert.equal(readFileSync(join(dir, "hosts.json"), "utf8"), before);
assert.deepEqual(moveHost(dir, a.host.id, 0).hosts, b.list.hosts);
assert.equal(readFileSync(join(dir, "hosts.json"), "utf8"), before);
});

test("hostInfos carries accentColor when the entry has one (and never fills waiting)", () => {
const colored = { id: "h1", name: "one", url: "http://one:1", accentColor: "#8b7ff0" };
const plain = { id: "h2", name: "two", url: "http://two:2" };
const infos = hostInfos({ version: 1, activeId: "h1", hosts: [colored, plain] });
assert.deepEqual(infos, [
{ id: "h1", name: "one", url: "http://one:1", active: true, accentColor: "#8b7ff0" },
{ id: "h2", name: "two", url: "http://two:2", active: false },
]);
for (const info of infos) assert.equal("waiting" in info, false);
});
68 changes: 63 additions & 5 deletions app/desktop/src/hosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export interface HostEntry {
* state when the tunnel is down — acceptable degradation, no v2 bump.
*/
remote?: string;
/**
* The host's instance accent color (`#…` hex as reported by the SPA's
* `theme-color` meta via `did-change-theme-color`), persisted so the
* host-switcher's edge bar survives cold start. Additive optional field
* like `lastPath` — the schema stays version 1.
*/
accentColor?: string;
}

export interface HostList {
Expand Down Expand Up @@ -71,9 +78,9 @@ export function normalizeOrigin(input: string): NormalizeResult {
/**
* Parse one stored entry. The required fields (id/name/url) must be strings —
* anything else rejects the entry (and, via parseHostList, the file). The
* optional `lastPath` and `remote` are tolerant: absent → fine, string
* kept, any other type → the field is dropped but the entry (and file) still
* loads.
* optional `lastPath`, `remote`, and `accentColor` are tolerant: absent →
* fine, string → kept, any other type → the field is dropped but the entry
* (and file) still loads.
*/
function parseHostEntry(value: unknown): HostEntry | null {
if (typeof value !== "object" || value === null) return null;
Expand All @@ -92,6 +99,9 @@ function parseHostEntry(value: unknown): HostEntry | null {
if ("remote" in value && typeof value.remote === "string") {
entry.remote = value.remote;
}
if ("accentColor" in value && typeof value.accentColor === "string") {
entry.accentColor = value.accentColor;
}
return entry;
}

Expand Down Expand Up @@ -201,6 +211,45 @@ export function setHostLastPath(dir: string, id: string, lastPath: string): Host
return next;
}

/**
* Record the host's instance accent color (captured from the view's
* `did-change-theme-color` reports in main.ts). Unknown id or an unchanged
* value is a no-op (nothing written) — capture fires on every theme-color
* report, so the fast path avoids rewriting an identical file.
*/
export function setHostAccentColor(dir: string, id: string, accentColor: string): HostList {
const list = loadHosts(dir);
const entry = list.hosts.find((h) => h.id === id);
if (!entry || entry.accentColor === accentColor) return list;
const next: HostList = {
...list,
hosts: list.hosts.map((h) => (h.id === id ? { ...h, accentColor } : h)),
};
saveHosts(dir, next);
return next;
}

/**
* Move a host to `toIndex`, clamped to the list bounds. Order is
* user-meaningful — it IS the ⌥⌘1–9/⇧Ctrl+1–9 accelerator map — so this is
* the reorder seam behind `servers:reorder`. Unknown id or a move landing on
* the entry's current index is a no-op (nothing written); `activeId` and
* every other field are untouched — only array order changes.
*/
export function moveHost(dir: string, id: string, toIndex: number): HostList {
const list = loadHosts(dir);
const from = list.hosts.findIndex((h) => h.id === id);
if (from === -1) return list;
const to = Math.min(Math.max(toIndex, 0), list.hosts.length - 1);
if (to === from) return list;
const hosts = [...list.hosts];
const [moved] = hosts.splice(from, 1);
hosts.splice(to, 0, moved);
const next: HostList = { ...list, hosts };
saveHosts(dir, next);
return next;
}

/**
* Resolve the host to load at startup / after a mutation: the active entry,
* falling back to the first host when `activeId` dangles, `null` when the
Expand All @@ -226,20 +275,29 @@ export interface HostInfo {
name: string;
url: string;
active: boolean;
/** The entry's persisted instance accent color, when known (never
* null/empty — absent entries omit the field). */
accentColor?: string;
/** Cached waiting-agent count from the view registry — NEVER filled here
* (this module is store-pure); the `servers:list` handler in main.ts
* joins it in. */
waiting?: number;
}

/**
* Read-only projection of the list for the `servers:list` IPC surface (the
* channel name is the SPA-facing contract and keeps its server naming): every
* entry plus an `active` flag derived via `resolveActiveHost`, so a dangling
* `activeId` marks the same first-host fallback that startup would load.
* `activeId` marks the same first-host fallback that startup would load. The
* optional `accentColor` rides along when the entry carries one.
*/
export function hostInfos(list: HostList): HostInfo[] {
const activeId = resolveActiveHost(list)?.id ?? null;
return list.hosts.map(({ id, name, url }) => ({
return list.hosts.map(({ id, name, url, accentColor }) => ({
id,
name,
url,
active: id === activeId,
...(accentColor !== undefined ? { accentColor } : {}),
}));
}
43 changes: 42 additions & 1 deletion app/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ import {
HostInfo,
hostInfos,
loadHosts,
moveHost,
normalizeOrigin,
removeHost,
resolveActiveHost,
setActiveHost,
setHostAccentColor,
setHostLastPath,
} from "./hosts";
import {
Expand Down Expand Up @@ -394,6 +396,11 @@ function createHostView(hostId: string): WebContentsView {
// the switch seam re-applies the incoming view's cached color instead).
contents.on("did-change-theme-color", (_event, color) => {
views = setViewThemeColor(views, hostId, color);
// Persist the accent per host entry so the host-switcher's edge bar
// survives cold start. A null report never clears the stored value; the
// dev sentinel view (__dev__) matches no entry — the membership guard
// silently covers it. Unchanged values short-circuit (no write).
if (color !== null) setHostAccentColor(userDataDir(), hostId, color);
if (views.activeHostId === hostId) {
applyOverlayColor(color ?? DEFAULT_STRIP_COLOR);
}
Expand Down Expand Up @@ -1117,6 +1124,14 @@ function parseAddPayload(value: unknown): { name: string; url: string } | null {
return { name, url: value.url };
}

function parseReorderPayload(value: unknown): { id: string; toIndex: number } | null {
if (typeof value !== "object" || value === null) return null;
if (!("id" in value) || typeof value.id !== "string") return null;
if (!("toIndex" in value) || typeof value.toIndex !== "number") return null;
if (!Number.isInteger(value.toIndex) || value.toIndex < 0) return null;
return { id: value.id, toIndex: value.toIndex };
}

function registerIpcHandlers(): void {
ipcMain.handle(
"welcome:test-host",
Expand Down Expand Up @@ -1179,7 +1194,18 @@ function registerIpcHandlers(): void {
// entries are hosts shell-side.
ipcMain.handle("servers:list", (event): ServersListResult => {
if (!isHostsSender(event)) return { ok: false, error: "Not allowed" };
return { ok: true, servers: hostInfos(loadHosts(userDataDir())) };
// Join the store projection with the view registry's cached badge counts:
// a host with a live view whose last `badge:set` report was > 0 carries
// `waiting` (the switcher menu's amber ● N); never-visited hosts (no
// view) and zero counts omit the field. The menu refetches on every
// open, so this open-time snapshot needs no subscription.
const servers = hostInfos(loadHosts(userDataDir())).map((info) => {
const view = getView(views, info.id);
return view !== null && view.badgeCount > 0
? { ...info, waiting: view.badgeCount }
: info;
});
return { ok: true, servers };
});

ipcMain.handle("servers:switch", (event, id: unknown): IpcResult => {
Expand All @@ -1198,6 +1224,21 @@ function registerIpcHandlers(): void {
return openAddHost();
});

// servers:reorder — move-by-id ({id, toIndex}); a full-array payload would
// trust renderer-supplied order, so only the immutable id + target index
// cross the bridge. List order IS the native menu's accelerator map, so a
// committed move rebuilds the menu to re-derive the ⌥⌘1–9/⇧Ctrl+1–9
// bindings. An unknown id is the store's no-op convention (still ok — the
// rebuild is harmless), not an error.
ipcMain.handle("servers:reorder", (event, payload: unknown): IpcResult => {
if (!isHostsSender(event)) return { ok: false, error: "Not allowed" };
const parsed = parseReorderPayload(payload);
if (!parsed) return { ok: false, error: "Invalid request" };
moveHost(userDataDir(), parsed.id, parsed.toIndex);
rebuildMenu();
return { ok: true };
});

// badge:* — the SPA's waiting-agent count report, gated exactly like
// `servers:*` (registered host origins + welcome). Structurally validated:
// only a non-negative integer reaches the OS badge surface. Counts are
Expand Down
6 changes: 4 additions & 2 deletions app/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* - `version` / `platform`: readable by EVERY page (including pages loaded
* from registered rk servers) — this is the SPA's shell-detection seam
* (`app/frontend/src/lib/shell.ts`).
* - `servers`: list/switch/add invokers for the SPA command palette and
* titlebar-strip host switcher. The group
* - `servers`: list/switch/add/reorder invokers for the SPA command
* palette and titlebar-strip host switcher. The group
* name and its `servers:*` channels are the web SPA's contract and keep
* their server naming (the entries are hosts — rk instances — shell-side).
* Privileged for registered host origins AND the welcome page — main.ts
Expand Down Expand Up @@ -48,6 +48,8 @@ contextBridge.exposeInMainWorld("runkitShell", {
list: (): Promise<unknown> => ipcRenderer.invoke("servers:list"),
switch: (id: string): Promise<unknown> => ipcRenderer.invoke("servers:switch", id),
add: (): Promise<unknown> => ipcRenderer.invoke("servers:add"),
reorder: (id: string, toIndex: number): Promise<unknown> =>
ipcRenderer.invoke("servers:reorder", { id, toIndex }),
},
badge: {
set: (count: number): Promise<unknown> => ipcRenderer.invoke("badge:set", count),
Expand Down
Loading
Loading