From bd8cc880f259f96a594de190a9aa7dc63bb2476a Mon Sep 17 00:00:00 2001 From: notcompromised Date: Sat, 10 Oct 2026 13:14:27 -0500 Subject: [PATCH 01/11] feat(desktop): follow a local palette file independently of servers --- apps/desktop/src/app/DesktopConfig.ts | 1 + apps/desktop/src/app/DesktopEnvironment.ts | 4 + .../src/app/DesktopLinuxUrlHandler.test.ts | 18 ++ .../desktop/src/app/DesktopLinuxUrlHandler.ts | 18 +- .../src/app/DesktopPreReadyPlatform.ts | 2 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/localTheme.ts | 15 ++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 7 + .../src/theme/DesktopLocalTheme.test.ts | 137 +++++++++++ apps/desktop/src/theme/DesktopLocalTheme.ts | 75 ++++++ apps/server/src/environmentTheme.ts | 52 +--- apps/web/src/hooks/useDefaultTheme.ts | 18 +- .../src/hooks/useDesktopLocalTheme.test.ts | 222 ++++++++++++++++++ apps/web/src/hooks/useDesktopLocalTheme.ts | 52 ++++ apps/web/src/hooks/useEnvironmentTheme.ts | 82 +------ apps/web/src/hooks/useTheme.ts | 28 +++ apps/web/src/publishedTheme.ts | 85 +++++++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/themePalette.ts | 37 +++ docs/user/appearance.md | 20 ++ packages/contracts/src/ipc.ts | 9 + packages/shared/package.json | 4 + packages/shared/src/themeFile.ts | 51 ++++ 25 files changed, 812 insertions(+), 133 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/localTheme.ts create mode 100644 apps/desktop/src/theme/DesktopLocalTheme.test.ts create mode 100644 apps/desktop/src/theme/DesktopLocalTheme.ts create mode 100644 apps/web/src/hooks/useDesktopLocalTheme.test.ts create mode 100644 apps/web/src/hooks/useDesktopLocalTheme.ts create mode 100644 apps/web/src/publishedTheme.ts create mode 100644 packages/shared/src/themeFile.ts diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 773ea24509cf1..21dfedd4a47d8 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -38,6 +38,7 @@ export const DesktopConfig = Config.all({ xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), xdgDataHome: trimmedString("XDG_DATA_HOME"), t3Home: trimmedString("T3CODE_HOME"), + localThemeFile: trimmedString("T3CODE_DESKTOP_THEME_FILE"), devServerUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option), appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 6a8bc8e0ea495..87ea6f8749515 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -48,6 +48,8 @@ export class DesktopEnvironment extends Context.Service< readonly stateDir: string; readonly desktopSettingsPath: string; readonly clientSettingsPath: string; + readonly localThemeFile: Option.Option; + readonly disableAutoUpdate: boolean; readonly savedEnvironmentRegistryPath: string; readonly serverSettingsPath: string; readonly logDir: string; @@ -208,6 +210,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( stateDir, desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), clientSettingsPath: path.join(stateDir, "client-settings.json"), + localThemeFile: Option.map(config.localThemeFile, (file) => path.resolve(homeDirectory, file)), + disableAutoUpdate: config.disableAutoUpdate, savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), logDir: path.join(stateDir, "logs"), diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index cbabe14dd6fc6..799f94bfe8f66 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -32,6 +32,8 @@ const makeEnvironment = (path: Path.Path, overrides: Record = { linuxDesktopEntryName: "com.t3tools.T3Code.desktop", linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", + localThemeFile: Option.none(), + disableAutoUpdate: false, appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), path, ...overrides, @@ -157,6 +159,22 @@ const emptyRecording = (): RecordedRegistration => ({ copies: [], }); +describe("local theme launch configuration", () => { + it("carries theme following and update hold into cold URL launches", () => { + const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code", + execTarget: "/home/alice/Custom T3.AppImage", + scheme: "t3code", + localThemeFile: "/home/alice/My palette.json", + disableAutoUpdate: true, + }); + assert.include( + entry, + 'Exec=env -u ELECTRON_RUN_AS_NODE "T3CODE_DESKTOP_THEME_FILE=/home/alice/My palette.json" T3CODE_DISABLE_AUTO_UPDATE=true "/home/alice/Custom T3.AppImage" %U', + ); + }); +}); + describe("DesktopLinuxUrlHandler", () => { it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e67cdcefe9fa3..90785a941a3db 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -88,12 +88,26 @@ export function renderUrlHandlerDesktopEntry(input: { readonly execTarget: string; readonly scheme: string; readonly iconPath?: string; + readonly localThemeFile?: string | undefined; + readonly disableAutoUpdate?: boolean; }): string { return [ "[Desktop Entry]", "Type=Application", `Name=${escapeDesktopEntryString(input.displayName)}`, - `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + `Exec=${[ + ...(input.localThemeFile === undefined + ? [] + : [ + "env", + "-u", + "ELECTRON_RUN_AS_NODE", + escapeDesktopEntryExecArgument(`T3CODE_DESKTOP_THEME_FILE=${input.localThemeFile}`), + ...(input.disableAutoUpdate ? ["T3CODE_DISABLE_AUTO_UPDATE=true"] : []), + ]), + escapeDesktopEntryExecArgument(input.execTarget), + "%U", + ].join(" ")}`, ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]), "Terminal=false", "NoDisplay=true", @@ -133,6 +147,8 @@ export const make = Effect.gen(function* () { displayName: environment.displayName, execTarget, scheme, + localThemeFile: Option.getOrUndefined(environment.localThemeFile), + disableAutoUpdate: environment.disableAutoUpdate, ...(environment.isPackaged ? { iconPath } : {}), }); // Pre-ready setup normally wrote this already. Avoid truncating a valid diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index b021ae02e826c..2b9edac69c70a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -101,6 +101,8 @@ export const make = Effect.gen(function* () { appVersion: Electron.app.getVersion(), }).displayName, execTarget: process.env.APPIMAGE?.trim() || process.execPath, + localThemeFile: process.env.T3CODE_DESKTOP_THEME_FILE?.trim() || undefined, + disableAutoUpdate: ["true", "1"].includes(process.env.T3CODE_DISABLE_AUTO_UPDATE ?? ""), scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), }), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e86..6c58f258bdf91 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import { getLocalTheme } from "./methods/localTheme.ts"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; @@ -101,6 +102,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(getLocalTheme); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); yield* ipc.handle(getConnectionCatalog); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index a6b617385b578..09249034c1853 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -35,6 +35,8 @@ export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environm export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; +export const GET_LOCAL_THEME_CHANNEL = "desktop:get-local-theme"; +export const LOCAL_THEME_CHANNEL = "desktop:local-theme"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; export const SETUP_SNAP_SHOT_CHANNEL = "desktop:setup-snap-shot"; diff --git a/apps/desktop/src/ipc/methods/localTheme.ts b/apps/desktop/src/ipc/methods/localTheme.ts new file mode 100644 index 0000000000000..c771d792bb78b --- /dev/null +++ b/apps/desktop/src/ipc/methods/localTheme.ts @@ -0,0 +1,15 @@ +import { DesktopLocalThemeStateSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as DesktopLocalTheme from "../../theme/DesktopLocalTheme.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getLocalTheme = makeIpcMethod({ + channel: IpcChannels.GET_LOCAL_THEME_CHANNEL, + payload: Schema.Void, + result: DesktopLocalThemeStateSchema, + handler: Effect.fn("desktop.ipc.localTheme.get")(function* () { + return yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef00c4d66ed89..faa95ed9604d2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -52,6 +52,7 @@ import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; +import * as DesktopLocalTheme from "./theme/DesktopLocalTheme.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopSnapShot from "./snapShot/DesktopSnapShot.ts"; @@ -224,6 +225,7 @@ const layerDesktopApplication = Layer.mergeAll( ).pipe( Layer.provideMerge(layerDesktopSnapShot), Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(DesktopLocalTheme.layer), Layer.provideMerge(layerDesktopWslBackend), Layer.provideMerge(layerDesktopLocalEnvironmentAuth), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index efd49ffa38648..70466ae2e6f29 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -115,6 +115,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), + getLocalTheme: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_THEME_CHANNEL), + onLocalTheme: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, theme: Parameters[0]) => + listener(theme); + ipcRenderer.on(IpcChannels.LOCAL_THEME_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.LOCAL_THEME_CHANNEL, handler); + }, getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/desktop/src/theme/DesktopLocalTheme.test.ts b/apps/desktop/src/theme/DesktopLocalTheme.test.ts new file mode 100644 index 0000000000000..ea8a9ce466392 --- /dev/null +++ b/apps/desktop/src/theme/DesktopLocalTheme.test.ts @@ -0,0 +1,137 @@ +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopLocalTheme from "./DesktopLocalTheme.ts"; + +vi.mock("electron", () => ({})); + +const dark = { name: "Desktop", appearance: "dark", canvas: "#123456", accent: "#abcdef" } as const; +const light = { ...dark, appearance: "light", canvas: "#f0e8d0" } as const; + +const withDirectory = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "desktop-theme-test-" }); +}); + +function layer(dir: string, file: string | undefined, messages: unknown[]) { + const environment = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: dir, + platform: "linux", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ T3CODE_HOME: dir, T3CODE_DESKTOP_THEME_FILE: file }), + ), + ), + ); + return DesktopLocalTheme.layer.pipe( + Layer.provide( + Layer.mergeAll( + environment, + NodeServices.layer, + Layer.mock(ElectronWindow.ElectronWindow)({ + sendAll: (_channel, theme) => + Effect.sync(() => { + messages.push(theme); + }), + }), + ), + ), + ); +} + +const write = Effect.fnUntraced(function* (file: string, value: unknown) { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(`${file}.tmp`, JSON.stringify(value)); + yield* fs.rename(`${file}.tmp`, file); +}); + +describe("desktop-local palette source", () => { + it.effect( + "loads with no backend and follows atomic replacements while rejecting invalid palettes", + () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const file = (yield* Path.Path).join(dir, "theme.json"); + yield* write(file, dark); + const messages: unknown[] = []; + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual(yield* themes.current, { enabled: true, theme: dark }); + yield* write(file, light); + assert.deepEqual(yield* themes.current, { enabled: true, theme: light }); + yield* write(file, { ...light, canvas: "not a color" }); + assert.deepEqual(yield* themes.current, { enabled: true, theme: light }); + yield* write(file, { name: "Empty", appearance: "dark" }); + assert.deepEqual(yield* themes.current, { enabled: true, theme: light }); + yield* (yield* FileSystem.FileSystem).writeFileString(file, "x".repeat(40_000)); + assert.deepEqual(yield* themes.current, { enabled: true, theme: light }); + assert.deepEqual(messages, [ + { enabled: true, theme: dark }, + { enabled: true, theme: light }, + ]); + }).pipe(Effect.provide(layer(dir, file, messages))); + yield* write(file, dark); + yield* Effect.gen(function* () { + assert.deepEqual(yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current, { + enabled: true, + theme: dark, + }); + }).pipe(Effect.provide(layer(dir, file, []))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("does nothing unless explicitly configured", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const messages: unknown[] = []; + yield* Effect.gen(function* () { + assert.deepEqual(yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current, { + enabled: false, + theme: null, + }); + assert.deepEqual(messages, []); + }).pipe(Effect.provide(layer(dir, undefined, messages))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("recovers when the palette directory is created after startup", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const subdir = (yield* Path.Path).join(dir, "later"); + const file = (yield* Path.Path).join(subdir, "theme.json"); + const messages: unknown[] = []; + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual(yield* themes.current, { enabled: true, theme: null }); + yield* (yield* FileSystem.FileSystem).makeDirectory(subdir); + yield* write(file, dark); + yield* TestClock.adjust("31 seconds"); + assert.deepEqual(messages, [{ enabled: true, theme: dark }]); + assert.deepEqual(yield* themes.current, { enabled: true, theme: dark }); + }).pipe(Effect.provide(layer(dir, file, messages))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); +}); diff --git a/apps/desktop/src/theme/DesktopLocalTheme.ts b/apps/desktop/src/theme/DesktopLocalTheme.ts new file mode 100644 index 0000000000000..efbd34f017682 --- /dev/null +++ b/apps/desktop/src/theme/DesktopLocalTheme.ts @@ -0,0 +1,75 @@ +import { + EnvironmentThemeFile, + type DesktopLocalThemeState, + environmentThemeFileHasColors, +} from "@t3tools/contracts"; +import { MAX_THEME_FILE_BYTES, readThemeFileGuarded } from "@t3tools/shared/themeFile"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; + +export class DesktopLocalTheme extends Context.Service< + DesktopLocalTheme, + { + readonly current: Effect.Effect; + } +>()("@t3tools/desktop/theme/DesktopLocalTheme") {} + +const decode = Schema.decodeUnknownExit(Schema.fromJsonString(EnvironmentThemeFile)); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const windows = yield* ElectronWindow.ElectronWindow; + const fs = yield* FileSystem.FileSystem; + const theme = yield* Ref.make(null); + if (Option.isNone(environment.localThemeFile)) { + return DesktopLocalTheme.of({ current: Effect.succeed({ enabled: false, theme: null }) }); + } + const file = environment.localThemeFile.value; + const semaphore = yield* Semaphore.make(1); + const refresh = semaphore.withPermits(1)( + Effect.gen(function* () { + const raw = readThemeFileGuarded(file, MAX_THEME_FILE_BYTES); + if (raw === null) return; + const decoded = decode(raw); + if (decoded._tag === "Failure" || !environmentThemeFileHasColors(decoded.value)) return; + const next = decoded.value; + if (Equal.equals(yield* Ref.get(theme), next)) return; + yield* Ref.set(theme, next); + yield* windows + .sendAll(IpcChannels.LOCAL_THEME_CHANNEL, { enabled: true, theme: next }) + .pipe(Effect.ignoreCause({ log: true })); + }), + ); + yield* refresh; + // Watch the directory because publishers replace the file atomically. The + // periodic read also recovers a missing directory, failed watch, or resume. + const events = fs.watch(environment.path.dirname(file)).pipe( + Stream.debounce("100 millis"), + Stream.map(() => undefined), + Stream.catchCause(() => Stream.empty), + ); + yield* Stream.merge(events, Stream.tick("30 seconds")).pipe( + Stream.runForEach(() => refresh), + Effect.forkScoped, + ); + return DesktopLocalTheme.of({ + current: refresh.pipe( + Effect.andThen(Ref.get(theme)), + Effect.map((theme) => ({ enabled: true, theme })), + ), + }); +}); + +export const layer = Layer.effect(DesktopLocalTheme, make); diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index b7eb11940cea8..bf3bf031ea2b4 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -14,7 +14,8 @@ * * @module EnvironmentTheme */ -import * as NodeFS from "node:fs"; +import { MAX_THEME_FILE_BYTES, readThemeFileGuarded } from "@t3tools/shared/themeFile"; +export { MAX_THEME_FILE_BYTES, readThemeFileGuarded } from "@t3tools/shared/themeFile"; import { EnvironmentTheme, @@ -54,8 +55,7 @@ const THEME_FILE_SUFFIX = ".json"; * cost of a mistake is capped rather than unbounded. */ const MAX_THEME_FILES = 32; -/** Exported so the publish path cannot accept a file the watcher will skip. */ -export const MAX_THEME_FILE_BYTES = 32 * 1024; + /** * The set travels whole in a websocket event to every subscriber, so the sum * matters more than any single file. An exported theme runs a few KB, leaving @@ -88,52 +88,6 @@ export class EnvironmentThemeService extends Context.Service< } >()("t3/environmentTheme/EnvironmentThemeService") {} -/** - * Reads a theme file through one opened handle, so every check binds to the - * file actually read rather than to a path that may have been swapped since: - * O_NOFOLLOW rejects a symlink outright (a symlinked themes directory stays - * usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from - * blocking the open, and the fstat type and size gate examines the open - * descriptor. Returns null for anything that is not a small regular file. - * - * Windows has neither flag (the constants are undefined, and OR-ing them in - * is a no-op), so the symlink check there is an lstat before the open. That - * leaves a window a swap could slip through, which the descriptor-bound - * checks below then narrow to "a regular file at that path". - */ -export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { - let fd: number; - try { - if (NodeFS.constants.O_NOFOLLOW === undefined && NodeFS.lstatSync(filePath).isSymbolicLink()) { - return null; - } - fd = NodeFS.openSync( - filePath, - NodeFS.constants.O_RDONLY | - (NodeFS.constants.O_NOFOLLOW ?? 0) | - (NodeFS.constants.O_NONBLOCK ?? 0), - ); - } catch { - return null; - } - try { - const info = NodeFS.fstatSync(fd); - if (!info.isFile() || info.size > maxBytes) return null; - const contents = Buffer.alloc(info.size); - let offset = 0; - while (offset < contents.length) { - const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset); - if (read <= 0) break; - offset += read; - } - return contents.subarray(0, offset).toString("utf8"); - } catch { - return null; - } finally { - NodeFS.closeSync(fd); - } -}; - /** * Every theme the directory actually publishes. A file that is missing, * unreadable, malformed, colorless, or misnamed is simply skipped; the rest of diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts index 01c908f736983..dad2b98e4e704 100644 --- a/apps/web/src/hooks/useDefaultTheme.ts +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -1,9 +1,14 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; import { primaryServerSettingsAtom } from "../state/server"; -import { getThemeDefinition, singleAppearanceOf } from "../themePalette"; +import { + getDesktopLocalThemeSource, + subscribeToCustomThemes, + getThemeDefinition, + singleAppearanceOf, +} from "../themePalette"; import { useEnvironmentThemeDefinitions } from "./useEnvironmentTheme"; import { useTheme } from "./useTheme"; @@ -71,6 +76,11 @@ function writeAppliedGeneration(storageKey: string, generation: string): void { * resolve yet — the setting and the palette it names arrive independently. */ export function useDefaultThemeAdoption(): void { + const desktopSource = useSyncExternalStore( + subscribeToCustomThemes, + getDesktopLocalThemeSource, + () => "unavailable", + ); const environmentId = useAtomValue(primaryEnvironmentIdAtom); const settings = useAtomValue(primaryServerSettingsAtom); const { defaultTheme, defaultThemeSetAt } = settings; @@ -80,7 +90,8 @@ export function useDefaultThemeAdoption(): void { const environmentThemes = useEnvironmentThemeDefinitions(); useEffect(() => { - if (typeof window === "undefined" || environmentId === null) return; + if (typeof window === "undefined" || environmentId === null || desktopSource !== "unavailable") + return; const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; const definition = getThemeDefinition(defaultTheme); const generation = defaultThemeToApply({ @@ -105,6 +116,7 @@ export function useDefaultThemeAdoption(): void { if (half !== null && !setAppearanceMode(half)) return; writeAppliedGeneration(storageKey, generation); }, [ + desktopSource, environmentId, defaultTheme, defaultThemeSetAt, diff --git a/apps/web/src/hooks/useDesktopLocalTheme.test.ts b/apps/web/src/hooks/useDesktopLocalTheme.test.ts new file mode 100644 index 0000000000000..3499be26de18f --- /dev/null +++ b/apps/web/src/hooks/useDesktopLocalTheme.test.ts @@ -0,0 +1,222 @@ +import type { EnvironmentThemeFile, DesktopLocalThemeState } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const darkTheme = { + name: "Desktop palette", + appearance: "dark", + canvas: "#123456", + accent: "#abcdef", +} as const satisfies EnvironmentThemeFile; +const lightTheme = { ...darkTheme, appearance: "light", canvas: "#f0e8d0" } as const; + +async function setup() { + const storage = new Map([ + ["t3code:theme", "t3-chat"], + ["t3code:theme-appearance-mode", "light"], + ["t3code:theme-halves:v1", JSON.stringify({ light: "t3-chat", dark: "t3-iris" })], + ["draft", "unfinished message"], + ]); + const originalStorage = new Map(storage); + const styles = new Map(); + const classes = new Set(); + const root = { + dataset: {} as Record, + style: { + setProperty: (k: string, v: string) => styles.set(k, v), + removeProperty: (k: string) => styles.delete(k), + }, + classList: { + add: (k: string) => classes.add(k), + remove: (k: string) => classes.delete(k), + toggle: (k: string, on: boolean) => (on ? classes.add(k) : classes.delete(k)), + }, + }; + let notify: ((theme: DesktopLocalThemeState) => void) | undefined; + let resolveInitial: ((theme: DesktopLocalThemeState) => void) | undefined; + const initial = new Promise((resolve) => { + resolveInitial = resolve; + }); + const unsubscribe = vi.fn(); + vi.stubGlobal("document", { documentElement: root }); + vi.stubGlobal("window", { + localStorage: { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => storage.set(k, v), + removeItem: (k: string) => storage.delete(k), + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + matchMedia: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), + desktopBridge: { + getLocalTheme: () => initial, + onLocalTheme: (listener: typeof notify) => { + notify = listener; + return unsubscribe; + }, + setTheme: vi.fn().mockResolvedValue(undefined), + }, + }); + vi.stubGlobal("requestAnimationFrame", vi.fn()); + const effects: Array<() => void | (() => void)> = []; + vi.doMock("react", () => ({ + useCallback: (fn: T) => fn, + useEffect: (effect: () => void | (() => void)) => effects.push(effect), + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => snapshot(), + })); + const { useDesktopLocalThemeSync } = await import("./useDesktopLocalTheme"); + const { useTheme } = await import("./useTheme"); + const palette = await import("../themePalette"); + useDesktopLocalThemeSync(); + const cleanups = effects.splice(0).map((effect) => effect()); + return { + storage, + originalStorage, + styles, + root, + classes, + palette, + useTheme, + publish: (theme: EnvironmentThemeFile | null) => notify?.({ enabled: true, theme }), + initial: async (theme: EnvironmentThemeFile | null) => { + resolveInitial?.({ enabled: true, theme }); + await initial; + await Promise.resolve(); + }, + cleanup: () => cleanups.forEach((cleanup) => cleanup?.()), + unsubscribe, + flushEffects: () => { + effects.splice(0).forEach((effect) => effect()); + }, + }; +} + +afterEach(() => { + vi.doUnmock("react"); + vi.doUnmock("@effect/atom-react"); + vi.doUnmock("../state/server"); + vi.doUnmock("../state/primaryEnvironment"); + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("desktop-local theme following", () => { + it("retints dark and light without an environment or changes to saved preferences and drafts", async () => { + const app = await setup(); + await app.initial(darkTheme); + const variable = app.palette.getThemeColorVariable("canvas"); + expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); + expect(app.classes.has("dark")).toBe(true); + expect(app.useTheme().resolvedTheme).toBe("dark"); + const darkCanvas = app.styles.get(variable); + app.publish(lightTheme); + expect(app.classes.has("dark")).toBe(false); + expect(app.useTheme().resolvedTheme).toBe("light"); + expect(app.styles.get(variable)).not.toBe(darkCanvas); + expect(app.storage).toEqual(app.originalStorage); + app.publish(null); + expect(app.root.dataset.themeId).toBe("t3-chat"); + expect(app.storage).toEqual(app.originalStorage); + }); + + it("lets a manual selection win until the next desktop palette change", async () => { + const app = await setup(); + await app.initial(darkTheme); + expect(app.useTheme().setTheme("t3-iris")).toBe(true); + expect(app.root.dataset.themeId).toBe("iris"); + app.publish(lightTheme); + expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); + expect(app.classes.has("dark")).toBe(false); + expect(app.storage.get("t3code:theme")).toBe("t3-iris"); + }); + + it("honors theme cards and appearance controls, with rollback when storage fails", async () => { + const app = await setup(); + await app.initial(darkTheme); + expect(app.useTheme().setThemeHalf("light", "iris")).toBe(true); + expect(app.root.dataset.themeId).toBe("iris"); + app.publish(darkTheme); + expect(app.useTheme().setAppearanceMode("light")).toBe(true); + expect(app.classes.has("dark")).toBe(false); + app.publish(darkTheme); + const savedPalette = app.palette.getDesktopLocalTheme(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const storageWrite = vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + expect(app.useTheme().setTheme("iris")).toBe(false); + expect(app.palette.getDesktopLocalTheme()).toBe(savedPalette); + app.useTheme().refreshTheme(); + expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); + storageWrite.mockRestore(); + error.mockRestore(); + }); + + it("does not replay the startup palette over a manual choice", async () => { + const app = await setup(); + expect(app.useTheme().setTheme("iris")).toBe(true); + await app.initial(darkTheme); + expect(app.root.dataset.themeId).toBe("iris"); + app.publish(lightTheme); + expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); + }); + + it("keeps environment defaults from changing saved preferences while loading or following a local file", async () => { + const app = await setup(); + vi.doMock("../state/server", () => ({ primaryServerSettingsAtom: "settings" })); + vi.doMock("../state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: "environment" })); + vi.doMock("@effect/atom-react", () => ({ + useAtomValue: (atom: string) => + atom === "settings" + ? { defaultTheme: "iris", defaultThemeSetAt: "one" } + : "remote-environment", + })); + const { useDefaultThemeAdoption } = await import("./useDefaultTheme"); + useDefaultThemeAdoption(); + app.flushEffects(); + expect(app.storage).toEqual(app.originalStorage); + await app.initial(darkTheme); + useDefaultThemeAdoption(); + app.flushEffects(); + expect(app.storage).toEqual(app.originalStorage); + app.useTheme().setTheme("t3-chat"); + const savedChoice = new Map(app.storage); + useDefaultThemeAdoption(); + app.flushEffects(); + expect(app.storage).toEqual(savedChoice); + expect(app.root.dataset.themeId).toBe("t3-chat"); + }); + + it("keeps a newer live palette when an older startup snapshot arrives late", async () => { + const app = await setup(); + app.publish(lightTheme); + const before = new Map(app.styles); + await app.initial(darkTheme); + expect(app.classes.has("dark")).toBe(false); + expect(app.styles).toEqual(before); + }); + + it("preserves theme editor previews and applies the newest palette on leaving the preview", async () => { + const app = await setup(); + await app.initial(darkTheme); + const colors = { ...app.palette.getDefaultThemeColors("light"), canvas: "#fedcba" }; + app.palette.applyThemeColorPreview(colors, "light"); + app.publish(lightTheme); + expect(app.root.dataset.themeId).toBe(app.palette.THEME_PREVIEW_ID); + expect(app.styles.get(app.palette.getThemeColorVariable("canvas"))).toBe(colors.canvas); + app.useTheme().refreshTheme(); + expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); + expect(app.classes.has("dark")).toBe(false); + }); + + it("restores saved appearance on cleanup and ignores late IPC results", async () => { + const app = await setup(); + app.publish(darkTheme); + app.cleanup(); + await app.initial(lightTheme); + app.publish(darkTheme); + expect(app.unsubscribe).toHaveBeenCalledOnce(); + expect(app.root.dataset.themeId).toBe("t3-chat"); + expect(app.storage).toEqual(app.originalStorage); + }); +}); diff --git a/apps/web/src/hooks/useDesktopLocalTheme.ts b/apps/web/src/hooks/useDesktopLocalTheme.ts new file mode 100644 index 0000000000000..eca4a9bfdd3bc --- /dev/null +++ b/apps/web/src/hooks/useDesktopLocalTheme.ts @@ -0,0 +1,52 @@ +import type { DesktopLocalThemeState } from "@t3tools/contracts"; +import { useEffect } from "react"; +import { + DESKTOP_LOCAL_THEME_ID, + setDesktopLocalTheme, + setDesktopLocalThemeSource, + getManualThemeSelectionRevision, +} from "../themePalette"; +import { environmentThemeDefinition } from "../publishedTheme"; +import { useTheme } from "./useTheme"; + +export function useDesktopLocalThemeSync(): void { + const { refreshTheme } = useTheme(); + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.getLocalTheme || !bridge.onLocalTheme) return; + setDesktopLocalThemeSource("loading"); + const startupSelection = getManualThemeSelectionRevision(); + let active = true; + let eventReceived = false; + const apply = (state: DesktopLocalThemeState) => { + if (!active) return; + setDesktopLocalThemeSource(state.enabled ? "configured" : "unavailable"); + const theme = state.theme; + const definition = + theme === null + ? null + : environmentThemeDefinition({ ...theme, id: DESKTOP_LOCAL_THEME_ID }); + if (setDesktopLocalTheme(definition)) refreshTheme({ preservePreview: true }); + }; + const unsubscribe = bridge.onLocalTheme((theme) => { + eventReceived = true; + apply(theme); + }); + void bridge + .getLocalTheme() + .then((theme) => { + if (!active || eventReceived) return; + if (startupSelection === getManualThemeSelectionRevision()) apply(theme); + else setDesktopLocalThemeSource(theme.enabled ? "configured" : "unavailable"); + }) + .catch(() => { + if (active) setDesktopLocalThemeSource("unavailable"); + }); + return () => { + active = false; + unsubscribe(); + setDesktopLocalThemeSource("unavailable"); + if (setDesktopLocalTheme(null)) refreshTheme({ preservePreview: true }); + }; + }, [refreshTheme]); +} diff --git a/apps/web/src/hooks/useEnvironmentTheme.ts b/apps/web/src/hooks/useEnvironmentTheme.ts index 53feabcf6a4f8..eced27df1ea97 100644 --- a/apps/web/src/hooks/useEnvironmentTheme.ts +++ b/apps/web/src/hooks/useEnvironmentTheme.ts @@ -5,93 +5,15 @@ import { useEffect, useRef, useSyncExternalStore } from "react"; import { primaryServerEnvironmentThemesAtom } from "../state/server"; import { - createVividThemeColors, - getDefaultThemeColors, getEnvironmentThemes, - isReservedThemeId, - lenientThemeColorOverrides, setEnvironmentThemes, subscribeToCustomThemes, - type ThemeAppearance, - type ThemeColors, type ThemeDefinition, } from "../themePalette"; import { useTheme } from "./useTheme"; -function publishedThemeColors( - theme: EnvironmentTheme, - appearance: ThemeAppearance, - colors: Readonly> | undefined, -): ThemeColors { - // Seeds generate the base with the guided theme editor's generator, so a - // desktop theme arrives as a coherent T3 Code palette rather than a foreign - // one — but only for the appearance they describe. A variant builds on that - // appearance's stock defaults: the generator follows the seed canvas's - // luminance, so dark seeds would give a light variant unreadable colors. - const base = - appearance === theme.appearance && theme.canvas !== undefined && theme.accent !== undefined - ? createVividThemeColors(appearance, theme.canvas, theme.accent) - : getDefaultThemeColors(appearance); - return { ...base, ...lenientThemeColorOverrides(colors ?? {}) }; -} - -/** - * A published theme as the theme library renders it. Both published forms are - * accepted: the seeded short form a desktop generates, and the standard - * exported theme file the Download button produces — so any theme someone - * shared can be dropped into the machine's themes directory as-is. - */ -export function environmentThemeDefinition(theme: EnvironmentTheme): ThemeDefinition { - const variants: Partial> = {}; - for (const [variantAppearance, variantColors] of Object.entries(theme.variants ?? {})) { - if (variantAppearance === theme.appearance) continue; - variants[variantAppearance as ThemeAppearance] = publishedThemeColors( - theme, - variantAppearance as ThemeAppearance, - variantColors, - ); - } - - return { - id: theme.id, - label: theme.name, - appearance: theme.appearance, - colors: publishedThemeColors(theme, theme.appearance, theme.colors), - ...(Object.keys(variants).length > 0 ? { variants } : {}), - // Only the pure seeded form is guided-generator output. An exported file, - // or seeds carrying explicit role overrides, must open Duplicate in the - // advanced editor -- the guided one regenerates from canvas and accent and - // would discard whatever the machine hand-tuned. - ...(theme.canvas !== undefined && - theme.accent !== undefined && - theme.colors === undefined && - theme.variants === undefined - ? { managed: true } - : {}), - }; -} - -/** - * Published palettes this client can render. Reserved ids are dropped here rather - * than rendered: a published `t3-iris.json` would show this palette on its - * card while "Use" resolved the built-in, and a published `dark.json` would - * capture everyone whose stored preference is the stock `"dark"`. - */ -export function publishedThemeDefinitions( - themes: ReadonlyArray, -): ReadonlyArray { - return themes - .filter((theme) => { - if (isReservedThemeId(theme.id)) return false; - if (theme.canvas !== undefined && theme.accent !== undefined) return true; - const otherAppearance = theme.appearance === "dark" ? "light" : "dark"; - return [theme.colors, theme.variants?.[otherAppearance]].some( - (colors) => - colors !== undefined && Object.keys(lenientThemeColorOverrides(colors)).length > 0, - ); - }) - .map(environmentThemeDefinition); -} +export { environmentThemeDefinition, publishedThemeDefinitions } from "../publishedTheme"; +import { publishedThemeDefinitions } from "../publishedTheme"; /** The published themes as library entries; empty while none are published. */ export function useEnvironmentThemeDefinitions(): ReadonlyArray { diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 7a750fd7434a8..c2fd3d2c022c0 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -4,6 +4,9 @@ import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; import { applyThemePalette, + getDesktopLocalTheme, + setDesktopLocalTheme, + recordManualThemeSelection, CUSTOM_THEMES_STORAGE_KEY, invalidateCustomThemes, canonicalThemePreference, @@ -64,6 +67,7 @@ export function readThemeHalvesRaw(): { light?: string; dark?: string } { } function readStoredThemeHalves(): ThemeHalves | null { + if (getDesktopLocalTheme() !== null) return null; if (typeof window === "undefined") return null; try { return parseThemeHalves(window.localStorage.getItem(THEME_HALVES_STORAGE_KEY)); @@ -171,6 +175,8 @@ function isThemePreferenceMode(value: string | null): value is ThemePreferenceMo } export function readAppearanceModePreference(theme: Theme): ThemePreferenceMode { + const localTheme = getDesktopLocalTheme(); + if (localTheme !== null) return localTheme.appearance; if (typeof window !== "undefined") { try { const raw = window.localStorage.getItem(THEME_APPEARANCE_MODE_STORAGE_KEY); @@ -233,6 +239,8 @@ export function writeThemePreference(theme: Theme): void { } function getStored(): Theme { + const localTheme = getDesktopLocalTheme(); + if (localTheme !== null) return localTheme.id; if (themeStorageReadFailure !== null) { return DEFAULT_THEME_SNAPSHOT.theme; } @@ -508,12 +516,21 @@ function subscribe(listener: () => void): () => void { }; } +function pauseDesktopLocalTheme(): () => void { + const previous = getDesktopLocalTheme(); + setDesktopLocalTheme(null); + return () => { + setDesktopLocalTheme(previous); + }; +} + export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const { theme, resolvedTheme } = snapshot; const setTheme = useCallback((next: Theme): boolean => { if (typeof window === "undefined") return false; + const restoreDesktopTheme = pauseDesktopLocalTheme(); try { // Preserve the current mode before replacing a legacy or inferred theme // preference. Otherwise a fresh System preference is re-inferred from @@ -537,6 +554,7 @@ export function useTheme() { throw cause; } } catch (cause) { + restoreDesktopTheme(); const error = isThemeStorageError(cause) ? cause : new ThemeStorageError({ @@ -553,6 +571,7 @@ export function useTheme() { }); return false; } + recordManualThemeSelection(); applyTheme(next, { suppressTransitions: true }); emitChange(); return true; @@ -560,9 +579,11 @@ export function useTheme() { const setAppearanceMode = useCallback((nextAppearanceMode: ThemePreferenceMode): boolean => { if (typeof window === "undefined") return false; + const restoreDesktopTheme = pauseDesktopLocalTheme(); try { writeAppearanceModePreference(nextAppearanceMode); } catch (cause) { + restoreDesktopTheme(); const error = isThemeStorageError(cause) ? cause : new ThemeStorageError({ @@ -578,6 +599,7 @@ export function useTheme() { return false; } themeStorageReadFailure = null; + recordManualThemeSelection(); applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; @@ -599,6 +621,7 @@ export function useTheme() { const setThemeHalf = useCallback( (appearance: ThemeAppearance, themeId: string | null): boolean => { if (typeof window === "undefined") return false; + const restoreDesktopTheme = pauseDesktopLocalTheme(); try { const current = readStoredThemeHalvesRaw(); const next: { light?: string; dark?: string } = { ...current }; @@ -610,6 +633,7 @@ export function useTheme() { window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(next)); } } catch (cause) { + restoreDesktopTheme(); const error = new ThemeStorageError({ operation: "write", storageKey: THEME_HALVES_STORAGE_KEY, @@ -622,6 +646,7 @@ export function useTheme() { }); return false; } + recordManualThemeSelection(); applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; @@ -631,9 +656,11 @@ export function useTheme() { const clearThemeHalves = useCallback((): boolean => { if (typeof window === "undefined") return false; + const restoreDesktopTheme = pauseDesktopLocalTheme(); try { window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); } catch (cause) { + restoreDesktopTheme(); const error = new ThemeStorageError({ operation: "write", storageKey: THEME_HALVES_STORAGE_KEY, @@ -646,6 +673,7 @@ export function useTheme() { }); return false; } + recordManualThemeSelection(); applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; diff --git a/apps/web/src/publishedTheme.ts b/apps/web/src/publishedTheme.ts new file mode 100644 index 0000000000000..2cad53ec0aae4 --- /dev/null +++ b/apps/web/src/publishedTheme.ts @@ -0,0 +1,85 @@ +import type { EnvironmentTheme } from "@t3tools/contracts"; +import { + createVividThemeColors, + getDefaultThemeColors, + isReservedThemeId, + lenientThemeColorOverrides, + type ThemeAppearance, + type ThemeColors, + type ThemeDefinition, +} from "./themePalette"; + +function publishedThemeColors( + theme: EnvironmentTheme, + appearance: ThemeAppearance, + colors: Readonly> | undefined, +): ThemeColors { + // Seeds generate the base with the guided theme editor's generator, so a + // desktop theme arrives as a coherent T3 Code palette rather than a foreign + // one — but only for the appearance they describe. A variant builds on that + // appearance's stock defaults: the generator follows the seed canvas's + // luminance, so dark seeds would give a light variant unreadable colors. + const base = + appearance === theme.appearance && theme.canvas !== undefined && theme.accent !== undefined + ? createVividThemeColors(appearance, theme.canvas, theme.accent) + : getDefaultThemeColors(appearance); + return { ...base, ...lenientThemeColorOverrides(colors ?? {}) }; +} + +/** + * A published theme as the theme library renders it. Both published forms are + * accepted: the seeded short form a desktop generates, and the standard + * exported theme file the Download button produces — so any theme someone + * shared can be dropped into the machine's themes directory as-is. + */ +export function environmentThemeDefinition(theme: EnvironmentTheme): ThemeDefinition { + const variants: Partial> = {}; + for (const [variantAppearance, variantColors] of Object.entries(theme.variants ?? {})) { + if (variantAppearance === theme.appearance) continue; + variants[variantAppearance as ThemeAppearance] = publishedThemeColors( + theme, + variantAppearance as ThemeAppearance, + variantColors, + ); + } + + return { + id: theme.id, + label: theme.name, + appearance: theme.appearance, + colors: publishedThemeColors(theme, theme.appearance, theme.colors), + ...(Object.keys(variants).length > 0 ? { variants } : {}), + // Only the pure seeded form is guided-generator output. An exported file, + // or seeds carrying explicit role overrides, must open Duplicate in the + // advanced editor -- the guided one regenerates from canvas and accent and + // would discard whatever the machine hand-tuned. + ...(theme.canvas !== undefined && + theme.accent !== undefined && + theme.colors === undefined && + theme.variants === undefined + ? { managed: true } + : {}), + }; +} + +/** + * Published palettes this client can render. Reserved ids are dropped here rather + * than rendered: a published `t3-iris.json` would show this palette on its + * card while "Use" resolved the built-in, and a published `dark.json` would + * capture everyone whose stored preference is the stock `"dark"`. + */ +export function publishedThemeDefinitions( + themes: ReadonlyArray, +): ReadonlyArray { + return themes + .filter((theme) => { + if (isReservedThemeId(theme.id)) return false; + if (theme.canvas !== undefined && theme.accent !== undefined) return true; + const otherAppearance = theme.appearance === "dark" ? "light" : "dark"; + return [theme.colors, theme.variants?.[otherAppearance]].some( + (colors) => + colors !== undefined && Object.keys(lenientThemeColorOverrides(colors)).length > 0, + ); + }) + .map(environmentThemeDefinition); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 91a0a15800785..c6e0387e7b7a0 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -42,6 +42,7 @@ import { ProviderAuthCallbackCoordinator } from "../components/settings/Provider import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; +import { useDesktopLocalThemeSync } from "../hooks/useDesktopLocalTheme"; import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme"; import { Button } from "../components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -280,6 +281,7 @@ function RootRouteView() { /** Follows the palette the primary environment's machine publishes, if any. */ function EnvironmentThemeSync() { useEnvironmentThemeSync(); + useDesktopLocalThemeSync(); // Ordered after the palette sync so a first-run client adopting the // environment's own theme finds it already in the library. useDefaultThemeAdoption(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index da9af454c5a25..b3e1d4a345308 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -247,6 +247,42 @@ export function getCustomThemes(): ReadonlyArray { return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES; } +export const DESKTOP_LOCAL_THEME_ID = "desktop-local-theme"; +let desktopLocalTheme: ThemeDefinition | null = null; + +type DesktopLocalThemeSource = "unavailable" | "loading" | "configured"; +let desktopLocalThemeSource: DesktopLocalThemeSource = "unavailable"; +let manualThemeSelectionRevision = 0; + +export function getDesktopLocalThemeSource(): DesktopLocalThemeSource { + return desktopLocalThemeSource; +} + +export function setDesktopLocalThemeSource(source: DesktopLocalThemeSource): void { + if (source === desktopLocalThemeSource) return; + desktopLocalThemeSource = source; + notifyCustomThemeListeners(); +} + +export function getManualThemeSelectionRevision(): number { + return manualThemeSelectionRevision; +} + +export function recordManualThemeSelection(): void { + manualThemeSelectionRevision += 1; +} + +export function getDesktopLocalTheme(): ThemeDefinition | null { + return desktopLocalTheme; +} + +export function setDesktopLocalTheme(theme: ThemeDefinition | null): boolean { + if (Equal.equals(desktopLocalTheme, theme)) return false; + desktopLocalTheme = theme; + notifyCustomThemeListeners(); + return true; +} + export function getEnvironmentThemes(): ReadonlyArray { return environmentThemeDefinitions; } @@ -1091,6 +1127,7 @@ const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = BUILT_IN_THEM export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null { const themeId = themeIdFromPreference(theme); + if (desktopLocalTheme?.id === themeId) return desktopLocalTheme; return ( BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ?? getCustomThemes().find((definition) => definition.id === themeId) ?? diff --git a/docs/user/appearance.md b/docs/user/appearance.md index ec02a28ed787a..7bc796f7bcb99 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -93,3 +93,23 @@ view. Write updates to a temporary file and rename it into place so clients never read a partial theme. Invalid files are not published. + +### Follow a desktop-local theme file + +Launch the desktop app with `T3CODE_DESKTOP_THEME_FILE` set to the absolute path +of a palette JSON file to follow a theme generated on your computer. It accepts +the same exported or seeded format described above. For example on Linux: + +```sh +T3CODE_DESKTOP_THEME_FILE="$HOME/.config/t3-theme.json" ./T3-Code.AppImage +``` + +The desktop applies the palette at startup and follows file changes even when +its local environment is disabled or it connects to a remote server. Replace +files atomically. Missing or invalid files leave the last valid palette in +place; files must be regular files no larger than 32 KiB, not symlinks. + +Your saved theme preferences and custom themes remain intact. Choosing another +theme takes precedence until the file's palette changes or the app restarts. +To stop following the file, remove the launch option and restart the desktop. +Web and mobile clients are unaffected. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e521c4c37271c..f618acdc0c92b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,4 +1,11 @@ import * as Schema from "effect/Schema"; +import { EnvironmentThemeFile } from "./server.ts"; + +export const DesktopLocalThemeStateSchema = Schema.Struct({ + enabled: Schema.Boolean, + theme: Schema.NullOr(EnvironmentThemeFile), +}); +export type DesktopLocalThemeState = typeof DesktopLocalThemeStateSchema.Type; import { SnapShotSource } from "./chatAttachment.ts"; import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -1116,6 +1123,8 @@ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { + getLocalTheme?: () => Promise; + onLocalTheme?: (listener: (state: DesktopLocalThemeState) => void) => () => void; getAppBranding: () => DesktopAppBranding | null; /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ getPathForFile?: (file: File) => string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 98c9379527675..e56f158f02b85 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -486,6 +486,10 @@ "./PtyAdapter": { "types": "./src/PtyAdapter.ts", "import": "./src/PtyAdapter.ts" + }, + "./themeFile": { + "types": "./src/themeFile.ts", + "import": "./src/themeFile.ts" } }, "scripts": { diff --git a/packages/shared/src/themeFile.ts b/packages/shared/src/themeFile.ts new file mode 100644 index 0000000000000..866e92b08b052 --- /dev/null +++ b/packages/shared/src/themeFile.ts @@ -0,0 +1,51 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Guarded reads require descriptor flags unavailable in FileSystem. +import * as NodeFS from "node:fs"; + +export const MAX_THEME_FILE_BYTES = 32 * 1024; + +/** + * Reads a theme file through one opened handle, so every check binds to the + * file actually read rather than to a path that may have been swapped since: + * O_NOFOLLOW rejects a symlink outright (a symlinked themes directory stays + * usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from + * blocking the open, and the fstat type and size gate examines the open + * descriptor. Returns null for anything that is not a small regular file. + * + * Windows has neither flag (the constants are undefined, and OR-ing them in + * is a no-op), so the symlink check there is an lstat before the open. That + * leaves a window a swap could slip through, which the descriptor-bound + * checks below then narrow to "a regular file at that path". + */ + +export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { + let fd: number; + try { + if (NodeFS.constants.O_NOFOLLOW === undefined && NodeFS.lstatSync(filePath).isSymbolicLink()) { + return null; + } + fd = NodeFS.openSync( + filePath, + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0), + ); + } catch { + return null; + } + try { + const info = NodeFS.fstatSync(fd); + if (!info.isFile() || info.size > maxBytes) return null; + const contents = Buffer.alloc(info.size); + let offset = 0; + while (offset < contents.length) { + const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset); + if (read <= 0) break; + offset += read; + } + return contents.subarray(0, offset).toString("utf8"); + } catch { + return null; + } finally { + NodeFS.closeSync(fd); + } +}; From 76ce3abd870dd1720ecbbe074d3b6d409850662b Mon Sep 17 00:00:00 2001 From: notcompromised Date: Sat, 10 Oct 2026 13:21:52 -0500 Subject: [PATCH 02/11] fix(desktop): preserve local theme ownership during startup --- apps/server/src/environmentTheme.ts | 2 - apps/web/src/hooks/useDefaultTheme.ts | 7 ++- .../src/hooks/useDesktopLocalTheme.test.ts | 43 ++++++++++++++----- apps/web/src/hooks/useDesktopLocalTheme.ts | 2 +- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index bf3bf031ea2b4..d08d2fea6ae8a 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -1,5 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off - the guarded file read needs open -// flags (O_NOFOLLOW, O_NONBLOCK) the FileSystem service does not expose. /** * EnvironmentTheme - palettes this machine publishes for clients to follow. * diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts index dad2b98e4e704..3d73a2840d10e 100644 --- a/apps/web/src/hooks/useDefaultTheme.ts +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -90,7 +90,12 @@ export function useDefaultThemeAdoption(): void { const environmentThemes = useEnvironmentThemeDefinitions(); useEffect(() => { - if (typeof window === "undefined" || environmentId === null || desktopSource !== "unavailable") + if ( + typeof window === "undefined" || + environmentId === null || + desktopSource !== "unavailable" || + getDesktopLocalThemeSource() !== "unavailable" + ) return; const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; const definition = getThemeDefinition(defaultTheme); diff --git a/apps/web/src/hooks/useDesktopLocalTheme.test.ts b/apps/web/src/hooks/useDesktopLocalTheme.test.ts index 3499be26de18f..07fcde46a8fe6 100644 --- a/apps/web/src/hooks/useDesktopLocalTheme.test.ts +++ b/apps/web/src/hooks/useDesktopLocalTheme.test.ts @@ -9,7 +9,7 @@ const darkTheme = { } as const satisfies EnvironmentThemeFile; const lightTheme = { ...darkTheme, appearance: "light", canvas: "#f0e8d0" } as const; -async function setup() { +async function setup(withDefaultAdoption = false) { const storage = new Map([ ["t3code:theme", "t3-chat"], ["t3code:theme-appearance-mode", "light"], @@ -32,8 +32,10 @@ async function setup() { }, }; let notify: ((theme: DesktopLocalThemeState) => void) | undefined; + let rejectInitial: ((error: Error) => void) | undefined; let resolveInitial: ((theme: DesktopLocalThemeState) => void) | undefined; - const initial = new Promise((resolve) => { + const initial = new Promise((resolve, reject) => { + rejectInitial = reject; resolveInitial = resolve; }); const unsubscribe = vi.fn(); @@ -66,7 +68,21 @@ async function setup() { const { useDesktopLocalThemeSync } = await import("./useDesktopLocalTheme"); const { useTheme } = await import("./useTheme"); const palette = await import("../themePalette"); + if (withDefaultAdoption) { + vi.doMock("../state/server", () => ({ primaryServerSettingsAtom: "settings" })); + vi.doMock("../state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: "environment" })); + vi.doMock("@effect/atom-react", () => ({ + useAtomValue: (atom: string) => + atom === "settings" + ? { defaultTheme: "iris", defaultThemeSetAt: "one" } + : "remote-environment", + })); + } useDesktopLocalThemeSync(); + if (withDefaultAdoption) { + const { useDefaultThemeAdoption } = await import("./useDefaultTheme"); + useDefaultThemeAdoption(); + } const cleanups = effects.splice(0).map((effect) => effect()); return { storage, @@ -82,6 +98,11 @@ async function setup() { await initial; await Promise.resolve(); }, + rejectInitial: async () => { + rejectInitial?.(new Error("IPC startup failed")); + await initial.catch(() => {}); + await Promise.resolve(); + }, cleanup: () => cleanups.forEach((cleanup) => cleanup?.()), unsubscribe, flushEffects: () => { @@ -162,15 +183,7 @@ describe("desktop-local theme following", () => { }); it("keeps environment defaults from changing saved preferences while loading or following a local file", async () => { - const app = await setup(); - vi.doMock("../state/server", () => ({ primaryServerSettingsAtom: "settings" })); - vi.doMock("../state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: "environment" })); - vi.doMock("@effect/atom-react", () => ({ - useAtomValue: (atom: string) => - atom === "settings" - ? { defaultTheme: "iris", defaultThemeSetAt: "one" } - : "remote-environment", - })); + const app = await setup(true); const { useDefaultThemeAdoption } = await import("./useDefaultTheme"); useDefaultThemeAdoption(); app.flushEffects(); @@ -196,6 +209,14 @@ describe("desktop-local theme following", () => { expect(app.styles).toEqual(before); }); + it("keeps a live source configured when startup IPC fails later", async () => { + const app = await setup(); + app.publish(lightTheme); + await app.rejectInitial(); + expect(app.palette.getDesktopLocalThemeSource()).toBe("configured"); + expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); + }); + it("preserves theme editor previews and applies the newest palette on leaving the preview", async () => { const app = await setup(); await app.initial(darkTheme); diff --git a/apps/web/src/hooks/useDesktopLocalTheme.ts b/apps/web/src/hooks/useDesktopLocalTheme.ts index eca4a9bfdd3bc..de3aad19cf585 100644 --- a/apps/web/src/hooks/useDesktopLocalTheme.ts +++ b/apps/web/src/hooks/useDesktopLocalTheme.ts @@ -40,7 +40,7 @@ export function useDesktopLocalThemeSync(): void { else setDesktopLocalThemeSource(theme.enabled ? "configured" : "unavailable"); }) .catch(() => { - if (active) setDesktopLocalThemeSource("unavailable"); + if (active && !eventReceived) setDesktopLocalThemeSource("unavailable"); }); return () => { active = false; From 7831fc79a289a1118100ac7552cd06ccd6714b20 Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:27:53 -0500 Subject: [PATCH 03/11] feat(desktop): match system colors and configure local palettes --- apps/desktop/package.json | 2 + apps/desktop/src/app/DesktopConfig.ts | 1 - apps/desktop/src/app/DesktopEnvironment.ts | 4 - apps/desktop/src/app/DesktopLifecycle.test.ts | 1 + .../src/app/DesktopLinuxUrlHandler.test.ts | 18 - .../desktop/src/app/DesktopLinuxUrlHandler.ts | 18 +- .../src/app/DesktopPreReadyPlatform.ts | 2 - .../src/backend/DesktopServerExposure.test.ts | 1 + .../src/electron/ElectronTheme.test.ts | 83 +++- apps/desktop/src/electron/ElectronTheme.ts | 36 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 +- apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/localTheme.ts | 31 ++ apps/desktop/src/preload.ts | 2 + .../src/settings/DesktopAppSettings.test.ts | 25 ++ .../src/settings/DesktopAppSettings.ts | 20 + .../src/theme/DesktopLocalTheme.test.ts | 331 +++++++++++++--- apps/desktop/src/theme/DesktopLocalTheme.ts | 191 +++++++-- .../desktop/src/updates/updatesTestHarness.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 2 + apps/web/src/components/CommandPalette.tsx | 26 +- .../settings/ThemeSettings.desktop.test.tsx | 180 +++++++++ .../src/components/settings/ThemeSettings.tsx | 140 ++++++- .../src/components/settings/settingsSearch.ts | 4 +- apps/web/src/desktopTheme.test.ts | 42 ++ apps/web/src/desktopTheme.ts | 47 +++ apps/web/src/hooks/useDefaultTheme.ts | 37 +- .../src/hooks/useDesktopLocalTheme.test.ts | 362 +++++++----------- apps/web/src/hooks/useDesktopLocalTheme.ts | 33 +- apps/web/src/hooks/useTheme.ts | 34 +- apps/web/src/routes/__root.tsx | 2 +- apps/web/src/themePalette.ts | 38 +- docs/user/appearance.md | 42 +- packages/contracts/src/ipc.ts | 8 +- packages/shared/src/themePalettes.ts | 1 + pnpm-lock.yaml | 6 + 36 files changed, 1300 insertions(+), 477 deletions(-) create mode 100644 apps/web/src/components/settings/ThemeSettings.desktop.test.tsx create mode 100644 apps/web/src/desktopTheme.test.ts create mode 100644 apps/web/src/desktopTheme.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8e736a7d54105..8cd891a1508a9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,6 +22,7 @@ "@t3tools/shared": "workspace:*", "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", + "culori": "^4.0.2", "dbus-next": "0.10.2", "effect": "catalog:", "electron": "44.4.5", @@ -35,6 +36,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@types/culori": "^4.0.1", "@types/node": "catalog:", "acorn": "8.16.0", "cross-env": "^10.1.0", diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 21dfedd4a47d8..773ea24509cf1 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -38,7 +38,6 @@ export const DesktopConfig = Config.all({ xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), xdgDataHome: trimmedString("XDG_DATA_HOME"), t3Home: trimmedString("T3CODE_HOME"), - localThemeFile: trimmedString("T3CODE_DESKTOP_THEME_FILE"), devServerUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option), appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 87ea6f8749515..6a8bc8e0ea495 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -48,8 +48,6 @@ export class DesktopEnvironment extends Context.Service< readonly stateDir: string; readonly desktopSettingsPath: string; readonly clientSettingsPath: string; - readonly localThemeFile: Option.Option; - readonly disableAutoUpdate: boolean; readonly savedEnvironmentRegistryPath: string; readonly serverSettingsPath: string; readonly logDir: string; @@ -210,8 +208,6 @@ const make = Effect.fn("desktop.environment.make")(function* ( stateDir, desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), clientSettingsPath: path.join(stateDir, "client-settings.json"), - localThemeFile: Option.map(config.localThemeFile, (file) => path.resolve(homeDirectory, file)), - disableAutoUpdate: config.disableAutoUpdate, savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), logDir: path.join(stateDir, "logs"), diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 5caa18a53aabf..2df9e42ce7bc0 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -56,6 +56,7 @@ function layerElectronApp( const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, { shouldUseDarkColors: Effect.succeed(false), + systemTheme: Effect.succeed({ appearance: "light" as const, accent: null }), setSource: () => Effect.void, onUpdated: () => Effect.void, }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 799f94bfe8f66..cbabe14dd6fc6 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -32,8 +32,6 @@ const makeEnvironment = (path: Path.Path, overrides: Record = { linuxDesktopEntryName: "com.t3tools.T3Code.desktop", linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", - localThemeFile: Option.none(), - disableAutoUpdate: false, appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), path, ...overrides, @@ -159,22 +157,6 @@ const emptyRecording = (): RecordedRegistration => ({ copies: [], }); -describe("local theme launch configuration", () => { - it("carries theme following and update hold into cold URL launches", () => { - const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code", - execTarget: "/home/alice/Custom T3.AppImage", - scheme: "t3code", - localThemeFile: "/home/alice/My palette.json", - disableAutoUpdate: true, - }); - assert.include( - entry, - 'Exec=env -u ELECTRON_RUN_AS_NODE "T3CODE_DESKTOP_THEME_FILE=/home/alice/My palette.json" T3CODE_DISABLE_AUTO_UPDATE=true "/home/alice/Custom T3.AppImage" %U', - ); - }); -}); - describe("DesktopLinuxUrlHandler", () => { it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 90785a941a3db..e67cdcefe9fa3 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -88,26 +88,12 @@ export function renderUrlHandlerDesktopEntry(input: { readonly execTarget: string; readonly scheme: string; readonly iconPath?: string; - readonly localThemeFile?: string | undefined; - readonly disableAutoUpdate?: boolean; }): string { return [ "[Desktop Entry]", "Type=Application", `Name=${escapeDesktopEntryString(input.displayName)}`, - `Exec=${[ - ...(input.localThemeFile === undefined - ? [] - : [ - "env", - "-u", - "ELECTRON_RUN_AS_NODE", - escapeDesktopEntryExecArgument(`T3CODE_DESKTOP_THEME_FILE=${input.localThemeFile}`), - ...(input.disableAutoUpdate ? ["T3CODE_DISABLE_AUTO_UPDATE=true"] : []), - ]), - escapeDesktopEntryExecArgument(input.execTarget), - "%U", - ].join(" ")}`, + `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]), "Terminal=false", "NoDisplay=true", @@ -147,8 +133,6 @@ export const make = Effect.gen(function* () { displayName: environment.displayName, execTarget, scheme, - localThemeFile: Option.getOrUndefined(environment.localThemeFile), - disableAutoUpdate: environment.disableAutoUpdate, ...(environment.isPackaged ? { iconPath } : {}), }); // Pre-ready setup normally wrote this already. Avoid truncating a valid diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 2b9edac69c70a..b021ae02e826c 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -101,8 +101,6 @@ export const make = Effect.gen(function* () { appVersion: Electron.app.getVersion(), }).displayName, execTarget: process.env.APPIMAGE?.trim() || process.execPath, - localThemeFile: process.env.T3CODE_DESKTOP_THEME_FILE?.trim() || undefined, - disableAutoUpdate: ["true", "1"].includes(process.env.T3CODE_DISABLE_AUTO_UPDATE ?? ""), scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), }), diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index b4d0a06265547..e7be26ded6195 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -260,6 +260,7 @@ describe("DesktopServerExposure", () => { setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), + setLocalThemeFile: () => Effect.die("unexpected local theme file change"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index b4028930af661..a3281b0c90961 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -1,18 +1,43 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { beforeEach, vi } from "vite-plus/test"; -const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ +const { + onMock, + removeListenerMock, + accentOnMock, + accentRemoveListenerMock, + subscribeMock, + unsubscribeMock, + themeState, +} = vi.hoisted(() => ({ onMock: vi.fn(), removeListenerMock: vi.fn(), + accentOnMock: vi.fn(), + accentRemoveListenerMock: vi.fn(), + subscribeMock: vi.fn(), + unsubscribeMock: vi.fn(), themeState: { shouldUseDarkColors: true, themeSource: "system", setSourceError: null as unknown, + accent: "aabbccff", + accentError: null as unknown, }, })); vi.mock("electron", () => ({ + systemPreferences: { + getAccentColor: () => { + if (themeState.accentError !== null) throw themeState.accentError; + return themeState.accent; + }, + on: accentOnMock, + removeListener: accentRemoveListenerMock, + subscribeNotification: subscribeMock, + unsubscribeNotification: unsubscribeMock, + }, nativeTheme: { get shouldUseDarkColors() { return themeState.shouldUseDarkColors; @@ -34,9 +59,15 @@ describe("ElectronTheme", () => { beforeEach(() => { onMock.mockClear(); removeListenerMock.mockClear(); + accentOnMock.mockClear(); + accentRemoveListenerMock.mockClear(); + subscribeMock.mockReset(); + unsubscribeMock.mockClear(); themeState.shouldUseDarkColors = true; themeState.themeSource = "system"; themeState.setSourceError = null; + themeState.accent = "aabbccff"; + themeState.accentError = null; }); it.effect("scopes native theme update listeners", () => @@ -52,9 +83,59 @@ describe("ElectronTheme", () => { assert.deepEqual(onMock.mock.calls, [["updated", listener]]); assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); + assert.deepEqual(accentOnMock.mock.calls, [["accent-color-changed", listener]]); + assert.deepEqual(accentRemoveListenerMock.mock.calls, [["accent-color-changed", listener]]); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); + + it.effect("reads native appearance and normalizes the system RGBA accent", () => + Effect.gen(function* () { + const electronTheme = yield* ElectronTheme.ElectronTheme; + themeState.accent = "12AbCDef"; + assert.deepEqual(yield* electronTheme.systemTheme, { appearance: "dark", accent: "#12abcd" }); + themeState.shouldUseDarkColors = false; + assert.deepEqual(yield* electronTheme.systemTheme, { + appearance: "light", + accent: "#12abcd", + }); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); + + it.effect("keeps native appearance when the desktop exposes no usable accent", () => + Effect.gen(function* () { + const electronTheme = yield* ElectronTheme.ElectronTheme; + themeState.accentError = new Error("not supported"); + assert.deepEqual(yield* electronTheme.systemTheme, { appearance: "dark", accent: null }); + themeState.accentError = null; + themeState.accent = "not a color"; + assert.deepEqual(yield* electronTheme.systemTheme, { appearance: "dark", accent: null }); + themeState.accent = "ffffff00"; + assert.deepEqual(yield* electronTheme.systemTheme, { appearance: "dark", accent: null }); }).pipe(Effect.provide(ElectronTheme.layer)), ); + it.effect("observes macOS accent notifications and unsubscribes when its scope closes", () => + Effect.gen(function* () { + const listener = vi.fn(); + subscribeMock.mockReturnValueOnce(101).mockReturnValueOnce(102); + yield* Effect.scoped( + Effect.gen(function* () { + yield* (yield* ElectronTheme.ElectronTheme).onUpdated(listener); + assert.deepEqual( + subscribeMock.mock.calls.map(([name]) => name), + ["AppleColorPreferencesChangedNotification", "AppleAquaColorVariantChanged"], + ); + subscribeMock.mock.calls[0]![1](); + assert.strictEqual(listener.mock.calls.length, 1); + }), + ); + assert.deepEqual(unsubscribeMock.mock.calls, [[101], [102]]); + }).pipe( + Effect.provide(ElectronTheme.layer), + Effect.provideService(HostProcess.Platform, "darwin"), + ), + ); + it.effect("preserves the requested source and cause when setting the theme fails", () => Effect.gen(function* () { const cause = new Error("theme source failed"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 42cac98d0f538..59a3909e3c942 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -1,4 +1,5 @@ import { DesktopThemeSchema, type DesktopTheme } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -23,6 +24,7 @@ export class ElectronTheme extends Context.Service< ElectronTheme, { readonly shouldUseDarkColors: Effect.Effect; + readonly systemTheme: Effect.Effect<{ appearance: "light" | "dark"; accent: string | null }>; readonly setSource: (theme: DesktopTheme) => Effect.Effect; readonly onUpdated: (listener: () => void) => Effect.Effect; } @@ -31,6 +33,20 @@ export class ElectronTheme extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), + systemTheme: Effect.sync(() => { + let accent: string | null = null; + try { + const rgba = Electron.systemPreferences.getAccentColor(); + if (/^[a-f\d]{8}$/i.test(rgba) && rgba.slice(6).toLowerCase() !== "00") + accent = `#${rgba.slice(0, 6).toLowerCase()}`; + } catch { + // Desktop sessions without an accent preference retain the palette's accent. + } + return { + appearance: Electron.nativeTheme.shouldUseDarkColors ? "dark" : "light", + accent, + }; + }), setSource: (theme) => Effect.try({ try: () => { @@ -40,16 +56,28 @@ export const make = ElectronTheme.of({ }), onUpdated: (listener) => Effect.acquireRelease( - Effect.suspend(() => { + Effect.gen(function* () { + const platform = yield* HostProcess.Platform; Electron.nativeTheme.on("updated", listener); - return Effect.void; + Electron.systemPreferences.on("accent-color-changed", listener); + const notifications = + platform === "darwin" + ? ["AppleColorPreferencesChangedNotification", "AppleAquaColorVariantChanged"].map( + (notification) => + Electron.systemPreferences.subscribeNotification(notification, listener), + ) + : []; + return notifications; }), - () => + (notifications) => Effect.suspend(() => { Electron.nativeTheme.removeListener("updated", listener); + Electron.systemPreferences.removeListener("accent-color-changed", listener); + for (const notification of notifications) + Electron.systemPreferences.unsubscribeNotification(notification); return Effect.void; }), - ), + ).pipe(Effect.asVoid), }); export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6c58f258bdf91..0d86b6f5297a3 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,5 +1,5 @@ import * as Effect from "effect/Effect"; -import { getLocalTheme } from "./methods/localTheme.ts"; +import { getLocalTheme, pickLocalThemeFile, setLocalThemeFile } from "./methods/localTheme.ts"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; @@ -103,6 +103,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getLocalTheme); + yield* ipc.handle(setLocalThemeFile); + yield* ipc.handle(pickLocalThemeFile); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); yield* ipc.handle(getConnectionCatalog); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 09249034c1853..1b8986b466dce 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -36,6 +36,8 @@ export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environm export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_LOCAL_THEME_CHANNEL = "desktop:get-local-theme"; +export const SET_LOCAL_THEME_FILE_CHANNEL = "desktop:set-local-theme-file"; +export const PICK_LOCAL_THEME_FILE_CHANNEL = "desktop:pick-local-theme-file"; export const LOCAL_THEME_CHANNEL = "desktop:local-theme"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; diff --git a/apps/desktop/src/ipc/methods/localTheme.ts b/apps/desktop/src/ipc/methods/localTheme.ts index c771d792bb78b..4c0f8e458cfd2 100644 --- a/apps/desktop/src/ipc/methods/localTheme.ts +++ b/apps/desktop/src/ipc/methods/localTheme.ts @@ -1,6 +1,9 @@ import { DesktopLocalThemeStateSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopLocalTheme from "../../theme/DesktopLocalTheme.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; @@ -13,3 +16,31 @@ export const getLocalTheme = makeIpcMethod({ return yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current; }), }); + +export const setLocalThemeFile = makeIpcMethod({ + channel: IpcChannels.SET_LOCAL_THEME_FILE_CHANNEL, + payload: Schema.NullOr(Schema.String), + result: DesktopLocalThemeStateSchema, + handler: Effect.fn("desktop.ipc.localTheme.setFile")(function* (path) { + return yield* (yield* DesktopLocalTheme.DesktopLocalTheme).setFile(path); + }), +}); + +export const pickLocalThemeFile = makeIpcMethod({ + channel: IpcChannels.PICK_LOCAL_THEME_FILE_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.localTheme.pickFile")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const windows = yield* ElectronWindow.ElectronWindow; + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + const state = yield* themes.current; + const paths = yield* dialog.pickFiles({ + owner: yield* windows.currentMainOrFirst, + defaultPath: Option.fromNullishOr(state.filePath), + filters: [{ name: "T3 Code theme", extensions: ["json"] }], + multiple: false, + }); + return paths[0] ?? null; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 70466ae2e6f29..2c040bd58fd69 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), getLocalTheme: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_THEME_CHANNEL), + setLocalThemeFile: (path) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_THEME_FILE_CHANNEL, path), + pickLocalThemeFile: () => ipcRenderer.invoke(IpcChannels.PICK_LOCAL_THEME_FILE_CHANNEL), onLocalTheme: (listener) => { const handler = (_event: Electron.IpcRendererEvent, theme: Parameters[0]) => listener(theme); diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index a98aa96079e67..cced1ccbc8174 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + localThemeFile: Schema.optionalKey(Schema.NullOr(Schema.String)), linuxPasswordStore: Schema.optionalKey( Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), ), @@ -91,6 +92,23 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect("remembers a local theme file across launches and can return to system colors", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + const before = yield* settings.get; + const themePath = "/home/alice/Themes/My palette.json"; + + assert.isTrue((yield* settings.setLocalThemeFile(themePath)).changed); + assert.deepEqual(yield* settings.load, { ...before, localThemeFile: themePath }); + assert.isFalse((yield* settings.setLocalThemeFile(themePath)).changed); + + assert.isTrue((yield* settings.setLocalThemeFile(null)).changed); + assert.deepEqual(yield* settings.load, before); + }), + ), + ); it.effect( "persists disabling and re-enabling local execution without clearing backend settings", () => @@ -125,6 +143,7 @@ describe("DesktopSettings", () => { { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -155,6 +174,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "gnome-libsecret", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -263,6 +283,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -320,6 +341,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -397,6 +419,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -426,6 +449,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -454,6 +478,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", localEnvironmentEnabled: true, + localThemeFile: null, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index efd9fbfbfd3bd..87f7819fd62d2 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -27,6 +27,7 @@ import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { readonly localEnvironmentEnabled: boolean; + readonly localThemeFile: string | null; readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; @@ -76,6 +77,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { localEnvironmentEnabled: true, + localThemeFile: null, linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -98,6 +100,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ const DesktopSettingsDocument = Schema.Struct({ localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), + localThemeFile: Schema.optionalKey(Schema.NullOr(Schema.String)), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -160,6 +163,9 @@ export class DesktopAppSettings extends Context.Service< readonly setLocalEnvironmentEnabled: ( enabled: boolean, ) => Effect.Effect; + readonly setLocalThemeFile: ( + path: string | null, + ) => Effect.Effect; readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, isMaximized: boolean, @@ -233,6 +239,7 @@ function normalizeDesktopSettingsDocument( return { localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false, + localThemeFile: parsed.localThemeFile ?? null, linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -260,6 +267,10 @@ function toDesktopSettingsDocument( document.localEnvironmentEnabled = settings.localEnvironmentEnabled; } + if (settings.localThemeFile !== defaults.localThemeFile) { + document.localThemeFile = settings.localThemeFile; + } + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -389,6 +400,10 @@ function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean) : { ...settings, localEnvironmentEnabled: enabled }; } +function setLocalThemeFile(settings: DesktopSettings, path: string | null): DesktopSettings { + return settings.localThemeFile === path ? settings : { ...settings, localThemeFile: path }; +} + function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } @@ -580,6 +595,10 @@ export const make = Effect.gen(function* () { persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), ), + setLocalThemeFile: (path) => + persist((settings) => setLocalThemeFile(settings, path)).pipe( + Effect.withSpan("desktop.settings.setLocalThemeFile"), + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -623,6 +642,7 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), setLocalEnvironmentEnabled: (enabled) => update((settings) => setLocalEnvironmentEnabled(settings, enabled)), + setLocalThemeFile: (path) => update((settings) => setLocalThemeFile(settings, path)), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/theme/DesktopLocalTheme.test.ts b/apps/desktop/src/theme/DesktopLocalTheme.test.ts index ea8a9ce466392..3a35c789fd8d3 100644 --- a/apps/desktop/src/theme/DesktopLocalTheme.test.ts +++ b/apps/desktop/src/theme/DesktopLocalTheme.test.ts @@ -2,30 +2,45 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; +import type { DesktopLocalThemeState } from "@t3tools/contracts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopLocalTheme from "./DesktopLocalTheme.ts"; vi.mock("electron", () => ({})); const dark = { name: "Desktop", appearance: "dark", canvas: "#123456", accent: "#abcdef" } as const; const light = { ...dark, appearance: "light", canvas: "#f0e8d0" } as const; +const native = { appearance: "light", accent: "#0055aa" } as const; const withDirectory = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; return yield* fs.makeTempDirectoryScoped({ prefix: "desktop-theme-test-" }); }); -function layer(dir: string, file: string | undefined, messages: unknown[]) { +function layer( + dir: string, + file: string | null, + send: (state: DesktopLocalThemeState) => Effect.Effect = () => Effect.void, + platform: NodeJS.Platform = "linux", + nativeSource: { + read: () => { appearance: "light" | "dark"; accent: string | null }; + listen: (listener: () => void) => void; + } = { read: () => native, listen: () => undefined }, +) { const environment = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: dir, - platform: "linux", + platform, processArch: "x64", appVersion: "1.2.3", appPath: "/repo", @@ -34,10 +49,7 @@ function layer(dir: string, file: string | undefined, messages: unknown[]) { runningUnderArm64Translation: false, }).pipe( Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ T3CODE_HOME: dir, T3CODE_DESKTOP_THEME_FILE: file }), - ), + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: dir })), ), ); return DesktopLocalTheme.layer.pipe( @@ -45,11 +57,16 @@ function layer(dir: string, file: string | undefined, messages: unknown[]) { Layer.mergeAll( environment, NodeServices.layer, + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localThemeFile: file, + }), + Layer.mock(ElectronTheme.ElectronTheme)({ + systemTheme: Effect.sync(nativeSource.read), + onUpdated: (listener) => Effect.sync(() => nativeSource.listen(listener)), + }), Layer.mock(ElectronWindow.ElectronWindow)({ - sendAll: (_channel, theme) => - Effect.sync(() => { - messages.push(theme); - }), + sendAll: (_channel, state) => send(state as DesktopLocalThemeState), }), ), ), @@ -62,75 +79,277 @@ const write = Effect.fnUntraced(function* (file: string, value: unknown) { yield* fs.rename(`${file}.tmp`, file); }); -describe("desktop-local palette source", () => { - it.effect( - "loads with no backend and follows atomic replacements while rejecting invalid palettes", - () => - Effect.scoped( - Effect.gen(function* () { - const dir = yield* withDirectory; - const file = (yield* Path.Path).join(dir, "theme.json"); - yield* write(file, dark); - const messages: unknown[] = []; - yield* Effect.gen(function* () { - const themes = yield* DesktopLocalTheme.DesktopLocalTheme; - assert.deepEqual(yield* themes.current, { enabled: true, theme: dark }); - yield* write(file, light); - assert.deepEqual(yield* themes.current, { enabled: true, theme: light }); - yield* write(file, { ...light, canvas: "not a color" }); - assert.deepEqual(yield* themes.current, { enabled: true, theme: light }); - yield* write(file, { name: "Empty", appearance: "dark" }); - assert.deepEqual(yield* themes.current, { enabled: true, theme: light }); - yield* (yield* FileSystem.FileSystem).writeFileString(file, "x".repeat(40_000)); - assert.deepEqual(yield* themes.current, { enabled: true, theme: light }); - assert.deepEqual(messages, [ - { enabled: true, theme: dark }, - { enabled: true, theme: light }, - ]); - }).pipe(Effect.provide(layer(dir, file, messages))); - yield* write(file, dark); - yield* Effect.gen(function* () { - assert.deepEqual(yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current, { - enabled: true, - theme: dark, - }); - }).pipe(Effect.provide(layer(dir, file, []))); - }).pipe(Effect.provide(NodeServices.layer)), - ), +function fileState(theme: typeof dark | typeof light, filePath: string): DesktopLocalThemeState { + return { + ...native, + appearance: theme.appearance, + source: "file", + filePath, + status: "ready", + theme, + }; +} + +describe("desktop system palette source", () => { + it.live("follows atomic replacements through notifications without a getter refresh", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const file = (yield* Path.Path).join(dir, "theme.json"); + yield* write(file, dark); + const updated = yield* Deferred.make(); + const send = (state: DesktopLocalThemeState) => + state.theme?.appearance === "light" + ? Deferred.succeed(updated, undefined).pipe(Effect.asVoid) + : Effect.void; + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual(yield* themes.current, fileState(dark, file)); + yield* write(file, light); + yield* Deferred.await(updated); + assert.deepEqual(yield* themes.current, fileState(light, file)); + }).pipe(Effect.provide(layer(dir, file, send))); + }).pipe(Effect.provide(NodeServices.layer)), + ), ); - it.effect("does nothing unless explicitly configured", () => + it.effect("provides native appearance and accent without configuration or servers", () => Effect.scoped( Effect.gen(function* () { const dir = yield* withDirectory; - const messages: unknown[] = []; yield* Effect.gen(function* () { assert.deepEqual(yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current, { - enabled: false, + ...native, + source: "native", + filePath: null, + status: "ready", + theme: null, + }); + }).pipe(Effect.provide(layer(dir, null))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("publishes native appearance and accent changes", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const updated = yield* Deferred.make(); + let current: { appearance: "light" | "dark"; accent: string | null } = native; + let notify: () => void = () => undefined; + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + current = { appearance: "dark", accent: "#fedcba" }; + notify(); + yield* Deferred.await(updated); + assert.deepEqual(yield* themes.current, { + ...current, + source: "native", + filePath: null, + status: "ready", + theme: null, + }); + }).pipe( + Effect.provide( + layer( + dir, + null, + () => Deferred.succeed(updated, undefined).pipe(Effect.asVoid), + "linux", + { + read: () => current, + listen: (listener) => { + notify = listener; + }, + }, + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("chooses a saved file and clears it back to automatic detection", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const file = (yield* Path.Path).join(dir, "theme.json"); + yield* write(file, dark); + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual(yield* themes.setFile(file), fileState(dark, file)); + const changedSource = yield* themes.setFile(`${file}.missing`); + assert.strictEqual(changedSource.source, "file"); + assert.strictEqual(changedSource.status, "missing"); + assert.strictEqual(changedSource.theme, null); + assert.deepEqual(yield* themes.setFile(null), { + ...native, + source: "native", + filePath: null, + status: "ready", theme: null, }); - assert.deepEqual(messages, []); - }).pipe(Effect.provide(layer(dir, undefined, messages))); + }).pipe(Effect.provide(layer(dir, null))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("rejects invalid exported role colors and retains the last valid palette", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const file = (yield* Path.Path).join(dir, "theme.json"); + const exported = { + name: "Exported", + appearance: "dark", + colors: { canvas: "oklch(0.2 0.01 240)", accent: "rebeccapurple" }, + } as const; + yield* write(file, exported); + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual((yield* themes.current).theme, exported); + for (const colors of [{ canvas: "not a color" }, { futureUnknownRole: "#ffffff" }]) { + yield* write(file, { ...exported, colors }); + const state = yield* themes.setFile(file); + assert.strictEqual(state.status, "invalid"); + assert.deepEqual(state.theme, exported); + } + yield* (yield* FileSystem.FileSystem).writeFileString(file, "x".repeat(40_000)); + const state = yield* themes.setFile(file); + assert.strictEqual(state.status, "invalid"); + assert.deepEqual(state.theme, exported); + }).pipe(Effect.provide(layer(dir, file))); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.effect("recovers when the palette directory is created after startup", () => + it.effect("recovers a missing configured directory without reading through the getter", () => Effect.scoped( Effect.gen(function* () { const dir = yield* withDirectory; const subdir = (yield* Path.Path).join(dir, "later"); const file = (yield* Path.Path).join(subdir, "theme.json"); - const messages: unknown[] = []; + const updated = yield* Deferred.make(); yield* Effect.gen(function* () { const themes = yield* DesktopLocalTheme.DesktopLocalTheme; - assert.deepEqual(yield* themes.current, { enabled: true, theme: null }); + assert.strictEqual((yield* themes.current).status, "missing"); yield* (yield* FileSystem.FileSystem).makeDirectory(subdir); yield* write(file, dark); yield* TestClock.adjust("31 seconds"); - assert.deepEqual(messages, [{ enabled: true, theme: dark }]); - assert.deepEqual(yield* themes.current, { enabled: true, theme: dark }); - }).pipe(Effect.provide(layer(dir, file, messages))); + yield* Deferred.await(updated); + assert.deepEqual(yield* themes.current, fileState(dark, file)); + }).pipe( + Effect.provide( + layer(dir, file, (state) => + state.theme === null + ? Effect.void + : Deferred.succeed(updated, undefined).pipe(Effect.asVoid), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.effect("detects an Omarchy file on Linux and ignores it on other platforms", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const fs = yield* FileSystem.FileSystem; + const file = (yield* Path.Path).join(dir, ".local/state/omarchy/current/theme/t3code.json"); + yield* fs.makeDirectory((yield* Path.Path).dirname(file), { recursive: true }); + yield* write(file, dark); + for (const platform of ["linux", "darwin", "win32"] as const) { + yield* Effect.gen(function* () { + const state = yield* (yield* DesktopLocalTheme.DesktopLocalTheme).current; + assert.strictEqual(state.source, platform === "linux" ? "omarchy" : "native"); + assert.deepEqual(state.theme, platform === "linux" ? dark : null); + }).pipe(Effect.provide(layer(dir, null, undefined, platform))); + } + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("keeps the last Omarchy palette while its file is missing or invalid and recovers", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = path.join(dir, ".local/state/omarchy/current/theme/t3code.json"); + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* write(file, dark); + const notifications = yield* Queue.unbounded(); + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + yield* Queue.take(notifications); + yield* fs.remove(file); + const missing = yield* Queue.take(notifications); + assert.deepEqual(missing, { + ...fileState(dark, file), + source: "omarchy", + status: "missing", + }); + assert.deepEqual(yield* themes.current, missing); + yield* write(file, { + name: "Invalid", + appearance: "dark", + colors: { canvas: "not a color" }, + }); + const invalid = yield* Queue.take(notifications); + assert.deepEqual(invalid, { ...missing, status: "invalid" }); + yield* write(file, light); + const recovered = yield* Queue.take(notifications); + assert.deepEqual(recovered, { ...fileState(light, file), source: "omarchy" }); + assert.deepEqual(yield* themes.current, recovered); + }).pipe( + Effect.provide( + layer(dir, null, (state) => Queue.offer(notifications, state).pipe(Effect.asVoid)), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("follows Omarchy's theme directory symlink switches", () => + Effect.scoped( + Effect.gen(function* () { + const dir = yield* withDirectory; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = path.join(dir, ".local/state/omarchy/current"); + const first = path.join(dir, "first"); + const second = path.join(dir, "second"); + yield* fs.makeDirectory(parent, { recursive: true }); + yield* fs.makeDirectory(first); + yield* fs.makeDirectory(second); + yield* write(path.join(first, "t3code.json"), dark); + yield* write(path.join(second, "t3code.json"), light); + yield* fs.symlink(first, path.join(parent, "theme")); + const updated = yield* Deferred.make(); + const recolored = yield* Deferred.make(); + const changed = { ...light, name: "Changed", canvas: "#ffeedd" }; + yield* Effect.gen(function* () { + const themes = yield* DesktopLocalTheme.DesktopLocalTheme; + assert.deepEqual((yield* themes.current).theme, dark); + yield* fs.symlink(second, path.join(parent, "next")); + yield* fs.rename(path.join(parent, "next"), path.join(parent, "theme")); + yield* Deferred.await(updated); + assert.deepEqual((yield* themes.current).theme, light); + yield* write(path.join(second, "t3code.json"), changed); + yield* Deferred.await(recolored); + assert.deepEqual((yield* themes.current).theme, changed); + }).pipe( + Effect.provide( + layer(dir, null, (state) => + state.theme?.name === "Changed" + ? Deferred.succeed(recolored, undefined).pipe(Effect.asVoid) + : state.theme?.appearance === "light" + ? Deferred.succeed(updated, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ); }).pipe(Effect.provide(NodeServices.layer)), ), ); diff --git a/apps/desktop/src/theme/DesktopLocalTheme.ts b/apps/desktop/src/theme/DesktopLocalTheme.ts index efbd34f017682..ba2389e62fa8d 100644 --- a/apps/desktop/src/theme/DesktopLocalTheme.ts +++ b/apps/desktop/src/theme/DesktopLocalTheme.ts @@ -1,74 +1,201 @@ -import { - EnvironmentThemeFile, - type DesktopLocalThemeState, - environmentThemeFileHasColors, -} from "@t3tools/contracts"; +// @effect-diagnostics nodeBuiltinImport:off -- Acquire watches before publishing snapshots, including after symlink switches. +import * as NodeFS from "node:fs"; +import { EnvironmentThemeFile, type DesktopLocalThemeState } from "@t3tools/contracts"; import { MAX_THEME_FILE_BYTES, readThemeFileGuarded } from "@t3tools/shared/themeFile"; +import { THEME_COLOR_ROLES } from "@t3tools/shared/themePalettes"; +import "culori/css"; +import { converter, parse } from "culori/fn"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Semaphore from "effect/Semaphore"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; export class DesktopLocalTheme extends Context.Service< DesktopLocalTheme, { readonly current: Effect.Effect; + readonly setFile: ( + path: string | null, + ) => Effect.Effect; } >()("@t3tools/desktop/theme/DesktopLocalTheme") {} const decode = Schema.decodeUnknownExit(Schema.fromJsonString(EnvironmentThemeFile)); +const toRgb = converter("rgb"); +const knownRoles = new Set(THEME_COLOR_ROLES); + +function usableColor(value: string): boolean { + const parsed = parse(value); + const rgb = parsed && toRgb(parsed); + if (!rgb) return false; + return [rgb.r, rgb.g, rgb.b, rgb.alpha ?? 1].every(Number.isFinite); +} + +function usablePalette(theme: EnvironmentThemeFile): boolean { + if (theme.canvas && theme.accent && usableColor(theme.canvas) && usableColor(theme.accent)) + return true; + return [theme.colors, theme.variants?.[theme.appearance === "dark" ? "light" : "dark"]].some( + (colors) => + Object.entries(colors ?? {}).some( + ([role, color]) => knownRoles.has(role) && usableColor(color), + ), + ); +} export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const native = yield* ElectronTheme.ElectronTheme; const windows = yield* ElectronWindow.ElectronWindow; const fs = yield* FileSystem.FileSystem; - const theme = yield* Ref.make(null); - if (Option.isNone(environment.localThemeFile)) { - return DesktopLocalTheme.of({ current: Effect.succeed({ enabled: false, theme: null }) }); - } - const file = environment.localThemeFile.value; + const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)); + const path = environment.path; + const nativeTheme = yield* native.systemTheme; + const state = yield* Ref.make({ + ...nativeTheme, + source: "native", + filePath: null, + status: "ready", + theme: null, + }); const semaphore = yield* Semaphore.make(1); + const updates = yield* Queue.unbounded(); + const fileUpdates = yield* Queue.unbounded(); + yield* Effect.addFinalizer(() => Queue.shutdown(updates)); + yield* Effect.addFinalizer(() => Queue.shutdown(fileUpdates)); + const omarchyFiles = + environment.platform === "linux" + ? [ + path.join(environment.homeDirectory, ".local/state/omarchy/current/theme/t3code.json"), + path.join(environment.homeDirectory, ".config/omarchy/current/theme/t3code.json"), + ] + : []; + const refresh = semaphore.withPermits(1)( Effect.gen(function* () { - const raw = readThemeFileGuarded(file, MAX_THEME_FILE_BYTES); - if (raw === null) return; - const decoded = decode(raw); - if (decoded._tag === "Failure" || !environmentThemeFileHasColors(decoded.value)) return; - const next = decoded.value; - if (Equal.equals(yield* Ref.get(theme), next)) return; - yield* Ref.set(theme, next); + const nativeTheme = yield* native.systemTheme; + const explicitFile = (yield* settings.get).localThemeFile; + const previous = yield* Ref.get(state); + let file = explicitFile; + if (file === null) { + for (const candidate of omarchyFiles) { + if (yield* exists(candidate)) { + file = candidate; + break; + } + } + if (file === null && previous.source === "omarchy") file = previous.filePath; + } + let next: DesktopLocalThemeState = { + ...nativeTheme, + source: "native", + filePath: null, + status: "ready", + theme: null, + }; + if (file !== null) { + const source = explicitFile === null ? "omarchy" : "file"; + const raw = readThemeFileGuarded(file, MAX_THEME_FILE_BYTES); + const decoded = raw === null ? null : decode(raw); + const theme = + decoded?._tag === "Success" && usablePalette(decoded.value) ? decoded.value : null; + const retained = + previous.source === source && previous.filePath === file ? previous.theme : null; + const currentTheme = theme ?? retained; + next = { + appearance: currentTheme?.appearance ?? nativeTheme.appearance, + accent: nativeTheme.accent, + source, + filePath: file, + status: theme !== null ? "ready" : (yield* exists(file)) ? "invalid" : "missing", + theme: currentTheme, + }; + } + if (Equal.equals(previous, next)) return; + yield* Ref.set(state, next); yield* windows - .sendAll(IpcChannels.LOCAL_THEME_CHANNEL, { enabled: true, theme: next }) + .sendAll(IpcChannels.LOCAL_THEME_CHANNEL, next) .pipe(Effect.ignoreCause({ log: true })); }), ); + + let watchers: NodeFS.FSWatcher[] = []; + const closeWatches = () => { + for (const watcher of watchers) watcher.close(); + watchers = []; + }; + yield* Effect.addFinalizer(() => Effect.sync(closeWatches)); + const watchSemaphore = yield* Semaphore.make(1); + const watchDirectories = watchSemaphore.withPermits(1)( + Effect.gen(function* () { + const file = (yield* settings.get).localThemeFile; + const directories = new Set( + [...omarchyFiles, ...(file === null ? [] : [file])].flatMap((file) => [ + path.dirname(file), + // Omarchy changes the theme directory symlink, not just its contents. + path.dirname(path.dirname(file)), + ]), + ); + yield* Effect.sync(() => { + closeWatches(); + for (const directory of directories) { + try { + const watcher = NodeFS.watch(directory, { persistent: false }, () => { + Queue.offerUnsafe(fileUpdates, undefined); + }); + watcher.on("error", () => watcher.close()); + watchers.push(watcher); + } catch { + // The recovery tick retries directories that do not exist yet. + } + } + }); + }), + ); + + yield* watchDirectories; + yield* native.onUpdated(() => { + Queue.offerUnsafe(updates, undefined); + }); yield* refresh; - // Watch the directory because publishers replace the file atomically. The - // periodic read also recovers a missing directory, failed watch, or resume. - const events = fs.watch(environment.path.dirname(file)).pipe( - Stream.debounce("100 millis"), - Stream.map(() => undefined), - Stream.catchCause(() => Stream.empty), + yield* Stream.merge( + Stream.fromQueue(updates), + Stream.fromQueue(fileUpdates).pipe(Stream.debounce("100 millis")), + ).pipe( + Stream.runForEach(() => watchDirectories.pipe(Effect.andThen(refresh))), + Effect.forkScoped, ); - yield* Stream.merge(events, Stream.tick("30 seconds")).pipe( - Stream.runForEach(() => refresh), + // Recover unavailable directories and attach new watches after they appear. + yield* Stream.tick("30 seconds").pipe( + Stream.runForEach(() => watchDirectories.pipe(Effect.andThen(refresh))), Effect.forkScoped, ); + return DesktopLocalTheme.of({ - current: refresh.pipe( - Effect.andThen(Ref.get(theme)), - Effect.map((theme) => ({ enabled: true, theme })), - ), + current: Ref.get(state), + setFile: (file) => + Effect.gen(function* () { + const resolved = + file === null || file.trim() === "" + ? null + : path.resolve(environment.homeDirectory, file.trim()); + yield* settings.setLocalThemeFile(resolved); + yield* watchDirectories; + yield* refresh; + return yield* Ref.get(state); + }), }); }); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 9d4556af42b47..fd0009d6af619 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -202,6 +202,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), + setLocalThemeFile: () => Effect.die("unexpected local theme file change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c9a49b1d735be..e27210d5915d3 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -196,6 +196,7 @@ const layerElectronMenu = Layer.succeed(ElectronMenu.ElectronMenu, { const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, { shouldUseDarkColors: Effect.succeed(false), + systemTheme: Effect.succeed({ appearance: "light" as const, accent: null }), setSource: () => Effect.void, onUpdated: () => Effect.void, } satisfies ElectronTheme.ElectronTheme["Service"]); @@ -265,6 +266,7 @@ function layerTest(input: { setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), + setLocalThemeFile: () => Effect.die("unexpected local theme file change"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6596e451e3fc6..8d16132ea02e8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -88,6 +88,7 @@ import { useReducer, useRef, useState, + useSyncExternalStore, type KeyboardEvent, type ReactNode, } from "react"; @@ -109,7 +110,13 @@ import { useTheme } from "../hooks/useTheme"; import { useCustomThemes } from "../hooks/useCustomThemes"; import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme"; import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; -import { getThemeDefinition } from "../themePalette"; +import { + DESKTOP_LOCAL_THEME_ID, + getDesktopThemeState, + getDesktopLocalTheme, + subscribeToCustomThemes, + getThemeDefinition, +} from "../themePalette"; import { STANDARD_THEME_CARDS, getThemeCardDefinition, @@ -711,11 +718,21 @@ function OpenCommandPaletteDialog(props: { } = useTheme(); const customThemes = useCustomThemes(); const environmentThemes = useEnvironmentThemeDefinitions(); + const desktopThemeState = useSyncExternalStore( + subscribeToCustomThemes, + getDesktopThemeState, + () => null, + ); const themeCards = useMemo(() => { const seen = new Set(); return [ ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })), - ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes] + ...[ + ...(desktopThemeState === null ? [] : [getDesktopLocalTheme()]), + ...BUILT_IN_THEMES, + ...customThemes, + ...environmentThemes, + ] .filter((definition) => { if (seen.has(definition.id)) return false; seen.add(definition.id); @@ -723,7 +740,7 @@ function OpenCommandPaletteDialog(props: { }) .map(getThemeCardDefinition), ]; - }, [customThemes, environmentThemes]); + }, [customThemes, environmentThemes, desktopThemeState]); const providers = useAtomValue(primaryServerProvidersAtom); const providerEntryByEnvironmentAndInstanceId = useMemo(() => { const map = new Map(); @@ -2134,7 +2151,8 @@ function OpenCommandPaletteDialog(props: { previews.length === 1 && id !== null ? setThemeHalf(previews[0]!.mode, id) : setTheme(id ?? appearanceMode); - if (!saved) notifyThemeSaveFailure(); + if (!saved || (id === DESKTOP_LOCAL_THEME_ID && !setAppearanceMode("system"))) + notifyThemeSaveFailure(); }, })), }, diff --git a/apps/web/src/components/settings/ThemeSettings.desktop.test.tsx b/apps/web/src/components/settings/ThemeSettings.desktop.test.tsx new file mode 100644 index 0000000000000..94b8dd458e7b0 --- /dev/null +++ b/apps/web/src/components/settings/ThemeSettings.desktop.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +import type { DesktopBridge, DesktopLocalThemeState } from "@t3tools/contracts"; +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../../hooks/useEnvironmentTheme", () => ({ + useEnvironmentThemeDefinitions: () => [], +})); + +import { applyDesktopThemeState } from "../../desktopTheme"; +import { useCustomThemes } from "../../hooks/useCustomThemes"; +import { useTheme } from "../../hooks/useTheme"; +import { DESKTOP_LOCAL_THEME_ID, getDesktopThemeState } from "../../themePalette"; +import { ThemeLibrary } from "./ThemeSettings"; + +const native: DesktopLocalThemeState = { + appearance: "dark", + accent: "#6688aa", + source: "native", + filePath: null, + status: "ready", + theme: null, +}; +const file: DesktopLocalThemeState = { + ...native, + source: "file", + filePath: "/themes/current.json", + theme: { + name: "Personal palette", + appearance: "dark", + canvas: "#123456", + accent: "#6688aa", + }, +}; +const picker = vi.fn<() => Promise>(); +const saveSource = vi.fn<(path: string | null) => Promise>(); +let root: Root; +let container: HTMLDivElement; +let controls: ReturnType; + +function Harness() { + const current = useTheme(); + useEffect(() => { + controls = current; + }, [current]); + const customThemes = useCustomThemes(); + return ( + undefined} + /> + ); +} + +function button(label: string) { + const found = [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + expect(found, `Button "${label}" should be available`).toBeDefined(); + return found!; +} + +async function click(label: string) { + await act(async () => button(label).click()); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + window.localStorage.clear(); + picker.mockReset().mockResolvedValue(null); + saveSource.mockReset().mockResolvedValue(native); + window.desktopBridge = { + pickLocalThemeFile: picker, + setLocalThemeFile: saveSource, + setTheme: vi.fn().mockResolvedValue(undefined), + } as unknown as DesktopBridge; + applyDesktopThemeState(native); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render()); + await act(async () => { + controls.setTheme("grove"); + controls.setAppearanceMode("light"); + controls.refreshTheme(); + }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + delete window.desktopBridge; + vi.unstubAllGlobals(); +}); + +describe("desktop theme settings", () => { + it("selects system colors and automatic appearance explicitly", async () => { + expect(container.textContent).toContain("Native system colors"); + expect(controls.theme).toBe("grove"); + + await click("System theme"); + + expect(controls.theme).toBe(DESKTOP_LOCAL_THEME_ID); + expect(controls.appearanceMode).toBe("system"); + expect(controls.resolvedTheme).toBe("dark"); + expect(document.documentElement.dataset.themeId).toBe(DESKTOP_LOCAL_THEME_ID); + }); + + it("leaves the source and manual theme untouched after picker cancellation", async () => { + await click("Choose theme file"); + + expect(picker).toHaveBeenCalledOnce(); + expect(saveSource).not.toHaveBeenCalled(); + expect(getDesktopThemeState()).toEqual(native); + expect(controls.theme).toBe("grove"); + expect(button("Choose theme file").disabled).toBe(false); + }); + + it("updates the file source without selecting it over a manual theme", async () => { + picker.mockResolvedValue(file.filePath); + saveSource.mockResolvedValue(file); + + await click("Choose theme file"); + + expect(saveSource).toHaveBeenCalledWith(file.filePath); + expect(container.textContent).toContain(file.filePath); + expect(container.textContent).toContain("Custom theme file"); + expect(controls.theme).toBe("grove"); + expect(controls.appearanceMode).toBe("light"); + }); + + it("shows a source failure and keeps the current palette", async () => { + picker.mockResolvedValue(file.filePath); + saveSource.mockRejectedValue(new Error("Cannot save settings")); + + await click("Choose theme file"); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Could not change the theme source", + ); + expect(getDesktopThemeState()).toEqual(native); + expect(controls.theme).toBe("grove"); + expect(button("Choose theme file").disabled).toBe(false); + }); + + it("disables source changes while saving and leaves the manual theme active", async () => { + let resolveSave!: (state: DesktopLocalThemeState) => void; + picker.mockResolvedValue(file.filePath); + saveSource.mockImplementation( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + + await click("Choose theme file"); + + expect(button("Saving source…").disabled).toBe(true); + expect(controls.theme).toBe("grove"); + await act(async () => resolveSave(file)); + expect(button("Change theme file").disabled).toBe(false); + expect(controls.theme).toBe("grove"); + }); + + it("returns to automatic colors while preserving the manual theme", async () => { + await act(async () => applyDesktopThemeState(file)); + + await click("Use automatic colors"); + + expect(saveSource).toHaveBeenCalledWith(null); + expect(container.textContent).toContain("Native system colors"); + expect(container.textContent).not.toContain(file.filePath); + expect(controls.theme).toBe("grove"); + expect(controls.appearanceMode).toBe("light"); + }); +}); diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 8934b1319b384..42945377f48fb 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -9,12 +9,17 @@ import { Trash2Icon, UploadIcon, } from "lucide-react"; -import { useCallback, useEffect, useState, type ReactElement } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore, type ReactElement } from "react"; +import { applyDesktopThemeState } from "../../desktopTheme"; import { useEnvironmentThemeDefinitions } from "../../hooks/useEnvironmentTheme"; import { readThemeHalvesRaw } from "../../hooks/useTheme"; import { cn } from "../../lib/utils"; import { getThemeDefinition, + getDesktopThemeState, + getDesktopLocalTheme, + DESKTOP_LOCAL_THEME_ID, + subscribeToCustomThemes, singleAppearanceOf, getThemeModes, removeCustomThemes, @@ -101,6 +106,7 @@ function ThemeLibraryCard({ theme, isActive, onUse, + useDescription, onUseMode, activeModes, onEdit, @@ -112,6 +118,7 @@ function ThemeLibraryCard({ theme: ThemeCardDefinition; isActive: boolean; onUse: () => void; + useDescription?: string; onUseMode: (mode: ThemeMode) => void; activeModes: ReadonlyArray; onEdit?: () => void; @@ -411,11 +418,12 @@ function ThemeLibraryCard({ } /> - {variantNavigation - ? "Use the first variants for light and dark" - : cardModes.length > 1 - ? "Use for both light and dark" - : `Use for ${cardModes[0]} mode only`} + {useDescription ?? + (variantNavigation + ? "Use the first variants for light and dark" + : cardModes.length > 1 + ? "Use for both light and dark" + : `Use for ${cardModes[0]} mode only`)} ); @@ -521,7 +529,7 @@ export function ThemeLibrary({ setAppearanceMode: (mode: ThemeMode) => boolean; customThemes: ReadonlyArray; initialAppearance: ThemeAppearance; - refreshTheme: () => void; + refreshTheme: (options?: { preservePreview?: boolean }) => void; isImportOpen: boolean; onImportOpenChange: (open: boolean) => void; themeHalves: ThemeHalves | null; @@ -529,6 +537,14 @@ export function ThemeLibrary({ }) { const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); const environmentThemes = useEnvironmentThemeDefinitions(); + const desktopThemeState = useSyncExternalStore( + subscribeToCustomThemes, + getDesktopThemeState, + () => null, + ); + const desktopTheme = desktopThemeState === null ? null : getDesktopLocalTheme(); + const [isChangingDesktopSource, setIsChangingDesktopSource] = useState(false); + const [desktopSourceError, setDesktopSourceError] = useState(null); const [themeRemovalTarget, setThemeRemovalTarget] = useState<{ theme: ThemeDefinition; collectionThemes: ReadonlyArray; @@ -686,6 +702,30 @@ export function ThemeLibrary({ if (!setAppearanceMode(mode)) notifyThemeSaveFailure(); }; + const useDesktopTheme = () => { + if (!persistTheme(DESKTOP_LOCAL_THEME_ID)) return; + if (!setAppearanceMode("system")) notifyThemeSaveFailure(); + }; + + const changeDesktopSource = async (pickFile: boolean) => { + const bridge = window.desktopBridge; + const picker = bridge?.pickLocalThemeFile; + if (!bridge?.setLocalThemeFile || (pickFile && !picker)) return; + setIsChangingDesktopSource(true); + setDesktopSourceError(null); + try { + const filePath = pickFile && picker ? await picker() : null; + if (pickFile && filePath === null) return; + const next = await bridge.setLocalThemeFile(filePath); + applyDesktopThemeState(next); + refreshTheme({ preservePreview: true }); + } catch { + setDesktopSourceError("Could not change the theme source. Try again."); + } finally { + setIsChangingDesktopSource(false); + } + }; + // ----- Wireframe tiles on top, two-ball cards below -------------------- const handlePairPick = (cardId: string | null) => (mode: ThemeMode) => { if (mode === "system") return; @@ -916,6 +956,92 @@ export function ThemeLibrary({ + {desktopThemeState !== null && desktopTheme !== null ? ( +
+
+ + + openThemeEditor({ + editingThemeId: null, + seedThemeId: DESKTOP_LOCAL_THEME_ID, + seedName: `${desktopTheme.label} copy`, + initialAppearance, + }) + } + theme={{ ...getThemeCardDefinition(desktopTheme), label: "System theme" }} + /> + +
+
+

+ Match your desktop appearance and available colors. +

+

+ {desktopThemeState.source === "omarchy" + ? "Omarchy palette" + : desktopThemeState.source === "file" + ? "Custom theme file" + : "Native system colors"} +

+ {desktopThemeState.filePath ? ( +

+ {desktopThemeState.filePath} +

+ ) : null} + {desktopThemeState.status === "missing" || desktopThemeState.status === "invalid" ? ( +

+ {desktopThemeState.status === "missing" + ? "Theme file is missing. Keeping the last valid colors." + : "Theme file is invalid. Keeping the last valid colors."} +

+ ) : null} + {desktopThemeState.status === "unavailable" ? ( +

+ System colors are unavailable. Using T3 Code colors. +

+ ) : null} + {window.desktopBridge?.setLocalThemeFile ? ( +
+ {window.desktopBridge.pickLocalThemeFile ? ( + + ) : null} + {desktopThemeState.filePath ? ( + + ) : null} +
+ ) : null} + {desktopSourceError ? ( +

+ {desktopSourceError} +

+ ) : null} +
+
+ ) : null} {renderPairGrid()} { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0ee55ef3785b4..8d8df3618c456 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -245,7 +245,9 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "theme", title: "Themes", to: "/settings/appearance", - searchTerms: ["appearance colors palette custom import"], + searchTerms: [ + "appearance colors palette custom import system theme native accent desktop follow file", + ], // Theme cards live directly under the scheme tiles; the section is the // stable scroll destination for both. targetId: "appearance", diff --git a/apps/web/src/desktopTheme.test.ts b/apps/web/src/desktopTheme.test.ts new file mode 100644 index 0000000000000..4bd857cebe16f --- /dev/null +++ b/apps/web/src/desktopTheme.test.ts @@ -0,0 +1,42 @@ +import "culori/css"; +import { formatHex, parse } from "culori/fn"; +import { describe, expect, it } from "vite-plus/test"; +import type { DesktopLocalThemeState } from "@t3tools/contracts"; +import { applyDesktopThemeState } from "./desktopTheme"; +import { + getDesktopLocalTheme, + getStandardThemeColors, + getThemeColorsForMode, +} from "./themePalette"; + +const native: DesktopLocalThemeState = { + appearance: "dark", + accent: null, + source: "native", + filePath: null, + status: "ready", + theme: null, +}; +describe("native desktop palettes", () => { + it("uses standard T3 colors for both appearances when no accent is exposed", () => { + applyDesktopThemeState(native); + for (const appearance of ["light", "dark"] as const) { + expect(getThemeColorsForMode(getDesktopLocalTheme(), appearance)).toEqual( + getStandardThemeColors(appearance), + ); + } + }); + it("uses the native accent for actions without replacing app surfaces or error colors", () => { + applyDesktopThemeState({ ...native, accent: "#0055aa" }); + for (const appearance of ["light", "dark"] as const) { + const colors = getThemeColorsForMode(getDesktopLocalTheme(), appearance)!; + const defaults = getStandardThemeColors(appearance); + expect(formatHex(parse(colors.accent))).toBe("#0055aa"); + expect(formatHex(parse(colors.messageAction))).toBe("#0055aa"); + expect(colors.canvas).toBe(defaults.canvas); + expect(colors.sidebar).toBe(defaults.sidebar); + expect(colors.error).toBe(defaults.error); + expect(colors.accentForeground).not.toBe(colors.accent); + } + }); +}); diff --git a/apps/web/src/desktopTheme.ts b/apps/web/src/desktopTheme.ts new file mode 100644 index 0000000000000..ca0ada463728e --- /dev/null +++ b/apps/web/src/desktopTheme.ts @@ -0,0 +1,47 @@ +import type { DesktopLocalThemeState } from "@t3tools/contracts"; +import { environmentThemeDefinition } from "./publishedTheme"; +import { + DESKTOP_LOCAL_THEME_ID, + getStandardThemeColors, + updateThemeColorFamily, + setDesktopLocalTheme, + setDesktopThemeState, + type ThemeAppearance, + type ThemeColors, +} from "./themePalette"; + +/** Native desktops expose appearance and accent, rather than a full app palette. */ +export function applyDesktopThemeState(state: DesktopLocalThemeState): boolean { + const nativeColors = (appearance: ThemeAppearance): ThemeColors => { + const defaults = getStandardThemeColors(appearance); + return state.accent === null + ? defaults + : updateThemeColorFamily( + appearance, + updateThemeColorFamily(appearance, defaults, "accent", state.accent), + "messageAction", + state.accent, + ); + }; + const published = + state.theme === null + ? null + : environmentThemeDefinition({ ...state.theme, id: DESKTOP_LOCAL_THEME_ID }); + const light = + published?.appearance === "light" + ? published.colors + : (published?.variants?.light ?? nativeColors("light")); + const dark = + published?.appearance === "dark" + ? published.colors + : (published?.variants?.dark ?? nativeColors("dark")); + const paletteChanged = setDesktopLocalTheme({ + id: DESKTOP_LOCAL_THEME_ID, + label: "System theme", + appearance: state.appearance, + colors: state.appearance === "dark" ? dark : light, + variants: state.appearance === "dark" ? { light } : { dark }, + }); + const stateChanged = setDesktopThemeState(state); + return paletteChanged || stateChanged; +} diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts index 3d73a2840d10e..3f5477f5d29ee 100644 --- a/apps/web/src/hooks/useDefaultTheme.ts +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -1,14 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useSyncExternalStore } from "react"; +import { useEffect } from "react"; import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; import { primaryServerSettingsAtom } from "../state/server"; -import { - getDesktopLocalThemeSource, - subscribeToCustomThemes, - getThemeDefinition, - singleAppearanceOf, -} from "../themePalette"; +import { DESKTOP_LOCAL_THEME_ID, getThemeDefinition, singleAppearanceOf } from "../themePalette"; import { useEnvironmentThemeDefinitions } from "./useEnvironmentTheme"; import { useTheme } from "./useTheme"; @@ -76,28 +71,29 @@ function writeAppliedGeneration(storageKey: string, generation: string): void { * resolve yet — the setting and the palette it names arrive independently. */ export function useDefaultThemeAdoption(): void { - const desktopSource = useSyncExternalStore( - subscribeToCustomThemes, - getDesktopLocalThemeSource, - () => "unavailable", - ); const environmentId = useAtomValue(primaryEnvironmentIdAtom); const settings = useAtomValue(primaryServerSettingsAtom); const { defaultTheme, defaultThemeSetAt } = settings; - const { setTheme, setAppearanceMode } = useTheme(); + const { theme, themeHalves, setTheme, setAppearanceMode } = useTheme(); // Re-runs adoption when a late-arriving published theme makes the // requested id resolvable. const environmentThemes = useEnvironmentThemeDefinitions(); useEffect(() => { + if (typeof window === "undefined" || environmentId === null) return; + const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; if ( - typeof window === "undefined" || - environmentId === null || - desktopSource !== "unavailable" || - getDesktopLocalThemeSource() !== "unavailable" - ) + theme === DESKTOP_LOCAL_THEME_ID || + themeHalves?.light === DESKTOP_LOCAL_THEME_ID || + themeHalves?.dark === DESKTOP_LOCAL_THEME_ID + ) { + // Consume a server default ignored while following this desktop, so + // leaving System theme cannot replay it over a later manual choice. + if (defaultTheme.length > 0) { + writeAppliedGeneration(storageKey, defaultThemeGeneration(defaultTheme, defaultThemeSetAt)); + } return; - const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; + } const definition = getThemeDefinition(defaultTheme); const generation = defaultThemeToApply({ environmentId, @@ -121,7 +117,8 @@ export function useDefaultThemeAdoption(): void { if (half !== null && !setAppearanceMode(half)) return; writeAppliedGeneration(storageKey, generation); }, [ - desktopSource, + theme, + themeHalves, environmentId, defaultTheme, defaultThemeSetAt, diff --git a/apps/web/src/hooks/useDesktopLocalTheme.test.ts b/apps/web/src/hooks/useDesktopLocalTheme.test.ts index 07fcde46a8fe6..4115cbcd5d9e9 100644 --- a/apps/web/src/hooks/useDesktopLocalTheme.test.ts +++ b/apps/web/src/hooks/useDesktopLocalTheme.test.ts @@ -1,54 +1,78 @@ -import type { EnvironmentThemeFile, DesktopLocalThemeState } from "@t3tools/contracts"; +// @vitest-environment jsdom +import type { DesktopLocalThemeState } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { act, createElement, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { useDesktopLocalThemeSync } from "./useDesktopLocalTheme"; +import { useDefaultThemeAdoption } from "./useDefaultTheme"; +import { readThemeHalves, useTheme } from "./useTheme"; +import { + DESKTOP_LOCAL_THEME_ID, + getDefaultThemeColors, + getThemeColorVariable, + applyThemeColorPreview, + THEME_PREVIEW_ID, +} from "../themePalette"; -const darkTheme = { - name: "Desktop palette", +const dark: DesktopLocalThemeState = { appearance: "dark", - canvas: "#123456", accent: "#abcdef", -} as const satisfies EnvironmentThemeFile; -const lightTheme = { ...darkTheme, appearance: "light", canvas: "#f0e8d0" } as const; + source: "file", + filePath: "/themes/current.json", + status: "ready", + theme: { name: "Desktop", appearance: "dark", canvas: "#123456", accent: "#abcdef" }, +}; +const light: DesktopLocalThemeState = { + ...dark, + appearance: "light", + theme: { ...dark.theme!, appearance: "light", canvas: "#f0e8d0" }, +}; +let root: Root | undefined; +let controls: ReturnType; +let notify: (state: DesktopLocalThemeState) => void; +let resolveInitial: (state: DesktopLocalThemeState) => void; +const unsubscribe = vi.fn(); +vi.mock("../state/server", () => ({ primaryServerSettingsAtom: "server-settings" })); +vi.mock("../state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: "environment" })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: string) => + atom === "server-settings" + ? { defaultTheme: "iris", defaultThemeSetAt: "generation-one" } + : "remote-environment", +})); +function DefaultAdoptionHarness() { + useDefaultThemeAdoption(); + return createElement(Harness); +} -async function setup(withDefaultAdoption = false) { - const storage = new Map([ - ["t3code:theme", "t3-chat"], - ["t3code:theme-appearance-mode", "light"], - ["t3code:theme-halves:v1", JSON.stringify({ light: "t3-chat", dark: "t3-iris" })], - ["draft", "unfinished message"], - ]); - const originalStorage = new Map(storage); - const styles = new Map(); - const classes = new Set(); - const root = { - dataset: {} as Record, - style: { - setProperty: (k: string, v: string) => styles.set(k, v), - removeProperty: (k: string) => styles.delete(k), - }, - classList: { - add: (k: string) => classes.add(k), - remove: (k: string) => classes.delete(k), - toggle: (k: string, on: boolean) => (on ? classes.add(k) : classes.delete(k)), - }, - }; - let notify: ((theme: DesktopLocalThemeState) => void) | undefined; - let rejectInitial: ((error: Error) => void) | undefined; - let resolveInitial: ((theme: DesktopLocalThemeState) => void) | undefined; - const initial = new Promise((resolve, reject) => { - rejectInitial = reject; - resolveInitial = resolve; - }); - const unsubscribe = vi.fn(); - vi.stubGlobal("document", { documentElement: root }); - vi.stubGlobal("window", { - localStorage: { - getItem: (k: string) => storage.get(k) ?? null, - setItem: (k: string, v: string) => storage.set(k, v), - removeItem: (k: string) => storage.delete(k), - }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - matchMedia: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), +function Harness() { + useDesktopLocalThemeSync(); + const current = useTheme(); + useEffect(() => { + controls = current; + }, [current]); + return null; +} +async function mount(adoptDefault = false) { + root = createRoot(document.createElement("div")); + await act(async () => + root!.render(createElement(adoptDefault ? DefaultAdoptionHarness : Harness)), + ); +} +async function setup(selected = true, deferred = false) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + window.localStorage.clear(); + document.documentElement.removeAttribute("data-theme-id"); + window.localStorage.setItem("t3code:theme", selected ? DESKTOP_LOCAL_THEME_ID : "iris"); + window.localStorage.setItem("t3code:theme-appearance-mode", "system"); + window.localStorage.setItem("draft", "unfinished message"); + const initial = deferred + ? new Promise((resolve) => { + resolveInitial = resolve; + }) + : Promise.resolve(dark); + Object.assign(window, { + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), desktopBridge: { getLocalTheme: () => initial, onLocalTheme: (listener: typeof notify) => { @@ -58,186 +82,96 @@ async function setup(withDefaultAdoption = false) { setTheme: vi.fn().mockResolvedValue(undefined), }, }); - vi.stubGlobal("requestAnimationFrame", vi.fn()); - const effects: Array<() => void | (() => void)> = []; - vi.doMock("react", () => ({ - useCallback: (fn: T) => fn, - useEffect: (effect: () => void | (() => void)) => effects.push(effect), - useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => snapshot(), - })); - const { useDesktopLocalThemeSync } = await import("./useDesktopLocalTheme"); - const { useTheme } = await import("./useTheme"); - const palette = await import("../themePalette"); - if (withDefaultAdoption) { - vi.doMock("../state/server", () => ({ primaryServerSettingsAtom: "settings" })); - vi.doMock("../state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: "environment" })); - vi.doMock("@effect/atom-react", () => ({ - useAtomValue: (atom: string) => - atom === "settings" - ? { defaultTheme: "iris", defaultThemeSetAt: "one" } - : "remote-environment", - })); - } - useDesktopLocalThemeSync(); - if (withDefaultAdoption) { - const { useDefaultThemeAdoption } = await import("./useDefaultTheme"); - useDefaultThemeAdoption(); - } - const cleanups = effects.splice(0).map((effect) => effect()); - return { - storage, - originalStorage, - styles, - root, - classes, - palette, - useTheme, - publish: (theme: EnvironmentThemeFile | null) => notify?.({ enabled: true, theme }), - initial: async (theme: EnvironmentThemeFile | null) => { - resolveInitial?.({ enabled: true, theme }); - await initial; - await Promise.resolve(); - }, - rejectInitial: async () => { - rejectInitial?.(new Error("IPC startup failed")); - await initial.catch(() => {}); - await Promise.resolve(); - }, - cleanup: () => cleanups.forEach((cleanup) => cleanup?.()), - unsubscribe, - flushEffects: () => { - effects.splice(0).forEach((effect) => effect()); - }, - }; + controls?.refreshTheme(); + await mount(); + return new Map(Object.entries(window.localStorage)); } - -afterEach(() => { - vi.doUnmock("react"); - vi.doUnmock("@effect/atom-react"); - vi.doUnmock("../state/server"); - vi.doUnmock("../state/primaryEnvironment"); - vi.resetModules(); +async function publish(state: DesktopLocalThemeState) { + await act(async () => notify(state)); +} +afterEach(async () => { + await act(async () => root?.unmount()); + root = undefined; + unsubscribe.mockClear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); -describe("desktop-local theme following", () => { - it("retints dark and light without an environment or changes to saved preferences and drafts", async () => { - const app = await setup(); - await app.initial(darkTheme); - const variable = app.palette.getThemeColorVariable("canvas"); - expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); - expect(app.classes.has("dark")).toBe(true); - expect(app.useTheme().resolvedTheme).toBe("dark"); - const darkCanvas = app.styles.get(variable); - app.publish(lightTheme); - expect(app.classes.has("dark")).toBe(false); - expect(app.useTheme().resolvedTheme).toBe("light"); - expect(app.styles.get(variable)).not.toBe(darkCanvas); - expect(app.storage).toEqual(app.originalStorage); - app.publish(null); - expect(app.root.dataset.themeId).toBe("t3-chat"); - expect(app.storage).toEqual(app.originalStorage); +describe("desktop theme selection", () => { + it("follows the selected source without changing saved preferences or drafts", async () => { + const saved = await setup(); + expect(document.documentElement.dataset.themeId).toBe(DESKTOP_LOCAL_THEME_ID); + expect(controls.resolvedTheme).toBe("dark"); + await publish(light); + expect(controls.resolvedTheme).toBe("light"); + expect(new Map(Object.entries(window.localStorage))).toEqual(saved); }); - - it("lets a manual selection win until the next desktop palette change", async () => { - const app = await setup(); - await app.initial(darkTheme); - expect(app.useTheme().setTheme("t3-iris")).toBe(true); - expect(app.root.dataset.themeId).toBe("iris"); - app.publish(lightTheme); - expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); - expect(app.classes.has("dark")).toBe(false); - expect(app.storage.get("t3code:theme")).toBe("t3-iris"); + it("keeps native discovery opt-in", async () => { + await setup(false); + expect(document.documentElement.dataset.themeId).toBe("iris"); + await publish(light); + expect(document.documentElement.dataset.themeId).toBe("iris"); }); - - it("honors theme cards and appearance controls, with rollback when storage fails", async () => { - const app = await setup(); - await app.initial(darkTheme); - expect(app.useTheme().setThemeHalf("light", "iris")).toBe(true); - expect(app.root.dataset.themeId).toBe("iris"); - app.publish(darkTheme); - expect(app.useTheme().setAppearanceMode("light")).toBe(true); - expect(app.classes.has("dark")).toBe(false); - app.publish(darkTheme); - const savedPalette = app.palette.getDesktopLocalTheme(); - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const storageWrite = vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { - throw new Error("quota exceeded"); + it("keeps a manual choice through updates and remounts", async () => { + await setup(); + await act(async () => { + controls.setTheme("iris"); }); - expect(app.useTheme().setTheme("iris")).toBe(false); - expect(app.palette.getDesktopLocalTheme()).toBe(savedPalette); - app.useTheme().refreshTheme(); - expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); - storageWrite.mockRestore(); - error.mockRestore(); + await publish(light); + expect(document.documentElement.dataset.themeId).toBe("iris"); + await act(async () => root!.unmount()); + await mount(); + expect(document.documentElement.dataset.themeId).toBe("iris"); }); - - it("does not replay the startup palette over a manual choice", async () => { - const app = await setup(); - expect(app.useTheme().setTheme("iris")).toBe(true); - await app.initial(darkTheme); - expect(app.root.dataset.themeId).toBe("iris"); - app.publish(lightTheme); - expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); + it("keeps per-appearance assignments and reset readers literal", async () => { + await setup(); + await act(async () => { + controls.setThemeHalf("light", "grove"); + controls.setThemeHalf("dark", "iris"); + }); + expect(readThemeHalves()).toEqual({ light: "grove", dark: "iris" }); + expect(controls.resolvedTheme).toBe("dark"); + expect(document.documentElement.dataset.themeId).toBe("iris"); + await act(async () => { + controls.setTheme("system"); + controls.clearThemeHalves(); + }); + expect(window.localStorage.getItem("t3code:theme-halves:v1")).toBeNull(); }); - - it("keeps environment defaults from changing saved preferences while loading or following a local file", async () => { - const app = await setup(true); - const { useDefaultThemeAdoption } = await import("./useDefaultTheme"); - useDefaultThemeAdoption(); - app.flushEffects(); - expect(app.storage).toEqual(app.originalStorage); - await app.initial(darkTheme); - useDefaultThemeAdoption(); - app.flushEffects(); - expect(app.storage).toEqual(app.originalStorage); - app.useTheme().setTheme("t3-chat"); - const savedChoice = new Map(app.storage); - useDefaultThemeAdoption(); - app.flushEffects(); - expect(app.storage).toEqual(savedChoice); - expect(app.root.dataset.themeId).toBe("t3-chat"); + it("keeps a newer event over a delayed startup response", async () => { + await setup(true, true); + await publish(light); + await act(async () => resolveInitial(dark)); + expect(controls.resolvedTheme).toBe("light"); }); - - it("keeps a newer live palette when an older startup snapshot arrives late", async () => { - const app = await setup(); - app.publish(lightTheme); - const before = new Map(app.styles); - await app.initial(darkTheme); - expect(app.classes.has("dark")).toBe(false); - expect(app.styles).toEqual(before); - }); - - it("keeps a live source configured when startup IPC fails later", async () => { - const app = await setup(); - app.publish(lightTheme); - await app.rejectInitial(); - expect(app.palette.getDesktopLocalThemeSource()).toBe("configured"); - expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); + it("does not let a delayed startup response change a manual selection", async () => { + await setup(true, true); + await act(async () => { + controls.setTheme("grove"); + }); + await act(async () => resolveInitial(dark)); + expect(document.documentElement.dataset.themeId).toBe("grove"); }); - - it("preserves theme editor previews and applies the newest palette on leaving the preview", async () => { - const app = await setup(); - await app.initial(darkTheme); - const colors = { ...app.palette.getDefaultThemeColors("light"), canvas: "#fedcba" }; - app.palette.applyThemeColorPreview(colors, "light"); - app.publish(lightTheme); - expect(app.root.dataset.themeId).toBe(app.palette.THEME_PREVIEW_ID); - expect(app.styles.get(app.palette.getThemeColorVariable("canvas"))).toBe(colors.canvas); - app.useTheme().refreshTheme(); - expect(app.root.dataset.themeId).toBe(app.palette.DESKTOP_LOCAL_THEME_ID); - expect(app.classes.has("dark")).toBe(false); + it("does not replay a skipped server default after choosing a manual theme", async () => { + await setup(); + await act(async () => root!.unmount()); + await mount(true); + await act(async () => { + controls.setTheme("grove"); + }); + expect(document.documentElement.dataset.themeId).toBe("grove"); }); - - it("restores saved appearance on cleanup and ignores late IPC results", async () => { - const app = await setup(); - app.publish(darkTheme); - app.cleanup(); - await app.initial(lightTheme); - app.publish(darkTheme); - expect(app.unsubscribe).toHaveBeenCalledOnce(); - expect(app.root.dataset.themeId).toBe("t3-chat"); - expect(app.storage).toEqual(app.originalStorage); + it("preserves editor previews while caching the newest source", async () => { + await setup(); + const colors = { ...getDefaultThemeColors("light"), canvas: "#fedcba" }; + applyThemeColorPreview(colors, "light"); + await publish(light); + expect(document.documentElement.dataset.themeId).toBe(THEME_PREVIEW_ID); + expect(document.documentElement.style.getPropertyValue(getThemeColorVariable("canvas"))).toBe( + colors.canvas, + ); + await act(async () => controls.refreshTheme()); + expect(document.documentElement.dataset.themeId).toBe(DESKTOP_LOCAL_THEME_ID); + expect(controls.resolvedTheme).toBe("light"); }); }); diff --git a/apps/web/src/hooks/useDesktopLocalTheme.ts b/apps/web/src/hooks/useDesktopLocalTheme.ts index de3aad19cf585..43cb1619cf27b 100644 --- a/apps/web/src/hooks/useDesktopLocalTheme.ts +++ b/apps/web/src/hooks/useDesktopLocalTheme.ts @@ -1,12 +1,6 @@ import type { DesktopLocalThemeState } from "@t3tools/contracts"; import { useEffect } from "react"; -import { - DESKTOP_LOCAL_THEME_ID, - setDesktopLocalTheme, - setDesktopLocalThemeSource, - getManualThemeSelectionRevision, -} from "../themePalette"; -import { environmentThemeDefinition } from "../publishedTheme"; +import { applyDesktopThemeState } from "../desktopTheme"; import { useTheme } from "./useTheme"; export function useDesktopLocalThemeSync(): void { @@ -14,39 +8,26 @@ export function useDesktopLocalThemeSync(): void { useEffect(() => { const bridge = window.desktopBridge; if (!bridge?.getLocalTheme || !bridge.onLocalTheme) return; - setDesktopLocalThemeSource("loading"); - const startupSelection = getManualThemeSelectionRevision(); let active = true; let eventReceived = false; const apply = (state: DesktopLocalThemeState) => { - if (!active) return; - setDesktopLocalThemeSource(state.enabled ? "configured" : "unavailable"); - const theme = state.theme; - const definition = - theme === null - ? null - : environmentThemeDefinition({ ...theme, id: DESKTOP_LOCAL_THEME_ID }); - if (setDesktopLocalTheme(definition)) refreshTheme({ preservePreview: true }); + if (active && applyDesktopThemeState(state)) refreshTheme({ preservePreview: true }); }; - const unsubscribe = bridge.onLocalTheme((theme) => { + const unsubscribe = bridge.onLocalTheme((state) => { eventReceived = true; - apply(theme); + apply(state); }); void bridge .getLocalTheme() - .then((theme) => { - if (!active || eventReceived) return; - if (startupSelection === getManualThemeSelectionRevision()) apply(theme); - else setDesktopLocalThemeSource(theme.enabled ? "configured" : "unavailable"); + .then((state) => { + if (!eventReceived) apply(state); }) .catch(() => { - if (active && !eventReceived) setDesktopLocalThemeSource("unavailable"); + // Older or unavailable desktop bridges leave the saved palette intact. }); return () => { active = false; unsubscribe(); - setDesktopLocalThemeSource("unavailable"); - if (setDesktopLocalTheme(null)) refreshTheme({ preservePreview: true }); }; }, [refreshTheme]); } diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index c2fd3d2c022c0..a0d639f25fe17 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -4,9 +4,8 @@ import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; import { applyThemePalette, - getDesktopLocalTheme, - setDesktopLocalTheme, - recordManualThemeSelection, + DESKTOP_LOCAL_THEME_ID, + getDesktopThemeState, CUSTOM_THEMES_STORAGE_KEY, invalidateCustomThemes, canonicalThemePreference, @@ -67,7 +66,6 @@ export function readThemeHalvesRaw(): { light?: string; dark?: string } { } function readStoredThemeHalves(): ThemeHalves | null { - if (getDesktopLocalTheme() !== null) return null; if (typeof window === "undefined") return null; try { return parseThemeHalves(window.localStorage.getItem(THEME_HALVES_STORAGE_KEY)); @@ -149,6 +147,10 @@ function emitChange() { } function getSystemDark() { + const desktop = getDesktopThemeState(); + if (desktop !== null && getStored() === DESKTOP_LOCAL_THEME_ID) { + return desktop.appearance === "dark"; + } return ( typeof window !== "undefined" && typeof window.matchMedia === "function" && @@ -175,8 +177,6 @@ function isThemePreferenceMode(value: string | null): value is ThemePreferenceMo } export function readAppearanceModePreference(theme: Theme): ThemePreferenceMode { - const localTheme = getDesktopLocalTheme(); - if (localTheme !== null) return localTheme.appearance; if (typeof window !== "undefined") { try { const raw = window.localStorage.getItem(THEME_APPEARANCE_MODE_STORAGE_KEY); @@ -239,8 +239,6 @@ export function writeThemePreference(theme: Theme): void { } function getStored(): Theme { - const localTheme = getDesktopLocalTheme(); - if (localTheme !== null) return localTheme.id; if (themeStorageReadFailure !== null) { return DEFAULT_THEME_SNAPSHOT.theme; } @@ -516,21 +514,12 @@ function subscribe(listener: () => void): () => void { }; } -function pauseDesktopLocalTheme(): () => void { - const previous = getDesktopLocalTheme(); - setDesktopLocalTheme(null); - return () => { - setDesktopLocalTheme(previous); - }; -} - export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const { theme, resolvedTheme } = snapshot; const setTheme = useCallback((next: Theme): boolean => { if (typeof window === "undefined") return false; - const restoreDesktopTheme = pauseDesktopLocalTheme(); try { // Preserve the current mode before replacing a legacy or inferred theme // preference. Otherwise a fresh System preference is re-inferred from @@ -554,7 +543,6 @@ export function useTheme() { throw cause; } } catch (cause) { - restoreDesktopTheme(); const error = isThemeStorageError(cause) ? cause : new ThemeStorageError({ @@ -571,7 +559,6 @@ export function useTheme() { }); return false; } - recordManualThemeSelection(); applyTheme(next, { suppressTransitions: true }); emitChange(); return true; @@ -579,11 +566,9 @@ export function useTheme() { const setAppearanceMode = useCallback((nextAppearanceMode: ThemePreferenceMode): boolean => { if (typeof window === "undefined") return false; - const restoreDesktopTheme = pauseDesktopLocalTheme(); try { writeAppearanceModePreference(nextAppearanceMode); } catch (cause) { - restoreDesktopTheme(); const error = isThemeStorageError(cause) ? cause : new ThemeStorageError({ @@ -599,7 +584,6 @@ export function useTheme() { return false; } themeStorageReadFailure = null; - recordManualThemeSelection(); applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; @@ -621,7 +605,6 @@ export function useTheme() { const setThemeHalf = useCallback( (appearance: ThemeAppearance, themeId: string | null): boolean => { if (typeof window === "undefined") return false; - const restoreDesktopTheme = pauseDesktopLocalTheme(); try { const current = readStoredThemeHalvesRaw(); const next: { light?: string; dark?: string } = { ...current }; @@ -633,7 +616,6 @@ export function useTheme() { window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(next)); } } catch (cause) { - restoreDesktopTheme(); const error = new ThemeStorageError({ operation: "write", storageKey: THEME_HALVES_STORAGE_KEY, @@ -646,7 +628,6 @@ export function useTheme() { }); return false; } - recordManualThemeSelection(); applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; @@ -656,11 +637,9 @@ export function useTheme() { const clearThemeHalves = useCallback((): boolean => { if (typeof window === "undefined") return false; - const restoreDesktopTheme = pauseDesktopLocalTheme(); try { window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); } catch (cause) { - restoreDesktopTheme(); const error = new ThemeStorageError({ operation: "write", storageKey: THEME_HALVES_STORAGE_KEY, @@ -673,7 +652,6 @@ export function useTheme() { }); return false; } - recordManualThemeSelection(); applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c6e0387e7b7a0..528ba3f8379fa 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -142,6 +142,7 @@ function RootRouteNotFoundView() { } function RootRouteView() { + useDesktopLocalThemeSync(); useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []); const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); @@ -281,7 +282,6 @@ function RootRouteView() { /** Follows the palette the primary environment's machine publishes, if any. */ function EnvironmentThemeSync() { useEnvironmentThemeSync(); - useDesktopLocalThemeSync(); // Ordered after the palette sync so a first-run client adopting the // environment's own theme finds it already in the library. useDefaultThemeAdoption(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index b3e1d4a345308..4fafa3521588e 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1,3 +1,4 @@ +import type { DesktopLocalThemeState } from "@t3tools/contracts"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import "culori/css"; @@ -248,35 +249,32 @@ export function getCustomThemes(): ReadonlyArray { } export const DESKTOP_LOCAL_THEME_ID = "desktop-local-theme"; -let desktopLocalTheme: ThemeDefinition | null = null; - -type DesktopLocalThemeSource = "unavailable" | "loading" | "configured"; -let desktopLocalThemeSource: DesktopLocalThemeSource = "unavailable"; -let manualThemeSelectionRevision = 0; +// Resolve a saved desktop selection before its first asynchronous snapshot. +let desktopLocalTheme: ThemeDefinition = { + id: DESKTOP_LOCAL_THEME_ID, + label: "System theme", + appearance: "light", + colors: T3_CODE_LIGHT_THEME_COLORS, + variants: { dark: T3_CODE_DARK_THEME_COLORS }, +}; +let desktopThemeState: DesktopLocalThemeState | null = null; -export function getDesktopLocalThemeSource(): DesktopLocalThemeSource { - return desktopLocalThemeSource; +export function getDesktopThemeState(): DesktopLocalThemeState | null { + return desktopThemeState; } -export function setDesktopLocalThemeSource(source: DesktopLocalThemeSource): void { - if (source === desktopLocalThemeSource) return; - desktopLocalThemeSource = source; +export function setDesktopThemeState(state: DesktopLocalThemeState | null): boolean { + if (Equal.equals(desktopThemeState, state)) return false; + desktopThemeState = state; notifyCustomThemeListeners(); + return true; } -export function getManualThemeSelectionRevision(): number { - return manualThemeSelectionRevision; -} - -export function recordManualThemeSelection(): void { - manualThemeSelectionRevision += 1; -} - -export function getDesktopLocalTheme(): ThemeDefinition | null { +export function getDesktopLocalTheme(): ThemeDefinition { return desktopLocalTheme; } -export function setDesktopLocalTheme(theme: ThemeDefinition | null): boolean { +export function setDesktopLocalTheme(theme: ThemeDefinition): boolean { if (Equal.equals(desktopLocalTheme, theme)) return false; desktopLocalTheme = theme; notifyCustomThemeListeners(); diff --git a/docs/user/appearance.md b/docs/user/appearance.md index 7bc796f7bcb99..cb20df6ef97f9 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -94,22 +94,26 @@ view. Write updates to a temporary file and rename it into place so clients never read a partial theme. Invalid files are not published. -### Follow a desktop-local theme file - -Launch the desktop app with `T3CODE_DESKTOP_THEME_FILE` set to the absolute path -of a palette JSON file to follow a theme generated on your computer. It accepts -the same exported or seeded format described above. For example on Linux: - -```sh -T3CODE_DESKTOP_THEME_FILE="$HOME/.config/t3-theme.json" ./T3-Code.AppImage -``` - -The desktop applies the palette at startup and follows file changes even when -its local environment is disabled or it connects to a remote server. Replace -files atomically. Missing or invalid files leave the last valid palette in -place; files must be regular files no larger than 32 KiB, not symlinks. - -Your saved theme preferences and custom themes remain intact. Choosing another -theme takes precedence until the file's palette changes or the app restarts. -To stop following the file, remove the launch option and restart the desktop. -Web and mobile clients are unaffected. +## Desktop colors + +On desktop, select **System theme** in **Settings → Appearance** to match your computer's +appearance and available accent color. Other surfaces keep T3 Code's standard colors. +If the desktop does not provide an accent, the standard accent is used. This works with local +environments disabled and remote server connections. Existing selections stay unchanged until +you choose System theme. + +Omarchy's generated T3 Code palette is detected automatically on Linux. When available, +System theme follows its full palette, including code and terminal colors. No launcher, +environment variable, or separate theme broker is required. + +For custom matching, import or create themes and assign their light and dark previews. +Choose **System** appearance to switch between them with your desktop. Alternatively, choose +**Choose theme file** beside System theme to follow JSON exported by T3 Code or produced by +another theme tool. The selected path and file status are shown in Settings; **Use automatic +colors** returns to desktop detection. Changing the source does not replace a manually selected +theme. Select System theme to use it. + +Replace files atomically. Missing or invalid files retain the last valid palette from that +source, or use native colors until a valid palette arrives. Files must be regular files no +larger than 32 KiB; symbolic links to files are not followed. Choosing another theme persists +through file updates, navigation, and app restarts. Web and mobile clients are unaffected. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f618acdc0c92b..23a7dc117b51b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -2,7 +2,11 @@ import * as Schema from "effect/Schema"; import { EnvironmentThemeFile } from "./server.ts"; export const DesktopLocalThemeStateSchema = Schema.Struct({ - enabled: Schema.Boolean, + appearance: Schema.Literals(["light", "dark"]), + accent: Schema.NullOr(Schema.String), + source: Schema.Literals(["native", "omarchy", "file"]), + filePath: Schema.NullOr(Schema.String), + status: Schema.Literals(["ready", "missing", "invalid", "unavailable"]), theme: Schema.NullOr(EnvironmentThemeFile), }); export type DesktopLocalThemeState = typeof DesktopLocalThemeStateSchema.Type; @@ -1124,6 +1128,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getLocalTheme?: () => Promise; + setLocalThemeFile?: (path: string | null) => Promise; + pickLocalThemeFile?: () => Promise; onLocalTheme?: (listener: (state: DesktopLocalThemeState) => void) => () => void; getAppBranding: () => DesktopAppBranding | null; /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index d6d7979de5507..76496d24f9018 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -18,6 +18,7 @@ export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] */ export const RESERVED_THEME_IDS: ReadonlySet = new Set([ "system", + "desktop-local-theme", "light", "dark", ...BUILT_IN_THEME_IDS, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebcf31218b8c9..6e0ff3a339c9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: '@t3tools/tailscale': specifier: workspace:* version: link:../../packages/tailscale + culori: + specifier: ^4.0.2 + version: 4.0.2 dbus-next: specifier: 0.10.2 version: 0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4) @@ -264,6 +267,9 @@ importers: '@effect/vitest': specifier: 4.0.2 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 '@types/node': specifier: 24.12.4 version: 24.12.4 From b61b94a92f19bc68c7ce86546c301e9a1bdbbb2f Mon Sep 17 00:00:00 2001 From: not-compromised Date: Sun, 11 Oct 2026 15:55:39 -0500 Subject: [PATCH 04/11] chore(fork): nightly rolling rebase and release pipeline --- .github/fork/boundary-exceptions | 1 + .github/fork/check-boundaries.sh | 27 +++ .github/workflows/fork-nightly.yml | 356 +++++++++++++++++++++++++++++ FORK.md | 101 ++++++++ 4 files changed, 485 insertions(+) create mode 100644 .github/fork/boundary-exceptions create mode 100755 .github/fork/check-boundaries.sh create mode 100644 .github/workflows/fork-nightly.yml create mode 100644 FORK.md diff --git a/.github/fork/boundary-exceptions b/.github/fork/boundary-exceptions new file mode 100644 index 0000000000000..51a82e0e97a81 --- /dev/null +++ b/.github/fork/boundary-exceptions @@ -0,0 +1 @@ +apps/server/src/environmentTheme.ts diff --git a/.github/fork/check-boundaries.sh b/.github/fork/check-boundaries.sh new file mode 100755 index 0000000000000..98169dab10a48 --- /dev/null +++ b/.github/fork/check-boundaries.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Fails when this fork's changes reach code the phone app or the servers run. +# Usage: check-boundaries.sh (default: origin/upstream-mirror) +# Exceptions the owner approved go in .github/fork/boundary-exceptions, one path per line. +set -euo pipefail +base="${1:-origin/upstream-mirror}" +exceptions=.github/fork/boundary-exceptions + +forbidden='^(apps/server/|apps/mobile/|packages/client-runtime/|packages/contracts/|packages/provider-|packages/source-control-|packages/effect-)' +allowed='^packages/contracts/src/ipc\.ts$' +careful='^packages/shared/' + +status=0 +while read -r path; do + [[ -z "$path" ]] && continue + if [[ -f "$exceptions" ]] && grep -qxF "$path" "$exceptions"; then + echo "approved exception: $path" + elif [[ "$path" =~ $allowed ]]; then + : + elif [[ "$path" =~ $forbidden ]]; then + echo "::error file=$path::Fork change reaches code the phone or servers run. See FORK.md, Boundaries." + status=1 + elif [[ "$path" =~ $careful ]]; then + echo "::warning file=$path::Shared helper changed: confirm the server and phone keep their behavior." + fi +done < <(git diff --name-only "$base" HEAD) +exit "$status" diff --git a/.github/workflows/fork-nightly.yml b/.github/workflows/fork-nightly.yml new file mode 100644 index 0000000000000..90ef1f3994240 --- /dev/null +++ b/.github/workflows/fork-nightly.yml @@ -0,0 +1,356 @@ +# Rolling rebase of this fork's `custom` branch onto the newest upstream +# nightly, then a Linux desktop build published as a release of this fork. +# Contract and rules: FORK.md. Release tags reuse the upstream version so +# installed apps, servers and the version-skew check all see one number. +name: Fork nightly + +on: + schedule: + # Finishes before the owner's 08:30 UTC digest scan, which reports a fork + # still behind upstream; servers install the release within 30 minutes. + - cron: "30 7 * * *" + workflow_dispatch: + inputs: + target_tag: + description: "Upstream nightly tag to rebase onto (default: newest)" + required: false + allow_protocol_change: + description: "Ship even though the orchestration protocol number changed" + type: boolean + default: false + pull_request: + branches: [custom] + +# Read-only by default; jobs that run repository code (check, build) keep it. +permissions: + contents: read + +concurrency: + group: fork-nightly-${{ github.event_name == 'pull_request' && github.ref || 'main' }} + cancel-in-progress: false + +env: + UPSTREAM_URL: https://github.com/pingdotgg/t3code.git + UPSTREAM_REPO: pingdotgg/t3code + # Public values stock builds embed (repository variables, as upstream keeps them). + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + T3CODE_RELAY_URL: ${{ vars.RELAY_URL }} + VITE_T3CODE_RELAY_URL: ${{ vars.RELAY_URL }} + +jobs: + rebase: + name: Rebase custom onto upstream nightly + permissions: + contents: read + actions: write + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + outputs: + skip: ${{ steps.plan.outputs.skip }} + tag: ${{ steps.plan.outputs.tag }} + version: ${{ steps.plan.outputs.version }} + old_custom: ${{ steps.plan.outputs.old_custom }} + candidate: ${{ steps.rebase.outputs.candidate }} + steps: + - name: Keep upstream workflows off + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/$GITHUB_REPOSITORY/actions/workflows" --paginate \ + --jq '.workflows[] | select(.state == "active") | .path' | + while read -r path; do + case "$path" in + .github/workflows/fork-*.yml) ;; + *) gh workflow disable "$(basename "$path")" -R "$GITHUB_REPOSITORY" || true ;; + esac + done + + # Upstream's vendored .repos tree holds orphaned gitlinks that break + # actions/checkout's cleanup, so check out by hand like upstream does. + - name: Checkout custom + env: + FORK_DEPLOY_KEY: ${{ secrets.FORK_DEPLOY_KEY }} + run: | + # Pushes go over the fork's deploy key: GITHUB_TOKEN may not push + # commits that change workflow files, and upstream's often do. + install -d -m 700 ~/.ssh + printf '%s\n' "$FORK_DEPLOY_KEY" > ~/.ssh/fork_deploy && chmod 600 ~/.ssh/fork_deploy + ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null + export GIT_SSH_COMMAND="ssh -i ~/.ssh/fork_deploy -o IdentitiesOnly=yes" + echo "GIT_SSH_COMMAND=$GIT_SSH_COMMAND" >> "$GITHUB_ENV" + git init -q . + git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git remote set-url --push origin "git@github.com:${GITHUB_REPOSITORY}.git" + git remote add upstream "$UPSTREAM_URL" + git fetch -q --filter=blob:none origin custom upstream-mirror + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout -q -B custom origin/custom + git config user.name "t3code fork bot" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Choose target + id: plan + env: + GH_TOKEN: ${{ github.token }} + TARGET_TAG: ${{ inputs.target_tag }} + run: | + tag="${TARGET_TAG:-}" + if [[ -z "$tag" ]]; then + tag="$(gh api "repos/$UPSTREAM_REPO/releases?per_page=30" \ + --jq '[.[] | select(.draft | not) | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\.[0-9]{8}\\.[0-9]+$"))][0].tag_name')" + fi + [[ -n "$tag" && "$tag" != null ]] || { echo "::error::no upstream nightly found"; exit 1; } + version="${tag#v}" + git fetch -q --filter=blob:none upstream "refs/tags/$tag:refs/tags/$tag" + old_custom="$(git rev-parse origin/custom)" + skip=false + if gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $tag already published; nothing to ship until upstream moves." + skip=true + fi + { + echo "tag=$tag"; echo "version=$version" + echo "old_custom=$old_custom"; echo "skip=$skip" + } >> "$GITHUB_OUTPUT" + + - name: Protocol guard + if: steps.plan.outputs.skip == 'false' && !inputs.allow_protocol_change + env: + TAG: ${{ steps.plan.outputs.tag }} + run: | + file=packages/contracts/src/environment.ts + pattern='ORCHESTRATION_PROTOCOL_VERSION = [0-9]+' + old="$(git show "origin/upstream-mirror:$file" | grep -oE "$pattern")" + new="$(git show "$TAG:$file" | grep -oE "$pattern")" + if [[ "$old" != "$new" ]]; then + echo "HOLD_REASON=protocol" >> "$GITHUB_ENV" + echo "::error::Upstream changed the phone/server protocol ($old -> $new). Ship by hand with allow_protocol_change after updating the phone app." + exit 1 + fi + + - name: Rebase + id: rebase + if: steps.plan.outputs.skip == 'false' + env: + TAG: ${{ steps.plan.outputs.tag }} + run: | + if ! git rebase --onto "$TAG" origin/upstream-mirror custom; then + git status --short | sed 's/^/conflict: /' + git rebase --abort + echo "HOLD_REASON=conflict" >> "$GITHUB_ENV" + echo "::error::custom does not replay cleanly onto $TAG" + exit 1 + fi + git push -q --force origin "custom:refs/heads/fork/candidate" + echo "candidate=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Record hold reason + if: failure() + run: | + mkdir -p hold + echo "${HOLD_REASON:-rebase-job}" > hold/reason + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: hold-reason + path: hold/reason + + check: + name: Check + needs: rebase + if: always() && (github.event_name == 'pull_request' || (needs.rebase.result == 'success' && needs.rebase.outputs.skip == 'false')) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout + env: + REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || needs.rebase.outputs.candidate }} + REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + run: | + git init -q . + git remote add origin "https://github.com/${REPO}.git" + git fetch -q --no-tags --depth=1 origin "$REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout -q --detach FETCH_HEAD + # Same system packages as upstream's CI: the desktop tests compile the + # libsecret helper and the process-tree fixtures need cc. + - name: Install test build libraries + run: | + sudo apt-get update -q + sudo apt-get install -y -q --no-install-recommends build-essential libsecret-1-dev pkg-config + - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: true + run-install: false + - run: vp install + - run: vp run --filter @t3tools/desktop ensure:electron + - name: Typecheck + run: vp run typecheck + - name: Fork boundary check + env: + TAG: ${{ needs.rebase.outputs.tag }} + run: | + if [[ -n "$TAG" ]]; then + git fetch -q --depth=1 "$UPSTREAM_URL" "refs/tags/$TAG:refs/tags/$TAG" + base="$TAG" + else + git fetch -q --depth=1 "https://github.com/${GITHUB_REPOSITORY}.git" upstream-mirror:refs/remotes/origin/upstream-mirror + base=origin/upstream-mirror + fi + bash .github/fork/check-boundaries.sh "$base" + - name: Test the client side + run: vp run --parallel --concurrency-limit 4 --filter @t3tools/desktop --filter @t3tools/shared --filter @t3tools/contracts --filter @t3tools/client-runtime test + - name: Test the web app + if: success() || failure() + run: vp run --filter @t3tools/web test + + build: + name: Build Linux desktop app + needs: rebase + if: needs.rebase.result == 'success' && needs.rebase.outputs.skip == 'false' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout + env: + REF: ${{ needs.rebase.outputs.candidate }} + run: | + git init -q . + git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git fetch -q --no-tags --depth=1 origin "$REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout -q --detach FETCH_HEAD + - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + with: + node-version-file: package.json + cache: true + run-install: false + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + targets: x86_64-unknown-linux-gnu + - name: Install Linux build libraries + run: | + sudo apt-get update -q + sudo apt-get install -y -q libsecret-1-dev pkg-config imagemagick + - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + - name: Align package versions + run: node scripts/update-release-package-versions.ts "${{ needs.rebase.outputs.version }}" + - name: Build + run: vp run build:desktop + - name: Package AppImage + # GITHUB_REPOSITORY points the app's updater at this fork's releases. + run: | + vp run dist:desktop:artifact --platform linux --target AppImage --arch x64 \ + --build-version "${{ needs.rebase.outputs.version }}" --skip-build --verbose + ls -la release + - uses: actions/upload-artifact@v4 + with: + name: desktop-linux + # The updater reads nightly-linux.yml; builder-debug.yml stays private. + path: | + release/*.AppImage + release/nightly-linux.yml + if-no-files-found: error + + publish: + name: Publish + permissions: + contents: write + issues: write + actions: write + needs: [rebase, check, build] + if: needs.rebase.outputs.skip == 'false' && needs.check.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-24.04 + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.rebase.outputs.tag }} + CANDIDATE: ${{ needs.rebase.outputs.candidate }} + OLD_CUSTOM: ${{ needs.rebase.outputs.old_custom }} + steps: + - uses: actions/download-artifact@v4 + with: + name: desktop-linux + path: dist + - name: Move branches + env: + FORK_DEPLOY_KEY: ${{ secrets.FORK_DEPLOY_KEY }} + run: | + # Pushes go over the fork's deploy key: GITHUB_TOKEN may not push + # commits that change workflow files, and upstream's often do. + install -d -m 700 ~/.ssh + printf '%s\n' "$FORK_DEPLOY_KEY" > ~/.ssh/fork_deploy && chmod 600 ~/.ssh/fork_deploy + ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null + export GIT_SSH_COMMAND="ssh -i ~/.ssh/fork_deploy -o IdentitiesOnly=yes" + echo "GIT_SSH_COMMAND=$GIT_SSH_COMMAND" >> "$GITHUB_ENV" + git init -q . + git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git remote set-url --push origin "git@github.com:${GITHUB_REPOSITORY}.git" + git fetch -q --filter=blob:none origin fork/candidate + git fetch -q --filter=blob:none "$UPSTREAM_URL" "refs/tags/$TAG:refs/tags/$TAG" + # Lease on the tip this run rebased, so a change merged meanwhile is never lost. + git push --force-with-lease="custom:$OLD_CUSTOM" origin "$CANDIDATE:refs/heads/custom" + git push --force origin "refs/tags/$TAG^{commit}:refs/heads/upstream-mirror" + - name: Release + run: | + gh release create "$TAG" dist/* -R "$GITHUB_REPOSITORY" --prerelease \ + --target "$CANDIDATE" --title "$TAG (fork)" \ + --notes "Upstream ${TAG} with this fork's custom branch rebased on top ($CANDIDATE). See FORK.md." + # Moving custom registers any workflow upstream added; switch it off now + # rather than at the next run, before it can fire with this repo's secrets. + - name: Keep upstream workflows off + run: | + sleep 15 + gh api "repos/$GITHUB_REPOSITORY/actions/workflows" --paginate \ + --jq '.workflows[] | select(.state == "active") | .path' | + while read -r path; do + case "$path" in + .github/workflows/fork-*.yml) ;; + *) gh workflow disable "$(basename "$path")" -R "$GITHUB_REPOSITORY" || true ;; + esac + done + - name: Close holds + run: | + gh issue list -R "$GITHUB_REPOSITORY" --label rebase-hold --state open --json number --jq '.[].number' | + while read -r n; do + gh issue close "$n" -R "$GITHUB_REPOSITORY" --comment "Shipped $TAG; hold cleared." + done + + hold: + name: Hold + permissions: + issues: write + needs: [rebase, check, build, publish] + if: always() && github.event_name != 'pull_request' && (needs.rebase.result == 'failure' || needs.check.result == 'failure' || needs.build.result == 'failure' || needs.publish.result == 'failure') + runs-on: ubuntu-24.04 + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.rebase.outputs.tag }} + steps: + - uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: hold-reason + path: hold + - name: Open or update the hold issue + run: | + reason="$(cat hold/reason 2>/dev/null || echo "")" + if [[ -z "$reason" ]]; then + if [[ "${{ needs.check.result }}" == failure ]]; then reason=check + elif [[ "${{ needs.build.result }}" == failure ]]; then reason=build + else reason=publish; fi + fi + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + body="Nightly held at ${TAG:-unknown target}: **${reason}**. Nothing shipped; every machine stays on the last release. + + Run: $run_url + + Fix by following FORK.md (Holds). The next successful run closes this issue." + gh label create rebase-hold -R "$GITHUB_REPOSITORY" --color B60205 --description "Nightly rebase did not ship" 2>/dev/null || true + existing="$(gh issue list -R "$GITHUB_REPOSITORY" --label rebase-hold --state open --json number --jq '.[0].number')" + if [[ -n "$existing" ]]; then + gh issue comment "$existing" -R "$GITHUB_REPOSITORY" --body "$body" + else + gh issue create -R "$GITHUB_REPOSITORY" --label rebase-hold --title "Nightly rebase held" --body "$body" + fi diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000000000..c58169bed5c36 --- /dev/null +++ b/FORK.md @@ -0,0 +1,101 @@ +# Fork contract + +This fork carries desktop-app changes on top of upstream T3 Code nightlies. +Servers and the phone app stay stock; only the Linux desktop app is built from +here. Every change lives on `custom` and is replayed onto each new upstream +nightly. + +## Branches + +| Branch | What it is | +|---|---| +| `custom` | Default branch. The upstream nightly in `upstream-mirror` plus this fork's commits on top. | +| `upstream-mirror` | The exact upstream nightly commit `custom` sits on. Never commit to it. | +| `fork/candidate` | Scratch output of the nightly run. Never base work on it. | +| `/` | A change to this fork, based on `custom`, merged by PR. | +| `upstream/` | An upstream pull request branch, based on upstream `main`. `feat/desktop-local-theme` (pingdotgg/t3code#17883) predates the naming. | + +## Rolling rebase + +- `custom` is a patch stack: upstream history, then one commit per customization. + Upstream never gets merged into it. Replay the stack with + `git rebase --onto upstream-mirror custom`. +- A change lands as a pull request into `custom`, rebased onto `custom` and + merged by squash (or rebase, for a few self-contained commits). The + repository refuses merge commits, which would break the next replay. +- When upstream ships a customization, drop its commit at the next rebase and + remove its row below. +- Resolve a conflict by reading both sides and keeping the intent of each + customization below. The `custom` force-push uses + `--force-with-lease=custom:`, so a change merged in the + meantime is never lost. + +## Nightly pipeline + +`.github/workflows/fork-nightly.yml` runs daily at 07:30 UTC and on demand. + +1. It picks the newest upstream nightly release tag. +2. It refuses to ship by itself if `ORCHESTRATION_PROTOCOL_VERSION` changed, + because the phone app refuses a server whose protocol number differs. That + run ships only by hand (`allow_protocol_change`), timed with the phone update. +3. It rebases `custom`, typechecks, runs the client-side tests and the + boundary check, and builds the Linux AppImage. The version is the upstream + version unchanged, and the update feed is this repository's releases. +4. On success it moves `custom` and `upstream-mirror` and publishes the release + `v`. Desktop apps update from it. +5. Any failure ships nothing and opens or updates a `rebase-hold` issue. The + next successful run closes it. + +The same check job runs on every pull request into `custom`. The workflow +also keeps upstream's own workflows disabled, since they need runners this +fork does not have. + +## Holds + +- **Conflict:** replay the stack by hand on a `rebase/` branch, resolving + each conflict by the intent in Customizations. Show + `git range-diff upstream-mirror..custom ..rebase/` to the owner. + After approval, push it to `custom` and `` to `upstream-mirror`, both + with `--force-with-lease`. Then run the workflow with `target_tag=`. +- **Check or build:** fix the customization in a pull request into `custom`, + then run the workflow. If upstream fails on its own, the next nightly + retries. +- **Protocol:** the owner decides; see Nightly pipeline, step 2. + +## Boundaries + +The desktop app must keep speaking the stock servers' protocol. +`.github/fork/check-boundaries.sh` enforces this: + +- **Free:** `apps/desktop`, `apps/web`, desktop IPC in `packages/contracts/src/ipc.ts`. +- **Careful (warning):** `packages/shared`. Additions are fine. Before + changing existing behavior, confirm that servers and the phone keep it. +- **Refused:** `apps/server`, `apps/mobile`, `packages/client-runtime`, the + rest of `packages/contracts`, `packages/provider-*`, `packages/source-control-*`, + `packages/effect-*`. Server code changed here never runs on a stock server, + and wire changes break the link to it. Changing the version scheme or the + update channel is also off limits. + +An owner-approved exception is listed in `.github/fork/boundary-exceptions` +with its reason in the table below. + +## Customizations + +| Commit subject | Purpose | Retire when | +|---|---|---| +| `feat(desktop): follow a local palette file independently of servers`, `fix(desktop): preserve local theme ownership during startup`, `feat(desktop): match system colors and configure local palettes` | The desktop app follows a local palette file (the system theme) whatever the server publishes. Exception: `apps/server/src/environmentTheme.ts` only moves its guarded file reader into `packages/shared/src/themeFile.ts` and re-exports it, with no behavior change. | Upstream merges pingdotgg/t3code#17883. | +| `chore(fork): nightly rolling rebase and release pipeline` | This file, the workflow, the boundary check. | Never. | + +## Verification + +A run is good when the release exists with `T3-Code-*.AppImage` and +`nightly-linux.yml`, and an installed fork build finds it through +Settings → Updates. Before moving `custom` by hand, run the boundary +check against `upstream-mirror` and the client-side tests from the workflow. + +## Rollback + +Every release stays published, and its tag points at the exact stack it +shipped. Roll a desktop back by installing an older release's AppImage; the +next release moves it forward again. Servers are never moved backward +automatically. From eb06a7cbcf7193968246dfe758c69d3968fd88d5 Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:27:00 -0500 Subject: [PATCH 05/11] docs(fork): state each customization's intent as checkable behaviour (#3) FORK.md's Customizations table gave each customization a one-line purpose, which says what it is but not what a reviewer or hold fixer must keep true. Each customization now has an entry listing its commits, the behaviour that must still hold, where the detail lives, and when it retires. A PR that touches a customization updates its entry; reviews and hold fixes check it. Closes #2 --- FORK.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/FORK.md b/FORK.md index c58169bed5c36..c5ffd56f30d7f 100644 --- a/FORK.md +++ b/FORK.md @@ -24,7 +24,7 @@ nightly. merged by squash (or rebase, for a few self-contained commits). The repository refuses merge commits, which would break the next replay. - When upstream ships a customization, drop its commit at the next rebase and - remove its row below. + remove its entry below. - Resolve a conflict by reading both sides and keeping the intent of each customization below. The `custom` force-push uses `--force-with-lease=custom:`, so a change merged in the @@ -77,14 +77,67 @@ The desktop app must keep speaking the stock servers' protocol. update channel is also off limits. An owner-approved exception is listed in `.github/fork/boundary-exceptions` -with its reason in the table below. +with its reason in that customization's entry below. ## Customizations -| Commit subject | Purpose | Retire when | -|---|---|---| -| `feat(desktop): follow a local palette file independently of servers`, `fix(desktop): preserve local theme ownership during startup`, `feat(desktop): match system colors and configure local palettes` | The desktop app follows a local palette file (the system theme) whatever the server publishes. Exception: `apps/server/src/environmentTheme.ts` only moves its guarded file reader into `packages/shared/src/themeFile.ts` and re-exports it, with no behavior change. | Upstream merges pingdotgg/t3code#17883. | -| `chore(fork): nightly rolling rebase and release pipeline` | This file, the workflow, the boundary check. | Never. | +One entry per customization. "Must still be true" lists the behaviour the +customization exists for, in terms a reviewer can check against the code, +tests and app. A pull request that adds or changes a customization updates +its entry in the same PR and adds itself under Detail. Reviewing a pull +request, resolving a conflict or fixing a hold means confirming every entry +the change touches still holds; when an entry and the code disagree, the +entry is the intent and the code is the bug, unless the owner rules +otherwise. + +### Local desktop theme + +- **Commits:** `feat(desktop): follow a local palette file independently of servers`, + `fix(desktop): preserve local theme ownership during startup`, + `feat(desktop): match system colors and configure local palettes` +- **Must still be true:** + - Choosing **System theme** in Settings → Appearance makes the desktop app + follow the computer's light/dark appearance and accent, with local + environments disabled and when connected to remote servers only. + - With no accent exposed, both appearances use T3's standard colors. An + accent recolours actions only, never app surfaces or error colours. + - On Linux, Omarchy's generated T3 palette is found with no launcher, + environment variable or broker, and an Omarchy theme switch is followed. + - **Choose theme file** follows that file through atomic replacements and + across restarts; **Use automatic colors** returns to detection. + - A missing or invalid file keeps the last valid palette from that source, + or native colours until one arrives. Files over 32 KiB, non-regular files + and symlinks to files are refused. + - A manually chosen theme survives file updates, a change of source, server + theme pushes, delayed startup responses, navigation and restarts. + Changing the source never replaces it. + - Web and mobile clients and the stock servers see no change; nothing the + desktop app sends a server changes. +- **Boundary exception:** `apps/server/src/environmentTheme.ts` only moves its + guarded file reader into `packages/shared/src/themeFile.ts` and re-exports + it, with no behaviour change. +- **Detail:** pingdotgg/t3code#17883 (pushed before fork PRs were required); + user guide in `docs/user/appearance.md`, Desktop colors. +- **Retire when:** upstream merges pingdotgg/t3code#17883. + +### Fork pipeline + +- **Commits:** `chore(fork): nightly rolling rebase and release pipeline` +- **Must still be true:** + - Daily at 07:30 UTC and on demand, the newest upstream nightly gets + `custom` replayed onto it, checked and built; it ships only when every + step is green. + - A changed `ORCHESTRATION_PROTOCOL_VERSION` never ships without + `allow_protocol_change`. + - A release carries the upstream version unchanged, and fork builds update + from this repository's releases. + - Any failure ships nothing and opens or updates one `rebase-hold` issue; + the next success closes it. + - The boundary check runs on every pull request and nightly and refuses the + refused zone except listed exceptions. + - Upstream's own workflows stay disabled. +- **Detail:** this file and `.github/workflows/fork-nightly.yml`. +- **Retire when:** never. ## Verification From 096f8fae0ca5005d1e9683ee913209f545fdb450 Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:25:29 -0500 Subject: [PATCH 06/11] test(fork): pipeline promises as tests (#4) * test(fork): pipeline promises as tests Target selection and the protocol guard move out of the workflow into scripts so they can be tested. node --test covers: only upstream nightlies ship, a protocol change holds, unreadable protocol holds, the boundary check refuses server/phone/wire paths and honours only listed exceptions, the version is never suffixed, the push key stays out of jobs that run repository code, custom moves only under a lease, and the release carries exactly the updater's files. Each test was shown failing against a deliberately broken pipeline. * fix(fork): the push key lives only in the two push steps Moving target selection and the protocol guard into scripts put repository code in the rebase job while it held the deploy key. The key is now written in the Rebase and Move branches steps only, removed on exit, and its ssh command is never exported; a test fails if either holds a repository script. --------- Co-authored-by: not-compromised --- .github/fork/fork-pipeline.test.cjs | 214 ++++++++++++++++++++++++++++ .github/fork/protocol-guard.sh | 22 +++ .github/fork/select-nightly.cjs | 27 ++++ .github/workflows/fork-nightly.yml | 39 +++-- FORK.md | 10 +- 5 files changed, 288 insertions(+), 24 deletions(-) create mode 100644 .github/fork/fork-pipeline.test.cjs create mode 100755 .github/fork/protocol-guard.sh create mode 100755 .github/fork/select-nightly.cjs diff --git a/.github/fork/fork-pipeline.test.cjs b/.github/fork/fork-pipeline.test.cjs new file mode 100644 index 0000000000000..79acb8fd39360 --- /dev/null +++ b/.github/fork/fork-pipeline.test.cjs @@ -0,0 +1,214 @@ +// What the fork's nightly pipeline promises, tested from the outside: +// it ships only upstream nightlies, never ships a phone/server protocol change +// by itself, refuses fork changes that the stock servers or the phone would +// see, and keeps its push credentials away from jobs that run repository code. +"use strict"; + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { execFileSync, spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { selectNightly } = require("./select-nightly.cjs"); + +const here = __dirname; +const workflow = fs.readFileSync(path.join(here, "../workflows/fork-nightly.yml"), "utf8"); + +function repo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-pipeline-")); + const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8" }).trim(); + git("init", "-q", "-b", "main"); + git("config", "user.email", "test@example.invalid"); + git("config", "user.name", "test"); + const write = (file, text) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text); + }; + const commit = (message) => { + git("add", "-A"); + git("commit", "-q", "--allow-empty", "-m", message); + return git("rev-parse", "HEAD"); + }; + const run = (script, ...args) => + spawnSync("bash", [path.join(here, script), ...args], { cwd: dir, encoding: "utf8" }); + return { dir, git, write, commit, run }; +} + +// --- Which upstream build the fork follows ------------------------------- + +test("ships the newest upstream nightly", () => { + const releases = [ + { tag_name: "v0.0.46-nightly.20261011.2994", draft: false }, + { tag_name: "v0.0.46-nightly.20261011.2980", draft: false }, + ]; + assert.equal(selectNightly(releases), "v0.0.46-nightly.20261011.2994"); +}); + +test("never follows a draft, preview, PR build or stable release", () => { + const releases = [ + { tag_name: "v0.0.46-nightly.20261012.3001", draft: true }, + { tag_name: "v0.0.46-preview.20261012.3000", draft: false }, + { tag_name: "v0.0.46-pr.18200.1", draft: false }, + { tag_name: "v0.0.45", draft: false }, + { tag_name: "v0.0.46-nightly.20261011.2994", draft: false }, + ]; + assert.equal(selectNightly(releases), "v0.0.46-nightly.20261011.2994"); +}); + +test("finds nothing rather than guessing when upstream has no nightly", () => { + assert.equal(selectNightly([{ tag_name: "v0.0.45", draft: false }]), null); + const cli = spawnSync("node", [path.join(here, "select-nightly.cjs")], { + input: JSON.stringify([{ tag_name: "v0.0.45", draft: false }]), + encoding: "utf8", + }); + assert.equal(cli.status, 1); +}); + +test("a release carries the upstream version unchanged, so servers and the app agree", () => { + // The skew banner compares nightly versions in full; any suffix would make + // the desktop app newer than every server. + assert.match(workflow, /version="\$\{tag#v\}"/); + assert.match(workflow, /--build-version "\$\{\{ needs\.rebase\.outputs\.version \}\}"/); + assert.match(workflow, /gh release create "\$TAG"/); +}); + +// --- The phone's protocol ------------------------------------------------ + +const protocolFile = "packages/contracts/src/environment.ts"; +const protocol = (n) => `export const ORCHESTRATION_PROTOCOL_VERSION = ${n};\n`; + +test("an unchanged protocol ships", () => { + const r = repo(); + r.write(protocolFile, protocol(2)); + const base = r.commit("base"); + r.write("apps/web/src/a.ts", "x\n"); + const target = r.commit("target"); + const result = r.run("protocol-guard.sh", base, target); + assert.equal(result.status, 0, result.stdout + result.stderr); +}); + +test("a protocol change holds instead of shipping", () => { + const r = repo(); + r.write(protocolFile, protocol(2)); + const base = r.commit("base"); + r.write(protocolFile, protocol(3)); + const target = r.commit("target"); + const result = r.run("protocol-guard.sh", base, target); + assert.equal(result.status, 1); + assert.match(result.stdout, /2 -> 3/); +}); + +test("an unreadable protocol holds rather than shipping blind", () => { + const r = repo(); + r.write(protocolFile, protocol(2)); + const base = r.commit("base"); + fs.rmSync(path.join(r.dir, protocolFile)); + const target = r.commit("moved"); + assert.equal(r.run("protocol-guard.sh", base, target).status, 1); +}); + +test("the protocol guard can only be skipped by an explicit manual input", () => { + assert.match(workflow, /if: steps\.plan\.outputs\.skip == 'false' && !inputs\.allow_protocol_change/); + assert.match(workflow, /allow_protocol_change:[\s\S]*?default: false/); +}); + +// --- What the fork may change --------------------------------------------- + +function boundary(changes, exceptions) { + const r = repo(); + r.write("README.md", "base\n"); + const base = r.commit("base"); + for (const file of changes) r.write(file, "changed\n"); + if (exceptions) r.write(".github/fork/boundary-exceptions", exceptions.join("\n") + "\n"); + r.commit("change"); + return r.run("check-boundaries.sh", base); +} + +test("desktop and web changes pass", () => { + const result = boundary(["apps/desktop/src/x.ts", "apps/web/src/y.tsx", "packages/contracts/src/ipc.ts"]); + assert.equal(result.status, 0, result.stdout); +}); + +for (const file of [ + "apps/server/src/ws.ts", + "apps/mobile/src/App.tsx", + "packages/client-runtime/src/connection/compatibility.ts", + "packages/contracts/src/environment.ts", + "packages/provider-grok/src/index.ts", +]) { + test(`a change the servers or phone would see is refused: ${file}`, () => { + const result = boundary([file]); + assert.equal(result.status, 1); + assert.match(result.stdout, new RegExp(file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + }); +} + +test("an owner-approved exception passes, and only that path", () => { + const ok = boundary(["apps/server/src/environmentTheme.ts"], ["apps/server/src/environmentTheme.ts"]); + assert.equal(ok.status, 0, ok.stdout); + const other = boundary( + ["apps/server/src/environmentTheme.ts", "apps/server/src/ws.ts"], + ["apps/server/src/environmentTheme.ts"], + ); + assert.equal(other.status, 1); +}); + +test("shared helpers pass with a warning to check servers and the phone", () => { + const result = boundary(["packages/shared/src/themeFile.ts"]); + assert.equal(result.status, 0); + assert.match(result.stdout, /::warning/); +}); + +// --- Credentials and rewrites --------------------------------------------- + +function job(name) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `job ${name} exists`); + const rest = workflow.slice(start + 1); + const next = rest.slice(3).search(/\n {2}[a-z_]+:\n/); + return next === -1 ? rest : rest.slice(0, next + 3); +} + +test("the push key never reaches jobs that run repository code", () => { + for (const name of ["check", "build"]) { + assert.doesNotMatch(job(name), /FORK_DEPLOY_KEY/, `${name} sees the deploy key`); + assert.doesNotMatch(job(name), /contents: write/, `${name} can write`); + } + assert.match(workflow, /^permissions:\n {2}contents: read\n/m); +}); + +test("the push key lives only in the two push steps, which run no repository code", () => { + const steps = workflow.split(/\n {6}- /).slice(1); + const holding = steps.filter((step) => step.includes("FORK_DEPLOY_KEY")); + assert.deepEqual( + holding.map((step) => step.match(/name: (.+)/)[1]), + ["Rebase", "Move branches"], + ); + for (const step of holding) { + assert.doesNotMatch(step, /\.github\/fork|\bnode |\bvp |\bbash \S/, "a key-holding step runs repository code"); + assert.doesNotMatch(step, /GIT_SSH_COMMAND[^\n]*GITHUB_ENV/, "the key's ssh command must not outlive its step"); + assert.match(step, /trap 'rm -f ~\/\.ssh\/fork_deploy' EXIT/); + } +}); + +test("custom is only ever replaced under a lease on the tip that was rebased", () => { + const publish = job("publish"); + assert.match(publish, /--force-with-lease="custom:\$OLD_CUSTOM"/); + assert.doesNotMatch(publish.replace(/--force-with-lease="custom:\$OLD_CUSTOM"[^\n]*/, ""), /:refs\/heads\/custom/); +}); + +test("nothing ships unless the rebase, the checks and the build all passed", () => { + const publish = job("publish"); + assert.match(publish, /needs: \[rebase, check, build\]/); + assert.match(publish, /needs\.check\.result == 'success' && needs\.build\.result == 'success'/); +}); + +test("the published files are exactly what the updater reads", () => { + const uploaded = job("build") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("release/")); + assert.deepEqual(uploaded, ["release/*.AppImage", "release/nightly-linux.yml"]); +}); diff --git a/.github/fork/protocol-guard.sh b/.github/fork/protocol-guard.sh new file mode 100755 index 0000000000000..5e24abb8a764e --- /dev/null +++ b/.github/fork/protocol-guard.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Refuses a target whose phone/server protocol number differs from the base +# custom sits on: the phone app connects only to a server with the same +# ORCHESTRATION_PROTOCOL_VERSION, so such a release is a fleet-wide cutover. +# Usage: protocol-guard.sh +set -euo pipefail +file=packages/contracts/src/environment.ts +pattern='ORCHESTRATION_PROTOCOL_VERSION = [0-9]+' +read_version() { + git show "$1:$file" 2>/dev/null | grep -oE "$pattern" | grep -oE '[0-9]+$' || true +} +old="$(read_version "$1")" +new="$(read_version "$2")" +if [[ -z "$old" || -z "$new" ]]; then + echo "::error::Cannot read ORCHESTRATION_PROTOCOL_VERSION from $file at $1 or $2; refusing to ship blind." + exit 1 +fi +if [[ "$old" != "$new" ]]; then + echo "::error::Upstream changed the phone/server protocol ($old -> $new). Ship by hand with allow_protocol_change after updating the phone app." + exit 1 +fi +echo "protocol unchanged ($old)" diff --git a/.github/fork/select-nightly.cjs b/.github/fork/select-nightly.cjs new file mode 100755 index 0000000000000..e189a0e6954b9 --- /dev/null +++ b/.github/fork/select-nightly.cjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Picks the upstream nightly the fork rebases onto: the newest non-draft +// release whose tag is a plain nightly version. Reads the GitHub releases +// API response (newest first) on stdin, prints the tag, exits 1 when none. +"use strict"; + +const NIGHTLY_TAG = /^v\d+\.\d+\.\d+-nightly\.\d{8}\.\d+$/; + +function selectNightly(releases) { + const release = releases.find((r) => !r.draft && NIGHTLY_TAG.test(r.tag_name)); + return release ? release.tag_name : null; +} + +module.exports = { selectNightly, NIGHTLY_TAG }; + +if (require.main === module) { + let input = ""; + process.stdin.on("data", (chunk) => (input += chunk)); + process.stdin.on("end", () => { + const tag = selectNightly(JSON.parse(input)); + if (!tag) { + console.error("no upstream nightly release found"); + process.exit(1); + } + console.log(tag); + }); +} diff --git a/.github/workflows/fork-nightly.yml b/.github/workflows/fork-nightly.yml index 90ef1f3994240..43d1d22cd09fe 100644 --- a/.github/workflows/fork-nightly.yml +++ b/.github/workflows/fork-nightly.yml @@ -68,16 +68,7 @@ jobs: # Upstream's vendored .repos tree holds orphaned gitlinks that break # actions/checkout's cleanup, so check out by hand like upstream does. - name: Checkout custom - env: - FORK_DEPLOY_KEY: ${{ secrets.FORK_DEPLOY_KEY }} run: | - # Pushes go over the fork's deploy key: GITHUB_TOKEN may not push - # commits that change workflow files, and upstream's often do. - install -d -m 700 ~/.ssh - printf '%s\n' "$FORK_DEPLOY_KEY" > ~/.ssh/fork_deploy && chmod 600 ~/.ssh/fork_deploy - ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null - export GIT_SSH_COMMAND="ssh -i ~/.ssh/fork_deploy -o IdentitiesOnly=yes" - echo "GIT_SSH_COMMAND=$GIT_SSH_COMMAND" >> "$GITHUB_ENV" git init -q . git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" git remote set-url --push origin "git@github.com:${GITHUB_REPOSITORY}.git" @@ -96,10 +87,9 @@ jobs: run: | tag="${TARGET_TAG:-}" if [[ -z "$tag" ]]; then - tag="$(gh api "repos/$UPSTREAM_REPO/releases?per_page=30" \ - --jq '[.[] | select(.draft | not) | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\.[0-9]{8}\\.[0-9]+$"))][0].tag_name')" + tag="$(gh api "repos/$UPSTREAM_REPO/releases?per_page=30" | node .github/fork/select-nightly.cjs)" fi - [[ -n "$tag" && "$tag" != null ]] || { echo "::error::no upstream nightly found"; exit 1; } + [[ -n "$tag" ]] || { echo "::error::no upstream nightly found"; exit 1; } version="${tag#v}" git fetch -q --filter=blob:none upstream "refs/tags/$tag:refs/tags/$tag" old_custom="$(git rev-parse origin/custom)" @@ -118,13 +108,8 @@ jobs: env: TAG: ${{ steps.plan.outputs.tag }} run: | - file=packages/contracts/src/environment.ts - pattern='ORCHESTRATION_PROTOCOL_VERSION = [0-9]+' - old="$(git show "origin/upstream-mirror:$file" | grep -oE "$pattern")" - new="$(git show "$TAG:$file" | grep -oE "$pattern")" - if [[ "$old" != "$new" ]]; then + if ! bash .github/fork/protocol-guard.sh origin/upstream-mirror "$TAG"; then echo "HOLD_REASON=protocol" >> "$GITHUB_ENV" - echo "::error::Upstream changed the phone/server protocol ($old -> $new). Ship by hand with allow_protocol_change after updating the phone app." exit 1 fi @@ -133,6 +118,7 @@ jobs: if: steps.plan.outputs.skip == 'false' env: TAG: ${{ steps.plan.outputs.tag }} + FORK_DEPLOY_KEY: ${{ secrets.FORK_DEPLOY_KEY }} run: | if ! git rebase --onto "$TAG" origin/upstream-mirror custom; then git status --short | sed 's/^/conflict: /' @@ -141,7 +127,15 @@ jobs: echo "::error::custom does not replay cleanly onto $TAG" exit 1 fi - git push -q --force origin "custom:refs/heads/fork/candidate" + # Pushes go over the fork's deploy key (GITHUB_TOKEN may not push + # workflow changes, and upstream's often carry some). The key lives + # only for this step, which runs no repository code. + install -d -m 700 ~/.ssh + printf '%s\n' "$FORK_DEPLOY_KEY" > ~/.ssh/fork_deploy && chmod 600 ~/.ssh/fork_deploy + trap 'rm -f ~/.ssh/fork_deploy' EXIT + ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null + GIT_SSH_COMMAND="ssh -i ~/.ssh/fork_deploy -o IdentitiesOnly=yes" \ + git push -q --force origin "custom:refs/heads/fork/candidate" echo "candidate=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Record hold reason @@ -187,6 +181,8 @@ jobs: - run: vp run --filter @t3tools/desktop ensure:electron - name: Typecheck run: vp run typecheck + - name: Fork pipeline tests + run: node --test .github/fork/*.test.cjs - name: Fork boundary check env: TAG: ${{ needs.rebase.outputs.tag }} @@ -277,13 +273,12 @@ jobs: env: FORK_DEPLOY_KEY: ${{ secrets.FORK_DEPLOY_KEY }} run: | - # Pushes go over the fork's deploy key: GITHUB_TOKEN may not push - # commits that change workflow files, and upstream's often do. + # Same deploy key as the rebase push, alive only for this step. install -d -m 700 ~/.ssh printf '%s\n' "$FORK_DEPLOY_KEY" > ~/.ssh/fork_deploy && chmod 600 ~/.ssh/fork_deploy + trap 'rm -f ~/.ssh/fork_deploy' EXIT ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null export GIT_SSH_COMMAND="ssh -i ~/.ssh/fork_deploy -o IdentitiesOnly=yes" - echo "GIT_SSH_COMMAND=$GIT_SSH_COMMAND" >> "$GITHUB_ENV" git init -q . git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" git remote set-url --push origin "git@github.com:${GITHUB_REPOSITORY}.git" diff --git a/FORK.md b/FORK.md index c5ffd56f30d7f..9d89a4ab9ba58 100644 --- a/FORK.md +++ b/FORK.md @@ -136,6 +136,11 @@ otherwise. - The boundary check runs on every pull request and nightly and refuses the refused zone except listed exceptions. - Upstream's own workflows stay disabled. + - The push key lives only in the two push steps, which run no repository + code; write access stays out of jobs that run repository code, + and `custom` is replaced only under a lease on the tip that was rebased. +- **Tests:** `node --test .github/fork/*.test.cjs` (Check job), one test per + promise above that can be checked without running Actions. - **Detail:** this file and `.github/workflows/fork-nightly.yml`. - **Retire when:** never. @@ -143,8 +148,9 @@ otherwise. A run is good when the release exists with `T3-Code-*.AppImage` and `nightly-linux.yml`, and an installed fork build finds it through -Settings → Updates. Before moving `custom` by hand, run the boundary -check against `upstream-mirror` and the client-side tests from the workflow. +Settings → Updates. Before moving `custom` by hand, run +`node --test .github/fork/*.test.cjs`, the boundary check against +`upstream-mirror`, and the client-side tests from the workflow. ## Rollback From 78f6d7870b6099469f6164f7c9a863eeb59b49a4 Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:09 -0500 Subject: [PATCH 07/11] fix(fork): revive the nightly schedule GitHub dropped (#6) The fork sat untouched from Jul 7 to Sep 30, so GitHub disabled its schedules on Sep 5 (60 days without activity). The 07:30 UTC nightly added on Oct 11 never fired. A person's commit that changes the cron line is what reactivates them, so move it to 07:35. --- .github/workflows/fork-nightly.yml | 4 +++- FORK.md | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/fork-nightly.yml b/.github/workflows/fork-nightly.yml index 43d1d22cd09fe..0a0e4d3d829f0 100644 --- a/.github/workflows/fork-nightly.yml +++ b/.github/workflows/fork-nightly.yml @@ -8,7 +8,9 @@ on: schedule: # Finishes before the owner's 08:30 UTC digest scan, which reports a fork # still behind upstream; servers install the release within 30 minutes. - - cron: "30 7 * * *" + # GitHub drops a public repo's schedules after 60 days without activity + # and revives them only when a person commits a change to this line. + - cron: "35 7 * * *" workflow_dispatch: inputs: target_tag: diff --git a/FORK.md b/FORK.md index 9d89a4ab9ba58..69bfa23194226 100644 --- a/FORK.md +++ b/FORK.md @@ -32,7 +32,7 @@ nightly. ## Nightly pipeline -`.github/workflows/fork-nightly.yml` runs daily at 07:30 UTC and on demand. +`.github/workflows/fork-nightly.yml` runs daily at 07:35 UTC and on demand. 1. It picks the newest upstream nightly release tag. 2. It refuses to ship by itself if `ORCHESTRATION_PROTOCOL_VERSION` changed, @@ -124,7 +124,7 @@ otherwise. - **Commits:** `chore(fork): nightly rolling rebase and release pipeline` - **Must still be true:** - - Daily at 07:30 UTC and on demand, the newest upstream nightly gets + - Daily at 07:35 UTC and on demand, the newest upstream nightly gets `custom` replayed onto it, checked and built; it ships only when every step is green. - A changed `ORCHESTRATION_PROTOCOL_VERSION` never ships without From 26d16d34e14933080abeb91379db2df9db36a006 Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:51:24 -0500 Subject: [PATCH 08/11] docs(fork): register the nightly schedule fix (#9) --- FORK.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/FORK.md b/FORK.md index 69bfa23194226..f835e4a226cf6 100644 --- a/FORK.md +++ b/FORK.md @@ -122,7 +122,8 @@ otherwise. ### Fork pipeline -- **Commits:** `chore(fork): nightly rolling rebase and release pipeline` +- **Commits:** `chore(fork): nightly rolling rebase and release pipeline`, + `fix(fork): revive the nightly schedule GitHub dropped` - **Must still be true:** - Daily at 07:35 UTC and on demand, the newest upstream nightly gets `custom` replayed onto it, checked and built; it ships only when every @@ -141,7 +142,8 @@ otherwise. and `custom` is replaced only under a lease on the tip that was rebased. - **Tests:** `node --test .github/fork/*.test.cjs` (Check job), one test per promise above that can be checked without running Actions. -- **Detail:** this file and `.github/workflows/fork-nightly.yml`. +- **Detail:** this file and `.github/workflows/fork-nightly.yml`; + not-compromised/t3code#6 (nightly schedule revival). - **Retire when:** never. ## Verification From 69b3f65455567f9a7aa84bcfa953c5d040609e6d Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:06:03 -0500 Subject: [PATCH 09/11] fix(fork): update dependencies with released security fixes (#10) --- .github/fork/dependency-consumers.test.cjs | 22 ++ FORK.md | 19 + patches/query-string@7.1.3.patch | 11 + pnpm-lock.yaml | 421 ++++++++++----------- pnpm-workspace.yaml | 15 + 5 files changed, 272 insertions(+), 216 deletions(-) create mode 100644 .github/fork/dependency-consumers.test.cjs create mode 100644 patches/query-string@7.1.3.patch diff --git a/.github/fork/dependency-consumers.test.cjs b/.github/fork/dependency-consumers.test.cjs new file mode 100644 index 0000000000000..c1f36faa247be --- /dev/null +++ b/.github/fork/dependency-consumers.test.cjs @@ -0,0 +1,22 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); +const { dirname, join } = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const mobileRequire = createRequire(join(__dirname, '../../apps/mobile/package.json')); +const nativeRequire = createRequire(mobileRequire.resolve('@react-navigation/native')); +const navigationDir = dirname(nativeRequire.resolve('@react-navigation/core')); +const screens = { Chat: 'chat' }; + +test('navigation decodes query parameters through the upgraded decoder', async () => { + const { getStateFromPath } = await import(pathToFileURL(join(navigationDir, 'getStateFromPath.js'))); + const state = getStateFromPath('/chat?message=hello%20world', { screens }); + assert.equal(state.routes[0].params.message, 'hello world'); +}); + +test('navigation encodes route parameters after dependency upgrades', async () => { + const { getPathFromState } = await import(pathToFileURL(join(navigationDir, 'getPathFromState.js'))); + const state = { routes: [{ name: 'Chat', params: { message: 'hello world' } }] }; + assert.equal(getPathFromState(state, { screens }), '/chat?message=hello%20world'); +}); diff --git a/FORK.md b/FORK.md index f835e4a226cf6..0a9a72d5258be 100644 --- a/FORK.md +++ b/FORK.md @@ -146,6 +146,25 @@ otherwise. not-compromised/t3code#6 (nightly schedule revival). - **Retire when:** never. +### Dependency security fixes + +- **Commits:** `fix(fork): update dependencies with released security fixes` +- **Must still be true:** + - The workspace lockfile resolves the dependencies named in #8 to releases + that fix the reported advisories. + - Security overrides use compatible minimum-version ranges, allowing newer + patch/minor releases within the chosen compatibility line. Remove them + when upstream raises its dependency requirements to fixed versions. + - The desktop build, markdown rendering, D-Bus integration and bundled + provider SDK consumers remain compatible. Navigation keeps query-string + 7's namespace API while its decoder resolves to a fixed ESM release. +- **Boundary exception:** the owner's dependency-update request permits + security overrides in `pnpm-workspace.yaml` and regenerated `pnpm-lock.yaml`. + Stock npm servers and the TestFlight app still receive dependencies from + upstream; this fork update changes their bundled fork counterparts only. +- **Detail:** not-compromised/t3code#8, not-compromised/t3code#10. +- **Retire when:** upstream's dependency requirements exclude the vulnerable versions. + ## Verification A run is good when the release exists with `T3-Code-*.AppImage` and diff --git a/patches/query-string@7.1.3.patch b/patches/query-string@7.1.3.patch new file mode 100644 index 0000000000000..916a641bf3d87 --- /dev/null +++ b/patches/query-string@7.1.3.patch @@ -0,0 +1,11 @@ +diff --git a/index.js b/index.js +index 4cf3c5e..3c4f9d3 100644 +--- a/index.js ++++ b/index.js +@@ -1,5 +1,5 @@ + 'use strict'; + const strictUriEncode = require('strict-uri-encode'); +-const decodeComponent = require('decode-uri-component'); ++const decodeComponent = require('decode-uri-component').default; + const splitOnFirst = require('split-on-first'); + const filterObject = require('filter-obj'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e0ff3a339c9b..f9f33861488f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,18 @@ catalogs: version: 11.0.5 overrides: + '@modelcontextprotocol/sdk@1.29.0': ^1.31.0 + proxy-addr@2.0.7: ^2.0.8 + seroval@1.5.4: ^1.6.3 + shell-quote@1.10.0: ^1.11.0 + sharp@0.35.4: ^0.35.5 + source-map-js@1.2.1: ^1.2.2 + smol-toml@1.8.0: ^1.9.0 + uuid@7.0.3: ^11.1.1 + valibot@1.2.0: ^1.4.2 + xml2js@0.4.23: ^0.6.0 + katex@0.16.47: ^0.18.2 + decode-uri-component@0.2.2: ^0.5.0 '@exodus/bytes>@noble/hashes': 1.8.0 '@react-navigation/core': 7.21.13 dbus-next>usocket: '-' @@ -165,6 +177,7 @@ patchedDependencies: expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 + query-string@7.1.3: cfe5f429f2bd1e7155cd91c78734a8e474af385f5443ae87f8bff1a8511c4592 react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 @@ -298,8 +311,8 @@ importers: specifier: ^7.3.5 version: 7.3.5(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) sharp: - specifier: 0.35.4 - version: 0.35.4(@types/node@24.12.4) + specifier: ^0.35.5 + version: 0.35.5(@types/node@24.12.4) devDependencies: '@astrojs/check': specifier: ^0.9.10 @@ -537,7 +550,7 @@ importers: version: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) react-native-enriched-markdown: specifier: file:deps/react-native-enriched-markdown-1.2.0-t3.11.tgz - version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz(katex@0.16.47)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz(katex@0.18.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-gesture-handler: specifier: ~3.2.1 version: 3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -619,7 +632,7 @@ importers: dependencies: '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.276 - version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5) + version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5))(zod@4.6.5) '@cursor/sdk': specifier: 1.0.35 version: 1.0.35 @@ -718,8 +731,8 @@ importers: specifier: 4.0.2 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0(zod@4.6.5) + specifier: ^1.31.0 + version: 1.31.0(zod@4.6.5) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -1035,10 +1048,10 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.80 - version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(ba3b2ceb33761bc36b3beb11002eb316) + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(49f496572593f908642e975f9dd4f1ba) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(da2d75dec181fbafaeb6986c8741457b) + version: 1.0.0-rc.5-ab785fc(59bf16287a03ba2888d338222244ec2f) effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) @@ -1870,8 +1883,8 @@ importers: specifier: 7.0.0 version: 7.0.0 sharp: - specifier: 0.35.4 - version: 0.35.4(@types/node@24.12.4) + specifier: ^0.35.5 + version: 0.35.5(@types/node@24.12.4) devDependencies: '@effect/vitest': specifier: 4.0.2 @@ -1964,7 +1977,7 @@ packages: engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' - '@modelcontextprotocol/sdk': ^1.29.0 + '@modelcontextprotocol/sdk': ^1.31.0 zod: ^4.0.0 '@anthropic-ai/sdk@0.93.0': @@ -3927,160 +3940,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -4245,8 +4258,8 @@ packages: '@mermaid-js/parser@1.2.1': resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/sdk@1.31.0': + resolution: {integrity: sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -8051,9 +8064,9 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - decode-uri-component@0.2.2: - resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} - engines: {node: '>=0.10'} + decode-uri-component@0.5.0: + resolution: {integrity: sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==} + engines: {node: '>=14.16'} decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} @@ -8229,7 +8242,7 @@ packages: sql.js: '>=1' sqlite3: '>=5' typebox: '>=1.2.0' - valibot: '>=1.0.0-beta.7' + valibot: ^1.4.2 zod: ^3.25.0 || ^4.0.0 peerDependenciesMeta: '@aws-sdk/client-rds-data': @@ -9533,8 +9546,8 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - katex@0.16.47: - resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + katex@0.18.2: + resolution: {integrity: sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==} hasBin: true keyv@4.5.4: @@ -10754,8 +10767,8 @@ packages: prosemirror-view@1.42.3: resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} pump@3.0.4: @@ -10852,7 +10865,7 @@ packages: resolution: {integrity: sha512-flSZglM98BMtQHffAu1HVq8Y295sff1aT9vMdUZMnSj/3QojV21DSlSITNNhPAArIsAr1rdUrLhipggO8TvfIg==, tarball: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz} version: 1.2.0-t3.11 peerDependencies: - katex: '>=0.16.0' + katex: ^0.18.2 react: '*' react-native: '*' peerDependenciesMeta: @@ -11218,10 +11231,10 @@ packages: resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} peerDependencies: - seroval: ^1.0 + seroval: ^1.6.3 - seroval@1.5.4: - resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + seroval@1.6.3: + resolution: {integrity: sha512-5PwLbVPpT4DEki1hYVSSsoxWY6xCNMVucAVLK/CH0mXSDfp/I4KaAex72q80GNt5hpn7CCMGdSpIg4YGqH/wXQ==} engines: {node: '>=10'} serve-static@1.16.3: @@ -11242,8 +11255,8 @@ packages: resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} engines: {node: '>=10'} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -11259,8 +11272,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.10.0: - resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + shell-quote@1.11.0: + resolution: {integrity: sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==} engines: {node: '>= 0.4'} shiki@4.2.0: @@ -11336,16 +11349,16 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.8.0: - resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} socks@2.8.9: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.21: @@ -11945,23 +11958,14 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@14.0.2: - resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true - uuid@7.0.3: - resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} hasBin: true - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -12281,10 +12285,6 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} - xml2js@0.4.23: - resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==} - engines: {node: '>=4.0.0'} - xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -12450,7 +12450,7 @@ snapshots: effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) magic-string: 0.30.21 mime: 4.1.0 - sharp: 0.35.4(@types/node@24.12.4) + sharp: 0.35.5(@types/node@24.12.4) unenv: 2.0.0-rc.24 workerd: 1.20260918.1 yauzl: 3.4.0 @@ -12482,10 +12482,10 @@ snapshots: package-manager-detector: 1.8.0 tinyexec: 1.3.1 - '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5)': + '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5))(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.6.5) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.6.5) + '@modelcontextprotocol/sdk': 1.31.0(zod@4.6.5) zod: 4.6.5 '@anthropic-ai/sdk@0.93.0(zod@4.6.5)': @@ -12581,7 +12581,7 @@ snapshots: picomatch: 4.0.7 retext-smartypants: 6.2.0 shiki: 4.2.0 - smol-toml: 1.8.0 + smol-toml: 1.9.0 unified: 11.0.5 '@astrojs/language-server@2.16.10(prettier@3.8.3)(typescript@6.0.3)': @@ -14846,108 +14846,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@inquirer/ansi@1.0.2': @@ -15126,7 +15126,7 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@modelcontextprotocol/sdk@1.29.0(zod@4.6.5)': + '@modelcontextprotocol/sdk@1.31.0(zod@4.6.5)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) ajv: 8.20.0 @@ -16084,7 +16084,7 @@ snapshots: escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.19 - query-string: 7.1.3 + query-string: 7.1.3(patch_hash=cfe5f429f2bd1e7155cd91c78734a8e474af385f5443ae87f8bff1a8511c4592) react: 19.3.0 react-is: 19.2.7 use-latest-callback: 0.2.6(react@19.3.0) @@ -16425,7 +16425,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.33.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -16531,8 +16531,8 @@ snapshots: dependencies: '@tanstack/history': 1.162.0 cookie-es: 3.1.1 - seroval: 1.5.4 - seroval-plugins: 1.5.4(seroval@1.5.4) + seroval: 1.6.3 + seroval-plugins: 1.5.4(seroval@1.6.3) '@tanstack/router-generator@1.167.12': dependencies: @@ -17616,7 +17616,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(ba3b2ceb33761bc36b3beb11002eb316): + alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(49f496572593f908642e975f9dd4f1ba): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)))(@effect/platform-node@4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)) @@ -17659,7 +17659,7 @@ snapshots: '@effect/sql-pg': 4.0.2(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)) '@effect/vitest': 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc(da2d75dec181fbafaeb6986c8741457b) + drizzle-orm: 1.0.0-rc.5-ab785fc(59bf16287a03ba2888d338222244ec2f) mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) pg: 8.23.0 vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' @@ -17825,7 +17825,7 @@ snapshots: piccolore: 0.1.3 picomatch: 4.0.7 shiki: 4.2.0 - smol-toml: 1.8.0 + smol-toml: 1.9.0 svgo: 4.1.0 tinyclip: 1.0.3 tinyexec: 1.3.1 @@ -17840,7 +17840,7 @@ snapshots: yargs-parser: 22.0.0 zod: 4.6.5 optionalDependencies: - sharp: 0.35.4(@types/node@24.12.4) + sharp: 0.35.5(@types/node@24.12.4) transitivePeerDependencies: - '@arethetypeswrong/core' - '@azure/app-configuration' @@ -18444,12 +18444,12 @@ snapshots: css-tree@2.2.1: dependencies: mdn-data: 2.0.28 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-tree@3.2.1: dependencies: mdn-data: 2.27.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-what@6.2.2: {} @@ -18666,7 +18666,7 @@ snapshots: jsbi: 2.0.5 long: 4.0.0 safe-buffer: 5.2.1 - xml2js: 0.4.23 + xml2js: 0.6.0 debounce-fn@4.0.0: dependencies: @@ -18690,7 +18690,7 @@ snapshots: dependencies: character-entities: 2.0.2 - decode-uri-component@0.2.2: {} + decode-uri-component@0.5.0: {} decompress-response@6.0.0: dependencies: @@ -18815,7 +18815,7 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.5-ab785fc(da2d75dec181fbafaeb6986c8741457b): + drizzle-orm@1.0.0-rc.5-ab785fc(59bf16287a03ba2888d338222244ec2f): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 '@effect/sql-d1': 4.0.0(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)) @@ -18828,7 +18828,6 @@ snapshots: effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) expo-sqlite: 58.0.8(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) pg: 8.23.0 - valibot: 1.2.0(typescript@7.0.2) zod: 4.4.3 dset@3.1.4: {} @@ -19528,7 +19527,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0 @@ -20284,7 +20283,7 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 - katex@0.16.47: + katex@0.18.2: dependencies: commander: 8.3.0 @@ -20307,7 +20306,7 @@ snapshots: oxc-parser: 0.147.0 oxc-resolver: 11.24.2 picomatch: 4.0.7 - smol-toml: 1.8.0 + smol-toml: 1.9.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 unbash: 4.0.11 @@ -20470,7 +20469,7 @@ snapshots: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 makeerror@1.0.12: dependencies: @@ -20695,7 +20694,7 @@ snapshots: dompurify: 3.4.16 es-toolkit: 1.52.0 fastdom: 1.0.12 - katex: 0.16.47 + katex: 0.18.2 khroma: 2.1.0 marked: 16.4.2 roughjs: 4.6.6 @@ -21800,7 +21799,7 @@ snapshots: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres-array@2.0.0: optional: true @@ -21955,7 +21954,7 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.1 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -21978,9 +21977,9 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 - query-string@7.1.3: + query-string@7.1.3(patch_hash=cfe5f429f2bd1e7155cd91c78734a8e474af385f5443ae87f8bff1a8511c4592): dependencies: - decode-uri-component: 0.2.2 + decode-uri-component: 0.5.0 filter-obj: 1.1.0 split-on-first: 1.1.0 strict-uri-encode: 2.0.0 @@ -22008,7 +22007,7 @@ snapshots: react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: - shell-quote: 1.10.0 + shell-quote: 1.11.0 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -22059,12 +22058,12 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz(katex@0.16.47)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.11.tgz(katex@0.18.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) optionalDependencies: - katex: 0.16.47 + katex: 0.18.2 react-native-gesture-handler@3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: @@ -22622,11 +22621,11 @@ snapshots: type-fest: 0.13.1 optional: true - seroval-plugins@1.5.4(seroval@1.5.4): + seroval-plugins@1.5.4(seroval@1.6.3): dependencies: - seroval: 1.5.4 + seroval: 1.6.3 - seroval@1.5.4: {} + seroval@1.6.3: {} serve-static@1.16.3: dependencies: @@ -22652,37 +22651,37 @@ snapshots: sf-symbols-typescript@2.2.0: {} - sharp@0.35.4(@types/node@24.12.4): + sharp@0.35.5(@types/node@24.12.4): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 24.12.4 shebang-command@2.0.0: @@ -22691,7 +22690,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.10.0: {} + shell-quote@1.11.0: {} shiki@4.2.0: dependencies: @@ -22791,7 +22790,7 @@ snapshots: smart-buffer@4.2.0: optional: true - smol-toml@1.8.0: {} + smol-toml@1.9.0: {} socks@2.8.9: dependencies: @@ -22799,7 +22798,7 @@ snapshots: smart-buffer: 4.2.0 optional: true - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map-support@0.5.21: dependencies: @@ -23338,14 +23337,9 @@ snapshots: utils-merge@1.0.1: {} - uuid@14.0.2: {} - - uuid@7.0.3: {} + uuid@11.1.1: {} - valibot@1.2.0(typescript@7.0.2): - optionalDependencies: - typescript: 7.0.2 - optional: true + uuid@14.0.2: {} validate-npm-package-name@5.0.1: {} @@ -23734,18 +23728,13 @@ snapshots: xcode@3.0.1: dependencies: simple-plist: 1.3.1 - uuid: 7.0.3 + uuid: 11.1.1 xml-name-validator@5.0.0: {} xml-naming@0.1.0: optional: true - xml2js@0.4.23: - dependencies: - sax: 1.6.1 - xmlbuilder: 11.0.1 - xml2js@0.6.0: dependencies: sax: 1.6.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 86279b45106c2..24bfa46238206 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -193,6 +193,19 @@ minimumReleaseAgeExclude: - "@muse-code/sdk@1.4.6" overrides: + # Fixed minimum versions keep compatible upstream patch/minor updates available. + "@modelcontextprotocol/sdk@1.29.0": "^1.31.0" + "proxy-addr@2.0.7": "^2.0.8" + "seroval@1.5.4": "^1.6.3" + "shell-quote@1.10.0": "^1.11.0" + "sharp@0.35.4": "^0.35.5" + "source-map-js@1.2.1": "^1.2.2" + "smol-toml@1.8.0": "^1.9.0" + "uuid@7.0.3": "^11.1.1" + "valibot@1.2.0": "^1.4.2" + "xml2js@0.4.23": "^0.6.0" + "katex@0.16.47": "^0.18.2" + "decode-uri-component@0.2.2": "^0.5.0" # Keep jsdom's optional peer consistent so Vite+ has one test-runner instance. "@exodus/bytes>@noble/hashes": "1.8.0" # Earlier React Navigation declarations create a circular type alias in TypeScript 7. @@ -288,6 +301,8 @@ packageExtensions: vite-plus: "catalog:" patchedDependencies: + # Preserve React Navigation's query-string API while using its fixed ESM decoder. + query-string@7.1.3: patches/query-string@7.1.3.patch alchemy@2.0.0-beta.80: patches/alchemy@2.0.0-beta.80.patch expo-glass-effect@58.0.3: patches/expo-glass-effect@58.0.3.patch expo-blur@58.0.3: patches/expo-blur@58.0.3.patch From c4f3f2de410e4f1b07d4dc470acf98c0ac91781d Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:38:20 -0500 Subject: [PATCH 10/11] chore(fork): dispatch the nightly from the owner's server (#13) GitHub's cron for fork-nightly.yml was silently disabled until Oct 12 and fired 7h40m late on Oct 13, shipping mid-day. The owner's server now dispatches it once a night, or by day after the owner approves; remove the schedule so a late GitHub run cannot ship without that approval. Closes #12 --- .github/workflows/fork-nightly.yml | 8 ++------ FORK.md | 13 ++++++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/fork-nightly.yml b/.github/workflows/fork-nightly.yml index 0a0e4d3d829f0..46e850df0d9d1 100644 --- a/.github/workflows/fork-nightly.yml +++ b/.github/workflows/fork-nightly.yml @@ -4,13 +4,9 @@ # installed apps, servers and the version-skew check all see one number. name: Fork nightly +# No schedule: the owner's server dispatches this once a night, or by day after +# the owner approves. GitHub's cron dropped runs and fired hours late (#12). on: - schedule: - # Finishes before the owner's 08:30 UTC digest scan, which reports a fork - # still behind upstream; servers install the release within 30 minutes. - # GitHub drops a public repo's schedules after 60 days without activity - # and revives them only when a person commits a change to this line. - - cron: "35 7 * * *" workflow_dispatch: inputs: target_tag: diff --git a/FORK.md b/FORK.md index 0a9a72d5258be..c1deb7fd511b9 100644 --- a/FORK.md +++ b/FORK.md @@ -123,11 +123,13 @@ otherwise. ### Fork pipeline - **Commits:** `chore(fork): nightly rolling rebase and release pipeline`, - `fix(fork): revive the nightly schedule GitHub dropped` + `fix(fork): revive the nightly schedule GitHub dropped`, + `chore(fork): dispatch the nightly from the owner's server` - **Must still be true:** - - Daily at 07:35 UTC and on demand, the newest upstream nightly gets - `custom` replayed onto it, checked and built; it ships only when every - step is green. + - On each dispatch, the newest upstream nightly gets `custom` replayed onto + it, checked and built; it ships only when every step is green. GitHub + never starts it on a schedule: the owner's server dispatches it once a + night, or by day after the owner approves. - A changed `ORCHESTRATION_PROTOCOL_VERSION` never ships without `allow_protocol_change`. - A release carries the upstream version unchanged, and fork builds update @@ -143,7 +145,8 @@ otherwise. - **Tests:** `node --test .github/fork/*.test.cjs` (Check job), one test per promise above that can be checked without running Actions. - **Detail:** this file and `.github/workflows/fork-nightly.yml`; - not-compromised/t3code#6 (nightly schedule revival). + not-compromised/t3code#6 (nightly schedule revival), + not-compromised/t3code#12 (dispatch from the owner's server). - **Retire when:** never. ### Dependency security fixes From 8b7abc2addb980b7ad38a1aca4c6950ebf2d98dd Mon Sep 17 00:00:00 2001 From: Peyton Lamb Date: Wed, 14 Oct 2026 02:04:03 -0500 Subject: [PATCH 11/11] fix(web): stop the sidebar keeping every past render alive The desktop renderer grew 100-200 MB an hour while agents ran, then crashed with SIGTRAP at V8's heap limit (pingdotgg/t3code#17295). Heap snapshots of a live renderer showed one retained Sidebar render scope per render since the window opened (370 in 90 minutes). That chain held 188 MB of a 363 MB heap, mostly old server configs carrying every environment's provider and skill catalogs. V8 gives all closures from one render a single shared scope. Some menu and gesture callbacks were memoized on serverConfigs, others on projects or projectByKey. Each kept alive the render that last rebuilt the other, so every render stayed reachable back to the first one. Those callbacks only run on user events, so they now read serverConfigs, projects and projectByKey through refs and stay stable across renders. Nothing memoized links one render to the next any more. Closes #14 --- FORK.md | 16 +++++++++ apps/web/src/components/Sidebar.tsx | 56 +++++++++++++++++------------ 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/FORK.md b/FORK.md index c1deb7fd511b9..c7791f02a63e3 100644 --- a/FORK.md +++ b/FORK.md @@ -168,6 +168,22 @@ otherwise. - **Detail:** not-compromised/t3code#8, not-compromised/t3code#10. - **Retire when:** upstream's dependency requirements exclude the vulnerable versions. +### Sidebar render leak + +- **Commits:** `fix(web): stop the sidebar keeping every past render alive` +- **Must still be true:** + - With agents running, the desktop window's Sidebar keeps a constant + handful of render scopes alive, not one per render. Renderer memory + stops growing from Sidebar re-renders and doesn't climb toward the V8 + heap limit. + - The thread, draft and multi-select context menus, the project settings + shortcut and the row action sweep act on the capabilities and projects + current when they run. +- **Detail:** not-compromised/t3code#14; upstream crash report + pingdotgg/t3code#17295. +- **Retire when:** upstream ships an equivalent fix to + `apps/web/src/components/Sidebar.tsx`. + ## Verification A run is good when the release exists with `T3-Code-*.AppImage` and diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0243fc502a444..938121cde758e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2599,6 +2599,16 @@ export default function Sidebar() { () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), [projects], ); + // Menu and gesture callbacks read these through refs. Every closure from one + // render shares that render's scope, so a callback memoized on server configs + // and another memoized on projects would each keep alive the render that last + // rebuilt the other, chaining every past render and its configs in memory. + const serverConfigsRef = useRef(serverConfigs); + serverConfigsRef.current = serverConfigs; + const projectsRef = useRef(projects); + projectsRef.current = projects; + const projectByKeyRef = useRef(projectByKey); + projectByKeyRef.current = projectByKey; const projectDisplayNameByKey = useMemo( () => new Map( @@ -2783,13 +2793,13 @@ export default function Sidebar() { projectKey: getSidebarProjectSettingsKey( projectGroup, projectGroupingSettings, - projects, + projectsRef.current, projectRef, ), }, }); }, - [isMobile, projectGroupingSettings, projects, router, setOpenMobile], + [isMobile, projectGroupingSettings, router, setOpenMobile], ); // Anchor for the scope popup: the header search field, not its icon trigger. const headerSearchRef = useRef(null); @@ -3634,7 +3644,7 @@ export default function Sidebar() { const canApply = (key: string) => { const thread = threadByKeyRef.current.get(key); const capabilities = - thread && serverConfigs.get(thread.environmentId)?.environment.capabilities; + thread && serverConfigsRef.current.get(thread.environmentId)?.environment.capabilities; return ( thread !== undefined && sectionByThreadKeyRef.current.get(key) === originSection && @@ -3686,7 +3696,7 @@ export default function Sidebar() { onAbort: () => {}, }); }, - [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads], + [attemptUnsettle, attemptUnsnooze, settleThreads], ); const pinnedKeys = useMemo( () => @@ -4309,6 +4319,7 @@ export default function Sidebar() { async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; + const configs = serverConfigsRef.current; // One exact actionable set: keys whose rows are actually rendered // right now. Selections can outlive their rows (settled-tail paging, // thread deletion elsewhere) and the menu labels must count only what @@ -4331,13 +4342,13 @@ export default function Sidebar() { ); const canSnoozeSelection = selectedThreads.every( (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && + configs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow.toISOString() }), ); const titleRegenerationThreads = selectedThreads.filter( (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true, + configs.get(thread.environmentId)?.environment.capabilities.threadTitleRegeneration === + true, ); const regeneratableTitleThreads = titleRegenerationThreads.filter( (thread) => thread.titleRegeneration == null, @@ -4351,8 +4362,8 @@ export default function Sidebar() { // is omitted entirely when nothing selected is pinned. const pinnedSelectedThreads = selectedThreads.filter( (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === - true && thread.pinnedAt != null, + configs.get(thread.environmentId)?.environment.capabilities.threadPinning === true && + thread.pinnedAt != null, ); const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, @@ -4573,7 +4584,6 @@ export default function Sidebar() { markThreadUnread, performSnooze, removeFromSelection, - serverConfigs, settleThreads, updateThreadMetadata, timestampFormat, @@ -4594,7 +4604,8 @@ export default function Sidebar() { ); const workspacePath = session.worktreePath ?? - projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot; + projectByKeyRef.current.get(`${session.environmentId}:${session.projectId}`) + ?.workspaceRoot; const clicked = await settlePromise(() => api.contextMenu.show( buildDraftActionMenuItems({ @@ -4630,7 +4641,7 @@ export default function Sidebar() { } })(); }, - [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey], + [copyBranchToClipboard, copyPathToClipboard, openProjectSettings], ); const handleThreadContextMenu = useCallback( @@ -4646,26 +4657,27 @@ export default function Sidebar() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; + const configs = serverConfigsRef.current; const threadWorkspacePath = thread.worktreePath ?? - projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ?? + projectByKeyRef.current.get(`${thread.environmentId}:${thread.projectId}`) + ?.workspaceRoot ?? null; // Un-settle pins the thread active until real activity clears the pin. // Environments without // the settlement capability get no lifecycle items at all. const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; + configs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + configs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const supportsPinning = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; + configs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; const supportsAutoSettleOptOut = - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadAutoSettleOptOut === true; + configs.get(thread.environmentId)?.environment.capabilities.threadAutoSettleOptOut === + true; const supportsTitleRegeneration = - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadTitleRegeneration === true; + configs.get(thread.environmentId)?.environment.capabilities.threadTitleRegeneration === + true; const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); @@ -4921,8 +4933,6 @@ export default function Sidebar() { markThreadUnread, openProjectSettings, projectScopeKey, - projectByKey, - serverConfigs, setProjectScopeKey, setThreadAutoSettle, startThreadRename,