mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat: add platform context and persistence helper (#139)
This commit is contained in:
@@ -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
29
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
46
src/app/context/platform.tsx
Normal file
46
src/app/context/platform.tsx
Normal 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;
|
||||
}
|
||||
253
src/app/utils/persist.ts
Normal file
253
src/app/utils/persist.ts
Normal 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];
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { render } from "solid-js/web";
|
||||
import { bootstrapTheme } from "./app/theme";
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user