diff --git a/package.json b/package.json index 5dff43c8..d1f5a0ff 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@opencode-ai/sdk": "^1.1.19", "@radix-ui/colors": "^3.0.0", + "@solid-primitives/storage": "^4.3.3", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "~2.6.0", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfe37ad0..40946480 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@radix-ui/colors': specifier: ^3.0.0 version: 3.0.0 + '@solid-primitives/storage': + specifier: ^4.3.3 + version: 4.3.3(solid-js@1.9.10) '@tauri-apps/api': specifier: ^2.0.0 version: 2.9.1 @@ -444,6 +447,23 @@ packages: cpu: [x64] os: [win32] + '@solid-primitives/storage@4.3.3': + resolution: {integrity: sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw==} + peerDependencies: + '@tauri-apps/plugin-store': '*' + solid-js: ^1.6.12 + solid-start: '*' + peerDependenciesMeta: + '@tauri-apps/plugin-store': + optional: true + solid-start: + optional: true + + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + peerDependencies: + solid-js: ^1.6.12 + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -1261,6 +1281,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@solid-primitives/storage@4.3.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/src/app/context/platform.tsx b/src/app/context/platform.tsx new file mode 100644 index 00000000..78df67a2 --- /dev/null +++ b/src/app/context/platform.tsx @@ -0,0 +1,46 @@ +import { createContext, useContext, type ParentProps } from "solid-js"; + +export type SyncStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +}; + +export type AsyncStorage = { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +}; + +export type Platform = { + platform: "web" | "desktop"; + os?: "macos" | "windows" | "linux"; + version?: string; + openLink(url: string): void; + restart(): Promise; + notify(title: string, description?: string, href?: string): Promise; + storage?: (name?: string) => SyncStorage | AsyncStorage; + checkUpdate?: () => Promise<{ updateAvailable: boolean; version?: string }>; + update?: () => Promise; + fetch?: typeof fetch; + getDefaultServerUrl?: () => Promise; + setDefaultServerUrl?: (url: string | null) => Promise; +}; + +const PlatformContext = createContext(undefined); + +export function PlatformProvider(props: ParentProps & { value: Platform }) { + return ( + + {props.children} + + ); +} + +export function usePlatform() { + const context = useContext(PlatformContext); + if (!context) { + throw new Error("Platform context is missing"); + } + return context; +} diff --git a/src/app/index.css b/src/app/index.css index 03140c04..5cee44b4 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -1,7 +1,7 @@ @import "tailwindcss"; -@config "../tailwind.config.ts"; +@config "../../tailwind.config.ts"; -@import "./styles/colors.css"; +@import "../styles/colors.css"; :root { color-scheme: light; diff --git a/src/app/pages/mcp.tsx b/src/app/pages/mcp.tsx index 84e6185d..12dc0d17 100644 --- a/src/app/pages/mcp.tsx +++ b/src/app/pages/mcp.tsx @@ -24,16 +24,6 @@ export type McpViewProps = { connectMcp: (entry: McpDirectoryInfo) => void; showMcpReloadBanner: boolean; reloadMcpEngine: () => void; - advancedName: string; - setAdvancedName: (value: string) => void; - advancedUrl: string; - setAdvancedUrl: (value: string) => void; - advancedOAuth: boolean; - setAdvancedOAuth: (value: boolean) => void; - advancedEnabled: boolean; - setAdvancedEnabled: (value: boolean) => void; - addAdvancedMcp: () => void; - testAdvancedMcp: () => void; }; const statusBadge = (status: "connected" | "needs_auth" | "needs_client_registration" | "failed" | "disabled" | "disconnected") => { diff --git a/src/app/utils/persist.ts b/src/app/utils/persist.ts new file mode 100644 index 00000000..9747465c --- /dev/null +++ b/src/app/utils/persist.ts @@ -0,0 +1,253 @@ +import { makePersisted } from "@solid-primitives/storage"; +import { createResource, type Accessor } from "solid-js"; +import type { SetStoreFunction, Store } from "solid-js/store"; + +import { usePlatform, type AsyncStorage, type SyncStorage } from "../context/platform"; + +type InitType = Promise | string | null; +type PersistedWithReady = [Store, SetStoreFunction, InitType, Accessor]; + +type PersistTarget = { + storage?: string; + key: string; + legacy?: string[]; + migrate?: (value: unknown) => unknown; +}; + +const LEGACY_STORAGE = "default.dat"; +const GLOBAL_STORAGE = "openwork.global.dat"; + +function snapshot(value: unknown) { + return JSON.parse(JSON.stringify(value)) as unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function merge(defaults: unknown, value: unknown): unknown { + if (value === undefined) return defaults; + if (value === null) return value; + + if (Array.isArray(defaults)) { + if (Array.isArray(value)) return value; + return defaults; + } + + if (isRecord(defaults)) { + if (!isRecord(value)) return defaults; + + const result: Record = { ...defaults }; + for (const key of Object.keys(value)) { + if (key in defaults) { + result[key] = merge((defaults as Record)[key], (value as Record)[key]); + } else { + result[key] = (value as Record)[key]; + } + } + return result; + } + + return value; +} + +function parse(value: string) { + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} + +function checksum(input: string) { + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash << 5) - hash + input.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +function workspaceStorage(dir: string) { + const head = dir.slice(0, 12) || "workspace"; + const sum = checksum(dir); + return `openwork.workspace.${head}.${sum}.dat`; +} + +function localStorageWithPrefix(prefix: string): SyncStorage { + const base = `${prefix}:`; + return { + getItem: (key) => localStorage.getItem(base + key), + setItem: (key, value) => localStorage.setItem(base + key, value), + removeItem: (key) => localStorage.removeItem(base + key), + }; +} + +export const Persist = { + global(key: string, legacy?: string[]): PersistTarget { + return { storage: GLOBAL_STORAGE, key, legacy }; + }, + workspace(dir: string, key: string, legacy?: string[]): PersistTarget { + return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }; + }, + session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { + return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }; + }, + scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { + if (session) return Persist.session(dir, session, key, legacy); + return Persist.workspace(dir, key, legacy); + }, +}; + +export function removePersisted(target: { storage?: string; key: string }) { + const platform = usePlatform(); + const isDesktop = platform.platform === "desktop" && !!platform.storage; + + if (isDesktop) { + return platform.storage?.(target.storage)?.removeItem(target.key); + } + + if (!target.storage) { + localStorage.removeItem(target.key); + return; + } + + localStorageWithPrefix(target.storage).removeItem(target.key); +} + +export function persisted( + target: string | PersistTarget, + store: [Store, SetStoreFunction], +): PersistedWithReady { + const platform = usePlatform(); + const config: PersistTarget = typeof target === "string" ? { key: target } : target; + + const defaults = snapshot(store[0]); + const legacy = config.legacy ?? []; + + const isDesktop = platform.platform === "desktop" && !!platform.storage; + + const currentStorage = (() => { + if (isDesktop) return platform.storage?.(config.storage); + if (!config.storage) return localStorage; + return localStorageWithPrefix(config.storage); + })(); + + const legacyStorage = (() => { + if (!isDesktop) return localStorage; + if (!config.storage) return platform.storage?.(); + return platform.storage?.(LEGACY_STORAGE); + })(); + + const storage = (() => { + if (!isDesktop) { + const current = currentStorage as SyncStorage; + const legacyStore = legacyStorage as SyncStorage; + + const api: SyncStorage = { + getItem: (key) => { + const raw = current.getItem(key); + if (raw !== null) { + const parsed = parse(raw); + if (parsed === undefined) return raw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (raw !== next) current.setItem(key, next); + return next; + } + + for (const legacyKey of legacy) { + const legacyRaw = legacyStore.getItem(legacyKey); + if (legacyRaw === null) continue; + + current.setItem(key, legacyRaw); + legacyStore.removeItem(legacyKey); + + const parsed = parse(legacyRaw); + if (parsed === undefined) return legacyRaw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (legacyRaw !== next) current.setItem(key, next); + return next; + } + + return null; + }, + setItem: (key, value) => { + current.setItem(key, value); + }, + removeItem: (key) => { + current.removeItem(key); + }, + }; + + return api; + } + + const current = currentStorage as AsyncStorage; + const legacyStore = legacyStorage as AsyncStorage | undefined; + + const api: AsyncStorage = { + getItem: async (key) => { + const raw = await current.getItem(key); + if (raw !== null) { + const parsed = parse(raw); + if (parsed === undefined) return raw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (raw !== next) await current.setItem(key, next); + return next; + } + + if (!legacyStore) return null; + + for (const legacyKey of legacy) { + const legacyRaw = await legacyStore.getItem(legacyKey); + if (legacyRaw === null) continue; + + await current.setItem(key, legacyRaw); + await legacyStore.removeItem(legacyKey); + + const parsed = parse(legacyRaw); + if (parsed === undefined) return legacyRaw; + + const migrated = config.migrate ? config.migrate(parsed) : parsed; + const merged = merge(defaults, migrated); + const next = JSON.stringify(merged); + if (legacyRaw !== next) await current.setItem(key, next); + return next; + } + + return null; + }, + setItem: async (key, value) => { + await current.setItem(key, value); + }, + removeItem: async (key) => { + await current.removeItem(key); + }, + }; + + return api; + })(); + + const [state, setState, init] = makePersisted(store, { name: config.key, storage }); + + const isAsync = init instanceof Promise; + const [ready] = createResource( + () => init, + async (initValue) => { + if (initValue instanceof Promise) await initValue; + return true; + }, + { initialValue: !isAsync }, + ); + + return [state, setState, init, () => ready() === true]; +} diff --git a/src/index.tsx b/src/index.tsx index c1451e74..01cd9bc6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,8 +2,10 @@ import { render } from "solid-js/web"; import { bootstrapTheme } from "./app/theme"; -import "./styles.css"; +import "./app/index.css"; import App from "./app/app"; +import { PlatformProvider, type Platform } from "./app/context/platform"; +import { isTauriRuntime } from "./app/utils"; bootstrapTheme(); @@ -13,4 +15,72 @@ if (!root) { throw new Error("Root element not found"); } -render(() => , root); +const platform: Platform = { + platform: isTauriRuntime() ? "desktop" : "web", + openLink(url: string) { + if (isTauriRuntime()) { + void import("@tauri-apps/plugin-opener") + .then(({ openUrl }) => openUrl(url)) + .catch(() => undefined); + return; + } + + window.open(url, "_blank"); + }, + restart: async () => { + if (isTauriRuntime()) { + const { relaunch } = await import("@tauri-apps/plugin-process"); + await relaunch(); + return; + } + + window.location.reload(); + }, + notify: async (title, description, href) => { + if (!("Notification" in window)) return; + + const permission = + Notification.permission === "default" + ? await Notification.requestPermission().catch(() => "denied") + : Notification.permission; + + if (permission !== "granted") return; + + const inView = document.visibilityState === "visible" && document.hasFocus(); + if (inView) return; + + await Promise.resolve() + .then(() => { + const notification = new Notification(title, { + body: description ?? "", + }); + notification.onclick = () => { + window.focus(); + if (href) { + window.history.pushState(null, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); + } + notification.close(); + }; + }) + .catch(() => undefined); + }, + storage: (name) => { + const prefix = name ? `${name}:` : ""; + return { + getItem: (key) => window.localStorage.getItem(prefix + key), + setItem: (key, value) => window.localStorage.setItem(prefix + key, value), + removeItem: (key) => window.localStorage.removeItem(prefix + key), + }; + }, + fetch, +}; + +render( + () => ( + + + + ), + root, +);