feat: add platform context and persistence helper

This commit is contained in:
Benjamin Shafii
2026-01-20 14:08:52 -08:00
parent 01461db16e
commit 1dbe539356
7 changed files with 403 additions and 14 deletions

View File

@@ -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",

29
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
};
export type Platform = {
platform: "web" | "desktop";
os?: "macos" | "windows" | "linux";
version?: string;
openLink(url: string): void;
restart(): Promise<void>;
notify(title: string, description?: string, href?: string): Promise<void>;
storage?: (name?: string) => SyncStorage | AsyncStorage;
checkUpdate?: () => Promise<{ updateAvailable: boolean; version?: string }>;
update?: () => Promise<void>;
fetch?: typeof fetch;
getDefaultServerUrl?: () => Promise<string | null>;
setDefaultServerUrl?: (url: string | null) => Promise<void>;
};
const PlatformContext = createContext<Platform | undefined>(undefined);
export function PlatformProvider(props: ParentProps & { value: Platform }) {
return (
<PlatformContext.Provider value={props.value}>
{props.children}
</PlatformContext.Provider>
);
}
export function usePlatform() {
const context = useContext(PlatformContext);
if (!context) {
throw new Error("Platform context is missing");
}
return context;
}

View File

@@ -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;

View File

@@ -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") => {

253
src/app/utils/persist.ts Normal file
View File

@@ -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> | string | null;
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>];
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<string, unknown> {
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<string, unknown> = { ...defaults };
for (const key of Object.keys(value)) {
if (key in defaults) {
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key]);
} else {
result[key] = (value as Record<string, unknown>)[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<T>(
target: string | PersistTarget,
store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
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];
}

View File

@@ -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(() => <App />, 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(
() => (
<PlatformProvider value={platform}>
<App />
</PlatformProvider>
),
root,
);