window-set
Describe a desk's windows, layouts, and geometry as data, then open and close them through your shell's adapter. The controller tracks the windows it opens; your application owns native integration and persistence.
import { useEffect, useRef, useState } from "react"
import { createWindowSet, windowSetOf, type WindowAdapter, type WindowRecord, type WindowSet, type WindowSetController } from "@/lib/window-set"
const DESK: WindowSet = windowSetOf([
{ id: "blotters", layoutId: "blotters", bounds: { x: 1600, y: 0, width: 800, height: 1000 } },
{ id: "main", layoutId: "desk", bounds: { x: 0, y: 0, width: 1600, height: 1000 }, main: true },
{ id: "charts", layoutId: "charts", bounds: { x: 0, y: 1000, width: 2400, height: 600 }, display: "second" },
])
interface Card {
id: string
url: string
record: WindowRecord
}
export default function WindowSetDemo() {
const [cards, setCards] = useState<Card[]>([])
const [snapshot, setSnapshot] = useState("")
const [shell] = useState(() => {
const closed = new Set<(id: string) => void>()
const adapter: WindowAdapter = {
open: (id, url, record) => setCards((list) => list.some((card) => card.id === id) ? list : [...list, { id, url, record }]),
close: (id) => setCards((list) => list.filter((card) => card.id !== id)),
onClosed: (cb) => {
closed.add(cb)
return () => closed.delete(cb)
},
bounds: (id) => {
const record = DESK.windows.find((w) => w.id === id)
return record?.bounds ? { ...record.bounds, x: record.bounds.x + 24, y: record.bounds.y + 24 } : null
},
}
const closeFromShell = (id: string) => {
setCards((list) => list.filter((card) => card.id !== id))
for (const cb of closed) cb(id)
}
return { adapter, closeFromShell }
})
const windows = useRef<WindowSetController | null>(null)
useEffect(() => {
const controller = createWindowSet(shell.adapter, { url: (w) => `tradecn://desk/?window=${w.id}&layout=${w.layoutId}` })
windows.current = controller
return () => {
windows.current = null
controller.dispose()
}
}, [shell])
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded-md border border-border px-2 py-1" onClick={() => void windows.current?.restore(DESK)}>Restore the desk</button>
<button type="button" className="rounded-md border border-border px-2 py-1" onClick={() => void windows.current?.closeAll()}>Close all</button>
<button type="button" className="rounded-md border border-border px-2 py-1" onClick={() => void windows.current?.snapshot().then((set) => setSnapshot(JSON.stringify(set, null, 2)))}>Snapshot</button>
</div>
<div className="w-2xl max-w-full space-y-3 text-xs lining-nums tabular-nums">
<p className="text-muted-foreground">A simulated shell: restore three windows, close a card, then take a snapshot. No native windows open.</p>
{cards.length > 0 ? (
<ul role="list" className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{cards.map((card) => (
<li key={card.id} data-window={card.id} className="flex min-w-0 flex-col gap-1 rounded-md border border-border bg-card p-2">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{card.id}{card.record.main && <span className="ml-1 text-muted-foreground"> (main)</span>}</span>
<button type="button" aria-label={`Close ${card.id} from the shell`} className="px-1 text-muted-foreground hover:text-foreground" onClick={() => shell.closeFromShell(card.id)}>×</button>
</div>
<span className="break-all font-(family-name:--tradecn-font-mono) text-muted-foreground">{card.url}</span>
<span className="text-muted-foreground">layout {card.record.layoutId}</span>
</li>
))}
</ul>
) : (
<p className="flex min-h-28 items-center justify-center text-muted-foreground">No windows open. Restore the desk to open main first.</p>
)}
{snapshot && <pre tabIndex={0} role="region" aria-label="Window set snapshot" className="max-h-48 overflow-auto rounded-md border border-border bg-muted/40 p-2 font-(family-name:--tradecn-font-mono) text-xs">{snapshot}</pre>}
</div>
</>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/window-set#v2.0.0
npx shadcn@latest add tradecn/ui/window-set#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/window-set#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/window-set#v2.0.0
-
Copy the files into your project:
lib/window-set.ts import { readSlot, setSlot, type Preferences } from "@/lib/preferences" // For shells whose windows are separate JavaScript contexts, a webview per window in a Rust-hosted shell or a // BrowserWindow per window in a Node-hosted one, the recipe in docs/workspace.md (one Workspace per window) // becomes data and a driver. The set is the data: which windows a desk has, which layout each shows, and where // each sits. The controller drives the consumer's adapter over the shell's own calls: open a window, close one, // hear one close, ask where one is. No shell package is imported; the shell is the consumer's, behind four // functions, and the layouts themselves live wherever the consumer keeps them, keyed by `layoutId`. export const WINDOW_SET_KIND = "tradecn-window-set" export const WINDOW_SET_VERSION = 1 /** The preferences slot a set travels in. */ export const WINDOW_SET_SLOT = "windows" /** What a set carries, and for whom, in the language `preferences` and the workspace layout already use. */ export const WINDOW_SET_BOUNDARIES = { /** Travels as a desk template: which windows there are, which layout each shows, which is the main one. */ template: ["window-ids", "layout-ids", "main-window"], /** The person's own: where each window sits, and on which display. */ user: ["bounds", "display"], /** Stored by nobody: what is open right now comes from the shell every launch. */ session: ["open-state", "focus"], } as const export type WindowSetBoundary = keyof typeof WINDOW_SET_BOUNDARIES export type WindowSetBoundaries = { readonly [K in WindowSetBoundary]: readonly string[] } const BOUNDARY_KEYS: readonly WindowSetBoundary[] = ["template", "user", "session"] export interface WindowBounds { x: number y: number width: number height: number } export interface WindowRecord { /** The shell's name for the window: a Tauri label, an Electron window's key in your own map. */ id: string /** The layout this window shows, keyed however you keep layouts: a `layout-manager` template's id, a file name. */ layoutId: string bounds?: WindowBounds /** The display the window sits on, as the shell names it. */ display?: string /** The one window that opens first. What closing it means is the owner's policy. At most one; the first wins. */ main?: boolean } export interface WindowSet { version: typeof WINDOW_SET_VERSION kind: typeof WINDOW_SET_KIND windows: WindowRecord[] /** What the writer put in and what it kept out, at the time it wrote. */ boundaries: WindowSetBoundaries } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) } function isFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) } function parseBounds(value: unknown): WindowBounds | undefined { if (!isObject(value)) return undefined const { x, y, width, height } = value return isFinite(x) && isFinite(y) && isFinite(width) && isFinite(height) && width > 0 && height > 0 ? { x, y, width, height } : undefined } /** One record, taken on no trust: an id and a layout id, or nothing; bounds and display only when whole. */ export function parseWindowRecord(value: unknown): WindowRecord | null { if (!isObject(value)) return null const { id, layoutId, bounds, display, main } = value if (typeof id !== "string" || id.trim() === "" || typeof layoutId !== "string" || layoutId.trim() === "") return null const record: WindowRecord = { id, layoutId } const box = parseBounds(bounds) if (box) record.bounds = box if (typeof display === "string" && display !== "") record.display = display if (main === true) record.main = true return record } function readBoundaries(value: unknown): WindowSetBoundaries { if (!isObject(value)) return WINDOW_SET_BOUNDARIES const out: Partial<Record<WindowSetBoundary, readonly string[]>> = {} for (const key of BOUNDARY_KEYS) { const list = value[key] if (!Array.isArray(list) || !list.every((entry) => typeof entry === "string")) return WINDOW_SET_BOUNDARIES out[key] = list } return { template: out.template ?? [], user: out.user ?? [], session: out.session ?? [] } } /** Records made into a set: duplicate ids dropped after the first, one main at most (the first keeps it), records copied. */ function normalize(records: readonly WindowRecord[]): WindowRecord[] { const seen = new Set<string>() let main = false const out: WindowRecord[] = [] for (const record of records) { if (seen.has(record.id)) continue seen.add(record.id) const copy: WindowRecord = { ...record } if (copy.main) { if (main) delete copy.main main = true } else delete copy.main out.push(copy) } return out } /** A set from records, with the boundaries this file names unless told otherwise. */ export function windowSetOf(windows: readonly WindowRecord[] = [], boundaries: WindowSetBoundaries = WINDOW_SET_BOUNDARIES): WindowSet { return { version: WINDOW_SET_VERSION, kind: WINDOW_SET_KIND, windows: normalize(windows), boundaries } } /** A stored set, as an object or as JSON text, taken on no trust. A malformed record is dropped and the rest kept; anything but a set is null. */ export function parseWindowSet(value: unknown): WindowSet | null { let raw: unknown = value if (typeof value === "string") { try { raw = JSON.parse(value) } catch { return null } } if (!isObject(raw) || raw.kind !== WINDOW_SET_KIND || raw.version !== WINDOW_SET_VERSION || !Array.isArray(raw.windows)) return null const records: WindowRecord[] = [] for (const entry of raw.windows) { const record = parseWindowRecord(entry) if (record) records.push(record) } return windowSetOf(records, readBoundaries(raw.boundaries)) } /** The window that opens first: the one marked main, else the first. What closing it means is the owner's policy. */ export function mainWindow(set: WindowSet): WindowRecord | undefined { return set.windows.find((w) => w.main) ?? set.windows[0] } /** The set kept in a `preferences` envelope, or null when the slot is empty or holds something else. */ export function readWindowSet(prefs: Preferences, slot: string = WINDOW_SET_SLOT): WindowSet | null { return parseWindowSet(readSlot(prefs, slot)) } /** The envelope with the set written to its slot. */ export function writeWindowSet(prefs: Preferences, set: WindowSet, slot: string = WINDOW_SET_SLOT): Preferences { return setSlot(prefs, slot, set, WINDOW_SET_VERSION) } /** The shell, behind four functions. Yours: a Tauri `WebviewWindow`, an Electron `BrowserWindow` through a preload bridge, anything that opens a window. */ export interface WindowAdapter { /** Open a window with this id at this url. Resolve when the window exists. */ open(id: string, url: string, record: WindowRecord): void | Promise<void> close(id: string): void | Promise<void> /** Hear the shell close a window, by the person or by the system; returns what stops listening. */ onClosed(cb: (id: string) => void): () => void /** Where a window is now, for a snapshot. Null or undefined leaves the record's own bounds in place. */ bounds?(id: string): WindowBounds | null | undefined | Promise<WindowBounds | null | undefined> } export interface WindowSetOptions { /** The url a window opens at. Default `defaultWindowUrl`: the document's own path with `?window=<id>&layout=<layoutId>`. */ url?: (record: WindowRecord) => string /** The boundaries a snapshot carries. */ boundaries?: WindowSetBoundaries } export interface WindowSetController { /** Open every window of the set that is not open, the main one first. Resolves to the records it opened. */ restore(set: WindowSet): Promise<WindowRecord[]> /** Open one window. False when it was open already. */ open(record: WindowRecord): Promise<boolean> /** Close one window through the shell. False when it was not open. */ close(id: string): Promise<boolean> closeAll(): Promise<void> /** The open windows, in the order they opened. */ windows(): readonly WindowRecord[] isOpen(id: string): boolean /** The set as it stands: the open windows, each with the bounds the shell reports now. */ snapshot(): Promise<WindowSet> /** Hear a window open or close. */ subscribe(cb: () => void): () => void /** Stop listening to the shell. Closes nothing. */ dispose(): void } /** `?window=<id>&layout=<layoutId>` on the document's own path, so every secondary window loads the same app and reads the query to know which it is; the initial main window receives its record at startup instead. */ export function defaultWindowUrl(record: WindowRecord): string { const path = typeof location === "object" && location !== null && typeof location.pathname === "string" ? location.pathname : "" return `${path}?window=${encodeURIComponent(record.id)}&layout=${encodeURIComponent(record.layoutId)}` } export function createWindowSet(adapter: WindowAdapter, options: WindowSetOptions = {}): WindowSetController { const url = options.url ?? defaultWindowUrl const boundaries = options.boundaries ?? WINDOW_SET_BOUNDARIES const open = new Map<string, WindowRecord>() const listeners = new Set<() => void>() const notify = () => { for (const cb of listeners) cb() } // The shell closed one: the person pressed its X, or the system took it. It leaves the set the same way. const stop = adapter.onClosed((id) => { if (open.delete(id)) notify() }) // Ids with a call to the shell in flight, so two opens or two closes of one window at once make one call. const opening = new Set<string>() const closing = new Set<string>() const controller: WindowSetController = { async open(record) { if (open.has(record.id) || opening.has(record.id)) return false opening.add(record.id) try { const copy: WindowRecord = { ...record } await adapter.open(copy.id, url(copy), copy) open.set(copy.id, copy) } finally { opening.delete(record.id) } notify() return true }, async restore(set) { const main = mainWindow(set) const order = main ? [main, ...set.windows.filter((w) => w !== main)] : [...set.windows] const opened: WindowRecord[] = [] for (const record of order) if (await controller.open(record)) opened.push(record) return opened }, async close(id) { if (!open.has(id) || closing.has(id)) return false closing.add(id) try { await adapter.close(id) } finally { closing.delete(id) } if (open.delete(id)) notify() return true }, async closeAll() { for (const id of [...open.keys()]) await controller.close(id) }, windows: () => [...open.values()], isOpen: (id) => open.has(id), async snapshot() { const records: WindowRecord[] = [] for (const record of open.values()) { const box = adapter.bounds ? await adapter.bounds(record.id) : undefined records.push(box ? { ...record, bounds: box } : { ...record }) } return windowSetOf(records, boundaries) }, subscribe(cb) { listeners.add(cb) return () => { listeners.delete(cb) } }, dispose() { stop() listeners.clear() }, } return controller }lib/preferences.ts // The envelope everything a trader or a desk may change without a build travels in: a workspace // layout, a grid's column state, hotkey overrides, rules, a threshold. Each is a slot with a version // and a JSON value, and the envelope says in its payload which slots may leave as a desk template, // which belong to one person, and which never leave a session, in the language the workspace layout's // boundaries already use. The items keep their controlled props; the consumer wires a slot to a prop. // This is not a store and not a provider, and where the envelope lives (localStorage, a file in a // desktop shell, a server) is the consumer's. Nothing here touches storage, and a stored envelope comes // back through `parsePreferences`, which takes nothing on trust. export const PREFERENCES_MARK = "preferences" export const PREFERENCES_VERSION = 1 export type PreferencesJson = string | number | boolean | null | PreferencesJson[] | { [key: string]: PreferencesJson } /** A slot: the shape's version, as the item that owns the shape numbers it, and the value as JSON. */ export interface PreferenceSlot { version: number value: PreferencesJson } /** * Where a slot may go. `template`: shareable as a desk template, handed to a colleague, restored next * quarter. `user`: belongs to the person, whichever desk they sit at. `session`: never exported, and * an import skips slots the incoming envelope marks session — though an import can replace and * reclassify the receiving desk's own session slot, since boundaries are read from the envelope. */ export type PreferenceBoundary = "template" | "user" | "session" export type PreferenceBoundaries = { readonly [K in PreferenceBoundary]: readonly string[] } export const PREFERENCE_BOUNDARIES: readonly PreferenceBoundary[] = ["template", "user", "session"] /** A slot named under no boundary is the person's. */ export const DEFAULT_BOUNDARY: PreferenceBoundary = "user" export interface Preferences { tradecn: typeof PREFERENCES_MARK version: typeof PREFERENCES_VERSION slots: Record<string, PreferenceSlot> /** Which slots go where, by name, at the time the envelope was written. */ boundaries: PreferenceBoundaries } /** How a consumer brings an older slot up to the shape it reads today. */ export interface PreferenceMigrator { /** The version the consumer's code reads. */ version: number /** The value at `from`, returned at `version`; null drops the slot. Called only for a slot older than `version`. */ migrate: (value: PreferencesJson, from: number) => PreferencesJson | null } export type PreferenceMigrators = Record<string, PreferenceMigrator> function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) } /** The value as JSON stores it: functions and undefined gone, a copy, or null when it cannot be stored. */ export function toJson(value: unknown): PreferencesJson | null { if (value === undefined) return null try { const stored: unknown = JSON.parse(JSON.stringify(value)) return stored === undefined ? null : (stored as PreferencesJson) } catch { return null } } function sameJson(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b) } function readBoundaries(value: unknown): PreferenceBoundaries { const out: Record<PreferenceBoundary, string[]> = { template: [], user: [], session: [] } if (!isObject(value)) return out for (const key of PREFERENCE_BOUNDARIES) { const list = value[key] if (Array.isArray(list)) out[key] = list.filter((entry): entry is string => typeof entry === "string") } return out } /** An empty envelope, with the boundaries you name. */ export function createPreferences(boundaries: Partial<{ [K in PreferenceBoundary]: readonly string[] }> = {}): Preferences { return { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots: {}, boundaries: { template: [...(boundaries.template ?? [])], user: [...(boundaries.user ?? [])], session: [...(boundaries.session ?? [])] }, } } /** * A stored envelope, checked. Takes the object or the JSON text of one, and returns null for anything * that is not a version 1 tradecn preferences envelope. A slot that is not an object with a whole-number * version and a JSON value is dropped, the rest are kept, and what comes back is a copy. */ export function parsePreferences(value: unknown): Preferences | null { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return null } } if (!isObject(raw) || raw.tradecn !== PREFERENCES_MARK || raw.version !== PREFERENCES_VERSION || !isObject(raw.slots)) return null const slots: [string, PreferenceSlot][] = [] for (const [name, slot] of Object.entries(raw.slots)) { if (!isObject(slot) || typeof slot.version !== "number" || !Number.isInteger(slot.version) || slot.version < 0 || !("value" in slot)) continue const stored = toJson(slot.value) if (stored === null && slot.value !== null) continue slots.push([name, { version: slot.version, value: stored }]) } // Defined rather than assigned, so a slot named `__proto__` is kept as a slot and doesn't set the object's prototype. return { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots: Object.fromEntries(slots), boundaries: readBoundaries(raw.boundaries) } } // A slot is read by its own key, so a name such as `constructor` or `toString` is a slot like any other and never // a member of Object.prototype. function slotNamed(prefs: Preferences, name: string): PreferenceSlot | undefined { return Object.prototype.hasOwnProperty.call(prefs.slots, name) ? prefs.slots[name] : undefined } /** The slot, or undefined. */ export function getSlot(prefs: Preferences, name: string): PreferenceSlot | undefined { return slotNamed(prefs, name) } /** * The envelope with the slot set. The value is stored as JSON at a version: the number given, or the version of * the migrator given, which is the one to pass when the value was read through `readSlot` with it; else the * slot's own, else 1. A value read through a migrator is at the migrator's version, and stored under the slot's * older one it would be migrated again on the next read. A value and version that change nothing hand back the * same envelope, so a subscriber or an autosave comparing by identity sees no change. */ export function setSlot(prefs: Preferences, name: string, value: unknown, version?: number | PreferenceMigrator): Preferences { const stored = toJson(value) if (stored === null && value !== null) return prefs const current = slotNamed(prefs, name) const at = typeof version === "object" && version !== null ? version.version : version const next: PreferenceSlot = { version: at ?? current?.version ?? 1, value: stored } if (current && current.version === next.version && sameJson(current.value, next.value)) return prefs return { ...prefs, slots: { ...prefs.slots, [name]: next } } } /** The envelope without the slot; the same envelope when there was none. */ export function removeSlot(prefs: Preferences, name: string): Preferences { if (!slotNamed(prefs, name)) return prefs const slots = { ...prefs.slots } delete slots[name] return { ...prefs, slots } } /** The boundary a slot is named under, `user` when it is under none. */ export function boundaryOf(prefs: Preferences, name: string): PreferenceBoundary { for (const key of PREFERENCE_BOUNDARIES) if (prefs.boundaries[key].includes(name)) return key return DEFAULT_BOUNDARY } /** The envelope with the slot named under one boundary and no other. */ export function withBoundary(prefs: Preferences, name: string, boundary: PreferenceBoundary): Preferences { if (boundaryOf(prefs, name) === boundary && prefs.boundaries[boundary].includes(name)) return prefs const boundaries = { template: [...prefs.boundaries.template], user: [...prefs.boundaries.user], session: [...prefs.boundaries.session] } for (const key of PREFERENCE_BOUNDARIES) boundaries[key] = boundaries[key].filter((entry) => entry !== name) boundaries[boundary].push(name) return { ...prefs, boundaries } } export interface PreferencesDiff { /** In `b` and not in `a`. */ added: string[] /** In `a` and not in `b`. */ removed: string[] /** In both with a different version or value. */ changed: string[] /** In both and the same. */ same: string[] } /** Which slots differ between two envelopes, by name, for a support view or a save prompt. */ export function diffPreferences(a: Preferences, b: Preferences): PreferencesDiff { const out: PreferencesDiff = { added: [], removed: [], changed: [], same: [] } const names = new Set([...Object.keys(a.slots), ...Object.keys(b.slots)]) for (const name of [...names].sort()) { const x = slotNamed(a, name) const y = slotNamed(b, name) if (x && !y) out.removed.push(name) else if (!x && y) out.added.push(name) else if (x && y) (x.version === y.version && sameJson(x.value, y.value) ? out.same : out.changed).push(name) } return out } /** The slots allowed out under a boundary: a template carries `template` slots only; the person's export carries `template` and `user`; nothing carries `session`. */ export function exportableSlots(prefs: Preferences, boundary: "template" | "user" = "user"): string[] { return Object.keys(prefs.slots).filter((name) => { const where = boundaryOf(prefs, name) return where === "template" || (boundary === "user" && where === "user") }) } /** * The envelope as JSON text, for a file or a clipboard. `template` keeps the slots shareable as a desk * template; `user` (the default) keeps those and the person's own. A `session` slot never goes out. * The boundaries travel with it, so the reader knows what it holds. */ export function exportPreferences(prefs: Preferences, options: { boundary?: "template" | "user"; indent?: number } = {}): string { const keep = new Set(exportableSlots(prefs, options.boundary ?? "user")) const slots = Object.fromEntries(Object.entries(prefs.slots).filter(([name]) => keep.has(name))) const out: Preferences = { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots, boundaries: prefs.boundaries } return JSON.stringify(out, null, options.indent ?? 2) } /** * Another envelope's slots merged into this one: the incoming slot wins where both have one, under the * boundary allowed in (`user` by default, `template` for a desk template), and never a `session` slot. * The boundaries the incoming envelope names for the slots it brings are taken; the rest stay. Null when * the text is not an envelope. */ export function importPreferences(target: Preferences, incoming: unknown, options: { boundary?: "template" | "user" } = {}): Preferences | null { const parsed = parsePreferences(incoming) if (!parsed) return null const allowed = new Set(exportableSlots(parsed, options.boundary ?? "user")) let out = target for (const [name, slot] of Object.entries(parsed.slots)) { if (!allowed.has(name)) continue out = setSlot(out, name, slot.value, slot.version) out = withBoundary(out, name, boundaryOf(parsed, name)) } return out } /** * Every slot brought up to the version its migrator reads: a slot older than `migrator.version` goes * through `migrate`, one it returns null for is dropped, a slot with no migrator or already current is * left alone, and a slot newer than the migrator's version is left alone too, since a newer build wrote * it. The same envelope comes back when nothing moved. */ export function migratePreferences(prefs: Preferences, migrators: PreferenceMigrators): Preferences { let out = prefs for (const [name, migrator] of Object.entries(migrators)) { const slot = slotNamed(prefs, name) if (!slot || slot.version >= migrator.version) continue const value = migrator.migrate(slot.value, slot.version) out = value === null ? removeSlot(out, name) : setSlot(out, name, value, migrator.version) } return out } /** * One slot's value at the version the consumer reads, through its migrator when it is older, or * undefined when the slot is missing, newer than the migrator reads, or dropped by the migration. The * envelope itself is not changed; call `migratePreferences` to write the result back. */ export function readSlot<V = PreferencesJson>(prefs: Preferences, name: string, migrator?: PreferenceMigrator): V | undefined { const slot = slotNamed(prefs, name) if (!slot) return undefined if (!migrator) return slot.value as V if (slot.version > migrator.version) return undefined if (slot.version === migrator.version) return slot.value as V const value = migrator.migrate(slot.value, slot.version) return value === null ? undefined : (value as V) }
Usage
This is a native-shell integration fragment. Supply the application's shell service, loaded prefs envelope, and durable persistPreferences function; the library does not create them.
import { createWindowSet, readWindowSet, windowSetOf, writeWindowSet, type WindowAdapter } from "@/lib/window-set"
// Application-owned shell service; open and close confirm native completion.
const adapter: WindowAdapter = {
open: (id, url, record) => shell.openWindow(id, url, record),
close: (id) => shell.closeWindow(id),
onClosed: (cb) => shell.onWindowClosed(cb), // Returns unsubscribe synchronously.
bounds: (id) => shell.windowBounds(id),
}
const windows = createWindowSet(adapter)
// On launch: the stored set, or a first desk of one window.
await windows.restore(readWindowSet(prefs) ?? windowSetOf([{ id: "main", layoutId: "desk", main: true }]))
// Before closing windows: snapshot, then durably save through your storage service.
prefs = writeWindowSet(prefs, await windows.snapshot())
await persistPreferences(prefs)
Create one controller in the desk's owner and await one startup restore. restore stops at the first window that fails to open and does not roll back, so the windows after it never open and drop out of the next snapshot; a launch that should survive one bad window opens each record itself, as Desktop shells shows. The controller starts empty and does not discover native windows. Make the adapter's open idempotent so it can adopt an existing initial window. Before a quit snapshot, stop new operations and wait for pending ones to finish; persist before destroying any windows.
Every secondary window loads the app and reads ?window=<id>&layout=<layoutId> to choose its identity and layout; the initial main window receives its record at startup instead. Desktop shells covers owner placement, initial-window adoption, entry URLs, geometry, and close/quit coordination for Tauri and Electron.
The preview uses cards as a simulated shell. Restore opens the missing records, main first; each card's close button reports a shell-initiated closure, while Close all asks the controller to close them. Snapshot reads synthetic bounds offset by 24 pixels from the saved positions. It stays unchanged until you take another snapshot, and nothing is saved across reloads.
API Reference
For separate JavaScript contexts, mount one Workspace per window. Keep layouts under each layoutId, in a layout-manager template or your own storage. This library imports no shell package and stores no layouts.
The set
WindowSet fields are all required:
| Field | Type | Value or purpose |
|---|---|---|
version |
1 |
WINDOW_SET_VERSION. |
kind |
"tradecn-window-set" |
WINDOW_SET_KIND; distinguishes a set from a layout or preferences envelope. |
windows |
WindowRecord[] |
Records in restore order, except that the main record goes first. |
boundaries |
WindowSetBoundaries |
Ownership metadata, defaulting to WINDOW_SET_BOUNDARIES in the helpers. |
Each WindowRecord:
| Field | Type | Required | Purpose |
|---|---|---|---|
id |
string |
Yes | The shell's window name, such as a Tauri label or a key in your Electron window map. |
layoutId |
string |
Yes | The key used to load this window's layout. |
bounds |
WindowBounds |
No | Saved geometry in the units and frame convention your adapter uses. |
display |
string |
No | Application metadata naming the display; the controller does not discover or move displays. |
main |
boolean |
No | Selects the first record to restore. Closing it does not automatically close the desk. |
WindowBounds has four required numbers. Choose physical or logical pixels, and outer or content size, consistently between the adapter's reads and writes; see the shell recipes.
| Field | Type | Purpose |
|---|---|---|
x, y |
number |
Window position. Negative coordinates are allowed by the parser. |
width, height |
number |
Window size. The parser requires both to be positive. |
windowSetOf keeps the first record for each id and the first retained main: true marker. It shallow-copies records, leaving nested bounds shared, and does not validate typed inputs. mainWindow returns the marked record, otherwise the first, or undefined for an empty set.
Boundaries
WindowSetBoundary is "template" | "user" | "session". WindowSetBoundaries has these three required, readonly arrays of strings; WINDOW_SET_BOUNDARIES supplies their defaults:
| Boundary | Default entries | Intended ownership |
|---|---|---|
template |
window-ids, layout-ids, main-window |
The desk's window and layout identities. |
user |
bounds, display |
The person's geometry and screens. |
session |
open-state, focus |
Live state, rebuilt during use rather than persisted as fields. |
These labels do not remove fields. preferences exports whole slots: marking the windows slot as template also exports any saved bounds and display. Remove those fields from a copy before writing a shareable template. An unclassified preferences slot defaults to user.
readWindowSet and writeWindowSet use WINDOW_SET_SLOT ("windows") unless given another slot name. Writing puts the set in a version 1 slot without changing the envelope's boundary assignment or saving it to storage.
Parsing
Use parsers for untrusted values. parseWindowRecord accepts an object with nonblank string id and layoutId, preserving their original spelling and whitespace. It keeps only complete, finite bounds with positive width and height; fractional values are valid. Invalid bounds are omitted without dropping the record. It keeps a nonempty string display and only main: true, and drops unknown fields. It does not parse JSON text on its own.
parseWindowSet accepts an object or JSON text with kind "tradecn-window-set", version 1, and a windows array. It drops invalid records, normalizes duplicates and main markers, and returns null for an invalid envelope or invalid JSON. Boundaries must supply all three arrays of strings; otherwise the entire boundary value falls back to WINDOW_SET_BOUNDARIES. Empty arrays and empty strings in those arrays are accepted.
| Helper | Inputs and defaults | Returns |
|---|---|---|
windowSetOf(windows?, boundaries?) |
windows: readonly WindowRecord[] = []; boundaries: WindowSetBoundaries = WINDOW_SET_BOUNDARIES |
WindowSet; normalizes trusted records. |
mainWindow(set) |
set: WindowSet |
WindowRecord | undefined. |
parseWindowRecord(value) |
value: unknown |
WindowRecord | null. |
parseWindowSet(value) |
value: unknown |
WindowSet | null. |
readWindowSet(prefs, slot?) |
prefs: Preferences; slot: string = WINDOW_SET_SLOT |
WindowSet | null; parses the slot's value, or returns null if absent or invalid. |
writeWindowSet(prefs, set, slot?) |
prefs: Preferences; set: WindowSet; slot: string = WINDOW_SET_SLOT |
Preferences; writes a JSON copy of the set into the slot. |
defaultWindowUrl(record) |
record: WindowRecord |
string; the current location.pathname plus ?window=<id>&layout=<layoutId>, with both values URL-encoded. Without a pathname, returns the query alone. |
The adapter
Supply a WindowAdapter to createWindowSet. Only bounds is optional.
| Method | Inputs | Returns | Contract |
|---|---|---|---|
open(id, url, record) |
id: string; url: string; record: WindowRecord |
void | Promise<void> |
Adopt or create the window and apply its record. Complete after successful native setup; reject failures. |
close(id) |
id: string |
void | Promise<void> |
Complete after confirmed closure. Reject cancellation so the controller can keep tracking the window. |
onClosed(cb) |
cb: (id: string) => void |
() => void |
Subscribe to completed native closures and return unsubscribe synchronously. |
bounds(id) |
id: string |
WindowBounds | null | undefined | Promise<WindowBounds | null | undefined> |
Read current geometry for a snapshot; null or undefined keeps the saved bounds. |
Prepare any asynchronous native event registration before constructing the controller. A close request alone does not fulfill close: once it resolves, the controller removes the record even if the native window still exists. Forward completed native closures through onClosed, including ones the controller did not request.
The default URL is a path and query, not an absolute shell entry URL. Supply options.url or resolve the record to your shell's entry point in open. The controller passes geometry and display metadata to the adapter; it does not position windows itself.
The controller
createWindowSet(adapter: WindowAdapter, options?: WindowSetOptions) returns a WindowSetController. Options default to {}:
| Option | Type | Default | Purpose |
|---|---|---|---|
url |
(record: WindowRecord) => string |
defaultWindowUrl |
Build the URL passed to adapter.open. |
boundaries |
WindowSetBoundaries |
WINDOW_SET_BOUNDARIES |
Metadata for snapshots. restore does not adopt the restored set's boundaries. |
| Method | Inputs | Returns | Behavior |
|---|---|---|---|
restore(set) |
set: WindowSet |
Promise<WindowRecord[]> |
Try the main record first, then the rest in order, awaiting each open it starts. Returns the input records it opened; skips tracked ids and ids already opening. |
open(record) |
record: WindowRecord |
Promise<boolean> |
true after successful adapter completion; false if the id is tracked or already opening. |
close(id) |
id: string |
Promise<boolean> |
true after adapter completion; false if the id is untracked or already closing. |
closeAll() |
None | Promise<void> |
Close the currently tracked ids sequentially, in their insertion order. |
windows() |
None | readonly WindowRecord[] |
A new array of tracked records, in successful-open order. The records themselves are shared. |
isOpen(id) |
id: string |
boolean |
Whether this controller tracks the id; does not query the shell. |
snapshot() |
None | Promise<WindowSet> |
Read bounds sequentially for tracked records and build a normalized set using the controller's boundaries. |
subscribe(cb) |
cb: () => void |
() => void |
Listen for tracked opens and closes; returns unsubscribe. No initial callback. |
dispose() |
None | void |
Unsubscribe from the shell and clear current subscribers. Does not close windows or clear records. |
A pending open is not yet tracked, so isOpen is false and close returns false for that id. Duplicate opens and closes return false without waiting for the original operation. Concurrent restores can therefore open a secondary while another restore is still opening main. Coordinate startup, snapshots, and shutdown in the owner; the controller does not serialize all operations or expose a separate readiness signal.
An adapter.open failure rejects without adding the record; an adapter.close failure leaves it tracked unless a native close notification already removed it. restore and closeAll stop at the first rejection without rolling back earlier successes. A rejected bounds read rejects snapshot; bounds returned successfully are trusted without parser validation.
Subscriber callbacks run synchronously. A throw stops later callbacks and can reject open or close after its tracked state has changed.
Native closure and requested-close completion notify only when they remove a tracked record. Wait for a pending close to finish before reopening its id: the old close can otherwise remove the new record. dispose leaves pending operations running and methods callable, so finish operations before disposing and stop using that controller afterward.
What it does not do
Native discovery, window creation and positioning, main-window shutdown policy, layout loading, and durable storage belong to the application. The controller calls your adapter and tracks the results. Mount a Workspace in each window and load the layout identified by its record or query.
This page is docs/window-set.md at v2.0.0.