Files
openwork/apps/app/src/react-app/kernel/platform.tsx
ben 1dbc9f713c feat(desktop): electron 1:1 port alongside Tauri + fix workspace-create visibility (#1522)
* feat(desktop): electron 1:1 port alongside Tauri, fix workspace-create visibility

Adds an Electron shell that mirrors the Tauri desktop runtime (bridge,
dialogs, deep links, runtime supervision for openwork-server / opencode /
opencode-router / orchestrator, packaging via electron-builder). Tauri
dev/build scripts remain the default; Electron runs via dev:electron and
package:electron.

Also fixes the "workspace I just created is invisible until I restart the
app" bug: the React routes only wrote to desktop-side state, so the running
openwork-server never learned about the new workspace and the sidebar (which
is populated from the server list) dropped it. The create flow now also
calls openworkClient.createLocalWorkspace so POST /workspaces/local
registers the workspace at runtime.

Other small fixes included:
- Clears the "OpenWork server Disconnected" flash caused by React 18
  StrictMode double-invoking the connection stores' start/dispose pair.
- Real app icon wired into Electron (dock + BrowserWindow + builder).
- Fix a latent rm-import bug in runtime.mjs that silently skipped
  orchestrator auth cleanup.
- Locale copy updated to say "OpenWork desktop app" instead of "Tauri app".
- Adds description/author to apps/desktop/package.json to silence
  electron-builder warnings.

* docs(prds): Tauri → Electron migration plan

Describes how we'll cut every current Tauri user over to the Electron
build via the existing Tauri updater (one last migration release that
downloads + launches the Electron installer), how we unify app identity
so Electron reads the same userData Tauri wrote (zero-copy data
migration), and how ongoing auto-updates switch to electron-updater
publishing to the same GitHub releases.

---------

Co-authored-by: Benjamin Shafii <benjamin@openworklabs.com>
2026-04-22 15:34:54 -07:00

125 lines
3.5 KiB
TypeScript

/** @jsxImportSource react */
import { createContext, useContext, type ReactNode } from "react";
import { openDesktopUrl, relaunchDesktopApp } from "../../app/lib/desktop";
import { isDesktopRuntime } from "../../app/utils";
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);
type PlatformProviderProps = {
value: Platform;
children: ReactNode;
};
export function PlatformProvider({ value, children }: PlatformProviderProps) {
return (
<PlatformContext.Provider value={value}>
{children}
</PlatformContext.Provider>
);
}
export function usePlatform(): Platform {
const context = useContext(PlatformContext);
if (!context) {
throw new Error("Platform context is missing");
}
return context;
}
function shouldOpenInCurrentTab(url: string) {
return /^(mailto|tel):/i.test(url.trim());
}
export function createDefaultPlatform(): Platform {
return {
platform: isDesktopRuntime() ? "desktop" : "web",
openLink(url: string) {
if (isDesktopRuntime()) {
void openDesktopUrl(url).catch(() => undefined);
return;
}
if (shouldOpenInCurrentTab(url)) {
window.location.href = url;
return;
}
window.open(url, "_blank");
},
restart: async () => {
if (isDesktopRuntime()) {
await relaunchDesktopApp();
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,
};
}