diff --git a/AGENTS.md b/AGENTS.md index 7c1fcd05d..78a2e07be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ bun run clean:workspaces # Clean all workspace node_modules 8. **Linear ticket format** - all tickets (creation, drafting, grooming) follow `.agents/skills/ticket-format/SKILL.md`. Read that file before creating or grooming a ticket. 9. **TanStack DB / Electric live queries are cache-first** - `useLiveQuery` can return persisted rows in `data` while the collection is still not `isReady`. Always render existing rows first. Use `isReady` only to decide what to show when no row/data exists yet: no data + not ready = loading/skeleton/null; no data + ready = empty/not-found. Never hide, blank, or replace existing `data` just because `isReady` is false or `isLoading` is true. This cache-first rendering rule does not apply to write/seeding side effects: wait for strict readiness before deriving missing rows or writing defaults, unless the write is provably idempotent. 10. **No agent attribution in git/gh** - Never mention Claude, Codex, or any AI agent in git or GitHub content — commit messages, PR titles/descriptions, issue text, review comments, branch names, etc. Omit `Co-Authored-By` agent trailers and "Generated with" / "🤖" lines. Write everything as the human developer. +11. **Fork delta tracking** - when a PR intentionally changes O3 Code behavior relative to upstream, update `docs/internal/fork-deltas/registry.md` with the PR link, affected area, short description, and upstream sync note. Keep the full implementation detail in the PR. --- diff --git a/apps/api/vercel.json b/apps/api/vercel.json new file mode 100644 index 000000000..81aef9b48 --- /dev/null +++ b/apps/api/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "ignoreCommand": "bash -lc 'repo=$(git rev-parse --show-toplevel) && exec bash \"$repo/scripts/vercel-ignore-if-unaffected.sh\" api'", + "installCommand": "bun install --frozen-lockfile --filter=@o3dotdev/code-api --concurrent-scripts=1" +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3c2cfc916..5d883fda8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -239,6 +239,7 @@ "use-resize-observer": "9.1.0", "utf-8-validate": "6.0.6", "uuid": "14.0.0", + "ws": "8.20.0", "zod": "4.3.6", "zustand": "5.0.12" }, @@ -259,6 +260,7 @@ "@types/react-syntax-highlighter": "15.5.13", "@types/semver": "7.7.1", "@types/shell-quote": "1.7.5", + "@types/ws": "8.18.1", "@vitejs/plugin-react": "5.2.0", "code-inspector-plugin": "1.4.5", "cross-env": "10.1.0", diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 2794ae94f..f1d65bbc8 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -40,6 +40,16 @@ import { setupSingleAgent } from "main/lib/agent-setup"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { getHostServiceCoordinator } from "main/lib/host-service-coordinator"; import { localDb } from "main/lib/local-db"; +import { + normalizePersistedWebAccessSettings, + parseTrustedWebAccessOriginsInput, +} from "main/web-access/helpers"; +import { + assertValidWebAccessPort, + getWebAccessSettingsResponse, + startWebAccessServer, + stopWebAccessServer, +} from "main/web-access/server"; import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET, DEFAULT_CONFIRM_ON_QUIT, @@ -100,6 +110,40 @@ function getSettings() { return row; } +const webAccessSettingsInputSchema = z.object({ + enabled: z.boolean().optional(), + port: z.number().int().optional(), + trustedOrigins: z.array(z.string()).optional(), +}); + +function persistWebAccessSettings({ + enabled, + port, + trustedOrigins, +}: { + enabled: boolean; + port: number; + trustedOrigins: string[]; +}) { + localDb + .insert(settings) + .values({ + id: 1, + webAccessEnabled: enabled, + webAccessPort: port, + webAccessTrustedOrigins: trustedOrigins, + }) + .onConflictDoUpdate({ + target: settings.id, + set: { + webAccessEnabled: enabled, + webAccessPort: port, + webAccessTrustedOrigins: trustedOrigins, + }, + }) + .run(); +} + function readRawTerminalPresets(): PresetWithUnknownMode[] { const row = getSettings(); return (row.terminalPresets ?? []) as PresetWithUnknownMode[]; @@ -675,6 +719,61 @@ export const createSettingsRouter = () => { return { restartedOrgCount }; }), + getWebAccessSettings: publicProcedure.query(() => { + return getWebAccessSettingsResponse(); + }), + + setWebAccessSettings: publicProcedure + .input(webAccessSettingsInputSchema) + .mutation(async ({ input }) => { + const row = getSettings(); + const current = normalizePersistedWebAccessSettings(row); + const next = { + enabled: input.enabled ?? current.enabled, + port: input.port ?? current.port, + trustedOrigins: current.trustedOrigins, + }; + + try { + assertValidWebAccessPort(next.port); + if (input.trustedOrigins) { + next.trustedOrigins = parseTrustedWebAccessOriginsInput( + input.trustedOrigins, + ); + } + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Invalid Web Access settings.", + }); + } + + if (next.enabled) { + try { + await startWebAccessServer(next.port); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to start Web Access.", + }); + } + } + + persistWebAccessSettings(next); + + if (!next.enabled) { + await stopWebAccessServer(); + } + + return getWebAccessSettingsResponse(); + }), + getShowPresetsBar: publicProcedure.query(() => { const row = getSettings(); return row.showPresetsBar ?? DEFAULT_SHOW_PRESETS_BAR; diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index fe7c58eee..4007253d8 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -1,5 +1,13 @@ +import { EventEmitter } from "node:events"; +import { observable } from "@trpc/server/observable"; import { appState } from "main/lib/app-state"; import type { TabsState, ThemeState } from "main/lib/app-state/schemas"; +import { + applySharedUiStatePatch, + ensureSharedUiState, + SHARED_UI_COLLECTION_NAMES, + type SharedUiStateEvent, +} from "shared/shared-ui-state"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -231,6 +239,49 @@ const themeStateSchema = z.object({ systemDarkThemeId: z.string().optional(), }); +const sharedUiStateEmitter = new EventEmitter(); + +const sharedUiRouteStateSchema = z.object({ + hashPath: z.string().min(1).startsWith("/"), + activeWorkspaceId: z.string().nullable(), + updatedAt: z.number().finite().nonnegative(), +}); + +const sharedUiCollectionRowsSchema = z.record(z.string(), z.unknown()); + +const sharedUiCollectionsPatchSchema = z + .object( + Object.fromEntries( + SHARED_UI_COLLECTION_NAMES.map((collectionName) => [ + collectionName, + sharedUiCollectionRowsSchema.optional(), + ]), + ) as Record< + (typeof SHARED_UI_COLLECTION_NAMES)[number], + z.ZodOptional + >, + ) + .partial(); + +const sharedUiPatchSchema = z + .object({ + clientId: z.string().min(1), + route: sharedUiRouteStateSchema.nullable().optional(), + organizationId: z.string().min(1).optional(), + collections: sharedUiCollectionsPatchSchema.optional(), + }) + .refine((input) => input.route !== undefined || input.collections, { + message: "At least one shared UI state field must be provided", + }) + .refine((input) => !input.collections || input.organizationId, { + message: "organizationId is required when patching collections", + path: ["organizationId"], + }); + +const sharedUiSubscribeSchema = z.object({ + clientId: z.string().min(1), +}); + /** * UI State router - manages tabs and theme persistence via lowdb */ @@ -272,5 +323,50 @@ export const createUiStateRouter = () => { return appState.data.hotkeysState; }), }), + + shared: router({ + get: publicProcedure.query(() => { + appState.data.sharedUiState = ensureSharedUiState( + appState.data.sharedUiState, + ); + return appState.data.sharedUiState; + }), + + patch: publicProcedure + .input(sharedUiPatchSchema) + .mutation(async ({ input }) => { + const nextSnapshot = applySharedUiStatePatch( + ensureSharedUiState(appState.data.sharedUiState), + input, + ); + appState.data.sharedUiState = nextSnapshot; + await appState.write(); + + sharedUiStateEmitter.emit("snapshot", { + type: "snapshot", + sourceClientId: input.clientId, + snapshot: nextSnapshot, + } satisfies SharedUiStateEvent); + + return nextSnapshot; + }), + + subscribe: publicProcedure + .input(sharedUiSubscribeSchema) + .subscription(({ input }) => { + return observable((emit) => { + const onSnapshot = (event: SharedUiStateEvent) => { + if (event.sourceClientId === input.clientId) return; + emit.next(event); + }; + + sharedUiStateEmitter.on("snapshot", onSnapshot); + + return () => { + sharedUiStateEmitter.off("snapshot", onSnapshot); + }; + }); + }), + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/shared.test.ts b/apps/desktop/src/lib/trpc/routers/ui-state/shared.test.ts new file mode 100644 index 000000000..fe07ae050 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ui-state/shared.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { + createDefaultSharedUiState, + type SharedUiStateSnapshot, +} from "shared/shared-ui-state"; + +const appState = { + data: { + tabsState: { + tabs: [], + panes: {}, + activeTabIds: {}, + focusedPaneIds: {}, + tabHistoryStacks: {}, + }, + themeState: { + activeThemeId: "dark", + customThemes: [], + systemLightThemeId: "light", + systemDarkThemeId: "dark", + }, + hotkeysState: { + version: 1, + byPlatform: { darwin: {}, win32: {}, linux: {} }, + }, + sharedUiState: createDefaultSharedUiState(), + }, + write: mock(async () => {}), +}; + +mock.module("main/lib/app-state", () => ({ appState })); + +const { createUiStateRouter } = await import("./index"); + +function resetAppState(): void { + appState.data.sharedUiState = createDefaultSharedUiState(); + appState.write.mockClear(); +} + +describe("uiState.shared router", () => { + beforeEach(() => { + resetAppState(); + }); + + it("returns default shared UI state", async () => { + const caller = createUiStateRouter().createCaller({}); + + await expect(caller.shared.get()).resolves.toEqual( + createDefaultSharedUiState(), + ); + }); + + it("persists route and collection patches", async () => { + const caller = createUiStateRouter().createCaller({}); + + const result = await caller.shared.patch({ + clientId: "client-a", + route: { + hashPath: "/v2-workspace/11111111-1111-1111-1111-111111111111", + activeWorkspaceId: "11111111-1111-1111-1111-111111111111", + updatedAt: 1, + }, + organizationId: "org-1", + collections: { + v2SidebarProjects: { + "22222222-2222-2222-2222-222222222222": { + projectId: "22222222-2222-2222-2222-222222222222", + tabOrder: 0, + }, + }, + }, + }); + + expect(result.revision).toBe(1); + expect(appState.data.sharedUiState).toEqual(result); + expect(appState.write).toHaveBeenCalledTimes(1); + expect( + result.organizations["org-1"]?.v2SidebarProjects[ + "22222222-2222-2222-2222-222222222222" + ], + ).toEqual({ + projectId: "22222222-2222-2222-2222-222222222222", + tabOrder: 0, + }); + }); + + it("rejects collection patches without an organization id", async () => { + const caller = createUiStateRouter().createCaller({}); + + await expect( + caller.shared.patch({ + clientId: "client-a", + collections: { + v2UserPreferences: { + preferences: { id: "preferences" }, + }, + }, + }), + ).rejects.toThrow("organizationId"); + }); + + it("broadcasts subscription events without echoing the writer", async () => { + const caller = createUiStateRouter().createCaller({}); + const observerEvents: SharedUiStateSnapshot[] = []; + const writerEvents: SharedUiStateSnapshot[] = []; + + const observerSubscription = await caller.shared.subscribe({ + clientId: "observer", + }); + const writerSubscription = await caller.shared.subscribe({ + clientId: "writer", + }); + + const observerUnsubscribe = observerSubscription.subscribe({ + next: (event) => { + observerEvents.push(event.snapshot); + }, + }); + const writerUnsubscribe = writerSubscription.subscribe({ + next: (event) => { + writerEvents.push(event.snapshot); + }, + }); + + await caller.shared.patch({ + clientId: "writer", + route: { + hashPath: "/tasks", + activeWorkspaceId: null, + updatedAt: 1, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(observerEvents).toHaveLength(1); + expect(observerEvents[0]?.route?.hashPath).toBe("/tasks"); + expect(writerEvents).toHaveLength(0); + + observerUnsubscribe.unsubscribe(); + writerUnsubscribe.unsubscribe(); + }); +}); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 540d42d16..c3bb40c7f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -50,7 +50,12 @@ import { } from "./lib/terminal-host/client"; import { disposeTray, initTray } from "./lib/tray"; import { startNetworkLogger, stopNetworkLogger } from "./network-logger"; -import { MainWindow } from "./windows/main"; +import { + setWebAccessRouterFactory, + stopWebAccessServer, + syncWebAccessServerWithSettings, +} from "./web-access/server"; +import { getAppRouter, MainWindow } from "./windows/main"; console.log("[main] Local database ready:", !!localDb); const IS_DEV = process.env.NODE_ENV === "development"; @@ -233,7 +238,7 @@ app.on("before-quit", async (event) => { } catch (error) { console.error("[main] Cleanup during quit failed:", error); } finally { - await stopNetworkLogger(); + await Promise.allSettled([stopWebAccessServer(), stopNetworkLogger()]); } app.exit(0); }); @@ -272,6 +277,7 @@ if (process.env.NODE_ENV === "development") { getHostServiceCoordinator().stopAll(); void Promise.allSettled([ teardownTerminalHost(), + stopWebAccessServer(), stopNetworkLogger(), ]).finally(() => app.exit(0)); }; @@ -423,7 +429,9 @@ if (!gotTheLock) { }); } + setWebAccessRouterFactory(getAppRouter); await makeAppSetup(() => MainWindow()); + await syncWebAccessServerWithSettings(); setupAutoUpdater(); initTray(); diff --git a/apps/desktop/src/main/lib/app-state/index.ts b/apps/desktop/src/main/lib/app-state/index.ts index 00e9fe790..6818751df 100644 --- a/apps/desktop/src/main/lib/app-state/index.ts +++ b/apps/desktop/src/main/lib/app-state/index.ts @@ -1,4 +1,5 @@ import { JSONFilePreset } from "lowdb/node"; +import { ensureSharedUiState } from "shared/shared-ui-state"; import { APP_STATE_PATH } from "../app-environment"; import type { AppState } from "./schemas"; import { defaultAppState } from "./schemas"; @@ -30,6 +31,7 @@ function ensureValidShape(data: Partial): AppState { ...(data.hotkeysState?.byPlatform ?? {}), }, }, + sharedUiState: ensureSharedUiState(data.sharedUiState), }; } diff --git a/apps/desktop/src/main/lib/app-state/schemas.ts b/apps/desktop/src/main/lib/app-state/schemas.ts index d381e08b2..25d63e3a6 100644 --- a/apps/desktop/src/main/lib/app-state/schemas.ts +++ b/apps/desktop/src/main/lib/app-state/schemas.ts @@ -1,6 +1,11 @@ /** * UI state schemas (persisted from renderer zustand stores) */ + +import { + createDefaultSharedUiState, + type SharedUiStateSnapshot, +} from "shared/shared-ui-state"; import type { BaseTabsState } from "shared/tabs-types"; import type { Theme } from "shared/themes"; @@ -24,6 +29,7 @@ export interface AppState { tabsState: BaseTabsState; themeState: ThemeState; hotkeysState: LegacyHotkeysState; + sharedUiState: SharedUiStateSnapshot; } export const defaultAppState: AppState = { @@ -44,4 +50,5 @@ export const defaultAppState: AppState = { version: 1, byPlatform: { darwin: {}, win32: {}, linux: {} }, }, + sharedUiState: createDefaultSharedUiState(), }; diff --git a/apps/desktop/src/main/web-access/helpers.test.ts b/apps/desktop/src/main/web-access/helpers.test.ts new file mode 100644 index 000000000..2fa4ac0c3 --- /dev/null +++ b/apps/desktop/src/main/web-access/helpers.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "bun:test"; +import { DEFAULT_WEB_ACCESS_PORT } from "shared/constants"; +import { + createWebAccessSettingsResponse, + getEffectiveWebAccessStatus, + getLocalWebAccessOrigins, + getWebAccessOrigin, + getWebAccessUrl, + isAllowedWebAccessOrigin, + isLocalWebAccessOrigin, + isValidWebAccessPort, + normalizePersistedWebAccessSettings, + normalizeTrustedWebAccessOrigin, + parseTrustedWebAccessOriginsInput, +} from "./helpers"; + +describe("web access helpers", () => { + it("constructs localhost URLs from the configured port", () => { + expect(getWebAccessUrl(44010)).toBe("http://127.0.0.1:44010/#/"); + expect(getWebAccessOrigin(44010)).toBe("http://127.0.0.1:44010"); + expect(getLocalWebAccessOrigins(44010)).toEqual([ + "http://127.0.0.1:44010", + "http://localhost:44010", + ]); + }); + + it("validates user-configurable ports", () => { + expect(isValidWebAccessPort(1024)).toBe(true); + expect(isValidWebAccessPort(65_535)).toBe(true); + expect(isValidWebAccessPort(1023)).toBe(false); + expect(isValidWebAccessPort(65_536)).toBe(false); + expect(isValidWebAccessPort(44010.5)).toBe(false); + expect(isValidWebAccessPort("44010")).toBe(false); + }); + + it("accepts only same-port localhost origins", () => { + expect(isLocalWebAccessOrigin("http://127.0.0.1:44010", 44010)).toBe(true); + expect(isLocalWebAccessOrigin("http://localhost:44010", 44010)).toBe(true); + expect(isLocalWebAccessOrigin("http://127.0.0.1:44011", 44010)).toBe(false); + expect(isLocalWebAccessOrigin("https://127.0.0.1:44010", 44010)).toBe( + false, + ); + expect(isLocalWebAccessOrigin(undefined, 44010)).toBe(false); + }); + + it("normalizes trusted reverse-proxy origins", () => { + expect( + normalizeTrustedWebAccessOrigin("https://device.example.com/path"), + ).toBe("https://device.example.com"); + expect(normalizeTrustedWebAccessOrigin("ftp://example.com")).toBeNull(); + expect(normalizeTrustedWebAccessOrigin("https://*.example.com")).toBeNull(); + expect( + normalizeTrustedWebAccessOrigin("https://user:pass@example.com"), + ).toBeNull(); + expect( + parseTrustedWebAccessOriginsInput([ + "https://device.example.com", + "https://device.example.com/", + "http://127.0.0.1:44010", + ]), + ).toEqual(["https://device.example.com"]); + }); + + it("accepts only local or explicitly trusted reverse-proxy origins", () => { + const trustedOrigins = ["https://device.example.com"]; + + expect( + isAllowedWebAccessOrigin({ + origin: "https://device.example.com", + port: 44010, + trustedOrigins, + }), + ).toBe(true); + expect( + isAllowedWebAccessOrigin({ + origin: "https://device.example.com", + port: 44010, + trustedOrigins: [], + }), + ).toBe(false); + expect( + isAllowedWebAccessOrigin({ + origin: "https://evil.example.com", + port: 44010, + trustedOrigins, + }), + ).toBe(false); + expect( + isAllowedWebAccessOrigin({ + origin: "https://attacker.example", + port: 44010, + trustedOrigins: [], + }), + ).toBe(false); + expect( + isAllowedWebAccessOrigin({ + origin: undefined, + port: 44010, + trustedOrigins, + }), + ).toBe(false); + expect( + isAllowedWebAccessOrigin({ + allowMissingOrigin: true, + origin: undefined, + hostHeader: "device.example.com", + port: 44010, + trustedOrigins, + }), + ).toBe(true); + expect( + isAllowedWebAccessOrigin({ + allowMissingOrigin: true, + origin: undefined, + hostHeader: "attacker.example", + port: 44010, + trustedOrigins, + }), + ).toBe(false); + expect( + isAllowedWebAccessOrigin({ + allowMissingOrigin: true, + origin: undefined, + hostHeader: "127.0.0.1:44010", + port: 44010, + trustedOrigins: [], + }), + ).toBe(true); + }); + + it("maps disabled persisted state to stopped regardless of runtime state", () => { + expect( + getEffectiveWebAccessStatus({ + enabled: false, + runtime: { + status: "running", + port: 44010, + url: getWebAccessUrl(44010), + }, + }), + ).toBe("stopped"); + expect( + getEffectiveWebAccessStatus({ + enabled: true, + runtime: { + status: "running", + port: 44010, + url: getWebAccessUrl(44010), + }, + }), + ).toBe("running"); + }); + + it("normalizes missing or invalid persisted values to defaults", () => { + expect(normalizePersistedWebAccessSettings(null)).toEqual({ + enabled: false, + port: DEFAULT_WEB_ACCESS_PORT, + trustedOrigins: [], + }); + expect( + normalizePersistedWebAccessSettings({ + webAccessEnabled: true, + webAccessPort: 80, + webAccessTrustedOrigins: [ + "https://device.example.com/path", + "ftp://invalid.example", + "http://localhost:44010", + ], + }), + ).toEqual({ + enabled: true, + port: DEFAULT_WEB_ACCESS_PORT, + trustedOrigins: ["https://device.example.com"], + }); + }); + + it("builds settings responses with disabled status mapping", () => { + expect( + createWebAccessSettingsResponse({ + persisted: { + enabled: false, + port: 44010, + trustedOrigins: ["https://device.example.com"], + }, + runtime: { + status: "running", + port: 44010, + url: getWebAccessUrl(44010), + }, + }), + ).toEqual({ + enabled: false, + localOrigins: ["http://127.0.0.1:44010", "http://localhost:44010"], + port: 44010, + trustedOrigins: ["https://device.example.com"], + status: "stopped", + url: getWebAccessUrl(44010), + error: undefined, + }); + }); +}); diff --git a/apps/desktop/src/main/web-access/helpers.ts b/apps/desktop/src/main/web-access/helpers.ts new file mode 100644 index 000000000..324491982 --- /dev/null +++ b/apps/desktop/src/main/web-access/helpers.ts @@ -0,0 +1,270 @@ +import { + DEFAULT_WEB_ACCESS_ENABLED, + DEFAULT_WEB_ACCESS_PORT, + DEFAULT_WEB_ACCESS_TRUSTED_ORIGINS, +} from "shared/constants"; + +export const WEB_ACCESS_HOST = "127.0.0.1" as const; +export const WEB_ACCESS_WS_PATH = "/trpc" as const; +export const WEB_ACCESS_HOST_SERVICE_PROXY_PREFIX = + "/_web-access/host-service/" as const; +export const WEB_ACCESS_MIN_PORT = 1024; +export const WEB_ACCESS_MAX_PORT = 65_535; + +export type WebAccessRuntimeStatus = + | "stopped" + | "starting" + | "running" + | "error"; + +export interface WebAccessRuntimeState { + error?: string; + port: number; + status: WebAccessRuntimeStatus; + url: string; +} + +export interface PersistedWebAccessSettings { + enabled: boolean; + port: number; + trustedOrigins: string[]; +} + +export interface WebAccessSettingsResponse + extends PersistedWebAccessSettings, + WebAccessRuntimeState { + localOrigins: string[]; +} + +export function isValidWebAccessPort(port: unknown): port is number { + return ( + typeof port === "number" && + Number.isInteger(port) && + port >= WEB_ACCESS_MIN_PORT && + port <= WEB_ACCESS_MAX_PORT + ); +} + +export function normalizePersistedWebAccessSettings( + input: + | { + webAccessEnabled?: boolean | null; + webAccessPort?: number | null; + webAccessTrustedOrigins?: unknown; + } + | null + | undefined, +): PersistedWebAccessSettings { + const port = input?.webAccessPort; + + return { + enabled: input?.webAccessEnabled ?? DEFAULT_WEB_ACCESS_ENABLED, + port: isValidWebAccessPort(port) ? port : DEFAULT_WEB_ACCESS_PORT, + trustedOrigins: normalizePersistedWebAccessTrustedOrigins( + input?.webAccessTrustedOrigins, + ), + }; +} + +export function getWebAccessUrl(port: number): string { + return `http://${WEB_ACCESS_HOST}:${port}/#/`; +} + +export function getWebAccessOrigin(port: number): string { + return `http://${WEB_ACCESS_HOST}:${port}`; +} + +export function getLocalWebAccessOrigins(port: number): string[] { + return [getWebAccessOrigin(port), `http://localhost:${port}`]; +} + +export function isLocalWebAccessOrigin( + origin: string | undefined, + port: number, +): boolean { + if (!origin) return false; + + try { + const parsed = new URL(origin); + return ( + parsed.protocol === "http:" && + (parsed.hostname === WEB_ACCESS_HOST || + parsed.hostname === "localhost") && + parsed.port === String(port) + ); + } catch { + return false; + } +} + +export function normalizeTrustedWebAccessOrigin(origin: string): string | null { + const trimmed = origin.trim(); + if (!trimmed) return null; + + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + if (parsed.username || parsed.password) return null; + if (!parsed.hostname || parsed.hostname.includes("*")) return null; + + return parsed.origin; + } catch { + return null; + } +} + +export function normalizePersistedWebAccessTrustedOrigins( + input: unknown, +): string[] { + if (!Array.isArray(input)) return DEFAULT_WEB_ACCESS_TRUSTED_ORIGINS; + + const normalizedOrigins: string[] = []; + for (const value of input) { + if (typeof value !== "string") continue; + + const normalized = normalizeTrustedWebAccessOrigin(value); + if (!normalized || isManagedLocalOrigin(normalized)) continue; + if (!normalizedOrigins.includes(normalized)) { + normalizedOrigins.push(normalized); + } + } + + return normalizedOrigins; +} + +export function parseTrustedWebAccessOriginsInput( + input: readonly string[], +): string[] { + const normalizedOrigins: string[] = []; + + for (const value of input) { + const normalized = normalizeTrustedWebAccessOrigin(value); + if (!normalized) { + throw new Error( + "Trusted origins must be valid HTTP or HTTPS origins without wildcards or credentials.", + ); + } + if (isManagedLocalOrigin(normalized)) continue; + if (!normalizedOrigins.includes(normalized)) { + normalizedOrigins.push(normalized); + } + } + + return normalizedOrigins; +} + +export function isAllowedWebAccessOrigin({ + allowMissingOrigin = false, + hostHeader, + origin, + port, + trustedOrigins = DEFAULT_WEB_ACCESS_TRUSTED_ORIGINS, +}: { + allowMissingOrigin?: boolean; + hostHeader?: string | undefined; + origin: string | undefined; + port: number; + trustedOrigins?: readonly string[]; +}): boolean { + if (isLocalWebAccessOrigin(origin, port)) return true; + if (!origin) { + return ( + allowMissingOrigin && + isAllowedMissingOriginHost({ hostHeader, port, trustedOrigins }) + ); + } + + const normalizedOrigin = normalizeTrustedWebAccessOrigin(origin); + if (!normalizedOrigin) return false; + + return trustedOrigins.includes(normalizedOrigin); +} + +function isAllowedMissingOriginHost({ + hostHeader, + port, + trustedOrigins, +}: { + hostHeader: string | undefined; + port: number; + trustedOrigins: readonly string[]; +}): boolean { + if (!hostHeader) return false; + + try { + const localHostUrl = new URL(`http://${hostHeader}`); + if ( + (localHostUrl.hostname === WEB_ACCESS_HOST || + localHostUrl.hostname === "localhost") && + getEffectivePort(localHostUrl) === String(port) + ) { + return true; + } + + const hostUrl = new URL(`https://${hostHeader}`); + return trustedOrigins.some((trustedOrigin) => { + const trustedUrl = new URL(trustedOrigin); + return ( + trustedUrl.hostname === hostUrl.hostname && + getEffectivePort(trustedUrl) === getEffectivePort(hostUrl) + ); + }); + } catch { + return false; + } +} + +function isManagedLocalOrigin(origin: string): boolean { + try { + const parsed = new URL(origin); + return ( + parsed.hostname === WEB_ACCESS_HOST || parsed.hostname === "localhost" + ); + } catch { + return false; + } +} + +function getEffectivePort(url: URL): string { + if (url.port) return url.port; + if (url.protocol === "https:") return "443"; + if (url.protocol === "http:") return "80"; + return ""; +} + +export function getEffectiveWebAccessStatus({ + enabled, + runtime, +}: { + enabled: boolean; + runtime: WebAccessRuntimeState; +}): WebAccessRuntimeStatus { + if (!enabled) return "stopped"; + return runtime.status; +} + +export function createWebAccessSettingsResponse({ + persisted, + runtime, +}: { + persisted: PersistedWebAccessSettings; + runtime: WebAccessRuntimeState; +}): WebAccessSettingsResponse { + const status = getEffectiveWebAccessStatus({ + enabled: persisted.enabled, + runtime, + }); + + return { + enabled: persisted.enabled, + localOrigins: getLocalWebAccessOrigins(persisted.port), + port: persisted.port, + trustedOrigins: persisted.trustedOrigins, + url: getWebAccessUrl(persisted.port), + status, + error: + status === "error" || status === "running" ? runtime.error : undefined, + }; +} diff --git a/apps/desktop/src/main/web-access/server.ts b/apps/desktop/src/main/web-access/server.ts new file mode 100644 index 000000000..0a7711c6f --- /dev/null +++ b/apps/desktop/src/main/web-access/server.ts @@ -0,0 +1,637 @@ +import { existsSync } from "node:fs"; +import { createServer, type IncomingMessage, type Server } from "node:http"; +import { join } from "node:path"; +import type { Duplex } from "node:stream"; +import { settings } from "@o3dotdev/code-local-db"; +import { applyWSSHandler } from "@trpc/server/adapters/ws"; +import express from "express"; +import httpProxy from "http-proxy"; +import type { AppRouter } from "lib/trpc/routers"; +import { env as mainEnv } from "main/env.main"; +import { localDb } from "main/lib/local-db"; +import { DEFAULT_WEB_ACCESS_PORT } from "shared/constants"; +import { env } from "shared/env.shared"; +import { WebSocketServer } from "ws"; +import { + createWebAccessSettingsResponse, + getWebAccessOrigin, + getWebAccessUrl, + isAllowedWebAccessOrigin, + isValidWebAccessPort, + normalizePersistedWebAccessSettings, + type PersistedWebAccessSettings, + WEB_ACCESS_HOST, + WEB_ACCESS_HOST_SERVICE_PROXY_PREFIX, + WEB_ACCESS_WS_PATH, + type WebAccessRuntimeState, + type WebAccessSettingsResponse, +} from "./helpers"; + +type WebAccessRouterFactory = () => AppRouter; +type WebAccessTrpcHandler = ReturnType>; +type ProxyServer = ReturnType; + +interface WebAccessServerHandle { + apiProxy: ProxyServer; + electricProxy: ProxyServer; + hostServiceProxy: ProxyServer; + httpServer: Server; + port: number; + rendererProxy: ProxyServer | null; + trpcHandler: WebAccessTrpcHandler; + url: string; + wss: WebSocketServer; +} + +let routerFactory: WebAccessRouterFactory | null = null; +let activeHandle: WebAccessServerHandle | null = null; + +let runtimeState: WebAccessRuntimeState = { + status: "stopped", + port: DEFAULT_WEB_ACCESS_PORT, + url: getWebAccessUrl(DEFAULT_WEB_ACCESS_PORT), +}; + +export function setWebAccessRouterFactory( + factory: WebAccessRouterFactory, +): void { + routerFactory = factory; +} + +export function getWebAccessRuntimeState(): WebAccessRuntimeState { + return { ...runtimeState }; +} + +export function readPersistedWebAccessSettings(): PersistedWebAccessSettings { + const row = localDb.select().from(settings).get(); + return normalizePersistedWebAccessSettings(row); +} + +export function getWebAccessSettingsResponse(): WebAccessSettingsResponse { + return createWebAccessSettingsResponse({ + persisted: readPersistedWebAccessSettings(), + runtime: getWebAccessRuntimeState(), + }); +} + +export function assertValidWebAccessPort(port: number): void { + if (isValidWebAccessPort(port)) return; + throw new Error("Web Access port must be an integer between 1024 and 65535."); +} + +export async function syncWebAccessServerWithSettings(): Promise { + const persisted = readPersistedWebAccessSettings(); + + if (!persisted.enabled) { + await stopWebAccessServer(); + runtimeState = { + status: "stopped", + port: persisted.port, + url: getWebAccessUrl(persisted.port), + }; + return; + } + + try { + await startWebAccessServer(persisted.port); + } catch (error) { + console.error("[web-access] Failed to start from settings:", error); + } +} + +export async function startWebAccessServer( + port: number, +): Promise { + assertValidWebAccessPort(port); + + if (activeHandle?.port === port) { + runtimeState = { + status: "running", + port, + url: activeHandle.url, + error: undefined, + }; + return getWebAccessRuntimeState(); + } + + const previousHandle = activeHandle; + runtimeState = { + status: "starting", + port, + url: getWebAccessUrl(port), + error: undefined, + }; + + try { + const nextHandle = await createWebAccessServerHandle(port); + activeHandle = nextHandle; + runtimeState = { + status: "running", + port, + url: nextHandle.url, + error: undefined, + }; + + if (previousHandle) { + void closeWebAccessServerHandle(previousHandle).catch((error) => { + console.error("[web-access] Failed to close previous server:", error); + }); + } + + return getWebAccessRuntimeState(); + } catch (error) { + const message = getErrorMessage(error); + + if (previousHandle) { + activeHandle = previousHandle; + runtimeState = { + status: "running", + port: previousHandle.port, + url: previousHandle.url, + error: message, + }; + } else { + activeHandle = null; + runtimeState = { + status: "error", + port, + url: getWebAccessUrl(port), + error: message, + }; + } + + throw new Error(message); + } +} + +export async function stopWebAccessServer(): Promise { + const handle = activeHandle; + activeHandle = null; + + if (handle) { + await closeWebAccessServerHandle(handle); + } + + runtimeState = { + status: "stopped", + port: runtimeState.port, + url: getWebAccessUrl(runtimeState.port), + error: undefined, + }; + + return getWebAccessRuntimeState(); +} + +async function createWebAccessServerHandle( + port: number, +): Promise { + const app = express(); + app.disable("x-powered-by"); + + app.get("/health", (_req, res) => { + res.json({ status: "ok" }); + }); + + const apiProxy = configureApiProxy(app); + const electricProxy = configureElectricProxy(app, port); + const hostServiceProxy = configureHostServiceProxy(app, port); + const rendererProxy = configureRendererServing(app, port); + const httpServer = createServer(app); + const wss = new WebSocketServer({ noServer: true }); + const trpcHandler = applyWSSHandler({ + wss, + router: getAppRouter(), + createContext: () => ({}), + keepAlive: { enabled: true, pingMs: 30_000, pongWaitMs: 5_000 }, + }); + + httpServer.on("upgrade", (request, socket, head) => { + const pathname = getUpgradePathname(request, port); + + if (pathname === WEB_ACCESS_WS_PATH) { + handleTrpcUpgrade({ request, socket, head, port, wss }); + return; + } + + if (request.url?.startsWith(WEB_ACCESS_HOST_SERVICE_PROXY_PREFIX)) { + handleHostServiceUpgrade({ + request, + socket, + head, + port, + proxy: hostServiceProxy, + }); + return; + } + + if (rendererProxy) { + if (!isAllowedUpgradeOrigin(request, port)) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + rendererProxy.ws(request, socket, head); + return; + } + + socket.destroy(); + }); + + await listenOnLocalhost(httpServer, port); + + httpServer.on("error", (error) => { + const message = getErrorMessage(error); + console.error("[web-access] Server error:", error); + runtimeState = { + status: "error", + port, + url: getWebAccessUrl(port), + error: message, + }; + }); + + console.log(`[web-access] Listening on ${getWebAccessUrl(port)}`); + + return { + httpServer, + apiProxy, + electricProxy, + hostServiceProxy, + port, + rendererProxy, + trpcHandler, + url: getWebAccessUrl(port), + wss, + }; +} + +function getAppRouter(): AppRouter { + if (!routerFactory) { + throw new Error("Web Access router factory has not been configured."); + } + return routerFactory(); +} + +function configureApiProxy(app: express.Express): ProxyServer { + const target = mainEnv.NEXT_PUBLIC_API_URL; + const targetOrigin = new URL(target).origin; + const proxy = httpProxy.createProxyServer({ + changeOrigin: true, + target: targetOrigin, + }); + + proxy.on("proxyReq", (proxyReq) => { + proxyReq.setHeader("origin", targetOrigin); + proxyReq.setHeader("referer", `${targetOrigin}/`); + }); + + proxy.on("error", (error, _req, res) => { + console.error("[web-access] API proxy error:", error); + + if (res && "writeHead" in res && !res.headersSent) { + res.writeHead(502, { "Content-Type": "text/plain" }); + res.end(`API server is not available at ${targetOrigin}.`); + } + }); + + app.use((req, res, next) => { + if (!req.url.startsWith("/api")) { + next(); + return; + } + + proxy.web(req, res); + }); + + return proxy; +} + +function configureElectricProxy( + app: express.Express, + port: number, +): ProxyServer { + const target = mainEnv.NEXT_PUBLIC_ELECTRIC_URL; + const targetUrl = new URL(target); + const targetOrigin = targetUrl.origin; + const proxy = httpProxy.createProxyServer({ + changeOrigin: true, + secure: !isLoopbackHostname(targetUrl.hostname), + target: targetOrigin, + }); + + proxy.on("proxyReq", (proxyReq) => { + proxyReq.setHeader("origin", targetOrigin); + proxyReq.setHeader("referer", `${targetOrigin}/`); + }); + + proxy.on("error", (error, _req, res) => { + console.error("[web-access] Electric proxy error:", error); + + if (res && "writeHead" in res && !res.headersSent) { + res.writeHead(502, { "Content-Type": "text/plain" }); + res.end(`Electric server is not available at ${targetOrigin}.`); + } + }); + + app.use((req, res, next) => { + if (!req.url.startsWith("/v1/shape")) { + next(); + return; + } + + if (!isAllowedHttpOrigin(req, port)) { + res.status(403).send("Forbidden origin."); + return; + } + + proxy.web(req, res); + }); + + return proxy; +} + +function configureHostServiceProxy( + app: express.Express, + port: number, +): ProxyServer { + const proxy = httpProxy.createProxyServer({ + changeOrigin: true, + ws: true, + }); + + proxy.on("error", (error, _req, res) => { + console.error("[web-access] Host service proxy error:", error); + + if (res && "writeHead" in res && !res.headersSent) { + res.writeHead(502, { "Content-Type": "text/plain" }); + res.end("Host service is not available."); + } + }); + + app.use((req, res, next) => { + if (!req.url.startsWith(WEB_ACCESS_HOST_SERVICE_PROXY_PREFIX)) { + next(); + return; + } + + if (!isAllowedHttpOrigin(req, port)) { + res.status(403).send("Forbidden origin."); + return; + } + + const route = parseHostServiceProxyRoute(req.url); + if (!route) { + res.status(400).send("Invalid host service proxy URL."); + return; + } + + req.url = route.path; + proxy.web(req, res, { target: route.targetOrigin }); + }); + + return proxy; +} + +function handleHostServiceUpgrade({ + request, + socket, + head, + port, + proxy, +}: { + head: Buffer; + port: number; + proxy: ProxyServer; + request: IncomingMessage; + socket: Duplex; +}): void { + if (!isAllowedUpgradeOrigin(request, port)) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + + const route = parseHostServiceProxyRoute(request.url ?? ""); + if (!route) { + socket.write("HTTP/1.1 400 Bad Request\r\n\r\n"); + socket.destroy(); + return; + } + + request.url = route.path; + proxy.ws(request, socket, head, { target: route.targetOrigin }); +} + +function parseHostServiceProxyRoute( + requestUrl: string, +): { path: string; targetOrigin: string } | null { + const remainder = requestUrl.slice( + WEB_ACCESS_HOST_SERVICE_PROXY_PREFIX.length, + ); + const pathStart = remainder.indexOf("/"); + if (pathStart < 0) return null; + + const encodedTarget = remainder.slice(0, pathStart); + const path = remainder.slice(pathStart); + + try { + const target = new URL(decodeURIComponent(encodedTarget)); + if (!isLoopbackHttpUrl(target)) return null; + return { path, targetOrigin: target.origin }; + } catch { + return null; + } +} + +function isLoopbackHttpUrl(url: URL): boolean { + return ( + (url.protocol === "http:" || url.protocol === "https:") && + isLoopbackHostname(url.hostname) + ); +} + +function isLoopbackHostname(hostname: string): boolean { + return ( + hostname === WEB_ACCESS_HOST || + hostname === "localhost" || + hostname === "::1" || + hostname === "[::1]" + ); +} + +function isAllowedHttpOrigin(req: express.Request, port: number): boolean { + return isAllowedWebAccessOrigin({ + allowMissingOrigin: true, + hostHeader: req.headers.host, + origin: req.headers.origin, + port, + trustedOrigins: readPersistedWebAccessSettings().trustedOrigins, + }); +} + +function isAllowedUpgradeOrigin( + request: IncomingMessage, + port: number, +): boolean { + return isAllowedWebAccessOrigin({ + hostHeader: request.headers.host, + origin: request.headers.origin, + port, + trustedOrigins: readPersistedWebAccessSettings().trustedOrigins, + }); +} + +function configureRendererServing( + app: express.Express, + _port: number, +): ProxyServer | null { + if (env.NODE_ENV === "development") { + const target = `http://localhost:${env.DESKTOP_VITE_PORT}`; + const proxy = httpProxy.createProxyServer({ + changeOrigin: true, + target, + ws: true, + }); + + proxy.on("error", (error, _req, res) => { + console.error("[web-access] Renderer dev proxy error:", error); + + if (res && "writeHead" in res && !res.headersSent) { + res.writeHead(502, { "Content-Type": "text/plain" }); + res.end(`Desktop renderer dev server is not available at ${target}.`); + } + }); + + app.use((req, res) => { + proxy.web(req, res); + }); + + return proxy; + } + + const rendererRoot = join(__dirname, "../renderer"); + const indexPath = join(rendererRoot, "index.html"); + + app.use(express.static(rendererRoot, { index: false })); + app.use((_req, res) => { + if (!existsSync(indexPath)) { + res.status(404).send("Renderer assets not found."); + return; + } + res.sendFile(indexPath); + }); + + return null; +} + +function handleTrpcUpgrade({ + request, + socket, + head, + port, + wss, +}: { + head: Buffer; + port: number; + request: IncomingMessage; + socket: Duplex; + wss: WebSocketServer; +}): void { + if ( + !isAllowedWebAccessOrigin({ + hostHeader: request.headers.host, + origin: request.headers.origin, + port, + trustedOrigins: readPersistedWebAccessSettings().trustedOrigins, + }) + ) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + + wss.handleUpgrade(request, socket, head, (webSocket) => { + wss.emit("connection", webSocket, request); + }); +} + +function getUpgradePathname(request: IncomingMessage, port: number): string { + try { + const base = request.headers.host + ? `http://${request.headers.host}` + : getWebAccessOrigin(port); + return new URL(request.url ?? "/", base).pathname; + } catch { + return "/"; + } +} + +function listenOnLocalhost(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + server.off("error", onError); + server.off("listening", onListening); + }; + const onError = (error: NodeJS.ErrnoException) => { + cleanup(); + if (error.code === "EADDRINUSE") { + reject(new Error(`Port ${port} is already in use.`)); + return; + } + reject(error); + }; + const onListening = () => { + cleanup(); + resolve(); + }; + + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, WEB_ACCESS_HOST); + }); +} + +async function closeWebAccessServerHandle( + handle: WebAccessServerHandle, +): Promise { + handle.trpcHandler.broadcastReconnectNotification(); + + for (const client of handle.wss.clients) { + client.close(1001, "Web Access server restarting"); + } + + await Promise.allSettled([ + closeWebSocketServer(handle.wss), + closeHttpServer(handle.httpServer), + ]); + + handle.apiProxy.close(); + handle.electricProxy.close(); + handle.hostServiceProxy.close(); + handle.rendererProxy?.close(); +} + +function closeWebSocketServer(wss: WebSocketServer): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + for (const client of wss.clients) { + client.terminate(); + } + }, 250); + wss.close(() => { + clearTimeout(timeout); + resolve(); + }); + }); +} + +function closeHttpServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index ea2a2be96..44ed391da 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -66,6 +66,15 @@ let currentWindow: BrowserWindow | null = null; // Routers receive this getter so they always see the current window, not a stale reference const getWindow = () => currentWindow; +let appRouter: ReturnType | null = null; + +export function getAppRouter() { + if (!appRouter) { + appRouter = createAppRouter(getWindow); + } + return appRouter; +} + // invalidate() alone may not rebuild corrupted GPU layers — a tiny resize // forces Chromium to reconstruct the compositor layer tree. const forceRepaint = (win: BrowserWindow) => { @@ -174,7 +183,7 @@ export async function MainWindow() { ipcHandler.attachWindow(window); } else { ipcHandler = createIPCHandler({ - router: createAppRouter(getWindow), + router: getAppRouter(), windows: [window], }); } diff --git a/apps/desktop/src/renderer/commandPalette/modules/settings/commands.ts b/apps/desktop/src/renderer/commandPalette/modules/settings/commands.ts index 5b63fcb63..78d380a5f 100644 --- a/apps/desktop/src/renderer/commandPalette/modules/settings/commands.ts +++ b/apps/desktop/src/renderer/commandPalette/modules/settings/commands.ts @@ -8,6 +8,7 @@ import { FileTextIcon, FolderIcon, GitBranchIcon, + GlobeIcon, KeyboardIcon, KeyRoundIcon, LinkIcon, @@ -123,6 +124,13 @@ const TABS: SettingsTab[] = [ path: "/settings/security", icon: KeyRoundIcon, }, + { + id: "web-access", + title: "Web Access", + path: "/settings/web-access", + icon: GlobeIcon, + keywords: ["web", "browser", "localhost", "port"], + }, { id: "agents", title: "Agents", path: "/settings/agents", icon: WrenchIcon }, { id: "presets", diff --git a/apps/desktop/src/renderer/components/Chat/utils/chat-service-client.ts b/apps/desktop/src/renderer/components/Chat/utils/chat-service-client.ts index 89aa99863..a1064199a 100644 --- a/apps/desktop/src/renderer/components/Chat/utils/chat-service-client.ts +++ b/apps/desktop/src/renderer/components/Chat/utils/chat-service-client.ts @@ -1,10 +1,10 @@ import { chatServiceTrpc } from "@o3dotdev/code-chat/client"; +import type { ChatServiceRouter } from "@o3dotdev/code-chat/server/desktop"; import type { TRPCLink } from "@trpc/client"; import type { AnyRouter } from "@trpc/server"; import { observable } from "@trpc/server/observable"; +import { createElectronTransportLink } from "renderer/lib/electron-trpc-transport"; import { sessionIdLink } from "renderer/lib/session-id-link"; -import superjson from "superjson"; -import { ipcLink } from "trpc-electron/renderer"; /** Prepends a router prefix to operation paths so a standalone-typed client can talk to a nested sub-router. */ function prefixLink( @@ -20,9 +20,9 @@ function prefixLink( export function createChatServiceIpcClient() { return chatServiceTrpc.createClient({ links: [ - prefixLink("chatService"), + prefixLink("chatService"), sessionIdLink(), - ipcLink({ transformer: superjson }), + createElectronTransportLink(), ], }); } diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/MarkdownEditor.tsx index 6e4654c6c..b8ba81d97 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor/MarkdownEditor.tsx @@ -36,8 +36,8 @@ import { BubbleMenu } from "@tiptap/react/menus"; import { common, createLowlight } from "lowlight"; import { useEffect, useRef } from "react"; import { BubbleMenuToolbar } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar"; -import { env } from "renderer/env.renderer"; import { useInlineUrlPolicy } from "renderer/lib/clickPolicy"; +import { getRuntimeApiUrl } from "renderer/lib/runtime-urls"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { Markdown } from "tiptap-markdown"; import { CodeBlockView } from "./components/CodeBlockView"; @@ -63,7 +63,7 @@ function isLinearImageUrl(src: string): boolean { } function getLinearProxyUrl(linearUrl: string): string { - const proxyUrl = new URL(`${env.NEXT_PUBLIC_API_URL}/api/proxy/linear-image`); + const proxyUrl = new URL(`${getRuntimeApiUrl()}/api/proxy/linear-image`); proxyUrl.searchParams.set("url", linearUrl); return proxyUrl.toString(); } diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/LinearImage/LinearImage.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/LinearImage/LinearImage.tsx index 44f1d8644..406039cc6 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor/components/LinearImage/LinearImage.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/LinearImage/LinearImage.tsx @@ -1,4 +1,4 @@ -import { env } from "renderer/env.renderer"; +import { getRuntimeApiUrl } from "renderer/lib/runtime-urls"; const LINEAR_IMAGE_HOST = "uploads.linear.app"; @@ -18,7 +18,7 @@ function isLinearImageUrl(src: string): boolean { * Converts a Linear image URL to our proxy URL. */ function getLinearProxyUrl(linearUrl: string): string { - const proxyUrl = new URL(`${env.NEXT_PUBLIC_API_URL}/api/proxy/linear-image`); + const proxyUrl = new URL(`${getRuntimeApiUrl()}/api/proxy/linear-image`); proxyUrl.searchParams.set("url", linearUrl); return proxyUrl.toString(); } diff --git a/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts b/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts index 4d055235c..da1fa1924 100644 --- a/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts +++ b/apps/desktop/src/renderer/hooks/useVersionCheck/useVersionCheck.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { env } from "renderer/env.renderer"; +import { getRuntimeApiUrl } from "renderer/lib/runtime-urls"; import { lt } from "semver"; interface VersionRequirements { @@ -32,9 +32,7 @@ export function useVersionCheck(): UseVersionCheckResult { } try { - const response = await fetch( - `${env.NEXT_PUBLIC_API_URL}/api/desktop/version`, - ); + const response = await fetch(`${getRuntimeApiUrl()}/api/desktop/version`); if (!response.ok) { // Fail open - if API is down, don't block users diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index d3422a73e..d51bbcbbf 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -1,3 +1,4 @@ +import { isBrowserMode } from "./lib/browser-bridge"; import { initSentry } from "./lib/sentry"; initSentry(); @@ -14,12 +15,16 @@ import { } from "./lib/boot-errors"; import { persistentHistory } from "./lib/persistent-hash-history"; import { posthog } from "./lib/posthog"; +import { initSharedUiStateRouteSync } from "./lib/shared-ui-state-route-sync"; import { electronQueryClient } from "./providers/ElectronTRPCProvider"; import { NotFound } from "./routes/not-found"; import { routeTree } from "./routeTree.gen"; import "./globals.css"; -import "./styles/bundled-fonts.css"; + +if (!isBrowserMode()) { + void import("./styles/bundled-fonts.css"); +} const rootElement = document.querySelector("app"); initBootErrorHandling(rootElement); @@ -34,10 +39,12 @@ const router = createRouter({ }, }); +const sharedUiRouteSync = initSharedUiStateRouteSync(router); const unsubscribe = router.subscribe("onResolved", (event) => { posthog.capture("$pageview", { $current_url: event.toLocation.pathname, }); + sharedUiRouteSync.recordResolvedPath(event.toLocation.href); }); const handleDeepLink = (path: string) => { @@ -56,6 +63,7 @@ if (ipcRenderer) { if (import.meta.hot) { import.meta.hot.dispose(() => { unsubscribe(); + sharedUiRouteSync.dispose(); if (ipcRenderer) { ipcRenderer.off("deep-link-navigate", handleDeepLink); } diff --git a/apps/desktop/src/renderer/lib/api-trpc-client.ts b/apps/desktop/src/renderer/lib/api-trpc-client.ts index c7ca0d1a0..675afa30e 100644 --- a/apps/desktop/src/renderer/lib/api-trpc-client.ts +++ b/apps/desktop/src/renderer/lib/api-trpc-client.ts @@ -1,8 +1,8 @@ import type { AppRouter } from "@o3dotdev/code-trpc"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; -import { env } from "renderer/env.renderer"; import superjson from "superjson"; import { getAuthToken } from "./auth-client"; +import { getRuntimeApiUrl } from "./runtime-urls"; /** * HTTP tRPC client for calling the API server. @@ -12,7 +12,7 @@ import { getAuthToken } from "./auth-client"; export const apiTrpcClient = createTRPCProxyClient({ links: [ httpBatchLink({ - url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, + url: `${getRuntimeApiUrl()}/api/trpc`, transformer: superjson, headers: () => { const token = getAuthToken(); diff --git a/apps/desktop/src/renderer/lib/auth-client.ts b/apps/desktop/src/renderer/lib/auth-client.ts index da00e2844..f052b4383 100644 --- a/apps/desktop/src/renderer/lib/auth-client.ts +++ b/apps/desktop/src/renderer/lib/auth-client.ts @@ -7,7 +7,7 @@ import { organizationClient, } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; -import { env } from "renderer/env.renderer"; +import { getRuntimeApiUrl } from "./runtime-urls"; let authToken: string | null = null; @@ -36,7 +36,7 @@ export function getJwt(): string | null { * Server has bearer() plugin enabled to accept bearer tokens. */ export const authClient = createAuthClient({ - baseURL: env.NEXT_PUBLIC_API_URL, + baseURL: getRuntimeApiUrl(), plugins: [ organizationClient({ teams: { enabled: true }, diff --git a/apps/desktop/src/renderer/lib/browser-bridge.ts b/apps/desktop/src/renderer/lib/browser-bridge.ts new file mode 100644 index 000000000..764c131b5 --- /dev/null +++ b/apps/desktop/src/renderer/lib/browser-bridge.ts @@ -0,0 +1,110 @@ +const WEB_ACCESS_WS_PATH = "/trpc"; + +type BrowserWindowGlobals = Window & { + __o3WebAccessShimsInstalled?: boolean; + electronTRPC?: unknown; +}; + +type BrowserIpcListener = (...args: unknown[]) => void; + +type BrowserIpcRenderer = { + invoke: (channel: string, ...args: unknown[]) => Promise; + off: (channel: string, listener: BrowserIpcListener) => void; + on: (channel: string, listener: BrowserIpcListener) => void; + send: (channel: string, ...args: unknown[]) => void; +}; + +type BrowserAppApi = { + appVersion: string; + sayHelloFromBridge: () => void; + username: string | undefined; +}; + +type BrowserWebUtils = { + getPathForFile: (file: File) => string; +}; + +function getBrowserWindow(): BrowserWindowGlobals | null { + if (typeof window === "undefined") return null; + return window as BrowserWindowGlobals; +} + +function hasElectronTrpc(): boolean { + const browserWindow = getBrowserWindow(); + const globalElectronTrpc = (globalThis as { electronTRPC?: unknown }) + .electronTRPC; + return !!(browserWindow?.electronTRPC ?? globalElectronTrpc); +} + +export function isBrowserMode(): boolean { + return typeof window !== "undefined" && !hasElectronTrpc(); +} + +export function getWebAccessTrpcWsUrl(): string { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}${WEB_ACCESS_WS_PATH}`; +} + +function createBrowserIpcRenderer(): BrowserIpcRenderer { + return { + invoke: async (channel) => { + console.warn( + `[browser-bridge] window.ipcRenderer.invoke("${channel}") is unavailable in Web Access mode`, + ); + return undefined; + }, + send: (channel) => { + console.warn( + `[browser-bridge] window.ipcRenderer.send("${channel}") is unavailable in Web Access mode`, + ); + }, + on: () => {}, + off: () => {}, + }; +} + +function createBrowserAppApi(): BrowserAppApi { + return { + sayHelloFromBridge: () => + console.log("[browser-bridge] Running in Web Access mode"), + username: "browser", + appVersion: "browser", + }; +} + +function createBrowserWebUtils(): BrowserWebUtils { + return { + getPathForFile: () => "", + }; +} + +export function installBrowserShims(): void { + const browserWindow = getBrowserWindow(); + if (!browserWindow || !isBrowserMode()) return; + if (browserWindow.__o3WebAccessShimsInstalled) return; + + if (!browserWindow.ipcRenderer) { + Object.defineProperty(browserWindow, "ipcRenderer", { + configurable: true, + value: createBrowserIpcRenderer(), + }); + } + + if (!browserWindow.App) { + Object.defineProperty(browserWindow, "App", { + configurable: true, + value: createBrowserAppApi(), + }); + } + + if (!browserWindow.webUtils) { + Object.defineProperty(browserWindow, "webUtils", { + configurable: true, + value: createBrowserWebUtils(), + }); + } + + browserWindow.__o3WebAccessShimsInstalled = true; +} + +installBrowserShims(); diff --git a/apps/desktop/src/renderer/lib/electron-trpc-transport.ts b/apps/desktop/src/renderer/lib/electron-trpc-transport.ts new file mode 100644 index 000000000..402bac5be --- /dev/null +++ b/apps/desktop/src/renderer/lib/electron-trpc-transport.ts @@ -0,0 +1,24 @@ +import { createWSClient, type TRPCLink, wsLink } from "@trpc/client"; +import type { AnyRouter } from "@trpc/server"; +import superjson from "superjson"; +import { ipcLink } from "trpc-electron/renderer"; +import { getWebAccessTrpcWsUrl, isBrowserMode } from "./browser-bridge"; + +const browserMode = isBrowserMode(); +const webAccessWsClient = browserMode + ? createWSClient({ url: getWebAccessTrpcWsUrl() }) + : null; + +export function createElectronTransportLink< + TRouter extends AnyRouter, +>(): TRPCLink { + if (browserMode) { + return wsLink({ + client: + webAccessWsClient ?? createWSClient({ url: getWebAccessTrpcWsUrl() }), + transformer: superjson, + }); + } + + return ipcLink({ transformer: superjson }) as TRPCLink; +} diff --git a/apps/desktop/src/renderer/lib/host-service-client.ts b/apps/desktop/src/renderer/lib/host-service-client.ts index 1aedb83fb..004e22da9 100644 --- a/apps/desktop/src/renderer/lib/host-service-client.ts +++ b/apps/desktop/src/renderer/lib/host-service-client.ts @@ -1,6 +1,7 @@ import type { AppRouter } from "@o3dotdev/code-host-service"; import { createTRPCClient, httpLink } from "@trpc/client"; import superjson from "superjson"; +import { isBrowserMode } from "./browser-bridge"; import { getHostServiceHeaders } from "./host-service-auth"; const clientCache = new Map< @@ -18,10 +19,11 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { const cached = clientCache.get(hostUrl); if (cached) return cached; + const requestUrl = getHostServiceRequestUrl(hostUrl); const client = createTRPCClient({ links: [ httpLink({ - url: `${hostUrl}/trpc`, + url: `${requestUrl}/trpc`, transformer: superjson, headers: () => getHostServiceHeaders(hostUrl), }), @@ -31,3 +33,27 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { clientCache.set(hostUrl, client); return client; } + +export function getHostServiceRequestUrl(hostUrl: string): string { + if (!isBrowserMode() || typeof window === "undefined") return hostUrl; + if (!isLoopbackHostServiceUrl(hostUrl)) return hostUrl; + + return `${window.location.origin}/_web-access/host-service/${encodeURIComponent( + hostUrl, + )}`; +} + +function isLoopbackHostServiceUrl(hostUrl: string): boolean { + try { + const url = new URL(hostUrl); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || + url.hostname === "localhost" || + url.hostname === "::1" || + url.hostname === "[::1]") + ); + } catch { + return false; + } +} diff --git a/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.test.ts b/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.test.ts index 9f0c76edd..53f448bba 100644 --- a/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.test.ts +++ b/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.test.ts @@ -48,6 +48,7 @@ const { createPersistentHashHistory } = await import( beforeEach(() => { storage.clear(); mockReplaceState.mockClear(); + (globalThis.window.location as { hash?: string }).hash = ""; }); afterEach(() => { @@ -211,6 +212,45 @@ describe("createPersistentHashHistory", () => { expect(history.location.pathname).toBe("/tasks"); }); + it("honors the current URL hash over restored localStorage", () => { + storage.set( + "router-history", + JSON.stringify({ + entries: ["/", "/tasks"], + index: 1, + }), + ); + (globalThis.window.location as { hash?: string }).hash = + "#/v2-workspace/abc"; + + const history = createPersistentHashHistory(); + expect(history.location.pathname).toBe("/v2-workspace/abc"); + + const stored = JSON.parse(storage.get("router-history") ?? "{}"); + expect(stored.entries).toEqual(["/", "/tasks", "/v2-workspace/abc"]); + expect(stored.index).toBe(2); + }); + + it("reuses an existing history entry when the current URL hash matches it", () => { + storage.set( + "router-history", + JSON.stringify({ + entries: ["/", "/tasks", "/v2-workspace/abc"], + index: 1, + }), + ); + (globalThis.window.location as { hash?: string }).hash = + "#/v2-workspace/abc"; + + const history = createPersistentHashHistory(); + expect(history.location.pathname).toBe("/v2-workspace/abc"); + expect(history.length).toBe(3); + + const stored = JSON.parse(storage.get("router-history") ?? "{}"); + expect(stored.entries).toEqual(["/", "/tasks", "/v2-workspace/abc"]); + expect(stored.index).toBe(2); + }); + it("falls back to / when localStorage is empty", () => { const history = createPersistentHashHistory(); expect(history.length).toBe(1); diff --git a/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.ts b/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.ts index fa4049fa4..fee247917 100644 --- a/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.ts +++ b/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.ts @@ -41,6 +41,31 @@ function loadPersistedState(): PersistedState { return { entries: ["/"], index: 0 }; } +function getCurrentHashPath(): string | null { + const hash = window.location.hash; + if (!hash || hash === "#") return null; + const path = hash.slice(1); + if (!path.startsWith("/")) return null; + return path || "/"; +} + +function resolveInitialState(): PersistedState { + const persisted = loadPersistedState(); + const hashPath = getCurrentHashPath(); + if (!hashPath) return persisted; + + const existingIndex = persisted.entries.indexOf(hashPath); + if (existingIndex >= 0) { + return { entries: persisted.entries, index: existingIndex }; + } + + const entries = [ + ...persisted.entries.slice(0, persisted.index + 1), + hashPath, + ]; + return { entries, index: entries.length - 1 }; +} + function persistState(entries: string[], index: number) { try { const capped = @@ -108,7 +133,7 @@ export interface PersistentHashHistory extends RouterHistory { } export function createPersistentHashHistory(): PersistentHashHistory { - const persisted = loadPersistedState(); + const persisted = resolveInitialState(); const entries: string[] = [...persisted.entries]; const timestamps: number[] = entries.map(() => Date.now()); @@ -125,6 +150,7 @@ export function createPersistentHashHistory(): PersistentHashHistory { >[0] = []; syncHash(entries[index] ?? "/"); + persistState(entries, index); const history = createHistory({ getLocation, diff --git a/apps/desktop/src/renderer/lib/runtime-urls.test.ts b/apps/desktop/src/renderer/lib/runtime-urls.test.ts new file mode 100644 index 000000000..7e89e01f5 --- /dev/null +++ b/apps/desktop/src/renderer/lib/runtime-urls.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { resolveRuntimeElectricUrl } from "./runtime-urls"; + +describe("resolveRuntimeElectricUrl", () => { + const configuredElectricUrl = "https://localhost:44020"; + + it("uses configured Electric URL outside browser mode", () => { + expect( + resolveRuntimeElectricUrl({ + browserMode: false, + configuredElectricUrl, + windowOrigin: "http://127.0.0.1:44010", + }), + ).toBe(configuredElectricUrl); + }); + + it("uses configured Electric URL for local Web Access origins", () => { + for (const windowOrigin of [ + "http://127.0.0.1:44010", + "http://localhost:44010", + "http://[::1]:44010", + ]) { + expect( + resolveRuntimeElectricUrl({ + browserMode: true, + configuredElectricUrl, + windowOrigin, + }), + ).toBe(configuredElectricUrl); + } + }); + + it("keeps same-origin Electric proxy for reverse-proxied Web Access", () => { + expect( + resolveRuntimeElectricUrl({ + browserMode: true, + configuredElectricUrl, + windowOrigin: "https://device.example.com", + }), + ).toBe("https://device.example.com"); + }); + + it("falls back to same-origin proxy when configured Electric URL is invalid", () => { + expect( + resolveRuntimeElectricUrl({ + browserMode: true, + configuredElectricUrl: "not a url", + windowOrigin: "http://127.0.0.1:44010", + }), + ).toBe("http://127.0.0.1:44010"); + }); +}); diff --git a/apps/desktop/src/renderer/lib/runtime-urls.ts b/apps/desktop/src/renderer/lib/runtime-urls.ts new file mode 100644 index 000000000..9293bbd69 --- /dev/null +++ b/apps/desktop/src/renderer/lib/runtime-urls.ts @@ -0,0 +1,75 @@ +import { env } from "renderer/env.renderer"; +import { isBrowserMode } from "./browser-bridge"; + +interface ResolveRuntimeElectricUrlInput { + browserMode: boolean; + configuredElectricUrl: string; + windowOrigin: string | undefined; +} + +function isLoopbackHostname(hostname: string): boolean { + return ( + hostname === "127.0.0.1" || + hostname === "localhost" || + hostname === "::1" || + hostname === "[::1]" + ); +} + +function isLoopbackHttpOrigin(origin: string): boolean { + try { + const url = new URL(origin); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + isLoopbackHostname(url.hostname) + ); + } catch { + return false; + } +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function getWindowOrigin(): string | undefined { + if (typeof window === "undefined") return undefined; + return window.location?.origin; +} + +export function resolveRuntimeElectricUrl({ + browserMode, + configuredElectricUrl, + windowOrigin, +}: ResolveRuntimeElectricUrlInput): string { + if (!browserMode || !windowOrigin) return configuredElectricUrl; + if (!isHttpUrl(configuredElectricUrl)) return windowOrigin; + + if (isLoopbackHttpOrigin(windowOrigin)) { + return configuredElectricUrl; + } + + return windowOrigin; +} + +export function getRuntimeApiUrl(): string { + const windowOrigin = getWindowOrigin(); + if (isBrowserMode() && windowOrigin) { + return windowOrigin; + } + + return env.NEXT_PUBLIC_API_URL; +} + +export function getRuntimeElectricUrl(): string { + return resolveRuntimeElectricUrl({ + browserMode: isBrowserMode(), + configuredElectricUrl: env.NEXT_PUBLIC_ELECTRIC_URL, + windowOrigin: getWindowOrigin(), + }); +} diff --git a/apps/desktop/src/renderer/lib/shared-ui-state-client.ts b/apps/desktop/src/renderer/lib/shared-ui-state-client.ts new file mode 100644 index 000000000..4879d0203 --- /dev/null +++ b/apps/desktop/src/renderer/lib/shared-ui-state-client.ts @@ -0,0 +1,14 @@ +let sharedUiStateClientId: string | null = null; + +function createClientId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + + return `client-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function getSharedUiStateClientId(): string { + sharedUiStateClientId ??= createClientId(); + return sharedUiStateClientId; +} diff --git a/apps/desktop/src/renderer/lib/shared-ui-state-route-policy.ts b/apps/desktop/src/renderer/lib/shared-ui-state-route-policy.ts new file mode 100644 index 000000000..01c26a08c --- /dev/null +++ b/apps/desktop/src/renderer/lib/shared-ui-state-route-policy.ts @@ -0,0 +1,30 @@ +export interface LocalRouteIntent { + hashPath: string; + ignoreRemoteUntil: number; +} + +export function shouldFollowSharedRoute({ + currentHashPath, + localRouteIntent, + now, + remoteHashPath, +}: { + currentHashPath: string; + localRouteIntent: LocalRouteIntent | null; + now: number; + remoteHashPath: string; +}): boolean { + if (!remoteHashPath.startsWith("/") || remoteHashPath === currentHashPath) { + return false; + } + + if ( + localRouteIntent && + now < localRouteIntent.ignoreRemoteUntil && + remoteHashPath !== localRouteIntent.hashPath + ) { + return false; + } + + return true; +} diff --git a/apps/desktop/src/renderer/lib/shared-ui-state-route-sync.test.ts b/apps/desktop/src/renderer/lib/shared-ui-state-route-sync.test.ts new file mode 100644 index 000000000..a3fb0f5a1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/shared-ui-state-route-sync.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { shouldFollowSharedRoute } from "./shared-ui-state-route-policy"; +import { getActiveWorkspaceIdFromHashPath } from "./shared-ui-state-route-utils"; + +describe("shared UI route sync helpers", () => { + it("extracts workspace ids from v1 and v2 workspace routes", () => { + expect( + getActiveWorkspaceIdFromHashPath( + "/v2-workspace/11111111-1111-4111-8111-111111111111", + ), + ).toBe("11111111-1111-4111-8111-111111111111"); + expect( + getActiveWorkspaceIdFromHashPath( + "/workspace/22222222-2222-4222-8222-222222222222?tab=main", + ), + ).toBe("22222222-2222-4222-8222-222222222222"); + }); + + it("returns null for non-workspace routes", () => { + expect(getActiveWorkspaceIdFromHashPath("/tasks")).toBeNull(); + expect(getActiveWorkspaceIdFromHashPath("/")).toBeNull(); + }); +}); + +describe("shared UI route follow policy", () => { + it("ignores invalid and same-route remote updates", () => { + expect( + shouldFollowSharedRoute({ + currentHashPath: "/settings/account", + localRouteIntent: null, + now: 1_000, + remoteHashPath: "settings/appearance", + }), + ).toBe(false); + expect( + shouldFollowSharedRoute({ + currentHashPath: "/settings/account", + localRouteIntent: null, + now: 1_000, + remoteHashPath: "/settings/account", + }), + ).toBe(false); + }); + + it("keeps a fresh local navigation from being overwritten before it publishes", () => { + expect( + shouldFollowSharedRoute({ + currentHashPath: "/workspaces", + localRouteIntent: { + hashPath: "/workspaces", + ignoreRemoteUntil: 2_000, + }, + now: 1_500, + remoteHashPath: "/welcome", + }), + ).toBe(false); + }); + + it("follows remote updates after the local navigation grace window expires", () => { + expect( + shouldFollowSharedRoute({ + currentHashPath: "/workspaces", + localRouteIntent: { + hashPath: "/workspaces", + ignoreRemoteUntil: 2_000, + }, + now: 2_001, + remoteHashPath: "/welcome", + }), + ).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/lib/shared-ui-state-route-sync.ts b/apps/desktop/src/renderer/lib/shared-ui-state-route-sync.ts new file mode 100644 index 000000000..7de7fca1b --- /dev/null +++ b/apps/desktop/src/renderer/lib/shared-ui-state-route-sync.ts @@ -0,0 +1,166 @@ +import { shouldApplySharedUiStateEvent } from "shared/shared-ui-state"; +import { getSharedUiStateClientId } from "./shared-ui-state-client"; +import { + type LocalRouteIntent, + shouldFollowSharedRoute, +} from "./shared-ui-state-route-policy"; +import { getActiveWorkspaceIdFromHashPath } from "./shared-ui-state-route-utils"; +import { electronTrpcClient } from "./trpc-client"; + +const ROUTE_WRITE_DEBOUNCE_MS = 300; +const LOCAL_ROUTE_INTENT_GRACE_MS = 2_000; + +interface RouteSyncRouter { + navigate: (options: { to: string }) => Promise | unknown; +} + +interface RouteSyncSubscription { + unsubscribe: () => void; +} + +export interface SharedUiRouteSync { + recordResolvedPath: (hashPath: string) => void; + dispose: () => void; +} + +function getCurrentHashPath(): string { + const hash = window.location.hash; + if (!hash || hash === "#") return "/"; + const path = hash.slice(1); + return path.startsWith("/") ? path : "/"; +} + +function hasExplicitInitialHash(): boolean { + const hashPath = getCurrentHashPath(); + return hashPath !== "/"; +} + +export function initSharedUiStateRouteSync( + router: RouteSyncRouter, +): SharedUiRouteSync { + const clientId = getSharedUiStateClientId(); + const explicitInitialHash = hasExplicitInitialHash(); + let lastSeenRevision = 0; + let applyingRemoteRoute = false; + let publishTimer: ReturnType | null = null; + let pendingHashPath: string | null = null; + let localRouteIntent: LocalRouteIntent | null = explicitInitialHash + ? { + hashPath: getCurrentHashPath(), + ignoreRemoteUntil: Date.now() + LOCAL_ROUTE_INTENT_GRACE_MS, + } + : null; + + const followRoute = async (hashPath: string): Promise => { + if ( + !shouldFollowSharedRoute({ + currentHashPath: getCurrentHashPath(), + localRouteIntent, + now: Date.now(), + remoteHashPath: hashPath, + }) + ) { + return; + } + + localRouteIntent = null; + applyingRemoteRoute = true; + try { + await router.navigate({ to: hashPath }); + } finally { + setTimeout(() => { + applyingRemoteRoute = false; + }, 0); + } + }; + + const subscription: RouteSyncSubscription = + electronTrpcClient.uiState.shared.subscribe.subscribe( + { clientId }, + { + onData: (event) => { + if ( + !shouldApplySharedUiStateEvent({ + clientId, + event, + lastSeenRevision, + }) + ) { + return; + } + + lastSeenRevision = event.snapshot.revision; + const route = event.snapshot.route; + if (!route) return; + + void followRoute(route.hashPath).catch((error) => { + console.error("[shared-ui-state] Failed to follow route", error); + }); + }, + onError: (error) => { + console.error("[shared-ui-state] Route subscription failed", error); + }, + }, + ); + + void electronTrpcClient.uiState.shared.get + .query() + .then((snapshot) => { + lastSeenRevision = Math.max(lastSeenRevision, snapshot.revision); + const route = snapshot.route; + if (!explicitInitialHash && route) { + return followRoute(route.hashPath); + } + }) + .catch((error) => { + console.error("[shared-ui-state] Failed to load route state", error); + }); + + const publish = async (hashPath: string): Promise => { + const snapshot = await electronTrpcClient.uiState.shared.patch.mutate({ + clientId, + route: { + hashPath, + activeWorkspaceId: getActiveWorkspaceIdFromHashPath(hashPath), + updatedAt: Date.now(), + }, + }); + lastSeenRevision = Math.max(lastSeenRevision, snapshot.revision); + if (localRouteIntent?.hashPath === hashPath) { + localRouteIntent = null; + } + }; + + return { + recordResolvedPath: (hashPath: string) => { + if (applyingRemoteRoute || !hashPath.startsWith("/")) return; + + pendingHashPath = hashPath; + localRouteIntent = { + hashPath, + ignoreRemoteUntil: Date.now() + LOCAL_ROUTE_INTENT_GRACE_MS, + }; + if (publishTimer) { + clearTimeout(publishTimer); + } + + publishTimer = setTimeout(() => { + publishTimer = null; + const nextHashPath = pendingHashPath; + pendingHashPath = null; + if (!nextHashPath) return; + + void publish(nextHashPath).catch((error) => { + console.error("[shared-ui-state] Failed to publish route", error); + }); + }, ROUTE_WRITE_DEBOUNCE_MS); + }, + dispose: () => { + if (publishTimer) { + clearTimeout(publishTimer); + publishTimer = null; + } + subscription.unsubscribe(); + }, + }; +} diff --git a/apps/desktop/src/renderer/lib/shared-ui-state-route-utils.ts b/apps/desktop/src/renderer/lib/shared-ui-state-route-utils.ts new file mode 100644 index 000000000..32b41f1f7 --- /dev/null +++ b/apps/desktop/src/renderer/lib/shared-ui-state-route-utils.ts @@ -0,0 +1,12 @@ +export function getActiveWorkspaceIdFromHashPath( + hashPath: string, +): string | null { + const match = hashPath.match(/^\/(?:workspace|v2-workspace)\/([^/?#]+)/); + if (!match?.[1]) return null; + + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } +} diff --git a/apps/desktop/src/renderer/lib/trpc-client.ts b/apps/desktop/src/renderer/lib/trpc-client.ts index c7b868b5c..e6e21c7e8 100644 --- a/apps/desktop/src/renderer/lib/trpc-client.ts +++ b/apps/desktop/src/renderer/lib/trpc-client.ts @@ -1,16 +1,17 @@ import { createTRPCProxyClient } from "@trpc/client"; import type { AppRouter } from "lib/trpc/routers"; -import superjson from "superjson"; -import { ipcLink } from "trpc-electron/renderer"; import { electronTrpc } from "./electron-trpc"; +import { createElectronTransportLink } from "./electron-trpc-transport"; import { sessionIdLink } from "./session-id-link"; +const transportLink = createElectronTransportLink(); + /** Electron tRPC React client for React hooks (used by ElectronTRPCProvider). */ export const electronReactClient = electronTrpc.createClient({ - links: [sessionIdLink(), ipcLink({ transformer: superjson })], + links: [sessionIdLink(), transportLink], }); /** Electron tRPC proxy client for imperative calls from stores/utilities. */ export const electronTrpcClient = createTRPCProxyClient({ - links: [sessionIdLink(), ipcLink({ transformer: superjson })], + links: [sessionIdLink(), transportLink], }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx index a8e2faadc..a8312eae5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx @@ -1,4 +1,3 @@ -import { authClient } from "@o3dotdev/code-auth/client"; import type { TaskPriority } from "@o3dotdev/code-db/enums"; import { Button } from "@o3dotdev/code-ui/button"; import { @@ -19,6 +18,7 @@ import { HiChevronRight, HiOutlinePaperClip, HiXMark } from "react-icons/hi2"; import { MarkdownEditor } from "renderer/components/MarkdownEditor"; import { PLATFORM } from "renderer/hotkeys"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { authClient } from "renderer/lib/auth-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { compareStatusesForDropdown } from "../../../../utils/sorting"; import type { TabValue } from "../../TasksTopBar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider/WorkspaceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider/WorkspaceProvider.tsx index ea8cbac90..56f69817d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider/WorkspaceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider/WorkspaceProvider.tsx @@ -6,6 +6,7 @@ import { getHostServiceHeaders, getHostServiceWsToken, } from "renderer/lib/host-service-auth"; +import { getHostServiceRequestUrl } from "renderer/lib/host-service-client"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { WorkspaceTrpcProvider } from "../WorkspaceTrpcProvider"; @@ -44,6 +45,7 @@ export function WorkspaceProvider({ key={`${workspace.id}:${hostUrl}`} hostUrl={hostUrl} headers={() => getHostServiceHeaders(hostUrl)} + requestHostUrl={getHostServiceRequestUrl(hostUrl)} wsToken={() => getHostServiceWsToken(hostUrl)} > {children} diff --git a/apps/desktop/src/renderer/routes/_authenticated/authenticated-layout-background.test.ts b/apps/desktop/src/renderer/routes/_authenticated/authenticated-layout-background.test.ts new file mode 100644 index 000000000..b4ef34bcc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/authenticated-layout-background.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; +import { shouldMountAuthenticatedBackgroundEffects } from "./authenticated-layout-background"; + +describe("shouldMountAuthenticatedBackgroundEffects", () => { + it("keeps desktop behavior unchanged", () => { + for (const pathname of [ + "/settings/appearance", + "/settings/web-access", + "/v2-workspace/workspace-id", + ]) { + expect( + shouldMountAuthenticatedBackgroundEffects({ + browserMode: false, + pathname, + }), + ).toBe(true); + } + }); + + it("mounts dashboard background effects on browser-mode workspace routes", () => { + for (const pathname of [ + "/", + "/workspace/workspace-id", + "/v2-workspace/workspace-id", + "/workspaces", + "/v2-workspaces", + "/tasks", + "/automations", + ]) { + expect( + shouldMountAuthenticatedBackgroundEffects({ + browserMode: true, + pathname, + }), + ).toBe(true); + } + }); + + it("skips dashboard background effects on browser-mode settings routes", () => { + for (const pathname of [ + "/settings/account", + "/settings/appearance", + "/settings/web-access", + ]) { + expect( + shouldMountAuthenticatedBackgroundEffects({ + browserMode: true, + pathname, + }), + ).toBe(false); + } + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/authenticated-layout-background.ts b/apps/desktop/src/renderer/routes/_authenticated/authenticated-layout-background.ts new file mode 100644 index 000000000..392a785c9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/authenticated-layout-background.ts @@ -0,0 +1,25 @@ +const BROWSER_MODE_BACKGROUND_PATH_PREFIXES = [ + "/automations", + "/new-project", + "/onboarding", + "/tasks", + "/v2-workspace", + "/v2-workspaces", + "/workspace", + "/workspaces", +] as const; + +export function shouldMountAuthenticatedBackgroundEffects({ + browserMode, + pathname, +}: { + browserMode: boolean; + pathname: string; +}): boolean { + if (!browserMode) return true; + if (pathname === "/") return true; + + return BROWSER_MODE_BACKGROUND_PATH_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DaemonAutoUpdateFailureDialog/DaemonAutoUpdateFailureDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DaemonAutoUpdateFailureDialog/DaemonAutoUpdateFailureDialog.tsx index 9a5abaa6d..ec173fd4c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DaemonAutoUpdateFailureDialog/DaemonAutoUpdateFailureDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DaemonAutoUpdateFailureDialog/DaemonAutoUpdateFailureDialog.tsx @@ -17,6 +17,7 @@ import { getHostServiceHeaders, getHostServiceWsToken, } from "renderer/lib/host-service-auth"; +import { getHostServiceRequestUrl } from "renderer/lib/host-service-client"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; const STATUS_REFETCH_MS = 5_000; @@ -49,6 +50,7 @@ export function DaemonAutoUpdateFailureDialog() { key={activeHostUrl} hostUrl={activeHostUrl} headers={() => getHostServiceHeaders(activeHostUrl)} + requestHostUrl={getHostServiceRequestUrl(activeHostUrl)} wsToken={() => getHostServiceWsToken(activeHostUrl)} > 0) return "mark-seeded"; + return "seed"; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.test.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.test.ts new file mode 100644 index 000000000..6ebbcbf6c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { getDevSeedV2SidebarDecision } from "./devSeedV2SidebarDecision"; + +describe("getDevSeedV2SidebarDecision", () => { + test("does not seed outside development unless browser mode is active", () => { + expect( + getDevSeedV2SidebarDecision({ + isDevelopment: false, + isBrowserMode: false, + hasVisibleSidebarWorkspace: false, + hasSeedFlag: false, + accessibleWorkspaceCount: 1, + workspaceLocalStateSize: 0, + }), + ).toBe("skip"); + }); + + test("respects the one-time seed flag in browser mode", () => { + expect( + getDevSeedV2SidebarDecision({ + isDevelopment: false, + isBrowserMode: true, + hasVisibleSidebarWorkspace: false, + hasSeedFlag: true, + accessibleWorkspaceCount: 1, + workspaceLocalStateSize: 0, + }), + ).toBe("skip"); + }); + + test("preserves hidden-only sidebar state in browser mode", () => { + expect( + getDevSeedV2SidebarDecision({ + isDevelopment: false, + isBrowserMode: true, + hasVisibleSidebarWorkspace: false, + hasSeedFlag: false, + accessibleWorkspaceCount: 1, + workspaceLocalStateSize: 1, + }), + ).toBe("mark-seeded"); + }); + + test("seeds a browser mode sidebar with accessible workspaces and no local state", () => { + expect( + getDevSeedV2SidebarDecision({ + isDevelopment: false, + isBrowserMode: true, + hasVisibleSidebarWorkspace: false, + hasSeedFlag: false, + accessibleWorkspaceCount: 1, + workspaceLocalStateSize: 0, + }), + ).toBe("seed"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts index 3a0358a3a..dc3e39394 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts @@ -1,8 +1,11 @@ import { useEffect } from "react"; import { env } from "renderer/env.renderer"; +import { isBrowserMode } from "renderer/lib/browser-bridge"; import { useAccessibleV2Workspaces } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { isSidebarWorkspaceVisible } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { getDevSeedV2SidebarDecision } from "./devSeedV2SidebarDecision"; const SEED_FLAG_KEY = "o3-code:dev:v2-sidebar-seeded"; @@ -20,14 +23,28 @@ export function useDevSeedV2Sidebar(): void { const { all: accessibleWorkspaces } = useAccessibleV2Workspaces(); useEffect(() => { - if (env.NODE_ENV !== "development") return; - if (window.localStorage.getItem(SEED_FLAG_KEY) === "1") return; - if (accessibleWorkspaces.length === 0) return; - if (collections.v2WorkspaceLocalState.state.size > 0) { + const browserMode = isBrowserMode(); + + const hasVisibleSidebarWorkspace = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ).some((workspace) => isSidebarWorkspaceVisible(workspace)); + + const decision = getDevSeedV2SidebarDecision({ + isDevelopment: env.NODE_ENV === "development", + isBrowserMode: browserMode, + hasVisibleSidebarWorkspace, + hasSeedFlag: window.localStorage.getItem(SEED_FLAG_KEY) === "1", + accessibleWorkspaceCount: accessibleWorkspaces.length, + workspaceLocalStateSize: collections.v2WorkspaceLocalState.state.size, + }); + + if (decision === "mark-seeded") { window.localStorage.setItem(SEED_FLAG_KEY, "1"); return; } + if (decision !== "seed") return; + for (const workspace of accessibleWorkspaces) { ensureWorkspaceInSidebar(workspace.id, workspace.projectId); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 075f19836..e3959dba9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -18,6 +18,7 @@ import { env } from "renderer/env.renderer"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useOnlineStatus } from "renderer/hooks/useOnlineStatus"; import { authClient, getAuthToken } from "renderer/lib/auth-client"; +import { isBrowserMode } from "renderer/lib/browser-bridge"; import { dragDropManager } from "renderer/lib/dnd"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { showWorkspaceAutoNameWarningToast } from "renderer/lib/workspaces/showWorkspaceAutoNameWarningToast"; @@ -32,6 +33,7 @@ import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener" import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { MOCK_ORG_ID, NOTIFICATION_EVENTS } from "shared/constants"; +import { shouldMountAuthenticatedBackgroundEffects } from "./authenticated-layout-background"; import { AgentHooks } from "./components/AgentHooks"; import { FileMenuListener } from "./components/FileMenuListener"; import { GlobalBrowserLifecycle } from "./components/GlobalBrowserLifecycle"; @@ -61,6 +63,10 @@ function AuthenticatedLayout() { const utils = electronTrpc.useUtils(); const shownWorkspaceInitWarningsRef = useRef(new Set()); const isV2CloudEnabled = useIsV2CloudEnabled(); + const mountBackgroundEffects = shouldMountAuthenticatedBackgroundEffects({ + browserMode: isBrowserMode(), + pathname: location.pathname, + }); const isSignedIn = env.SKIP_ENV_VALIDATION || !!session?.user; const activeOrganizationId = env.SKIP_ENV_VALIDATION @@ -209,27 +215,28 @@ function AuthenticatedLayout() { return ( - + {mountBackgroundEffects && } - + {mountBackgroundEffects && } - - + {mountBackgroundEffects && } + {mountBackgroundEffects && } - - - {isV2CloudEnabled ? ( - - ) : ( - - )} - - + {mountBackgroundEffects && } + {mountBackgroundEffects && } + {mountBackgroundEffects && + (isV2CloudEnabled ? ( + + ) : ( + + ))} + {mountBackgroundEffects && } + {mountBackgroundEffects && } diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx index f55622950..ef8e3af8c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx @@ -9,8 +9,10 @@ import { } from "react"; import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; +import { isBrowserMode } from "renderer/lib/browser-bridge"; import { MOCK_ORG_ID } from "shared/constants"; import { getCollections, preloadCollections } from "./collections"; +import { shouldPreloadActiveOrganizationCollections } from "./preload-policy"; type CollectionsContextType = ReturnType & { switchOrganization: (organizationId: string) => Promise; @@ -22,6 +24,7 @@ export function preloadActiveOrganizationCollections( activeOrganizationId: string | null | undefined, ): void { if (!activeOrganizationId) return; + if (!shouldPreloadActiveOrganizationCollections(isBrowserMode())) return; void preloadCollections(activeOrganizationId).catch((error) => { console.error( "[collections-provider] Failed to preload active org collections:", @@ -43,7 +46,9 @@ export function CollectionsProvider({ children }: { children: ReactNode }) { setIsSwitching(true); try { await authClient.organization.setActive({ organizationId }); - await preloadCollections(organizationId); + if (shouldPreloadActiveOrganizationCollections(isBrowserMode())) { + await preloadCollections(organizationId); + } await refetchSession(); } finally { setIsSwitching(false); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index 734c7ee9a..b7c1a38d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -46,14 +46,18 @@ import { } from "@tanstack/react-db"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import type { inferRouterOutputs } from "@trpc/server"; -import { env } from "renderer/env.renderer"; import { authClient, getAuthToken, getJwt, setJwt, } from "renderer/lib/auth-client"; +import { isBrowserMode } from "renderer/lib/browser-bridge"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + getRuntimeApiUrl, + getRuntimeElectricUrl, +} from "renderer/lib/runtime-urls"; import superjson from "superjson"; import { z } from "zod"; import { @@ -73,11 +77,12 @@ import { type WorkspacesCreateInput, workspaceLocalStateSchema, } from "./dashboardSidebarLocal"; +import { startSharedUiStateCollectionSync } from "./sharedUiStateCollectionSync"; import { withReadHeal } from "./withReadHeal"; const columnMapper = snakeCamelMapper(); -const electricUrl = `${env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`; +const electricUrl = `${getRuntimeElectricUrl()}/v1/shape`; export const ELECTRIC_WRITE_SYNC_TIMEOUT_MS = 30_000; @@ -99,6 +104,7 @@ export interface WorkspaceCreateMutationMetadata { const persistence = createElectronSQLitePersistence({ invoke: (channel, request) => window.ipcRenderer.invoke(channel, request), }); +const skipSqlitePersistence = isBrowserMode(); const indexDefaults = { autoIndex: "eager", @@ -113,6 +119,14 @@ const createIndexedCollection = (( type ElectricSyncConfig = ReturnType; const createPersistedElectricCollection = ((config: ElectricSyncConfig) => { + if (skipSqlitePersistence) { + return createCollection({ + ...config, + ...indexDefaults, + // biome-ignore lint/suspicious/noExplicitAny: Electric config overloads widen without SQLite persistence + } as any); + } + const persisted = persistedCollectionOptions({ ...config, persistence, @@ -220,7 +234,7 @@ function getCollectionsCacheKey(organizationId: string): string { const apiClient = createTRPCProxyClient({ links: [ httpBatchLink({ - url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, + url: `${getRuntimeApiUrl()}/api/trpc`, headers: () => { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; @@ -879,6 +893,13 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); + startSharedUiStateCollectionSync(organizationId, { + v2SidebarProjects, + v2WorkspaceLocalState, + v2SidebarSections, + v2UserPreferences, + }); + return { tasks, taskStatuses, diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/preload-policy.test.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/preload-policy.test.ts new file mode 100644 index 000000000..63d3d872c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/preload-policy.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test"; +import { shouldPreloadActiveOrganizationCollections } from "./preload-policy"; + +describe("shouldPreloadActiveOrganizationCollections", () => { + it("preloads all collections in desktop mode", () => { + expect(shouldPreloadActiveOrganizationCollections(false)).toBe(true); + }); + + it("does not preload all collections in browser mode", () => { + expect(shouldPreloadActiveOrganizationCollections(true)).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/preload-policy.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/preload-policy.ts new file mode 100644 index 000000000..ffa1c676a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/preload-policy.ts @@ -0,0 +1,5 @@ +export function shouldPreloadActiveOrganizationCollections( + browserMode: boolean, +): boolean { + return !browserMode; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/sharedUiStateCollectionSync.test.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/sharedUiStateCollectionSync.test.ts new file mode 100644 index 000000000..79fed9c2a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/sharedUiStateCollectionSync.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { + createCollection, + localStorageCollectionOptions, +} from "@tanstack/react-db"; +import { + applySharedUiStatePatch, + createDefaultSharedUiState, + type SharedUiStateEvent, + type SharedUiStatePatch, + type SharedUiStateSnapshot, +} from "shared/shared-ui-state"; +import { + DEFAULT_V2_USER_PREFERENCES, + dashboardSidebarProjectSchema, + dashboardSidebarSectionSchema, + v2UserPreferencesSchema, + workspaceLocalStateSchema, +} from "./dashboardSidebarLocal"; + +let sharedSnapshot: SharedUiStateSnapshot = createDefaultSharedUiState(); +let patchInputs: SharedUiStatePatch[] = []; +let subscribers: Array<{ + onData?: (event: SharedUiStateEvent) => void; + onError?: (error: unknown) => void; +}> = []; + +mock.module("renderer/lib/trpc-client", () => ({ + electronTrpcClient: { + uiState: { + shared: { + get: { + query: async () => sharedSnapshot, + }, + patch: { + mutate: async (input: SharedUiStatePatch) => { + patchInputs.push(input); + sharedSnapshot = applySharedUiStatePatch( + sharedSnapshot, + input, + Date.now(), + ); + const event: SharedUiStateEvent = { + type: "snapshot", + sourceClientId: input.clientId, + snapshot: sharedSnapshot, + }; + for (const subscriber of subscribers) { + subscriber.onData?.(event); + } + return sharedSnapshot; + }, + }, + subscribe: { + subscribe: ( + _input: { clientId: string }, + handlers: { + onData?: (event: SharedUiStateEvent) => void; + onError?: (error: unknown) => void; + }, + ) => { + subscribers.push(handlers); + return { + unsubscribe: () => { + subscribers = subscribers.filter( + (subscriber) => subscriber !== handlers, + ); + }, + }; + }, + }, + }, + }, + }, +})); + +const { startSharedUiStateCollectionSync } = await import( + "./sharedUiStateCollectionSync" +); + +function makeMapStorage() { + const map = new Map(); + return { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => { + map.set(key, value); + }, + removeItem: (key: string) => { + map.delete(key); + }, + }; +} + +const noopEvents = { + addEventListener: () => {}, + removeEventListener: () => {}, +}; + +function createTestCollections(prefix: string) { + const storage = makeMapStorage(); + return { + v2SidebarProjects: createCollection( + localStorageCollectionOptions({ + id: `${prefix}-projects`, + storageKey: `${prefix}-projects`, + schema: dashboardSidebarProjectSchema, + getKey: (item) => item.projectId, + storage, + storageEventApi: noopEvents, + }), + ), + v2WorkspaceLocalState: createCollection( + localStorageCollectionOptions({ + id: `${prefix}-workspace-local-state`, + storageKey: `${prefix}-workspace-local-state`, + schema: workspaceLocalStateSchema, + getKey: (item) => item.workspaceId, + storage, + storageEventApi: noopEvents, + }), + ), + v2SidebarSections: createCollection( + localStorageCollectionOptions({ + id: `${prefix}-sections`, + storageKey: `${prefix}-sections`, + schema: dashboardSidebarSectionSchema, + getKey: (item) => item.sectionId, + storage, + storageEventApi: noopEvents, + }), + ), + v2UserPreferences: createCollection( + localStorageCollectionOptions({ + id: `${prefix}-preferences`, + storageKey: `${prefix}-preferences`, + schema: v2UserPreferencesSchema, + getKey: (item) => item.id as string, + storage, + storageEventApi: noopEvents, + }), + ), + }; +} + +async function waitFor(assertion: () => boolean): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + if (assertion()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(assertion()).toBe(true); +} + +describe("shared UI state collection sync", () => { + beforeEach(() => { + sharedSnapshot = createDefaultSharedUiState(); + patchInputs = []; + subscribers = []; + }); + + it("hydrates V2 collections from the host snapshot", async () => { + const organizationId = "org-hydrate"; + const projectId = "11111111-1111-4111-8111-111111111111"; + const collections = createTestCollections("hydrate"); + sharedSnapshot = applySharedUiStatePatch( + createDefaultSharedUiState(), + { + clientId: "host", + organizationId, + collections: { + v2SidebarProjects: { + [projectId]: { + projectId, + createdAt: "2026-01-01T00:00:00.000Z", + isCollapsed: true, + tabOrder: 4, + defaultOpenInApp: null, + }, + }, + }, + }, + 1, + ); + + startSharedUiStateCollectionSync(organizationId, collections); + + await waitFor(() => collections.v2SidebarProjects.get(projectId) != null); + expect(collections.v2SidebarProjects.get(projectId)?.isCollapsed).toBe( + true, + ); + expect(patchInputs).toHaveLength(0); + }); + + it("migrates localStorage rows to the host when the host snapshot is empty", async () => { + const organizationId = "org-migrate"; + const projectId = "22222222-2222-4222-8222-222222222222"; + const collections = createTestCollections("migrate"); + const insert = collections.v2SidebarProjects.insert({ + projectId, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + isCollapsed: false, + tabOrder: 7, + defaultOpenInApp: null, + }); + await insert.isPersisted.promise; + + startSharedUiStateCollectionSync(organizationId, collections); + + await waitFor(() => patchInputs.length > 0); + expect( + patchInputs[0]?.collections?.v2SidebarProjects?.[projectId], + ).toMatchObject({ + projectId, + tabOrder: 7, + }); + }); + + it("applies remote updates after startup without writing them back", async () => { + const organizationId = "org-remote"; + const projectId = "33333333-3333-4333-8333-333333333333"; + const collections = createTestCollections("remote"); + + startSharedUiStateCollectionSync(organizationId, collections); + await waitFor(() => subscribers.length > 0); + + sharedSnapshot = applySharedUiStatePatch( + sharedSnapshot, + { + clientId: "remote-client", + organizationId, + collections: { + v2SidebarProjects: { + [projectId]: { + projectId, + createdAt: "2026-01-01T00:00:00.000Z", + isCollapsed: true, + tabOrder: 2, + defaultOpenInApp: null, + }, + }, + v2UserPreferences: { + preferences: DEFAULT_V2_USER_PREFERENCES, + }, + }, + }, + 2, + ); + + for (const subscriber of subscribers) { + subscriber.onData?.({ + type: "snapshot", + sourceClientId: "remote-client", + snapshot: sharedSnapshot, + }); + } + + await waitFor(() => collections.v2SidebarProjects.get(projectId) != null); + expect(collections.v2SidebarProjects.get(projectId)?.tabOrder).toBe(2); + expect(patchInputs).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/sharedUiStateCollectionSync.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/sharedUiStateCollectionSync.ts new file mode 100644 index 000000000..7ac431ca7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/sharedUiStateCollectionSync.ts @@ -0,0 +1,379 @@ +import type { + Collection, + LocalStorageCollectionUtils, +} from "@tanstack/react-db"; +import { getSharedUiStateClientId } from "renderer/lib/shared-ui-state-client"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { + hasSharedUiOrganizationRows, + normalizeSharedUiOrganizationState, + SHARED_UI_COLLECTION_NAMES, + type SharedUiStateCollectionName, + type SharedUiStateCollectionRows, + type SharedUiStateOrganizationState, + shouldApplySharedUiStateEvent, +} from "shared/shared-ui-state"; +import type { z } from "zod"; +import type { + DashboardSidebarProjectRow, + DashboardSidebarSectionRow, + dashboardSidebarProjectSchema, + dashboardSidebarSectionSchema, + V2UserPreferencesRow, + v2UserPreferencesSchema, + WorkspaceLocalStateRow, + workspaceLocalStateSchema, +} from "./dashboardSidebarLocal"; + +const SHARED_UI_COLLECTION_WRITE_DEBOUNCE_MS = 300; + +type CollectionTransaction = { + isPersisted: { + promise: Promise; + }; +}; + +type CollectionSubscription = { + unsubscribe: () => void; +}; + +type MutableSyncedCollection = { + state: Map; + get: (key: string) => TRow | undefined; + insert: (row: TRow) => CollectionTransaction; + update: ( + key: string, + callback: (draft: Record) => void, + ) => CollectionTransaction; + delete: (key: string | string[]) => CollectionTransaction; + preload: () => Promise; + subscribeChanges: ( + callback: () => void, + options?: { includeInitialState?: boolean }, + ) => CollectionSubscription; +}; + +export interface SharedUiStateSyncedCollections { + v2SidebarProjects: Collection< + DashboardSidebarProjectRow, + string, + LocalStorageCollectionUtils, + typeof dashboardSidebarProjectSchema, + z.input + >; + v2WorkspaceLocalState: Collection< + WorkspaceLocalStateRow, + string, + LocalStorageCollectionUtils, + typeof workspaceLocalStateSchema, + z.input + >; + v2SidebarSections: Collection< + DashboardSidebarSectionRow, + string, + LocalStorageCollectionUtils, + typeof dashboardSidebarSectionSchema, + z.input + >; + v2UserPreferences: Collection< + V2UserPreferencesRow, + string, + LocalStorageCollectionUtils, + typeof v2UserPreferencesSchema, + z.input + >; +} + +const activeSyncs = new Map(); + +function asMutableCollection( + collection: Collection, +): MutableSyncedCollection { + return collection as unknown as MutableSyncedCollection; +} + +function isObjectRow(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function toPersistedRow(row: object): Record { + const persisted: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (!key.startsWith("$")) { + persisted[key] = value; + } + } + return persisted; +} + +function replaceDraft( + draft: Record, + next: Record, +): void { + for (const key of Object.keys(draft)) { + if (!(key in next)) { + delete draft[key]; + } + } + + for (const [key, value] of Object.entries(next)) { + draft[key] = value; + } +} + +function normalizeRows( + rows: SharedUiStateCollectionRows | undefined, +): SharedUiStateCollectionRows { + if (!rows) return {}; + + const normalized: SharedUiStateCollectionRows = {}; + for (const [key, value] of Object.entries(rows)) { + if (isObjectRow(value)) { + normalized[key] = value; + } + } + return normalized; +} + +class SharedUiStateCollectionSync { + private readonly clientId = getSharedUiStateClientId(); + private localSubscriptions: CollectionSubscription[] = []; + private remoteSubscription: CollectionSubscription | null = null; + private lastSeenRevision = 0; + private publishTimer: ReturnType | null = null; + private applyingRemote = false; + private started = false; + + constructor( + private readonly organizationId: string, + private readonly collections: SharedUiStateSyncedCollections, + ) {} + + start(): void { + if (this.started) return; + this.started = true; + void this.startAsync().catch((error) => { + console.error("[shared-ui-state] Failed to start collection sync", error); + }); + } + + private async startAsync(): Promise { + this.remoteSubscription = + electronTrpcClient.uiState.shared.subscribe.subscribe( + { clientId: this.clientId }, + { + onData: (event) => { + if ( + !shouldApplySharedUiStateEvent({ + clientId: this.clientId, + event, + lastSeenRevision: this.lastSeenRevision, + }) + ) { + return; + } + + this.lastSeenRevision = event.snapshot.revision; + const organizationState = + event.snapshot.organizations[this.organizationId]; + if (!organizationState) return; + + void this.applyOrganizationState(organizationState).catch( + (error) => { + console.error( + "[shared-ui-state] Failed to apply remote collection state", + error, + ); + }, + ); + }, + onError: (error) => { + console.error( + "[shared-ui-state] Collection subscription failed", + error, + ); + }, + }, + ); + + await this.preloadCollections(); + + const snapshot = await electronTrpcClient.uiState.shared.get.query(); + if (snapshot.revision >= this.lastSeenRevision) { + this.lastSeenRevision = snapshot.revision; + const organizationState = snapshot.organizations[this.organizationId]; + if (hasSharedUiOrganizationRows(organizationState)) { + await this.applyOrganizationState(organizationState); + } else if (this.hasLocalRows()) { + await this.publishNow(); + } + } + + this.subscribeLocalChanges(); + } + + private async preloadCollections(): Promise { + await Promise.all( + SHARED_UI_COLLECTION_NAMES.map((collectionName) => + this.getCollection(collectionName).preload(), + ), + ); + } + + private subscribeLocalChanges(): void { + for (const collectionName of SHARED_UI_COLLECTION_NAMES) { + const subscription = this.getCollection(collectionName).subscribeChanges( + () => { + this.schedulePublish(); + }, + { includeInitialState: false }, + ); + this.localSubscriptions.push(subscription); + } + } + + private getCollection( + collectionName: SharedUiStateCollectionName, + ): MutableSyncedCollection { + return asMutableCollection( + this.collections[collectionName] as unknown as Collection, + ); + } + + private hasLocalRows(): boolean { + return SHARED_UI_COLLECTION_NAMES.some( + (collectionName) => this.getCollection(collectionName).state.size > 0, + ); + } + + private buildOrganizationState(): SharedUiStateOrganizationState { + const organizationState = normalizeSharedUiOrganizationState(null); + + for (const collectionName of SHARED_UI_COLLECTION_NAMES) { + const rows: SharedUiStateCollectionRows = {}; + for (const [key, row] of this.getCollection(collectionName).state) { + rows[key] = toPersistedRow(row); + } + organizationState[collectionName] = rows; + } + + return organizationState; + } + + private schedulePublish(): void { + if (this.applyingRemote) return; + + if (this.publishTimer) { + clearTimeout(this.publishTimer); + } + + this.publishTimer = setTimeout(() => { + this.publishTimer = null; + void this.publishNow().catch((error) => { + console.error( + "[shared-ui-state] Failed to publish collection state", + error, + ); + }); + }, SHARED_UI_COLLECTION_WRITE_DEBOUNCE_MS); + } + + private async publishNow(): Promise { + if (this.applyingRemote) return; + + const organizationState = this.buildOrganizationState(); + const snapshot = await electronTrpcClient.uiState.shared.patch.mutate({ + clientId: this.clientId, + organizationId: this.organizationId, + collections: organizationState, + }); + this.lastSeenRevision = Math.max(this.lastSeenRevision, snapshot.revision); + } + + private async applyOrganizationState( + organizationState: SharedUiStateOrganizationState, + ): Promise { + this.applyingRemote = true; + try { + const transactions: CollectionTransaction[] = []; + for (const collectionName of SHARED_UI_COLLECTION_NAMES) { + transactions.push( + ...this.applyCollectionRows( + collectionName, + normalizeRows(organizationState[collectionName]), + ), + ); + } + + await Promise.all( + transactions.map((transaction) => + transaction.isPersisted.promise.catch((error) => { + console.error( + "[shared-ui-state] Failed to persist remote collection row", + error, + ); + }), + ), + ); + } finally { + this.applyingRemote = false; + } + } + + private applyCollectionRows( + collectionName: SharedUiStateCollectionName, + rows: SharedUiStateCollectionRows, + ): CollectionTransaction[] { + const collection = this.getCollection(collectionName); + const transactions: CollectionTransaction[] = []; + const remainingLocalKeys = new Set(collection.state.keys()); + + for (const [key, row] of Object.entries(rows)) { + if (!isObjectRow(row)) continue; + + remainingLocalKeys.delete(key); + if (collection.get(key)) { + transactions.push( + collection.update(key, (draft) => { + replaceDraft(draft, row); + }), + ); + } else { + transactions.push(collection.insert(row)); + } + } + + if (remainingLocalKeys.size > 0) { + transactions.push(collection.delete([...remainingLocalKeys])); + } + + return transactions; + } + + stop(): void { + if (this.publishTimer) { + clearTimeout(this.publishTimer); + this.publishTimer = null; + } + + for (const subscription of this.localSubscriptions) { + subscription.unsubscribe(); + } + this.localSubscriptions = []; + this.remoteSubscription?.unsubscribe(); + this.remoteSubscription = null; + this.started = false; + } +} + +export function startSharedUiStateCollectionSync( + organizationId: string, + collections: SharedUiStateSyncedCollections, +): void { + const existing = activeSyncs.get(organizationId); + if (existing) return; + + const sync = new SharedUiStateCollectionSync(organizationId, collections); + activeSyncs.set(organizationId, sync); + sync.start(); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx index 9f2b09c8f..b726ae748 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx @@ -83,9 +83,8 @@ export function LocalHostServiceProvider({ [organizations, activeOrganizationId], ); - const value = useMemo(() => { - if (!machineIdData) return null; - const machineId = machineIdData.machineId; + const value = useMemo(() => { + const machineId = machineIdData?.machineId ?? ""; const hostServiceStatus: HostServiceAvailabilityStatus = activeConnection?.port != null ? "running" @@ -121,8 +120,6 @@ export function LocalHostServiceProvider({ processStatus?.status, ]); - if (!value) return null; - return ( {children} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index 56d7a31f4..d1fdc4e7b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -10,6 +10,7 @@ import { HiOutlineCpuChip, HiOutlineCreditCard, HiOutlineFolder, + HiOutlineGlobeAlt, HiOutlineKey, HiOutlineLink, HiOutlineLockClosed, @@ -48,6 +49,7 @@ type SettingsRoute = | "/settings/billing" | "/settings/api-keys" | "/settings/security" + | "/settings/web-access" | "/settings/permissions" | "/settings/projects" | "/settings/hosts"; @@ -192,6 +194,12 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Security", icon: , }, + { + id: "/settings/web-access", + section: "webAccess", + label: "Web Access", + icon: , + }, { id: "/settings/permissions", section: "permissions", diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index 13f4401d9..dd6555dc4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -40,6 +40,8 @@ const SECTION_ORDER: SettingsSection[] = [ "integrations", "billing", "apikeys", + "security", + "webAccess", "permissions", "hosts", "experimental", @@ -59,6 +61,8 @@ function getSectionFromPath(pathname: string): SettingsSection | null { if (pathname.includes("/settings/models")) return "models"; if (pathname.includes("/settings/experimental")) return "experimental"; if (pathname.includes("/settings/integrations")) return "integrations"; + if (pathname.includes("/settings/security")) return "security"; + if (pathname.includes("/settings/web-access")) return "webAccess"; if (pathname.includes("/settings/permissions")) return "permissions"; if (pathname.includes("/settings/hosts")) return "hosts"; if (pathname.includes("/settings/project")) return "project"; @@ -93,6 +97,10 @@ function getPathFromSection(section: SettingsSection): string { return "/settings/experimental"; case "integrations": return "/settings/integrations"; + case "security": + return "/settings/security"; + case "webAccess": + return "/settings/web-access"; case "permissions": return "/settings/permissions"; case "hosts": diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts index 489e84d7a..be8811dde 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts @@ -79,3 +79,29 @@ describe("settings search - font settings", () => { ]); }); }); + +describe("settings search - Web Access", () => { + it("allows the Web Access section in both desktop variants", () => { + expect(getAllowedSectionsForVariant(false).has("webAccess")).toBe(true); + expect(getAllowedSectionsForVariant(true).has("webAccess")).toBe(true); + }); + + it.each([ + "web", + "browser", + "localhost", + "port", + "tailscale", + "origin", + "trusted", + "proxy", + "remote", + ])('searching "%s" includes Web Access settings', (query) => { + const ids = getIds(searchSettings(query)); + expect(ids).toContain( + query === "port" + ? SETTING_ITEM_ID.WEB_ACCESS_PORT + : SETTING_ITEM_ID.WEB_ACCESS_TRUSTED_ORIGINS, + ); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 730e7939e..bb3ee10cc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -81,6 +81,11 @@ export const SETTING_ITEM_ID = { SECURITY_EXPOSE_HOST_SERVICE_VIA_RELAY: "security-expose-host-service-via-relay", + WEB_ACCESS_ENABLED: "web-access-enabled", + WEB_ACCESS_PORT: "web-access-port", + WEB_ACCESS_URL: "web-access-url", + WEB_ACCESS_TRUSTED_ORIGINS: "web-access-trusted-origins", + HOST_MEMBERS: "host-members", HOST_INVITE_MEMBER: "host-invite-member", HOST_MEMBER_ROLE: "host-member-role", @@ -190,6 +195,11 @@ export const SETTING_ITEM_VARIANT: Record = { [SETTING_ITEM_ID.SECURITY_EXPOSE_HOST_SERVICE_VIA_RELAY]: "shared", + [SETTING_ITEM_ID.WEB_ACCESS_ENABLED]: "shared", + [SETTING_ITEM_ID.WEB_ACCESS_PORT]: "shared", + [SETTING_ITEM_ID.WEB_ACCESS_URL]: "shared", + [SETTING_ITEM_ID.WEB_ACCESS_TRUSTED_ORIGINS]: "shared", + [SETTING_ITEM_ID.HOST_MEMBERS]: "shared", [SETTING_ITEM_ID.HOST_INVITE_MEMBER]: "shared", [SETTING_ITEM_ID.HOST_MEMBER_ROLE]: "shared", @@ -1276,6 +1286,82 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "attack surface", ], }, + { + id: SETTING_ITEM_ID.WEB_ACCESS_ENABLED, + section: "webAccess", + title: "Enable Web Access", + description: "Run the desktop app from a same-machine browser tab", + keywords: [ + "web", + "browser", + "localhost", + "local", + "desktop", + "access", + "port", + "enable", + "disable", + "127.0.0.1", + "trpc", + "websocket", + ], + }, + { + id: SETTING_ITEM_ID.WEB_ACCESS_PORT, + section: "webAccess", + title: "Web Access Port", + description: "Choose the localhost port for Web Access", + keywords: [ + "web", + "browser", + "localhost", + "local", + "port", + "tcp", + "44010", + "127.0.0.1", + "websocket", + "trpc", + ], + }, + { + id: SETTING_ITEM_ID.WEB_ACCESS_URL, + section: "webAccess", + title: "Web Access URL", + description: "Copy or open the local Web Access URL", + keywords: [ + "web", + "browser", + "localhost", + "local", + "url", + "link", + "copy", + "open", + "127.0.0.1", + ], + }, + { + id: SETTING_ITEM_ID.WEB_ACCESS_TRUSTED_ORIGINS, + section: "webAccess", + title: "Web Access Trusted Origins", + description: "Allow exact reverse-proxy origins for Web Access", + keywords: [ + "web", + "browser", + "trusted", + "origin", + "origins", + "tailscale", + "reverse proxy", + "proxy", + "remote", + "ts.net", + "host", + "localhost", + "security", + ], + }, { id: SETTING_ITEM_ID.HOST_MEMBERS, section: "hosts", diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/components/WebAccessSettings/WebAccessSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/components/WebAccessSettings/WebAccessSettings.tsx new file mode 100644 index 000000000..4b3a38c13 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/components/WebAccessSettings/WebAccessSettings.tsx @@ -0,0 +1,468 @@ +import { Badge } from "@o3dotdev/code-ui/badge"; +import { Button } from "@o3dotdev/code-ui/button"; +import { Input } from "@o3dotdev/code-ui/input"; +import { Label } from "@o3dotdev/code-ui/label"; +import { toast } from "@o3dotdev/code-ui/sonner"; +import { Switch } from "@o3dotdev/code-ui/switch"; +import { CopyIcon, ExternalLinkIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { DEFAULT_WEB_ACCESS_PORT } from "shared/constants"; +import { SettingsRow } from "../../../components/SettingsRow"; +import { + isItemVisible, + SETTING_ITEM_ID, + type SettingItemId, +} from "../../../utils/settings-search"; + +const MIN_PORT = 1024; +const MAX_PORT = 65_535; +const TAILSCALE_CLI = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + +interface WebAccessSettingsProps { + visibleItems?: SettingItemId[] | null; +} + +function isValidPort(port: number): boolean { + return Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT; +} + +function getLocalWebAccessUrl(port: number): string { + return `http://127.0.0.1:${port}/`; +} + +function getStartTailscaleCommand(port: number): string { + return `TAILSCALE_BE_CLI=1 ${TAILSCALE_CLI} serve --bg --yes ${getLocalWebAccessUrl(port)}`; +} + +function getStopTailscaleCommand(): string { + return `TAILSCALE_BE_CLI=1 ${TAILSCALE_CLI} serve reset`; +} + +function normalizeOriginInput(input: string): string | null { + try { + const parsed = new URL(input.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + if (parsed.username || parsed.password) return null; + if (!parsed.hostname || parsed.hostname.includes("*")) return null; + return parsed.origin; + } catch { + return null; + } +} + +function getStatusLabel(status: string | undefined): string { + switch (status) { + case "running": + return "Running"; + case "starting": + return "Starting"; + case "error": + return "Error"; + default: + return "Stopped"; + } +} + +function getStatusVariant( + status: string | undefined, +): "default" | "destructive" | "secondary" { + switch (status) { + case "running": + return "default"; + case "error": + return "destructive"; + default: + return "secondary"; + } +} + +export function WebAccessSettings({ visibleItems }: WebAccessSettingsProps) { + const showEnabled = isItemVisible( + SETTING_ITEM_ID.WEB_ACCESS_ENABLED, + visibleItems, + ); + const showPort = isItemVisible(SETTING_ITEM_ID.WEB_ACCESS_PORT, visibleItems); + const showUrl = isItemVisible(SETTING_ITEM_ID.WEB_ACCESS_URL, visibleItems); + const showTrustedOrigins = isItemVisible( + SETTING_ITEM_ID.WEB_ACCESS_TRUSTED_ORIGINS, + visibleItems, + ); + const hasVisibleItems = + showEnabled || showPort || showUrl || showTrustedOrigins; + + const utils = electronTrpc.useUtils(); + const { data, isLoading } = + electronTrpc.settings.getWebAccessSettings.useQuery(undefined, { + refetchInterval: 5000, + }); + const webAccessSettings = useMemo( + () => + data ?? { + enabled: false, + port: DEFAULT_WEB_ACCESS_PORT, + status: "stopped" as const, + url: `http://127.0.0.1:${DEFAULT_WEB_ACCESS_PORT}/#/`, + localOrigins: [ + `http://127.0.0.1:${DEFAULT_WEB_ACCESS_PORT}`, + `http://localhost:${DEFAULT_WEB_ACCESS_PORT}`, + ], + trustedOrigins: [], + error: undefined, + }, + [data], + ); + const [portInput, setPortInput] = useState(String(webAccessSettings.port)); + const [trustedOriginInput, setTrustedOriginInput] = useState(""); + + useEffect(() => { + setPortInput(String(webAccessSettings.port)); + }, [webAccessSettings.port]); + + const setWebAccessSettings = + electronTrpc.settings.setWebAccessSettings.useMutation({ + onSuccess: (nextSettings) => { + utils.settings.getWebAccessSettings.setData(undefined, nextSettings); + }, + onError: (error) => { + toast.error(error.message); + }, + onSettled: () => { + utils.settings.getWebAccessSettings.invalidate(); + }, + }); + + const parsedPort = Number(portInput); + const portIsValid = isValidPort(parsedPort); + const portChanged = parsedPort !== webAccessSettings.port; + const controlsDisabled = isLoading || setWebAccessSettings.isPending; + const isRunning = webAccessSettings.status === "running"; + const startTailscaleCommand = getStartTailscaleCommand( + webAccessSettings.port, + ); + const stopTailscaleCommand = getStopTailscaleCommand(); + + const savePort = () => { + if (!portIsValid) { + toast.error( + `Port must be an integer between ${MIN_PORT} and ${MAX_PORT}`, + ); + return; + } + + setWebAccessSettings.mutate({ port: parsedPort }); + }; + + const handleEnabledChange = (enabled: boolean) => { + if (enabled && !portIsValid) { + toast.error( + `Port must be an integer between ${MIN_PORT} and ${MAX_PORT}`, + ); + return; + } + + setWebAccessSettings.mutate({ + enabled, + ...(enabled ? { port: parsedPort } : {}), + }); + }; + + const addTrustedOrigin = () => { + const normalizedOrigin = normalizeOriginInput(trustedOriginInput); + if (!normalizedOrigin) { + toast.error("Enter a valid HTTP or HTTPS origin."); + return; + } + + if ( + webAccessSettings.localOrigins.includes(normalizedOrigin) || + webAccessSettings.trustedOrigins.includes(normalizedOrigin) + ) { + setTrustedOriginInput(""); + toast.success("Origin is already trusted"); + return; + } + + setWebAccessSettings.mutate({ + trustedOrigins: [...webAccessSettings.trustedOrigins, normalizedOrigin], + }); + setTrustedOriginInput(""); + }; + + const removeTrustedOrigin = (origin: string) => { + setWebAccessSettings.mutate({ + trustedOrigins: webAccessSettings.trustedOrigins.filter( + (trustedOrigin) => trustedOrigin !== origin, + ), + }); + }; + + const copyText = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`Failed to copy ${label.toLowerCase()}: ${message}`); + } + }; + + const openUrl = () => { + window.open(webAccessSettings.url, "_blank", "noopener,noreferrer"); + }; + + return ( +
+
+

Web Access

+

+ Run the desktop app in a localhost browser tab. +

+
+ + {hasVisibleItems ? ( +
+
+
+

Status

+

+ {isRunning ? webAccessSettings.url : "Not running"} +

+ {webAccessSettings.error && ( +

+ {webAccessSettings.error} +

+ )} +
+ + {getStatusLabel(webAccessSettings.status)} + +
+ + {showEnabled && ( + + + + )} + + {showPort && ( + +
+ setPortInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + savePort(); + } + }} + /> + +
+
+ )} + + {showTrustedOrigins && ( +
+
+ +

+ Local browser access is always trusted. Add exact HTTPS + origins here when Web Access is served through a trusted + reverse proxy such as Tailscale. +

+
+ +
+

+ Built in +

+
+ {webAccessSettings.localOrigins.map((origin) => ( +
+ + {origin} + + Local +
+ ))} +
+
+ +
+

+ Reverse proxies +

+ {webAccessSettings.trustedOrigins.length > 0 ? ( +
+ {webAccessSettings.trustedOrigins.map((origin) => ( +
+ + {origin} + + +
+ ))} +
+ ) : ( +

+ No reverse-proxy origins trusted. +

+ )} + +
+ + setTrustedOriginInput(event.target.value) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + addTrustedOrigin(); + } + }} + /> + +
+
+ +
+
+

Tailscale

+

+ Use Tailscale Serve to open this dashboard from another + device, then add the Tailscale HTTPS origin above. +

+
+
+
+ + {startTailscaleCommand} + + +
+
+ + {stopTailscaleCommand} + + +
+
+
+
+ )} + + {showUrl && ( +
+
+ +

+ {webAccessSettings.url} +

+
+
+ + +
+
+ )} +
+ ) : ( +

No matching settings.

+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/components/WebAccessSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/components/WebAccessSettings/index.ts new file mode 100644 index 000000000..dce88df90 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/components/WebAccessSettings/index.ts @@ -0,0 +1 @@ +export { WebAccessSettings } from "./WebAccessSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/page.tsx new file mode 100644 index 000000000..100f703dc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/web-access/page.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { getMatchingItemsForSection } from "../utils/settings-search"; +import { WebAccessSettings } from "./components/WebAccessSettings"; + +export const Route = createFileRoute("/_authenticated/settings/web-access/")({ + component: WebAccessSettingsPage, +}); + +function WebAccessSettingsPage() { + const searchQuery = useSettingsSearchQuery(); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "webAccess").map( + (item) => item.id, + ); + }, [searchQuery]); + + return ; +} diff --git a/apps/desktop/src/renderer/routes/sign-in/page.tsx b/apps/desktop/src/renderer/routes/sign-in/page.tsx index ffc4afe98..0718411ee 100644 --- a/apps/desktop/src/renderer/routes/sign-in/page.tsx +++ b/apps/desktop/src/renderer/routes/sign-in/page.tsx @@ -14,6 +14,7 @@ import { env } from "renderer/env.renderer"; import { track } from "renderer/lib/analytics"; import { setAuthToken } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getRuntimeApiUrl } from "renderer/lib/runtime-urls"; import { O3CodeLogo } from "./components/O3CodeLogo"; import { useSessionRecovery } from "./hooks/useSessionRecovery"; @@ -58,7 +59,7 @@ function SignInPage() { setDevError(null); const postAuth = async (path: string, body: Record) => { - const response = await fetch(`${env.NEXT_PUBLIC_API_URL}${path}`, { + const response = await fetch(`${getRuntimeApiUrl()}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "omit", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts index 89e3ad22e..9998bdfa2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts @@ -3,7 +3,6 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { StartFreshSessionResult } from "renderer/components/Chat/ChatInterface/types"; -import { env } from "renderer/env.renderer"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { authClient, getAuthToken } from "renderer/lib/auth-client"; import { @@ -13,13 +12,14 @@ import { } from "renderer/lib/dev-chat"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { posthog } from "renderer/lib/posthog"; +import { getRuntimeApiUrl } from "renderer/lib/runtime-urls"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { ChatLaunchConfig } from "shared/tabs-types"; import { reportChatError } from "../../utils/reportChatError"; import { createSessionInitRunner } from "../../utils/session-init-runner"; -const apiUrl = env.NEXT_PUBLIC_API_URL; +const apiUrl = getRuntimeApiUrl(); const SESSION_INIT_RETRY_DELAY_MS = 1500; const SESSION_INIT_MAX_RETRIES = 3; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/utils/chat-runtime-service-client.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/utils/chat-runtime-service-client.ts index 7149e5bff..3ff632da1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/utils/chat-runtime-service-client.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/utils/chat-runtime-service-client.ts @@ -3,9 +3,8 @@ import type { ChatRuntimeServiceRouter } from "@o3dotdev/code-chat/server/trpc"; import type { TRPCLink } from "@trpc/client"; import type { AnyRouter } from "@trpc/server"; import { observable } from "@trpc/server/observable"; +import { createElectronTransportLink } from "renderer/lib/electron-trpc-transport"; import { sessionIdLink } from "renderer/lib/session-id-link"; -import superjson from "superjson"; -import { ipcLink } from "trpc-electron/renderer"; /** Prepends a router prefix so a standalone client can call a nested Electron router. */ function prefixLink( @@ -23,7 +22,7 @@ export function createChatRuntimeServiceIpcClient() { links: [ prefixLink("chatRuntimeService"), sessionIdLink(), - ipcLink({ transformer: superjson }), + createElectronTransportLink(), ], }); } diff --git a/apps/desktop/src/renderer/stores/settings-state.ts b/apps/desktop/src/renderer/stores/settings-state.ts index d5fbec62f..72176c2e4 100644 --- a/apps/desktop/src/renderer/stores/settings-state.ts +++ b/apps/desktop/src/renderer/stores/settings-state.ts @@ -20,6 +20,7 @@ export type SettingsSection = | "apikeys" | "permissions" | "security" + | "webAccess" | "project" | "hosts"; diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index a06dfe816..6d49c5e12 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -52,6 +52,9 @@ export const DEFAULT_TELEMETRY_ENABLED = true; export const DEFAULT_SHOW_RESOURCE_MONITOR = true; export const DEFAULT_OPEN_LINKS_IN_APP = false; export const DEFAULT_EXPOSE_HOST_SERVICE_VIA_RELAY = false; +export const DEFAULT_WEB_ACCESS_ENABLED = false; +export const DEFAULT_WEB_ACCESS_PORT = 44010; +export const DEFAULT_WEB_ACCESS_TRUSTED_ORIGINS: string[] = []; // External links (documentation, help resources, etc.) export const EXTERNAL_LINKS = { diff --git a/apps/desktop/src/shared/shared-ui-state.test.ts b/apps/desktop/src/shared/shared-ui-state.test.ts new file mode 100644 index 000000000..8b6e5c8b0 --- /dev/null +++ b/apps/desktop/src/shared/shared-ui-state.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "bun:test"; +import { + applySharedUiStatePatch, + createDefaultSharedUiState, + ensureSharedUiState, + hasSharedUiOrganizationRows, + shouldApplySharedUiStateEvent, +} from "./shared-ui-state"; + +describe("shared UI state reducer", () => { + it("normalizes missing snapshots to a default revisioned state", () => { + expect(ensureSharedUiState(null)).toEqual({ + revision: 0, + updatedAt: 0, + route: null, + organizations: {}, + }); + }); + + it("applies route and collection patches with a new revision", () => { + const snapshot = createDefaultSharedUiState(); + const next = applySharedUiStatePatch( + snapshot, + { + clientId: "client-a", + route: { + hashPath: "/v2-workspace/11111111-1111-1111-1111-111111111111", + activeWorkspaceId: "11111111-1111-1111-1111-111111111111", + updatedAt: 10, + }, + organizationId: "org-1", + collections: { + v2SidebarProjects: { + "project-1": { projectId: "project-1", tabOrder: 0 }, + }, + }, + }, + 20, + ); + + expect(next.revision).toBe(1); + expect(next.updatedAt).toBe(20); + expect(next.route?.activeWorkspaceId).toBe( + "11111111-1111-1111-1111-111111111111", + ); + expect(next.organizations["org-1"]?.v2SidebarProjects).toEqual({ + "project-1": { projectId: "project-1", tabOrder: 0 }, + }); + expect(next.organizations["org-1"]?.v2WorkspaceLocalState).toEqual({}); + }); + + it("keeps existing organization collections when patching only route", () => { + const withCollections = applySharedUiStatePatch( + createDefaultSharedUiState(), + { + clientId: "client-a", + organizationId: "org-1", + collections: { + v2SidebarSections: { + "section-1": { sectionId: "section-1" }, + }, + }, + }, + 1, + ); + + const next = applySharedUiStatePatch( + withCollections, + { + clientId: "client-b", + route: { + hashPath: "/tasks", + activeWorkspaceId: null, + updatedAt: 2, + }, + }, + 2, + ); + + expect(next.revision).toBe(2); + expect(next.organizations["org-1"]?.v2SidebarSections).toEqual({ + "section-1": { sectionId: "section-1" }, + }); + }); + + it("rejects stale and same-client events", () => { + const snapshot = applySharedUiStatePatch( + createDefaultSharedUiState(), + { clientId: "client-a", route: null }, + 1, + ); + + expect( + shouldApplySharedUiStateEvent({ + clientId: "client-b", + lastSeenRevision: 0, + event: { + type: "snapshot", + sourceClientId: "client-a", + snapshot, + }, + }), + ).toBe(true); + expect( + shouldApplySharedUiStateEvent({ + clientId: "client-a", + lastSeenRevision: 0, + event: { + type: "snapshot", + sourceClientId: "client-a", + snapshot, + }, + }), + ).toBe(false); + expect( + shouldApplySharedUiStateEvent({ + clientId: "client-b", + lastSeenRevision: snapshot.revision, + event: { + type: "snapshot", + sourceClientId: "client-a", + snapshot, + }, + }), + ).toBe(false); + }); + + it("detects whether an organization snapshot has rows", () => { + const snapshot = applySharedUiStatePatch( + createDefaultSharedUiState(), + { + clientId: "client-a", + organizationId: "org-1", + collections: { + v2UserPreferences: { + preferences: { id: "preferences" }, + }, + }, + }, + 1, + ); + + expect(hasSharedUiOrganizationRows(undefined)).toBe(false); + expect(hasSharedUiOrganizationRows(snapshot.organizations["org-1"])).toBe( + true, + ); + }); +}); diff --git a/apps/desktop/src/shared/shared-ui-state.ts b/apps/desktop/src/shared/shared-ui-state.ts new file mode 100644 index 000000000..c888fda48 --- /dev/null +++ b/apps/desktop/src/shared/shared-ui-state.ts @@ -0,0 +1,196 @@ +export const SHARED_UI_COLLECTION_NAMES = [ + "v2WorkspaceLocalState", + "v2SidebarProjects", + "v2SidebarSections", + "v2UserPreferences", +] as const; + +export type SharedUiStateCollectionName = + (typeof SHARED_UI_COLLECTION_NAMES)[number]; + +export type SharedUiStateCollectionRows = Record; + +export type SharedUiStateOrganizationState = Record< + SharedUiStateCollectionName, + SharedUiStateCollectionRows +>; + +export interface SharedUiRouteState { + hashPath: string; + activeWorkspaceId: string | null; + updatedAt: number; +} + +export interface SharedUiStateSnapshot { + revision: number; + updatedAt: number; + route: SharedUiRouteState | null; + organizations: Record; +} + +export interface SharedUiStatePatch { + clientId: string; + route?: SharedUiRouteState | null; + organizationId?: string; + collections?: Partial< + Record + >; +} + +export interface SharedUiStateEvent { + type: "snapshot"; + sourceClientId: string; + snapshot: SharedUiStateSnapshot; +} + +export interface SharedUiStateEventDecisionInput { + clientId: string; + lastSeenRevision: number; + event: SharedUiStateEvent; +} + +export function createEmptySharedUiOrganizationState(): SharedUiStateOrganizationState { + return { + v2WorkspaceLocalState: {}, + v2SidebarProjects: {}, + v2SidebarSections: {}, + v2UserPreferences: {}, + }; +} + +export function createDefaultSharedUiState(): SharedUiStateSnapshot { + return { + revision: 0, + updatedAt: 0, + route: null, + organizations: {}, + }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function normalizeCollectionRows(value: unknown): SharedUiStateCollectionRows { + return isRecord(value) ? { ...value } : {}; +} + +export function normalizeSharedUiOrganizationState( + value: unknown, +): SharedUiStateOrganizationState { + const record = isRecord(value) ? value : {}; + return { + v2WorkspaceLocalState: normalizeCollectionRows( + record.v2WorkspaceLocalState, + ), + v2SidebarProjects: normalizeCollectionRows(record.v2SidebarProjects), + v2SidebarSections: normalizeCollectionRows(record.v2SidebarSections), + v2UserPreferences: normalizeCollectionRows(record.v2UserPreferences), + }; +} + +function normalizeRoute(value: unknown): SharedUiRouteState | null { + if (!isRecord(value)) return null; + if (typeof value.hashPath !== "string" || !value.hashPath.startsWith("/")) { + return null; + } + + return { + hashPath: value.hashPath, + activeWorkspaceId: + typeof value.activeWorkspaceId === "string" + ? value.activeWorkspaceId + : null, + updatedAt: + typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt) + ? value.updatedAt + : 0, + }; +} + +export function ensureSharedUiState( + value: Partial | null | undefined, +): SharedUiStateSnapshot { + const record = isRecord(value) ? value : {}; + const organizations: Record = {}; + const rawOrganizations = isRecord(record.organizations) + ? record.organizations + : {}; + + for (const [organizationId, organizationState] of Object.entries( + rawOrganizations, + )) { + organizations[organizationId] = + normalizeSharedUiOrganizationState(organizationState); + } + + return { + revision: + typeof record.revision === "number" && Number.isFinite(record.revision) + ? Math.max(0, Math.trunc(record.revision)) + : 0, + updatedAt: + typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt) + ? record.updatedAt + : 0, + route: normalizeRoute(record.route), + organizations, + }; +} + +export function applySharedUiStatePatch( + snapshot: SharedUiStateSnapshot, + patch: SharedUiStatePatch, + now = Date.now(), +): SharedUiStateSnapshot { + const current = ensureSharedUiState(snapshot); + const next: SharedUiStateSnapshot = { + ...current, + revision: current.revision + 1, + updatedAt: now, + organizations: { ...current.organizations }, + }; + + if (patch.route !== undefined) { + next.route = patch.route; + } + + if (patch.organizationId && patch.collections) { + const organizationState = + current.organizations[patch.organizationId] ?? + createEmptySharedUiOrganizationState(); + const nextOrganizationState: SharedUiStateOrganizationState = { + ...organizationState, + }; + + for (const collectionName of SHARED_UI_COLLECTION_NAMES) { + const rows = patch.collections[collectionName]; + if (rows !== undefined) { + nextOrganizationState[collectionName] = { ...rows }; + } + } + + next.organizations[patch.organizationId] = nextOrganizationState; + } + + return next; +} + +export function shouldApplySharedUiStateEvent({ + clientId, + event, + lastSeenRevision, +}: SharedUiStateEventDecisionInput): boolean { + if (event.sourceClientId === clientId) return false; + return event.snapshot.revision > lastSeenRevision; +} + +export function hasSharedUiOrganizationRows( + organizationState: SharedUiStateOrganizationState | null | undefined, +): boolean { + if (!organizationState) return false; + return SHARED_UI_COLLECTION_NAMES.some( + (collectionName) => + Object.keys(organizationState[collectionName] ?? {}).length > 0, + ); +} diff --git a/apps/docs/vercel.json b/apps/docs/vercel.json new file mode 100644 index 000000000..f92a127db --- /dev/null +++ b/apps/docs/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "ignoreCommand": "bash -lc 'repo=$(git rev-parse --show-toplevel) && exec bash \"$repo/scripts/vercel-ignore-if-unaffected.sh\" docs'", + "installCommand": "bun install --frozen-lockfile --filter=@o3dotdev/code-docs --concurrent-scripts=1" +} diff --git a/apps/marketing/vercel.json b/apps/marketing/vercel.json new file mode 100644 index 000000000..af373a63e --- /dev/null +++ b/apps/marketing/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "ignoreCommand": "bash -lc 'repo=$(git rev-parse --show-toplevel) && exec bash \"$repo/scripts/vercel-ignore-if-unaffected.sh\" marketing'", + "installCommand": "bun install --frozen-lockfile --filter=@o3dotdev/code-marketing --concurrent-scripts=1" +} diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 000000000..fcfc4409b --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "ignoreCommand": "bash -lc 'repo=$(git rev-parse --show-toplevel) && exec bash \"$repo/scripts/vercel-ignore-if-unaffected.sh\" web'", + "installCommand": "bun install --frozen-lockfile --filter=@o3dotdev/code-web --concurrent-scripts=1" +} diff --git a/bun.lock b/bun.lock index 9e55ef306..f5e547ec0 100644 --- a/bun.lock +++ b/bun.lock @@ -315,6 +315,7 @@ "use-resize-observer": "9.1.0", "utf-8-validate": "6.0.6", "uuid": "14.0.0", + "ws": "8.20.0", "zod": "4.3.6", "zustand": "5.0.12", }, @@ -335,6 +336,7 @@ "@types/react-syntax-highlighter": "15.5.13", "@types/semver": "7.7.1", "@types/shell-quote": "1.7.5", + "@types/ws": "8.18.1", "@vitejs/plugin-react": "5.2.0", "code-inspector-plugin": "1.4.5", "cross-env": "10.1.0", diff --git a/docs/internal/README.md b/docs/internal/README.md new file mode 100644 index 000000000..e7b2b0f98 --- /dev/null +++ b/docs/internal/README.md @@ -0,0 +1,11 @@ +# Internal Docs + +This directory is for maintainer-facing notes that should live in the repo but +should not be published through the public docs app. Public product +documentation belongs under `apps/docs/content`. + +## Sections + +- [Fork Deltas](fork-deltas/README.md) tracks intentional changes this fork + carries on top of the upstream source, with links to the PRs that explain the + full implementation detail. diff --git a/docs/internal/fork-deltas/README.md b/docs/internal/fork-deltas/README.md new file mode 100644 index 000000000..fe1e14265 --- /dev/null +++ b/docs/internal/fork-deltas/README.md @@ -0,0 +1,37 @@ +# Fork Deltas + +This section tracks intentional differences between O3 Code and the upstream +Superset source. It is a small registry for humans and repo automation to check +before rebasing, cherry-picking, or porting upstream changes. + +Upstream references: + +- Mirror branch in this repo: `origin/upstream` +- Source remote: `superset/main` + +The active registry is [registry.md](registry.md). + +## What Gets an Entry + +Add an entry when a PR creates or changes a fork-specific behavior that future +upstream sync work must preserve or consciously revisit. Typical examples: + +- Product identity, package names, protocols, domains, and persisted paths. +- Database migration history or schema ownership rules that differ from + upstream. +- Desktop runtime, IPC, bridge, release, update, or host-service behavior. +- Security policy, authentication, networking, or local/remote exposure rules. +- Any bundle of compatibility patches that upstream changes could accidentally + undo. + +Skip routine dependency bumps, formatting-only changes, and bug fixes that do +not encode a durable fork policy. + +## Entry Format + +Each entry should stay short. Link the PR, name the affected area, include the +date, and explain why the delta matters during upstream sync. Keep full +implementation details in the PR description and code review. + +When a delta is removed, upstreamed, or replaced, keep the original row and +update the upstream sync note with the resolving PR. diff --git a/docs/internal/fork-deltas/registry.md b/docs/internal/fork-deltas/registry.md new file mode 100644 index 000000000..0750b1665 --- /dev/null +++ b/docs/internal/fork-deltas/registry.md @@ -0,0 +1,8 @@ +# Fork Delta Register + +Last updated: 2026-05-31 + +| PR | Area | Date | Delta | Upstream sync note | +| --- | --- | --- | --- | --- | +| [#35 - refactor: use O3 Code across the monorepo](https://github.com/o3dotdev/o3-code/pull/35) | Branding, packaging, database baselines | 2026-05-31 | Rebranded the repo from Superset to O3 Code, including npm scope, CLI binary, config/state paths, env prefix, deep-link protocol, bundle ID, GitHub owner, domains, and workspace directory names. Squashed inherited Drizzle migrations into fork-owned baselines. | Preserve O3 Code identity during upstream ports. For database changes, merge schema changes and generate new fork migrations instead of importing upstream migration files directly. | +| [#36 - feat(desktop): add desktop web access](https://github.com/o3dotdev/o3-code/pull/36) | Desktop Web Access | 2026-05-31 | Adds Settings -> Web Access for a desktop-hosted browser UI, localhost serving, `/trpc` WebSocket transport, browser-mode Electron shims, local Web Access settings, Tailscale-compatible reverse-proxy behavior, and desktop-hosted UI state sync. | Desktop renderer code can now run outside Electron. Upstream changes touching IPC, tRPC transport, renderer storage, routing, sidebar state, or host-service networking need browser-mode compatibility checks. | diff --git a/packages/local-db/drizzle/0043_web_access_settings.sql b/packages/local-db/drizzle/0043_web_access_settings.sql new file mode 100644 index 000000000..28bae5427 --- /dev/null +++ b/packages/local-db/drizzle/0043_web_access_settings.sql @@ -0,0 +1,3 @@ +ALTER TABLE `settings` ADD `web_access_enabled` integer;--> statement-breakpoint +ALTER TABLE `settings` ADD `web_access_port` integer;--> statement-breakpoint +ALTER TABLE `settings` ADD `web_access_trusted_origins` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0043_snapshot.json b/packages/local-db/drizzle/meta/0043_snapshot.json new file mode 100644 index 000000000..0811e1b41 --- /dev/null +++ b/packages/local-db/drizzle/meta/0043_snapshot.json @@ -0,0 +1,1508 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2ebca1fb-4fbd-42d8-a6f3-5c669a6cdef8", + "prevId": "525c23fb-a1c9-4d93-a1cb-c01cf767ef1c", + "tables": { + "browser_history": { + "name": "browser_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_visited_at": { + "name": "last_visited_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_count": { + "name": "visit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "browser_history_url_unique": { + "name": "browser_history_url_unique", + "columns": [ + "url" + ], + "isUnique": true + }, + "browser_history_url_idx": { + "name": "browser_history_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "browser_history_last_visited_at_idx": { + "name": "browser_history_last_visited_at_idx", + "columns": [ + "last_visited_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_base_branch": { + "name": "workspace_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_image": { + "name": "hide_image", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_app": { + "name": "default_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_preset_overrides": { + "name": "agent_preset_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_custom_definitions": { + "name": "agent_custom_definitions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_preset_permissions_migrated_at": { + "name": "agent_preset_permissions_migrated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persist_terminal": { + "name": "persist_terminal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "auto_apply_default_preset": { + "name": "auto_apply_default_preset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_sounds_muted": { + "name": "notification_sounds_muted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_volume": { + "name": "notification_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delete_local_branch": { + "name": "delete_local_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_open_mode": { + "name": "file_open_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_presets_bar": { + "name": "show_presets_bar", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_compact_terminal_add_button": { + "name": "use_compact_terminal_add_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_family": { + "name": "terminal_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_size": { + "name": "terminal_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_family": { + "name": "editor_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_size": { + "name": "editor_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_resource_monitor": { + "name": "show_resource_monitor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_links_in_app": { + "name": "open_links_in_app", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_editor": { + "name": "default_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expose_host_service_via_relay": { + "name": "expose_host_service_via_relay", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_access_enabled": { + "name": "web_access_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_access_port": { + "name": "web_access_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_access_trusted_origins": { + "name": "web_access_trusted_origins", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "v1_migration_state": { + "name": "v1_migration_state", + "columns": { + "v1_id": { + "name": "v1_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "v2_id": { + "name": "v2_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "v1_migration_state_v2_id_idx": { + "name": "v1_migration_state_v2_id_idx", + "columns": [ + "v2_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "v1_migration_state_organization_id_v1_id_kind_pk": { + "columns": [ + "organization_id", + "v1_id", + "kind" + ], + "name": "v1_migration_state_organization_id_v1_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sections": { + "name": "workspace_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_collapsed": { + "name": "is_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_sections_project_id_idx": { + "name": "workspace_sections_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sections_project_id_projects_id_fk": { + "name": "workspace_sections_project_id_projects_id_fk", + "tableFrom": "workspace_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unnamed": { + "name": "is_unnamed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_base": { + "name": "port_base", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + }, + "workspaces_section_id_idx": { + "name": "workspaces_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_section_id_workspace_sections_id_fk": { + "name": "workspaces_section_id_workspace_sections_id_fk", + "tableFrom": "workspaces", + "tableTo": "workspace_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_o3_code": { + "name": "created_by_o3_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index af8cc5b2d..571a156e0 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1780219851264, "tag": "0042_squashed_baseline", "breakpoints": true + }, + { + "idx": 43, + "version": "6", + "when": 1780248408820, + "tag": "0043_web_access_settings", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 992c3ad58..1ea78685b 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -234,6 +234,11 @@ export const settings = sqliteTable("settings", { exposeHostServiceViaRelay: integer("expose_host_service_via_relay", { mode: "boolean", }), + webAccessEnabled: integer("web_access_enabled", { mode: "boolean" }), + webAccessPort: integer("web_access_port"), + webAccessTrustedOrigins: text("web_access_trusted_origins", { + mode: "json", + }).$type(), }); export type InsertSettings = typeof settings.$inferInsert; diff --git a/packages/workspace-client/src/hooks/useEventBus/useEventBus.ts b/packages/workspace-client/src/hooks/useEventBus/useEventBus.ts index 360927bf2..b148ba133 100644 --- a/packages/workspace-client/src/hooks/useEventBus/useEventBus.ts +++ b/packages/workspace-client/src/hooks/useEventBus/useEventBus.ts @@ -7,6 +7,9 @@ import { useWorkspaceClient } from "../../providers/WorkspaceClientProvider"; * One WS connection is shared across all components using the same host. */ export function useEventBus(): EventBusHandle { - const { hostUrl, getWsToken } = useWorkspaceClient(); - return useMemo(() => getEventBus(hostUrl, getWsToken), [hostUrl, getWsToken]); + const { requestHostUrl, getWsToken } = useWorkspaceClient(); + return useMemo( + () => getEventBus(requestHostUrl, getWsToken), + [requestHostUrl, getWsToken], + ); } diff --git a/packages/workspace-client/src/hooks/useGitChangeEvents/useGitChangeEvents.ts b/packages/workspace-client/src/hooks/useGitChangeEvents/useGitChangeEvents.ts index 8e5c65b8e..9aba4b343 100644 --- a/packages/workspace-client/src/hooks/useGitChangeEvents/useGitChangeEvents.ts +++ b/packages/workspace-client/src/hooks/useGitChangeEvents/useGitChangeEvents.ts @@ -13,15 +13,15 @@ export function useGitChangeEvents( onChanged: (workspaceId: string, payload: GitChangedPayload) => void, enabled = true, ): void { - const { hostUrl, getWsToken } = useWorkspaceClient(); + const { requestHostUrl, getWsToken } = useWorkspaceClient(); const handler = useEffectEvent(onChanged); useEffect(() => { if (!enabled) return; - const bus = getEventBus(hostUrl, getWsToken); + const bus = getEventBus(requestHostUrl, getWsToken); return bus.on("git:changed", workspaceId, (id, payload) => { handler(id, payload); }); - }, [hostUrl, getWsToken, workspaceId, enabled]); + }, [requestHostUrl, getWsToken, workspaceId, enabled]); } diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx index 3e359b6a8..445707d12 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx @@ -16,6 +16,7 @@ function isTimeoutError(error: unknown): boolean { export interface WorkspaceClientContextValue { hostUrl: string; queryClient: QueryClient; + requestHostUrl: string; trpcClient: ReturnType; getWsToken: () => string | null; } @@ -25,12 +26,14 @@ interface WorkspaceClientProviderProps { hostUrl: string; children: ReactNode; headers?: () => Record; + requestHostUrl?: string; wsToken?: () => string | null; } interface WorkspaceClients { hostUrl: string; queryClient: QueryClient; + requestHostUrl: string; trpcClient: ReturnType; getWsToken: () => string | null; } @@ -42,10 +45,12 @@ const WorkspaceClientContext = function getWorkspaceClients( cacheKey: string, hostUrl: string, + requestHostUrl: string | undefined, headers?: () => Record, wsToken?: () => string | null, ): WorkspaceClients { - const clientKey = `${cacheKey}:${hostUrl}`; + const transportHostUrl = requestHostUrl ?? hostUrl; + const clientKey = `${cacheKey}:${hostUrl}:${transportHostUrl}`; const cached = workspaceClientsCache.get(clientKey); if (cached) { return cached; @@ -76,7 +81,7 @@ function getWorkspaceClients( const trpcClient = workspaceTrpc.createClient({ links: [ httpBatchStreamLink({ - url: `${hostUrl}/trpc`, + url: `${transportHostUrl}/trpc`, transformer: superjson, headers: headers ?? (() => ({})), }), @@ -87,6 +92,7 @@ function getWorkspaceClients( const clients: WorkspaceClients = { hostUrl, queryClient, + requestHostUrl: transportHostUrl, trpcClient, getWsToken, }; @@ -98,13 +104,21 @@ export function WorkspaceClientProvider({ cacheKey, hostUrl, headers, + requestHostUrl, wsToken, children, }: WorkspaceClientProviderProps) { - const clients = getWorkspaceClients(cacheKey, hostUrl, headers, wsToken); + const clients = getWorkspaceClients( + cacheKey, + hostUrl, + requestHostUrl, + headers, + wsToken, + ); const contextValue: WorkspaceClientContextValue = { hostUrl: clients.hostUrl, queryClient: clients.queryClient, + requestHostUrl: clients.requestHostUrl, trpcClient: clients.trpcClient, getWsToken: clients.getWsToken, }; @@ -142,8 +156,8 @@ export function useWorkspaceWsUrl( path: string, params?: Record, ): string { - const { hostUrl, getWsToken } = useWorkspaceClient(); - const url = new URL(`${hostUrl}${path}`); + const { requestHostUrl, getWsToken } = useWorkspaceClient(); + const url = new URL(`${requestHostUrl}${path}`); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; if (params) { for (const [key, value] of Object.entries(params)) { diff --git a/scripts/vercel-ignore-if-unaffected.sh b/scripts/vercel-ignore-if-unaffected.sh new file mode 100755 index 000000000..d456631cf --- /dev/null +++ b/scripts/vercel-ignore-if-unaffected.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="${1:-}" + +if [[ -z "$APP" ]]; then + echo "[vercel-ignore] Missing app name." + exit 1 +fi + +case "$APP" in + api | docs | marketing | web) ;; + *) + echo "[vercel-ignore] Unknown app: $APP" + exit 1 + ;; +esac + +ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$ROOT_DIR" + +HEAD_SHA="${VERCEL_GIT_COMMIT_SHA:-HEAD}" +BASE_SHA="${VERCEL_GIT_PREVIOUS_SHA:-}" + +if ! git rev-parse --verify --quiet "${HEAD_SHA}^{commit}" >/dev/null; then + HEAD_SHA="HEAD" +fi + +if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]] || ! git rev-parse --verify --quiet "${BASE_SHA}^{commit}" >/dev/null; then + if git rev-parse --verify --quiet "${HEAD_SHA}^" >/dev/null; then + BASE_SHA="${HEAD_SHA}^" + else + echo "[vercel-ignore] Could not determine a base commit; continuing build." + exit 1 + fi +fi + +is_app_file() { + local file="$1" + local app_dir="apps/$APP" + + case "$file" in + "$app_dir/src/"* | "$app_dir/content/"* | "$app_dir/public/"* | "$app_dir/.source/"*) return 0 ;; + "$app_dir/package.json" | "$app_dir/next.config."* | "$app_dir/postcss.config."*) return 0 ;; + "$app_dir/sentry."*".config.ts" | "$app_dir/source.config.ts" | "$app_dir/tsconfig.json" | "$app_dir/cli.json") return 0 ;; + esac + + return 1 +} + +is_shared_file() { + local file="$1" + + case "$file" in + turbo.json | turbo.jsonc | tooling/typescript/*) return 0 ;; + esac + + case "$APP:$file" in + api:packages/auth/* | api:packages/db/* | api:packages/mcp/* | api:packages/mcp-v2/* | api:packages/shared/* | api:packages/trpc/*) return 0 ;; + docs:packages/shared/*) return 0 ;; + marketing:packages/auth/* | marketing:packages/db/* | marketing:packages/email/* | marketing:packages/shared/* | marketing:packages/ui/*) return 0 ;; + web:packages/auth/* | web:packages/db/* | web:packages/shared/* | web:packages/trpc/* | web:packages/ui/*) return 0 ;; + esac + + return 1 +} + +while IFS= read -r file; do + if is_app_file "$file" || is_shared_file "$file"; then + echo "[vercel-ignore] $APP is affected by $file; continuing build." + exit 1 + fi +done < <(git diff --name-only "$BASE_SHA" "$HEAD_SHA") + +echo "[vercel-ignore] $APP is unaffected; skipping build." +exit 0