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>
This commit is contained in:
ben
2026-04-22 15:34:54 -07:00
committed by GitHub
parent 5ed3958a46
commit 1dbc9f713c
68 changed files with 7766 additions and 1254 deletions

View File

@@ -0,0 +1,61 @@
name: Build Electron Desktop
on:
workflow_dispatch:
push:
branches:
- dev
paths:
- apps/app/**
- apps/desktop/**
- packages/ui/**
- constants.json
- pnpm-lock.yaml
- package.json
- .github/workflows/build-electron-desktop.yml
jobs:
build-electron:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
artifact: macos
- os: ubuntu-latest
artifact: linux
- os: windows-latest
artifact: windows
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.27.0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Package Electron desktop
run: pnpm --filter @openwork/desktop package:electron:dir
- name: Upload Electron artifacts
uses: actions/upload-artifact@v4
with:
name: openwork-electron-${{ matrix.artifact }}
path: apps/desktop/dist-electron/**

View File

@@ -1,6 +1,6 @@
import type { WorkspaceDisplay } from "../types";
import { parseOpenworkWorkspaceIdFromUrl } from "../lib/openwork-server";
import type { WorkspaceInfo } from "../lib/tauri";
import type { WorkspaceInfo } from "../lib/desktop";
import type { BundleImportTarget, BundleV1 } from "./types";
export function buildImportPayloadFromBundle(bundle: BundleV1): {

View File

@@ -1,7 +1,6 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { desktopFetch } from "../lib/desktop";
import type { OpenworkServerClient } from "../lib/openwork-server";
import { isTauriRuntime, safeStringify } from "../utils";
import { isDesktopRuntime, safeStringify } from "../utils";
import { parseBundlePayload } from "./schema";
import type { BundleImportIntent, BundleRequest, BundleV1 } from "./types";
import { extractBundleId, isConfiguredBundlePublisherUrl } from "./url-policy";
@@ -130,8 +129,8 @@ export async function fetchBundle(
try {
let response: Response;
try {
response = isTauriRuntime()
? await tauriFetch(targetUrl.toString(), {
response = isDesktopRuntime()
? await desktopFetch(targetUrl.toString(), {
method: "GET",
headers: { Accept: "application/json" },
signal: controller.signal,

View File

@@ -1,4 +1,3 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import {
normalizeDesktopConfig,
type DesktopConfig as SharedDesktopConfig,
@@ -16,11 +15,12 @@ import {
dispatchDenSettingsChanged,
} from "./den-session-events";
import {
desktopFetch,
getDesktopBootstrapConfig as getDesktopBootstrapConfigFromShell,
setDesktopBootstrapConfig as setDesktopBootstrapConfigInShell,
type DesktopBootstrapConfig as ShellDesktopBootstrapConfig,
} from "./tauri";
import { isTauriRuntime } from "../utils";
} from "./desktop";
import { isDesktopRuntime } from "../utils";
import type { DenOrgSkillCard } from "../types";
const STORAGE_BASE_URL = "openwork.den.baseUrl";
@@ -409,7 +409,7 @@ export function readDenBootstrapConfig(): DenBootstrapConfig {
}
export async function initializeDenBootstrapConfig(): Promise<DenBootstrapConfig> {
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
desktopBootstrapConfig = resolveDenBootstrapConfig({
baseUrl: BUILD_DEN_BASE_URL,
apiBaseUrl: BUILD_DEN_API_BASE_URL,
@@ -438,7 +438,7 @@ export async function setDenBootstrapConfig(
): Promise<DenBootstrapConfig> {
const normalized = resolveDenBootstrapConfig(next);
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
const persisted = await setDesktopBootstrapConfigInShell({
baseUrl: normalized.baseUrl,
apiBaseUrl: normalized.apiBaseUrl,
@@ -1030,7 +1030,7 @@ function getBillingSummary(payload: unknown): DenBillingSummary | null {
};
}
const resolveFetch = () => (isTauriRuntime() ? tauriFetch : globalThis.fetch);
const resolveFetch = () => (isDesktopRuntime() ? desktopFetch : globalThis.fetch);
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,311 @@
import * as tauriBridge from "./desktop-tauri";
import { nativeDeepLinkEvent } from "./deep-link-bridge";
export type * from "./desktop-tauri";
export type DesktopBridge = typeof tauriBridge;
declare global {
interface Window {
__OPENWORK_ELECTRON__?: {
bridge?: Partial<DesktopBridge>;
invokeDesktop?: (command: string, ...args: unknown[]) => Promise<unknown>;
shell?: {
openExternal?: (url: string) => Promise<void>;
relaunch?: () => Promise<void>;
};
meta?: {
initialDeepLinks?: string[];
platform?: "darwin" | "linux" | "windows";
version?: string;
};
};
}
}
function missingElectronMethod(method: string): never {
throw new Error(`Electron desktop bridge method is not implemented yet: ${method}`);
}
function isElectronDesktopRuntime() {
return typeof window !== "undefined" && window.__OPENWORK_ELECTRON__ != null;
}
function isTauriDesktopRuntime() {
return typeof window !== "undefined" && (window as any).__TAURI_INTERNALS__ != null;
}
async function invokeElectronHelper<T>(command: string, ...args: unknown[]): Promise<T> {
const invokeDesktop = window.__OPENWORK_ELECTRON__?.invokeDesktop;
if (!invokeDesktop) {
throw new Error(`Electron desktop helper is unavailable: ${command}`);
}
return (await invokeDesktop(command, ...args)) as T;
}
function resolveElectronBridge(): DesktopBridge {
const exposed = window.__OPENWORK_ELECTRON__?.bridge ?? {};
const invokeDesktop = window.__OPENWORK_ELECTRON__?.invokeDesktop;
return new Proxy(exposed as DesktopBridge, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (value != null) {
return value;
}
if (prop === "resolveWorkspaceListSelectedId") {
return tauriBridge.resolveWorkspaceListSelectedId;
}
if (typeof prop === "string" && invokeDesktop) {
return (...args: unknown[]) => invokeDesktop(prop, ...args);
}
if (typeof prop === "string") {
return (..._args: unknown[]) => missingElectronMethod(prop);
}
return value;
},
});
}
function resolveDesktopBridge(): DesktopBridge {
if (
typeof window !== "undefined" &&
(window.__OPENWORK_ELECTRON__?.bridge || window.__OPENWORK_ELECTRON__?.invokeDesktop)
) {
return resolveElectronBridge();
}
return tauriBridge;
}
export const desktopBridge: DesktopBridge = new Proxy({} as DesktopBridge, {
get(_target, prop, receiver) {
return Reflect.get(resolveDesktopBridge(), prop, receiver);
},
});
export const desktopFetch: typeof globalThis.fetch = (input, init) => {
if (isElectronDesktopRuntime()) {
return globalThis.fetch(input, init);
}
return tauriBridge.desktopFetch(input, init);
};
export async function openDesktopUrl(url: string): Promise<void> {
if (isElectronDesktopRuntime()) {
await window.__OPENWORK_ELECTRON__?.shell?.openExternal?.(url);
return;
}
if (isTauriDesktopRuntime()) {
await tauriBridge.openDesktopUrl(url);
return;
}
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}
export async function openDesktopPath(target: string): Promise<void> {
if (isElectronDesktopRuntime()) {
const result = await invokeElectronHelper<string | null>("__openPath", target);
if (typeof result === "string" && result.trim()) {
throw new Error(result);
}
return;
}
await tauriBridge.openDesktopPath(target);
}
export async function revealDesktopItemInDir(target: string): Promise<void> {
if (isElectronDesktopRuntime()) {
await invokeElectronHelper<void>("__revealItemInDir", target);
return;
}
await tauriBridge.revealDesktopItemInDir(target);
}
export async function relaunchDesktopApp(): Promise<void> {
if (isElectronDesktopRuntime()) {
await window.__OPENWORK_ELECTRON__?.shell?.relaunch?.();
return;
}
await tauriBridge.relaunchDesktopApp();
}
export async function getDesktopHomeDir(): Promise<string> {
if (isElectronDesktopRuntime()) {
return invokeElectronHelper<string>("__homeDir");
}
return tauriBridge.getDesktopHomeDir();
}
export async function joinDesktopPath(...parts: string[]): Promise<string> {
if (isElectronDesktopRuntime()) {
return invokeElectronHelper<string>("__joinPath", ...parts);
}
return tauriBridge.joinDesktopPath(...parts);
}
export async function setDesktopZoomFactor(value: number): Promise<boolean> {
if (isElectronDesktopRuntime()) {
return invokeElectronHelper<boolean>("__setZoomFactor", value);
}
return tauriBridge.setDesktopZoomFactor(value);
}
export async function subscribeDesktopDeepLinks(
handler: (urls: string[]) => void,
): Promise<() => void> {
if (isElectronDesktopRuntime()) {
const listener = (event: Event) => {
const customEvent = event as CustomEvent<string[]>;
if (Array.isArray(customEvent.detail)) {
handler(customEvent.detail);
}
};
window.addEventListener(nativeDeepLinkEvent, listener as EventListener);
const initialUrls = window.__OPENWORK_ELECTRON__?.meta?.initialDeepLinks;
if (Array.isArray(initialUrls) && initialUrls.length > 0) {
handler(initialUrls);
}
return () => {
window.removeEventListener(nativeDeepLinkEvent, listener as EventListener);
};
}
return tauriBridge.subscribeDesktopDeepLinks(handler);
}
const {
resolveWorkspaceListSelectedId,
engineStart,
workspaceBootstrap,
workspaceSetSelected,
workspaceSetRuntimeActive,
workspaceCreate,
workspaceCreateRemote,
workspaceUpdateRemote,
workspaceUpdateDisplayName,
workspaceForget,
workspaceAddAuthorizedRoot,
workspaceExportConfig,
workspaceImportConfig,
workspaceOpenworkRead,
workspaceOpenworkWrite,
opencodeCommandList,
opencodeCommandWrite,
opencodeCommandDelete,
engineStop,
engineRestart,
orchestratorStatus,
orchestratorWorkspaceActivate,
orchestratorInstanceDispose,
appBuildInfo,
getDesktopBootstrapConfig,
setDesktopBootstrapConfig,
nukeOpenworkAndOpencodeConfigAndExit,
orchestratorStartDetached,
sandboxDoctor,
sandboxStop,
sandboxCleanupOpenworkContainers,
sandboxDebugProbe,
openworkServerInfo,
openworkServerRestart,
engineInfo,
engineDoctor,
pickDirectory,
pickFile,
saveFile,
engineInstall,
importSkill,
installSkillTemplate,
listLocalSkills,
readLocalSkill,
writeLocalSkill,
uninstallSkill,
updaterEnvironment,
readOpencodeConfig,
writeOpencodeConfig,
resetOpenworkState,
resetOpencodeCache,
schedulerListJobs,
schedulerDeleteJob,
getOpenCodeRouterStatus,
getOpenCodeRouterStatusDetailed,
opencodeRouterInfo,
getOpenCodeRouterGroupsEnabled,
setOpenCodeRouterGroupsEnabled,
opencodeMcpAuth,
opencodeRouterStop,
opencodeRouterStart,
opencodeRouterRestart,
setWindowDecorations,
} = desktopBridge;
export {
resolveWorkspaceListSelectedId,
engineStart,
workspaceBootstrap,
workspaceSetSelected,
workspaceSetRuntimeActive,
workspaceCreate,
workspaceCreateRemote,
workspaceUpdateRemote,
workspaceUpdateDisplayName,
workspaceForget,
workspaceAddAuthorizedRoot,
workspaceExportConfig,
workspaceImportConfig,
workspaceOpenworkRead,
workspaceOpenworkWrite,
opencodeCommandList,
opencodeCommandWrite,
opencodeCommandDelete,
engineStop,
engineRestart,
orchestratorStatus,
orchestratorWorkspaceActivate,
orchestratorInstanceDispose,
appBuildInfo,
getDesktopBootstrapConfig,
setDesktopBootstrapConfig,
nukeOpenworkAndOpencodeConfigAndExit,
orchestratorStartDetached,
sandboxDoctor,
sandboxStop,
sandboxCleanupOpenworkContainers,
sandboxDebugProbe,
openworkServerInfo,
openworkServerRestart,
engineInfo,
engineDoctor,
pickDirectory,
pickFile,
saveFile,
engineInstall,
importSkill,
installSkillTemplate,
listLocalSkills,
readLocalSkill,
writeLocalSkill,
uninstallSkill,
updaterEnvironment,
readOpencodeConfig,
writeOpencodeConfig,
resetOpenworkState,
resetOpencodeCache,
schedulerListJobs,
schedulerDeleteJob,
getOpenCodeRouterStatus,
getOpenCodeRouterStatusDetailed,
opencodeRouterInfo,
getOpenCodeRouterGroupsEnabled,
setOpenCodeRouterGroupsEnabled,
opencodeMcpAuth,
opencodeRouterStop,
opencodeRouterStart,
opencodeRouterRestart,
setWindowDecorations,
};

View File

@@ -1,8 +1,8 @@
import { createOpencodeClient, type Message, type Part, type Session, type Todo } from "@opencode-ai/sdk/v2/client";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { desktopFetch } from "./desktop";
import { createOpenworkServerClient, OpenworkServerError } from "./openwork-server";
import { isTauriRuntime } from "../utils";
import { isDesktopRuntime } from "../utils";
type FieldsResult<T> =
| ({ data: T; error?: undefined } & { request: Request; response: Response })
@@ -292,7 +292,7 @@ function nativeFetchRef(): typeof globalThis.fetch {
return globalThis.fetch as typeof globalThis.fetch;
}
const createTauriFetch = (auth?: OpencodeAuth) => {
const createDesktopFetch = (auth?: OpencodeAuth) => {
const authHeader = resolveAuthHeader(auth);
const addAuth = (headers: Headers) => {
if (!authHeader || headers.has("Authorization")) return;
@@ -305,7 +305,7 @@ const createTauriFetch = (auth?: OpencodeAuth) => {
const shouldStream = requestIsStreaming(input, init);
const underlyingFetch = shouldStream
? nativeFetchRef()
: (tauriFetch as unknown as typeof globalThis.fetch);
: desktopFetch;
// Streams should never be timed out at the transport layer; the caller
// aborts via AbortSignal when the subscription unmounts.
const timeoutMs = shouldStream ? 0 : DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS;
@@ -346,15 +346,15 @@ export function unwrap<T>(result: FieldsResult<T>): NonNullable<T> {
export function createClient(baseUrl: string, directory?: string, auth?: OpencodeAuth) {
const headers: Record<string, string> = {};
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
const authHeader = resolveAuthHeader(auth);
if (authHeader) {
headers.Authorization = authHeader;
}
}
const fetchImpl = isTauriRuntime()
? createTauriFetch(auth)
const fetchImpl = isDesktopRuntime()
? createDesktopFetch(auth)
: (input: RequestInfo | URL, init?: RequestInit) =>
fetchWithTimeout(globalThis.fetch, input, init, DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS);
const client = createOpencodeClient({

View File

@@ -1,7 +1,7 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import type { Message, Part, Session, Todo } from "@opencode-ai/sdk/v2/client";
import { isTauriRuntime } from "../utils";
import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri";
import { desktopFetch } from "./desktop";
import { isDesktopRuntime } from "../utils";
import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./desktop";
export type OpenworkServerCapabilities = {
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
@@ -761,11 +761,11 @@ function isStreamUrl(url: string): boolean {
}
const resolveFetch = (url?: string) => {
if (!isTauriRuntime()) return globalThis.fetch;
if (!isDesktopRuntime()) return globalThis.fetch;
if (url && isStreamUrl(url)) {
return typeof window !== "undefined" ? window.fetch.bind(window) : globalThis.fetch;
}
return tauriFetch;
return desktopFetch;
};
const DEFAULT_OPENWORK_SERVER_TIMEOUT_MS = 10_000;

View File

@@ -1,936 +1 @@
import { invoke } from "@tauri-apps/api/core";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { isTauriRuntime } from "../utils";
import { validateMcpServerName } from "../mcp";
export type EngineInfo = {
running: boolean;
runtime: "direct" | "openwork-orchestrator";
baseUrl: string | null;
projectDir: string | null;
hostname: string | null;
port: number | null;
opencodeUsername: string | null;
opencodePassword: string | null;
pid: number | null;
lastStdout: string | null;
lastStderr: string | null;
};
export type OpenworkServerInfo = {
running: boolean;
remoteAccessEnabled: boolean;
host: string | null;
port: number | null;
baseUrl: string | null;
connectUrl: string | null;
mdnsUrl: string | null;
lanUrl: string | null;
clientToken: string | null;
ownerToken: string | null;
hostToken: string | null;
pid: number | null;
lastStdout: string | null;
lastStderr: string | null;
};
export type OrchestratorDaemonState = {
pid: number;
port: number;
baseUrl: string;
startedAt: number;
};
export type OrchestratorOpencodeState = {
pid: number;
port: number;
baseUrl: string;
startedAt: number;
};
export type OrchestratorBinaryInfo = {
path: string;
source: string;
expectedVersion?: string | null;
actualVersion?: string | null;
};
export type OrchestratorBinaryState = {
opencode?: OrchestratorBinaryInfo | null;
};
export type OrchestratorSidecarInfo = {
dir?: string | null;
baseUrl?: string | null;
manifestUrl?: string | null;
target?: string | null;
source?: string | null;
opencodeSource?: string | null;
allowExternal?: boolean | null;
};
export type OrchestratorWorkspace = {
id: string;
name: string;
path: string;
workspaceType: string;
baseUrl?: string | null;
directory?: string | null;
createdAt?: number | null;
lastUsedAt?: number | null;
};
export type OrchestratorStatus = {
running: boolean;
dataDir: string;
daemon: OrchestratorDaemonState | null;
opencode: OrchestratorOpencodeState | null;
cliVersion?: string | null;
sidecar?: OrchestratorSidecarInfo | null;
binaries?: OrchestratorBinaryState | null;
activeId: string | null;
workspaceCount: number;
workspaces: OrchestratorWorkspace[];
lastError: string | null;
};
export type EngineDoctorResult = {
found: boolean;
inPath: boolean;
resolvedPath: string | null;
version: string | null;
supportsServe: boolean;
notes: string[];
serveHelpStatus: number | null;
serveHelpStdout: string | null;
serveHelpStderr: string | null;
};
export type WorkspaceInfo = {
id: string;
name: string;
path: string;
preset: string;
workspaceType: "local" | "remote";
remoteType?: "openwork" | "opencode" | null;
baseUrl?: string | null;
directory?: string | null;
displayName?: string | null;
openworkHostUrl?: string | null;
openworkToken?: string | null;
openworkClientToken?: string | null;
openworkHostToken?: string | null;
openworkWorkspaceId?: string | null;
openworkWorkspaceName?: string | null;
// Sandbox lifecycle metadata (desktop-managed)
sandboxBackend?: "docker" | "microsandbox" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
};
export type WorkspaceList = {
// UI-selected workspace persisted by the desktop shell.
selectedId?: string;
// Runtime/watch target currently followed by the desktop host.
watchedId?: string | null;
// Legacy desktop payloads used activeId for the UI-selected workspace.
activeId?: string | null;
workspaces: WorkspaceInfo[];
};
export function resolveWorkspaceListSelectedId(
list: Pick<WorkspaceList, "selectedId" | "activeId"> | null | undefined,
): string {
return list?.selectedId?.trim() || list?.activeId?.trim() || "";
}
export type WorkspaceExportSummary = {
outputPath: string;
included: number;
excluded: string[];
};
export async function engineStart(
projectDir: string,
options?: {
preferSidecar?: boolean;
runtime?: "direct" | "openwork-orchestrator";
workspacePaths?: string[];
opencodeBinPath?: string | null;
opencodeEnableExa?: boolean;
openworkRemoteAccess?: boolean;
},
): Promise<EngineInfo> {
return invoke<EngineInfo>("engine_start", {
projectDir,
preferSidecar: options?.preferSidecar ?? false,
opencodeBinPath: options?.opencodeBinPath ?? null,
opencodeEnableExa: options?.opencodeEnableExa ?? null,
openworkRemoteAccess: options?.openworkRemoteAccess ?? null,
runtime: options?.runtime ?? null,
workspacePaths: options?.workspacePaths ?? null,
});
}
export async function workspaceBootstrap(): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_bootstrap");
}
export async function workspaceSetSelected(workspaceId: string): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_set_selected", { workspaceId });
}
export async function workspaceSetRuntimeActive(workspaceId: string | null): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_set_runtime_active", { workspaceId: workspaceId ?? "" });
}
export async function workspaceCreate(input: {
folderPath: string;
name: string;
preset: string;
}): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_create", {
folderPath: input.folderPath,
name: input.name,
preset: input.preset,
});
}
export async function workspaceCreateRemote(input: {
baseUrl: string;
directory?: string | null;
displayName?: string | null;
remoteType?: "openwork" | "opencode" | null;
openworkHostUrl?: string | null;
openworkToken?: string | null;
openworkClientToken?: string | null;
openworkHostToken?: string | null;
openworkWorkspaceId?: string | null;
openworkWorkspaceName?: string | null;
// Sandbox lifecycle metadata (desktop-managed)
sandboxBackend?: "docker" | "microsandbox" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
}): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_create_remote", {
baseUrl: input.baseUrl,
directory: input.directory ?? null,
displayName: input.displayName ?? null,
remoteType: input.remoteType ?? null,
openworkHostUrl: input.openworkHostUrl ?? null,
openworkToken: input.openworkToken ?? null,
openworkClientToken: input.openworkClientToken ?? null,
openworkHostToken: input.openworkHostToken ?? null,
openworkWorkspaceId: input.openworkWorkspaceId ?? null,
openworkWorkspaceName: input.openworkWorkspaceName ?? null,
sandboxBackend: input.sandboxBackend ?? null,
sandboxRunId: input.sandboxRunId ?? null,
sandboxContainerName: input.sandboxContainerName ?? null,
});
}
export async function workspaceUpdateRemote(input: {
workspaceId: string;
baseUrl?: string | null;
directory?: string | null;
displayName?: string | null;
remoteType?: "openwork" | "opencode" | null;
openworkHostUrl?: string | null;
openworkToken?: string | null;
openworkClientToken?: string | null;
openworkHostToken?: string | null;
openworkWorkspaceId?: string | null;
openworkWorkspaceName?: string | null;
// Sandbox lifecycle metadata (desktop-managed)
sandboxBackend?: "docker" | "microsandbox" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
}): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_update_remote", {
workspaceId: input.workspaceId,
baseUrl: input.baseUrl ?? null,
directory: input.directory ?? null,
displayName: input.displayName ?? null,
remoteType: input.remoteType ?? null,
openworkHostUrl: input.openworkHostUrl ?? null,
openworkToken: input.openworkToken ?? null,
openworkClientToken: input.openworkClientToken ?? null,
openworkHostToken: input.openworkHostToken ?? null,
openworkWorkspaceId: input.openworkWorkspaceId ?? null,
openworkWorkspaceName: input.openworkWorkspaceName ?? null,
sandboxBackend: input.sandboxBackend ?? null,
sandboxRunId: input.sandboxRunId ?? null,
sandboxContainerName: input.sandboxContainerName ?? null,
});
}
export async function workspaceUpdateDisplayName(input: {
workspaceId: string;
displayName?: string | null;
}): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_update_display_name", {
workspaceId: input.workspaceId,
displayName: input.displayName ?? null,
});
}
export async function workspaceForget(workspaceId: string): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_forget", { workspaceId });
}
export async function workspaceAddAuthorizedRoot(input: {
workspacePath: string;
folderPath: string;
}): Promise<ExecResult> {
return invoke<ExecResult>("workspace_add_authorized_root", {
workspacePath: input.workspacePath,
folderPath: input.folderPath,
});
}
export async function workspaceExportConfig(input: {
workspaceId: string;
outputPath: string;
}): Promise<WorkspaceExportSummary> {
return invoke<WorkspaceExportSummary>("workspace_export_config", {
workspaceId: input.workspaceId,
outputPath: input.outputPath,
});
}
export async function workspaceImportConfig(input: {
archivePath: string;
targetDir: string;
name?: string | null;
}): Promise<WorkspaceList> {
return invoke<WorkspaceList>("workspace_import_config", {
archivePath: input.archivePath,
targetDir: input.targetDir,
name: input.name ?? null,
});
}
export type OpencodeCommandDraft = {
name: string;
description?: string;
template: string;
agent?: string;
model?: string;
subtask?: boolean;
};
export type WorkspaceOpenworkConfig = {
version: number;
workspace?: {
name?: string | null;
createdAt?: number | null;
preset?: string | null;
} | null;
authorizedRoots: string[];
reload?: {
auto?: boolean;
resume?: boolean;
} | null;
};
export async function workspaceOpenworkRead(input: {
workspacePath: string;
}): Promise<WorkspaceOpenworkConfig> {
return invoke<WorkspaceOpenworkConfig>("workspace_openwork_read", {
workspacePath: input.workspacePath,
});
}
export async function workspaceOpenworkWrite(input: {
workspacePath: string;
config: WorkspaceOpenworkConfig;
}): Promise<ExecResult> {
return invoke<ExecResult>("workspace_openwork_write", {
workspacePath: input.workspacePath,
config: input.config,
});
}
export async function opencodeCommandList(input: {
scope: "workspace" | "global";
projectDir: string;
}): Promise<string[]> {
return invoke<string[]>("opencode_command_list", {
scope: input.scope,
projectDir: input.projectDir,
});
}
export async function opencodeCommandWrite(input: {
scope: "workspace" | "global";
projectDir: string;
command: OpencodeCommandDraft;
}): Promise<ExecResult> {
return invoke<ExecResult>("opencode_command_write", {
scope: input.scope,
projectDir: input.projectDir,
command: input.command,
});
}
export async function opencodeCommandDelete(input: {
scope: "workspace" | "global";
projectDir: string;
name: string;
}): Promise<ExecResult> {
return invoke<ExecResult>("opencode_command_delete", {
scope: input.scope,
projectDir: input.projectDir,
name: input.name,
});
}
export async function engineStop(): Promise<EngineInfo> {
return invoke<EngineInfo>("engine_stop");
}
export async function engineRestart(options?: {
opencodeEnableExa?: boolean;
openworkRemoteAccess?: boolean;
}): Promise<EngineInfo> {
return invoke<EngineInfo>("engine_restart", {
opencodeEnableExa: options?.opencodeEnableExa ?? null,
openworkRemoteAccess: options?.openworkRemoteAccess ?? null,
});
}
export async function orchestratorStatus(): Promise<OrchestratorStatus> {
return invoke<OrchestratorStatus>("orchestrator_status");
}
export async function orchestratorWorkspaceActivate(input: {
workspacePath: string;
name?: string | null;
}): Promise<OrchestratorWorkspace> {
return invoke<OrchestratorWorkspace>("orchestrator_workspace_activate", {
workspacePath: input.workspacePath,
name: input.name ?? null,
});
}
export async function orchestratorInstanceDispose(workspacePath: string): Promise<boolean> {
return invoke<boolean>("orchestrator_instance_dispose", { workspacePath });
}
export type AppBuildInfo = {
version: string;
gitSha?: string | null;
buildEpoch?: string | null;
openworkDevMode?: boolean;
};
export type DesktopBootstrapConfig = {
baseUrl: string;
apiBaseUrl?: string | null;
requireSignin: boolean;
};
export async function appBuildInfo(): Promise<AppBuildInfo> {
return invoke<AppBuildInfo>("app_build_info");
}
export async function getDesktopBootstrapConfig(): Promise<DesktopBootstrapConfig> {
return invoke<DesktopBootstrapConfig>("get_desktop_bootstrap_config");
}
export async function setDesktopBootstrapConfig(
config: DesktopBootstrapConfig,
): Promise<DesktopBootstrapConfig> {
return invoke<DesktopBootstrapConfig>("set_desktop_bootstrap_config", { config });
}
export async function nukeOpenworkAndOpencodeConfigAndExit(): Promise<void> {
return invoke<void>("nuke_openwork_and_opencode_config_and_exit");
}
export type OrchestratorDetachedHost = {
openworkUrl: string;
token: string;
ownerToken?: string | null;
hostToken: string;
port: number;
sandboxBackend?: "docker" | "microsandbox" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
};
export async function orchestratorStartDetached(input: {
workspacePath: string;
sandboxBackend?: "none" | "docker" | "microsandbox" | null;
sandboxImageRef?: string | null;
runId?: string | null;
openworkToken?: string | null;
openworkHostToken?: string | null;
}): Promise<OrchestratorDetachedHost> {
return invoke<OrchestratorDetachedHost>("orchestrator_start_detached", {
workspacePath: input.workspacePath,
sandboxBackend: input.sandboxBackend ?? null,
sandboxImageRef: input.sandboxImageRef ?? null,
runId: input.runId ?? null,
openworkToken: input.openworkToken ?? null,
openworkHostToken: input.openworkHostToken ?? null,
});
}
export type SandboxDoctorResult = {
installed: boolean;
daemonRunning: boolean;
permissionOk: boolean;
ready: boolean;
clientVersion?: string | null;
serverVersion?: string | null;
error?: string | null;
debug?: {
candidates: string[];
selectedBin?: string | null;
versionCommand?: {
status: number;
stdout: string;
stderr: string;
} | null;
infoCommand?: {
status: number;
stdout: string;
stderr: string;
} | null;
} | null;
};
export async function sandboxDoctor(): Promise<SandboxDoctorResult> {
return invoke<SandboxDoctorResult>("sandbox_doctor");
}
export async function sandboxStop(containerName: string): Promise<ExecResult> {
return invoke<ExecResult>("sandbox_stop", { containerName });
}
export type OpenworkDockerCleanupResult = {
candidates: string[];
removed: string[];
errors: string[];
};
export async function sandboxCleanupOpenworkContainers(): Promise<OpenworkDockerCleanupResult> {
return invoke<OpenworkDockerCleanupResult>("sandbox_cleanup_openwork_containers");
}
export type SandboxDebugProbeResult = {
startedAt: number;
finishedAt: number;
runId: string;
workspacePath: string;
ready: boolean;
doctor: SandboxDoctorResult;
detachedHost?: OrchestratorDetachedHost | null;
dockerInspect?: {
status: number;
stdout: string;
stderr: string;
} | null;
dockerLogs?: {
status: number;
stdout: string;
stderr: string;
} | null;
cleanup: {
containerName?: string | null;
containerRemoved: boolean;
removeResult?: {
status: number;
stdout: string;
stderr: string;
} | null;
workspaceRemoved: boolean;
errors: string[];
};
error?: string | null;
};
export async function sandboxDebugProbe(): Promise<SandboxDebugProbeResult> {
return invoke<SandboxDebugProbeResult>("sandbox_debug_probe");
}
export async function openworkServerInfo(): Promise<OpenworkServerInfo> {
return invoke<OpenworkServerInfo>("openwork_server_info");
}
export async function openworkServerRestart(options?: {
remoteAccessEnabled?: boolean;
}): Promise<OpenworkServerInfo> {
return invoke<OpenworkServerInfo>("openwork_server_restart", {
remoteAccessEnabled: options?.remoteAccessEnabled ?? null,
});
}
export async function engineInfo(): Promise<EngineInfo> {
return invoke<EngineInfo>("engine_info");
}
export async function engineDoctor(options?: {
preferSidecar?: boolean;
opencodeBinPath?: string | null;
}): Promise<EngineDoctorResult> {
return invoke<EngineDoctorResult>("engine_doctor", {
preferSidecar: options?.preferSidecar ?? false,
opencodeBinPath: options?.opencodeBinPath ?? null,
});
}
export async function pickDirectory(options?: {
title?: string;
defaultPath?: string;
multiple?: boolean;
}): Promise<string | string[] | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
return open({
title: options?.title,
defaultPath: options?.defaultPath,
directory: true,
multiple: options?.multiple,
});
}
export async function pickFile(options?: {
title?: string;
defaultPath?: string;
multiple?: boolean;
filters?: Array<{ name: string; extensions: string[] }>;
}): Promise<string | string[] | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
return open({
title: options?.title,
defaultPath: options?.defaultPath,
directory: false,
multiple: options?.multiple,
filters: options?.filters,
});
}
export async function saveFile(options?: {
title?: string;
defaultPath?: string;
filters?: Array<{ name: string; extensions: string[] }>;
}): Promise<string | null> {
const { save } = await import("@tauri-apps/plugin-dialog");
return save({
title: options?.title,
defaultPath: options?.defaultPath,
filters: options?.filters,
});
}
export type ExecResult = {
ok: boolean;
status: number;
stdout: string;
stderr: string;
};
export type ScheduledJobRun = {
prompt?: string;
command?: string;
arguments?: string;
files?: string[];
agent?: string;
model?: string;
variant?: string;
title?: string;
share?: boolean;
continue?: boolean;
session?: string;
runFormat?: string;
attachUrl?: string;
port?: number;
};
export type ScheduledJob = {
scopeId?: string;
timeoutSeconds?: number;
invocation?: { command: string; args: string[] };
slug: string;
name: string;
schedule: string;
prompt?: string;
attachUrl?: string;
run?: ScheduledJobRun;
source?: string;
workdir?: string;
createdAt: string;
updatedAt?: string;
lastRunAt?: string;
lastRunExitCode?: number;
lastRunError?: string;
lastRunSource?: string;
lastRunStatus?: string;
};
export async function engineInstall(): Promise<ExecResult> {
return invoke<ExecResult>("engine_install");
}
export async function importSkill(
projectDir: string,
sourceDir: string,
options?: { overwrite?: boolean },
): Promise<ExecResult> {
return invoke<ExecResult>("import_skill", {
projectDir,
sourceDir,
overwrite: options?.overwrite ?? false,
});
}
export async function installSkillTemplate(
projectDir: string,
name: string,
content: string,
options?: { overwrite?: boolean },
): Promise<ExecResult> {
return invoke<ExecResult>("install_skill_template", {
projectDir,
name,
content,
overwrite: options?.overwrite ?? false,
});
}
export type LocalSkillCard = {
name: string;
path: string;
description?: string;
trigger?: string;
};
export type LocalSkillContent = {
path: string;
content: string;
};
export async function listLocalSkills(projectDir: string): Promise<LocalSkillCard[]> {
return invoke<LocalSkillCard[]>("list_local_skills", { projectDir });
}
export async function readLocalSkill(projectDir: string, name: string): Promise<LocalSkillContent> {
return invoke<LocalSkillContent>("read_local_skill", { projectDir, name });
}
export async function writeLocalSkill(projectDir: string, name: string, content: string): Promise<ExecResult> {
return invoke<ExecResult>("write_local_skill", { projectDir, name, content });
}
export async function uninstallSkill(projectDir: string, name: string): Promise<ExecResult> {
return invoke<ExecResult>("uninstall_skill", { projectDir, name });
}
export type OpencodeConfigFile = {
path: string;
exists: boolean;
content: string | null;
};
export type UpdaterEnvironment = {
supported: boolean;
reason: string | null;
executablePath: string | null;
appBundlePath: string | null;
};
export async function updaterEnvironment(): Promise<UpdaterEnvironment> {
return invoke<UpdaterEnvironment>("updater_environment");
}
export async function readOpencodeConfig(
scope: "project" | "global",
projectDir: string,
): Promise<OpencodeConfigFile> {
return invoke<OpencodeConfigFile>("read_opencode_config", { scope, projectDir });
}
export async function writeOpencodeConfig(
scope: "project" | "global",
projectDir: string,
content: string,
): Promise<ExecResult> {
return invoke<ExecResult>("write_opencode_config", { scope, projectDir, content });
}
export async function resetOpenworkState(mode: "onboarding" | "all"): Promise<void> {
return invoke<void>("reset_openwork_state", { mode });
}
export type CacheResetResult = {
removed: string[];
missing: string[];
errors: string[];
};
export async function resetOpencodeCache(): Promise<CacheResetResult> {
return invoke<CacheResetResult>("reset_opencode_cache");
}
export async function schedulerListJobs(scopeRoot?: string): Promise<ScheduledJob[]> {
return invoke<ScheduledJob[]>("scheduler_list_jobs", { scopeRoot });
}
export async function schedulerDeleteJob(name: string, scopeRoot?: string): Promise<ScheduledJob> {
return invoke<ScheduledJob>("scheduler_delete_job", { name, scopeRoot });
}
// OpenCodeRouter types
export type OpenCodeRouterIdentityItem = {
id: string;
enabled: boolean;
running?: boolean;
};
export type OpenCodeRouterChannelStatus = {
items: OpenCodeRouterIdentityItem[];
};
export type OpenCodeRouterStatus = {
running: boolean;
config: string;
healthPort?: number | null;
telegram: OpenCodeRouterChannelStatus;
slack: OpenCodeRouterChannelStatus;
opencode: { url: string; directory?: string };
};
export type OpenCodeRouterStatusResult =
| { ok: true; status: OpenCodeRouterStatus }
| { ok: false; error: string };
export type OpenCodeRouterInfo = {
running: boolean;
version: string | null;
workspacePath: string | null;
opencodeUrl: string | null;
healthPort: number | null;
pid: number | null;
lastStdout: string | null;
lastStderr: string | null;
};
// OpenCodeRouter functions - call Tauri commands that wrap opencodeRouter CLI
export async function getOpenCodeRouterStatus(): Promise<OpenCodeRouterStatus | null> {
try {
return await invoke<OpenCodeRouterStatus>("opencodeRouter_status");
} catch {
return null;
}
}
export async function getOpenCodeRouterStatusDetailed(): Promise<OpenCodeRouterStatusResult> {
try {
const status = await invoke<OpenCodeRouterStatus>("opencodeRouter_status");
return { ok: true, status };
} catch (error) {
return { ok: false, error: String(error) };
}
}
export async function opencodeRouterInfo(): Promise<OpenCodeRouterInfo> {
return invoke<OpenCodeRouterInfo>("opencodeRouter_info");
}
export async function getOpenCodeRouterGroupsEnabled(): Promise<boolean | null> {
try {
const status = await getOpenCodeRouterStatus();
const healthPort = status?.healthPort ?? 3005;
const response = await (isTauriRuntime() ? tauriFetch : fetch)(`http://127.0.0.1:${healthPort}/config/groups`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
return null;
}
const data = await response.json();
return data?.groupsEnabled ?? null;
} catch {
return null;
}
}
export async function setOpenCodeRouterGroupsEnabled(enabled: boolean): Promise<ExecResult> {
try {
const status = await getOpenCodeRouterStatus();
const healthPort = status?.healthPort ?? 3005;
const response = await (isTauriRuntime() ? tauriFetch : fetch)(`http://127.0.0.1:${healthPort}/config/groups`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
const message = await response.text();
return { ok: false, status: response.status, stdout: "", stderr: message };
}
return { ok: true, status: 0, stdout: "", stderr: "" };
} catch (e) {
return { ok: false, status: 1, stdout: "", stderr: String(e) };
}
}
export async function opencodeMcpAuth(
projectDir: string,
serverName: string,
): Promise<ExecResult> {
const safeProjectDir = projectDir.trim();
if (!safeProjectDir) {
throw new Error("project_dir is required");
}
const safeServerName = validateMcpServerName(serverName);
return invoke<ExecResult>("opencode_mcp_auth", {
projectDir: safeProjectDir,
serverName: safeServerName,
});
}
export async function opencodeRouterStop(): Promise<OpenCodeRouterInfo> {
return invoke<OpenCodeRouterInfo>("opencodeRouter_stop");
}
export async function opencodeRouterStart(options: {
workspacePath: string;
opencodeUrl?: string;
opencodeUsername?: string;
opencodePassword?: string;
healthPort?: number;
}): Promise<OpenCodeRouterInfo> {
return invoke<OpenCodeRouterInfo>("opencodeRouter_start", {
workspacePath: options.workspacePath,
opencodeUrl: options.opencodeUrl ?? null,
opencodeUsername: options.opencodeUsername ?? null,
opencodePassword: options.opencodePassword ?? null,
healthPort: options.healthPort ?? null,
});
}
export async function opencodeRouterRestart(options: {
workspacePath: string;
opencodeUrl?: string;
opencodeUsername?: string;
opencodePassword?: string;
healthPort?: number;
}): Promise<OpenCodeRouterInfo> {
await opencodeRouterStop();
return opencodeRouterStart(options);
}
/**
* Set window decorations (titlebar) visibility.
* When `decorations` is false, the native titlebar is hidden.
* Useful for tiling window managers on Linux (e.g., Hyprland, i3, sway).
*/
export async function setWindowDecorations(decorations: boolean): Promise<void> {
return invoke<void>("set_window_decorations", { decorations });
}
export * from "./desktop";

View File

@@ -1,6 +1,6 @@
import { parse } from "jsonc-parser";
import type { McpServerConfig, McpServerEntry } from "./types";
import { readOpencodeConfig, writeOpencodeConfig } from "./lib/tauri";
import { readOpencodeConfig, writeOpencodeConfig } from "./lib/desktop";
import { CHROME_DEVTOOLS_MCP_COMMAND, CHROME_DEVTOOLS_MCP_ID } from "./constants";
type McpConfigValue = Record<string, unknown> | null | undefined;

View File

@@ -7,7 +7,7 @@ import type {
Session,
} from "@opencode-ai/sdk/v2/client";
import type { createClient } from "./lib/opencode";
import type { OpencodeConfigFile, ScheduledJob as TauriScheduledJob, WorkspaceInfo } from "./lib/tauri";
import type { OpencodeConfigFile, ScheduledJob as TauriScheduledJob, WorkspaceInfo } from "./lib/desktop";
export type Client = ReturnType<typeof createClient>;

View File

@@ -10,7 +10,7 @@ import type {
PlaceholderAssistantMessage,
ProviderListItem,
} from "../types";
import type { WorkspaceInfo } from "../lib/tauri";
import type { WorkspaceInfo } from "../lib/desktop";
export function formatModelRef(model: ModelRef) {
return `${model.providerID}/${model.modelID}`;
@@ -73,6 +73,14 @@ export function isTauriRuntime() {
return typeof window !== "undefined" && (window as any).__TAURI_INTERNALS__ != null;
}
export function isElectronRuntime() {
return typeof window !== "undefined" && (window as Window).__OPENWORK_ELECTRON__ != null;
}
export function isDesktopRuntime() {
return isTauriRuntime() || isElectronRuntime();
}
export function isWindowsPlatform() {
if (typeof navigator === "undefined") return false;

View File

@@ -1,6 +1,6 @@
import { parse } from "jsonc-parser";
import type { OpencodeConfigFile } from "../lib/tauri";
import type { OpencodeConfigFile } from "../lib/desktop";
type PluginListValue = string | string[] | null | undefined;

View File

@@ -13,7 +13,7 @@ export default {
"app.error.install_failed": "La instal·lació d'OpenCode ha fallat. Mira els registres de més amunt.",
"app.error.pick_workspace_folder": "Tria primer una carpeta de workspace.",
"app.error.remote_base_url_required": "Afegeix lURL del servidor per continuar.",
"app.error.tauri_required": "Aquesta acció requereix el temps d'execució de l'aplicació Tauri.",
"app.error.tauri_required": "Aquesta acció requereix el temps d'execució de l'aplicació d'escriptori d'OpenWork.",
"app.error_audit_load": "No s'ha pogut carregar el registre d'auditoria.",
"app.error_auth_failed": "L'autenticació ha fallat",
"app.error_auto_compact_scope": "La compactació automàtica del context només es pot canviar per a un workspace local o per a un workspace d'un servidor OpenWork amb permisos d'escriptura.",

View File

@@ -13,7 +13,7 @@ export default {
"app.error.install_failed": "OpenCode install failed. See logs above.",
"app.error.pick_workspace_folder": "Pick a workspace folder first.",
"app.error.remote_base_url_required": "Add a server URL to continue.",
"app.error.tauri_required": "This action requires the Tauri app runtime.",
"app.error.tauri_required": "This action requires the OpenWork desktop app runtime.",
"app.error_audit_load": "Failed to load audit log.",
"app.error_auth_failed": "Authentication failed",
"app.error_auto_compact_scope": "Auto context compaction can only be changed for a local workspace or a writable OpenWork server workspace.",

View File

@@ -13,7 +13,7 @@ export default {
"app.error.install_failed": "La instalación de OpenCode ha fallado. Mira los logs arriba.",
"app.error.pick_workspace_folder": "Elige una carpeta para el workspace primero.",
"app.error.remote_base_url_required": "Añade una URL del servidor para continuar.",
"app.error.tauri_required": "Esta acción necesita el runtime de la Tauri app.",
"app.error.tauri_required": "Esta acción necesita el runtime de la app de escritorio de OpenWork.",
"app.error_audit_load": "No se pudo cargar el audit log.",
"app.error_auth_failed": "Error de autenticación",
"app.error_auto_compact_scope": "La compactación automática del contexto solo se puede cambiar en un espacio de trabajo local o en un editable del servidor de OpenWork.",

View File

@@ -13,7 +13,7 @@ export default {
"app.error.install_failed": "L'installation d'OpenCode a échoué. Voir les journaux ci-dessus.",
"app.error.pick_workspace_folder": "Choisissez d'abord un dossier d'espace de travail.",
"app.error.remote_base_url_required": "Ajoutez une URL de serveur pour continuer.",
"app.error.tauri_required": "Cette action nécessite l'environnement d'exécution de l'application Tauri.",
"app.error.tauri_required": "Cette action nécessite l'environnement d'exécution de l'application de bureau OpenWork.",
"app.error_audit_load": "Échec du chargement du journal d'audit.",
"app.error_auth_failed": "Échec de l'authentification",
"app.error_auto_compact_scope": "La compaction automatique du contexte ne peut être modifiée que pour un espace de travail local ou un espace de travail de serveur OpenWork accessible en écriture.",

View File

@@ -12,7 +12,7 @@ export default {
"app.error.install_failed": "OpenCodeのインストールに失敗しました。上のログをご確認ください。",
"app.error.pick_workspace_folder": "最初にワークスペースフォルダを選択してください。",
"app.error.remote_base_url_required": "続行するにはサーバーURLを追加してください。",
"app.error.tauri_required": "この操作にはTauriアプリランタイムが必要です。",
"app.error.tauri_required": "この操作にはOpenWorkデスクトップアプリランタイムが必要です。",
"app.error_audit_load": "監査ログの読み込みに失敗しました。",
"app.error_auth_failed": "認証に失敗しました",
"app.error_auto_compact_scope": "自動コンテキスト圧縮は、ローカルワークスペースまたは書き込み可能なOpenWorkサーバーワークスペースでのみ変更できます。",

View File

@@ -13,7 +13,7 @@ export default {
"app.error.install_failed": "Falha na instalação do OpenCode. Veja os logs acima.",
"app.error.pick_workspace_folder": "Selecione primeiro uma pasta de workspace.",
"app.error.remote_base_url_required": "Adicione uma URL de servidor para continuar.",
"app.error.tauri_required": "Esta ação requer o runtime do app Tauri.",
"app.error.tauri_required": "Esta ação requer o runtime do app desktop do OpenWork.",
"app.error_audit_load": "Falha ao carregar o log de auditoria.",
"app.error_auth_failed": "Falha na autenticação",
"app.error_auto_compact_scope": "A compactação automática de contexto só pode ser alterada para um workspace local ou um workspace de servidor OpenWork com permissão de escrita.",

View File

@@ -13,7 +13,7 @@ export default {
"app.error.install_failed": "ติดตั้ง OpenCode ไม่สำเร็จ ดู logs ด้านบน",
"app.error.pick_workspace_folder": "เลือกโฟลเดอร์พื้นที่ทำงานก่อน",
"app.error.remote_base_url_required": "เพิ่ม URL ของเซิร์ฟเวอร์เพื่อดำเนินการต่อ",
"app.error.tauri_required": "การดำเนินการนี้ต้องใช้ Tauri app runtime",
"app.error.tauri_required": "การดำเนินการนี้ต้องใช้รันไทม์ของแอปเดสก์ท็อป OpenWork",
"app.error_audit_load": "โหลดบันทึกการตรวจสอบไม่สำเร็จ",
"app.error_auth_failed": "การยืนยันตัวตนล้มเหลว",
"app.error_auto_compact_scope": "การบีบอัดบริบทอัตโนมัติสามารถเปลี่ยนได้เฉพาะสำหรับพื้นที่ทำงานภายในเครื่องหรือ OpenWork server ที่เขียนได้",

View File

@@ -13,7 +13,7 @@ export default {
"app.error.install_failed": "Cài đặt OpenCode thất bại. Xem nhật ký ở trên.",
"app.error.pick_workspace_folder": "Vui lòng chọn thư mục workspace trước.",
"app.error.remote_base_url_required": "Vui lòng nhập URL máy chủ để tiếp tục.",
"app.error.tauri_required": "Thao tác này yêu cầu môi trường Tauri.",
"app.error.tauri_required": "Thao tác này yêu cầu môi trường ứng dụng máy tính OpenWork.",
"app.error_audit_load": "Tải nhật ký kiểm toán thất bại.",
"app.error_auth_failed": "Xác thực thất bại",
"app.error_auto_compact_scope": "Tự động thu gọn ngữ cảnh chỉ có thể thay đổi cho workspace nội bộ hoặc workspace OpenWork có quyền ghi.",

View File

@@ -16,7 +16,7 @@ export default {
"app.error.install_failed": "OpenCode安装失败。请查看上方日志。",
"app.error.pick_workspace_folder": "请先选择一个工作区文件夹。",
"app.error.remote_base_url_required": "请先填写服务器地址。",
"app.error.tauri_required": "此操作需要Tauri应用运行时。",
"app.error.tauri_required": "此操作需要OpenWork桌面应用运行时。",
"app.error_audit_load": "加载审计日志失败。",
"app.error_auth_failed": "认证失败",
"app.error_auto_compact_scope": "自动上下文压缩仅适用于本地工作区或可写的OpenWork服务器工作区。",

View File

@@ -6,7 +6,7 @@ import { BrowserRouter, HashRouter } from "react-router-dom";
import { getOpenWorkDeployment } from "./app/lib/openwork-deployment";
import { bootstrapTheme } from "./app/theme";
import { isTauriRuntime } from "./app/utils";
import { isDesktopRuntime } from "./app/utils";
import { initLocale } from "./i18n";
import { getReactQueryClient } from "./react-app/infra/query-client";
import {
@@ -32,7 +32,7 @@ root.dataset.openworkDeployment = getOpenWorkDeployment();
const platform = createDefaultPlatform();
const queryClient = getReactQueryClient();
const Router = isTauriRuntime() ? HashRouter : BrowserRouter;
const Router = isDesktopRuntime() ? HashRouter : BrowserRouter;
ReactDOM.createRoot(root).render(
<React.StrictMode>

View File

@@ -10,7 +10,7 @@ import {
X,
} from "lucide-react";
import type { WorkspaceInfo } from "../../../app/lib/tauri";
import type { WorkspaceInfo } from "../../../app/lib/desktop";
import { currentLocale, t } from "../../../i18n";
import { isSandboxWorkspace } from "../../../app/utils";
import { Button } from "../../design-system/button";

View File

@@ -3,11 +3,11 @@ import { useEffect, useRef, useState } from "react";
import { CheckCircle2, Loader2, RefreshCcw, X } from "lucide-react";
import type { McpDirectoryInfo } from "../../../app/constants";
import { openDesktopUrl, opencodeMcpAuth } from "../../../app/lib/desktop";
import { unwrap } from "../../../app/lib/opencode";
import { opencodeMcpAuth } from "../../../app/lib/tauri";
import { validateMcpServerName } from "../../../app/mcp";
import type { Client } from "../../../app/types";
import { isTauriRuntime, normalizeDirectoryPath } from "../../../app/utils";
import { isDesktopRuntime, normalizeDirectoryPath } from "../../../app/utils";
import { t, type Language } from "../../../i18n";
import { Button } from "../../design-system/button";
import { TextInput } from "../../design-system/text-input";
@@ -96,9 +96,8 @@ export function McpAuthModal(props: McpAuthModalProps) {
}, []);
const openAuthorizationUrl = async (url: string) => {
if (isTauriRuntime()) {
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(url);
if (isDesktopRuntime()) {
await openDesktopUrl(url);
return;
}
@@ -330,7 +329,7 @@ export function McpAuthModal(props: McpAuthModalProps) {
};
const handleCliReauth = async () => {
if (!props.entry || cliAuthBusy || props.isRemoteWorkspace || !isTauriRuntime()) return;
if (!props.entry || cliAuthBusy || props.isRemoteWorkspace || !isDesktopRuntime()) return;
setCliAuthBusy(true);
setCliAuthResult(null);
@@ -759,7 +758,7 @@ export function McpAuthModal(props: McpAuthModalProps) {
<div className="space-y-2 pt-2">
<p className="text-xs text-red-11">{translate("mcp.auth.invalid_refresh_token")}</p>
{!props.isRemoteWorkspace ? (
isTauriRuntime() ? (
isDesktopRuntime() ? (
<Button variant="secondary" onClick={() => void handleCliReauth()} disabled={cliAuthBusy}>
{cliAuthBusy ? <Loader2 size={14} className="animate-spin" /> : null}
{cliAuthBusy

View File

@@ -1,6 +1,6 @@
/** @jsxImportSource react */
import type { McpDirectoryInfo } from "../../../app/constants";
import type { OpencodeConfigFile } from "../../../app/lib/tauri";
import type { OpencodeConfigFile } from "../../../app/lib/desktop";
import type { McpServerEntry, McpStatusMap } from "../../../app/types";
import PresentationalMcpView from "../settings/pages/mcp-view";

View File

@@ -2,7 +2,7 @@ import { useSyncExternalStore } from "react";
import { t, currentLocale } from "../../../i18n";
import type { StartupPreference, WorkspaceDisplay } from "../../../app/types";
import { isTauriRuntime } from "../../../app/utils";
import { isDesktopRuntime } from "../../../app/utils";
import {
openworkServerInfo,
openworkServerRestart,
@@ -11,7 +11,7 @@ import {
type OpenCodeRouterInfo,
type OpenworkServerInfo,
type OrchestratorStatus,
} from "../../../app/lib/tauri";
} from "../../../app/lib/desktop";
import {
clearOpenworkServerSettings,
createOpenworkServerClient,
@@ -121,7 +121,7 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
openworkServerCapabilities: null,
openworkServerCheckedAt: null,
openworkServerHostInfo: null,
openworkServerHostInfoReady: !isTauriRuntime(),
openworkServerHostInfoReady: !isDesktopRuntime(),
openworkServerDiagnostics: null,
openworkReconnectBusy: false,
opencodeRouterInfoState: null,
@@ -267,13 +267,13 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
};
const shouldWaitForLocalHostInfo = () =>
isTauriRuntime() &&
isDesktopRuntime() &&
options.startupPreference() !== "server" &&
!state.openworkServerHostInfoReady;
const shouldRetryStartupCheck = (status: OpenworkServerStatus) =>
status !== "connected" &&
isTauriRuntime() &&
isDesktopRuntime() &&
options.startupPreference() !== "server" &&
Date.now() - bootStartedAt < 5_000;
@@ -396,7 +396,7 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
refreshSnapshot();
emitChange();
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
const port = state.openworkServerHostInfo?.port;
if (!port) return;
if (state.openworkServerSettings.portOverride === port) return;
@@ -421,14 +421,20 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
};
const start = () => {
if (started || disposed || typeof window === "undefined") return;
if (typeof window === "undefined") return;
if (started) return;
// Allow restart after a prior dispose() (React 18 StrictMode double-mounts
// each effect in dev: mount → dispose → re-mount). If we early-return when
// `disposed` is true, the real mount never arms polling and the UI stays
// on stale/empty state forever.
disposed = false;
started = true;
syncFromOptions();
queueHealthCheck(0);
const refreshHostInfo = () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
if (!options.documentVisible()) return;
void (async () => {
try {
@@ -478,7 +484,7 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
startInterval("diagnostics", refreshDiagnostics, 10_000);
const refreshRouterInfo = () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
if (!options.documentVisible()) return;
if (!options.developerMode()) {
setStateField("opencodeRouterInfoState", null);
@@ -498,7 +504,7 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
startInterval("router", refreshRouterInfo, 10_000);
const refreshOrchestratorStatus = () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
if (!options.documentVisible()) return;
if (!options.developerMode()) {
setStateField("orchestratorStatusState", null);
@@ -632,7 +638,7 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
}));
const ok = result.status === "connected" || result.status === "limited";
if (ok && !isTauriRuntime()) {
if (ok && !isDesktopRuntime()) {
const active = options.selectedWorkspaceDisplay();
const shouldAttach =
!options.activeClient() ||
@@ -656,7 +662,7 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
try {
let hostInfo = state.openworkServerHostInfo;
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
try {
hostInfo = await openworkServerInfo();
mutateState((current) => ({ ...current, openworkServerHostInfo: hostInfo }));
@@ -718,7 +724,7 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
}
}
if (!isTauriRuntime()) return null;
if (!isDesktopRuntime()) return null;
try {
hostInfo = await openworkServerRestart({
@@ -761,7 +767,7 @@ export function createOpenworkServerStore(options: CreateOpenworkServerStoreOpti
updateOpenworkServerSettings(next);
try {
if (isTauriRuntime() && options.selectedWorkspaceDisplay().workspaceType === "local") {
if (isDesktopRuntime() && options.selectedWorkspaceDisplay().workspaceType === "local") {
const restarted = await options.restartLocalServer();
if (!restarted) {
throw new Error(t("app.error_restart_local_worker", currentLocale()));

View File

@@ -14,7 +14,8 @@ import {
type KeyboardEvent,
} from "react";
import { isTauriRuntime } from "../../../../app/utils";
import { openDesktopUrl } from "../../../../app/lib/desktop";
import { isDesktopRuntime } from "../../../../app/utils";
import { compareProviders } from "../../../../app/utils/providers";
import { Button } from "../../../design-system/button";
import { ProviderIcon } from "../../../design-system/provider-icon";
@@ -348,9 +349,8 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
const openOauthUrl = async (url: string) => {
if (!url) return;
if (isTauriRuntime()) {
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(url);
if (isDesktopRuntime()) {
await openDesktopUrl(url);
setOauthBrowserOpened(true);
return;
}

View File

@@ -20,13 +20,13 @@ import {
writeOpencodeConfig,
workspaceOpenworkRead,
workspaceOpenworkWrite,
} from "../../../../app/lib/tauri";
} from "../../../../app/lib/desktop";
import type {
Client,
ProviderListItem,
WorkspaceDisplay,
} from "../../../../app/types";
import { isTauriRuntime, safeStringify } from "../../../../app/utils";
import { isDesktopRuntime, safeStringify } from "../../../../app/utils";
import {
compareProviders,
filterProviderList,
@@ -313,7 +313,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
return config.openwork ?? {};
}
if (isLocalWorkspace && isTauriRuntime() && root) {
if (isLocalWorkspace && isDesktopRuntime() && root) {
return (await workspaceOpenworkRead({
workspacePath: root,
})) as unknown as Record<string, unknown>;
@@ -343,7 +343,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
return true;
}
if (isLocalWorkspace && isTauriRuntime() && root) {
if (isLocalWorkspace && isDesktopRuntime() && root) {
const result = await workspaceOpenworkWrite({
workspacePath: root,
config: config as never,
@@ -408,7 +408,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
return await openworkClient.readOpencodeConfigFile(openworkWorkspaceId, "project");
}
if (isLocalWorkspace && isTauriRuntime() && root) {
if (isLocalWorkspace && isDesktopRuntime() && root) {
return await readOpencodeConfig("project", root);
}
@@ -442,7 +442,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
return true;
}
if (isLocalWorkspace && isTauriRuntime() && root) {
if (isLocalWorkspace && isDesktopRuntime() && root) {
const result = await writeOpencodeConfig("project", root, content);
if (!result.ok) {
throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc");
@@ -1369,7 +1369,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
};
const start = () => {
if (started || disposed) return;
if (started) return;
// StrictMode double-mount re-arms after dispose.
disposed = false;
started = true;
lastWorkspaceKey = currentWorkspaceKey();
if (typeof window !== "undefined") {
@@ -1402,6 +1404,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
const dispose = () => {
if (disposed) return;
disposed = true;
started = false;
denSessionCleanup?.();
denSessionCleanup = null;
listeners.clear();

View File

@@ -1,6 +1,5 @@
import { useSyncExternalStore } from "react";
import { homeDir } from "@tauri-apps/api/path";
import { parse } from "jsonc-parser";
import { currentLocale, t } from "../../../i18n";
@@ -12,10 +11,11 @@ import {
import { createClient, unwrap } from "../../../app/lib/opencode";
import { finishPerf, perfNow, recordPerfLog } from "../../../app/lib/perf-log";
import {
getDesktopHomeDir,
readOpencodeConfig,
writeOpencodeConfig,
type OpencodeConfigFile,
} from "../../../app/lib/tauri";
} from "../../../app/lib/desktop";
import { toSessionTransportDirectory } from "../../../app/lib/session-scope";
import {
parseMcpServersFromContent,
@@ -30,7 +30,7 @@ import type {
ReloadReason,
ReloadTrigger,
} from "../../../app/types";
import { isTauriRuntime, normalizeDirectoryPath, safeStringify } from "../../../app/utils";
import { isDesktopRuntime, normalizeDirectoryPath, safeStringify } from "../../../app/utils";
import type { OpenworkServerStore } from "./openwork-server-store";
@@ -151,7 +151,7 @@ export function createConnectionsStore(options: {
return openworkClient.readOpencodeConfigFile(openworkWorkspaceId, scope);
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
return null;
}
@@ -323,7 +323,7 @@ export function createConnectionsStore(options: {
return;
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
mutateState((current) => ({
...current,
mcpStatus: "MCP configuration is only available for local workspaces.",
@@ -390,7 +390,7 @@ export function createConnectionsStore(options: {
const openworkSnapshot = getOpenworkSnapshot();
const isRemoteWorkspace =
options.workspaceType() === "remote" ||
(!isTauriRuntime() && openworkSnapshot.openworkServerStatus === "connected");
(!isDesktopRuntime() && openworkSnapshot.openworkServerStatus === "connected");
const projectDir = options.projectDir().trim();
const entryType = entry.type ?? "remote";
@@ -412,7 +412,7 @@ export function createConnectionsStore(options: {
return;
}
if (!canUseOpenworkServer && !isTauriRuntime()) {
if (!canUseOpenworkServer && !isDesktopRuntime()) {
setStateField("mcpStatus", translate("mcp.desktop_required"));
finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, {
reason: "desktop-required",
@@ -478,10 +478,10 @@ export function createConnectionsStore(options: {
if (
slug === CHROME_DEVTOOLS_MCP_ID &&
usesChromeDevtoolsAutoConnect(entry.command) &&
isTauriRuntime()
isDesktopRuntime()
) {
try {
const hostHome = (await homeDir()).replace(/[\\/]+$/, "");
const hostHome = (await getDesktopHomeDir()).replace(/[\\/]+$/, "");
if (hostHome) {
mcpEnvironment = { HOME: hostHome };
mcpEntryConfig["environment"] = mcpEnvironment;
@@ -619,7 +619,7 @@ export function createConnectionsStore(options: {
const openworkSnapshot = getOpenworkSnapshot();
const isRemoteWorkspace =
options.workspaceType() === "remote" ||
(!isTauriRuntime() && openworkSnapshot.openworkServerStatus === "connected");
(!isDesktopRuntime() && openworkSnapshot.openworkServerStatus === "connected");
const projectDir = options.projectDir().trim();
const { openworkClient, openworkWorkspaceId, canUseOpenworkServer } =
@@ -630,7 +630,7 @@ export function createConnectionsStore(options: {
return;
}
if (!canUseOpenworkServer && !isTauriRuntime()) {
if (!canUseOpenworkServer && !isDesktopRuntime()) {
setStateField("mcpStatus", translate("mcp.desktop_required"));
return;
}
@@ -740,7 +740,7 @@ export function createConnectionsStore(options: {
lastWorkspaceContextKey = workspaceContextKey;
lastProjectDir = projectDir;
if (!started || disposed || !isTauriRuntime() || !changed) {
if (!started || disposed || !isDesktopRuntime() || !changed) {
return;
}
@@ -748,7 +748,9 @@ export function createConnectionsStore(options: {
};
const start = () => {
if (started || disposed) return;
if (started) return;
// StrictMode double-mount re-arms after dispose.
disposed = false;
started = true;
syncFromOptions();
};

View File

@@ -6,7 +6,7 @@ import { t } from "../../../../i18n";
import { buildOpenworkWorkspaceBaseUrl, type OpenworkServerClient, type OpenworkServerStatus } from "../../../../app/lib/openwork-server";
import { getDisplaySessionTitle } from "../../../../app/lib/session-title";
import type { BootPhase } from "../../../../app/lib/startup-boot";
import type { WorkspaceInfo } from "../../../../app/lib/tauri";
import type { WorkspaceInfo } from "../../../../app/lib/desktop";
import type {
PendingPermission,
PendingQuestion,

View File

@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronRight, Loader2, MoreHorizontal, Plus } from "lucide-react";
import { getDisplaySessionTitle } from "../../../../app/lib/session-title";
import type { WorkspaceInfo } from "../../../../app/lib/tauri";
import type { WorkspaceInfo } from "../../../../app/lib/desktop";
import type {
WorkspaceConnectionState,
WorkspaceSessionGroup,

View File

@@ -5,12 +5,13 @@ import type { Part } from "@opencode-ai/sdk/v2/client";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Check, ChevronDown, CircleAlert, Copy, File as FileIcon } from "lucide-react";
import { openDesktopPath, revealDesktopItemInDir } from "../../../../app/lib/desktop";
import {
SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX,
type MessageGroup,
type StepGroupMode,
} from "../../../../app/types";
import { groupMessageParts, summarizeStep } from "../../../../app/utils";
import { groupMessageParts, isDesktopRuntime, summarizeStep } from "../../../../app/utils";
import { MarkdownBlock } from "./markdown";
import { applyTextHighlights } from "./text-highlights";
@@ -343,18 +344,9 @@ function hasStructuredValue(value: unknown) {
return true;
}
function isDesktopRuntime() {
try {
return Boolean((window as unknown as Record<string, unknown>).__TAURI_INTERNALS__);
} catch {
return false;
}
}
async function openFileWithOS(path: string) {
try {
const { openPath } = await import("@tauri-apps/plugin-opener");
await openPath(path);
await openDesktopPath(path);
} catch {
// silently fail on web
}
@@ -362,8 +354,7 @@ async function openFileWithOS(path: string) {
async function revealFileInFinder(path: string) {
try {
const { revealItemInDir } = await import("@tauri-apps/plugin-opener");
await revealItemInDir(path);
await revealDesktopItemInDir(path);
} catch {
// silently fail on web
}

View File

@@ -4,8 +4,8 @@ import { CircleAlert, Cpu, RefreshCcw, Server, Zap } from "lucide-react";
import type { OpencodeConnectStatus } from "../../../../app/types";
import type { OpenworkServerStatus } from "../../../../app/lib/openwork-server";
import type { EngineInfo } from "../../../../app/lib/tauri";
import { isTauriRuntime } from "../../../../app/utils";
import type { EngineInfo } from "../../../../app/lib/desktop";
import { isDesktopRuntime } from "../../../../app/utils";
import { t } from "../../../../i18n";
import { Button } from "../../../design-system/button";
@@ -267,7 +267,7 @@ export function AdvancedView(props: AdvancedViewProps) {
variant="outline"
className="h-8 shrink-0 px-3 py-0 text-xs"
onClick={props.toggleMicrosandboxCreateSandbox}
disabled={props.busy || !isTauriRuntime()}
disabled={props.busy || !isDesktopRuntime()}
>
{props.microsandboxCreateSandboxEnabled ? "On" : "Off"}
</Button>
@@ -299,7 +299,7 @@ export function AdvancedView(props: AdvancedViewProps) {
</div>
</div>
{isTauriRuntime() && props.opencodeDevModeEnabled && props.developerMode ? (
{isDesktopRuntime() && props.opencodeDevModeEnabled && props.developerMode ? (
<div className={`${settingsPanelSoftClass} space-y-3`}>
<div className="flex items-start justify-between gap-3">
<div>

View File

@@ -1,6 +1,6 @@
/** @jsxImportSource react */
import { LANGUAGE_OPTIONS, t, type Language } from "../../../../i18n";
import { isTauriRuntime } from "../../../../app/utils";
import { isDesktopRuntime } from "../../../../app/utils";
import { Button } from "../../../design-system/button";
const settingsPanelClass = "rounded-[28px] border border-dls-border bg-dls-surface p-5 md:p-6";
@@ -72,7 +72,7 @@ export function AppearanceView(props: AppearanceViewProps) {
<div className="text-xs text-gray-8">{t("settings.theme_system_hint")}</div>
</div>
{isTauriRuntime() ? (
{isDesktopRuntime() ? (
<div className="space-y-3 rounded-2xl border border-gray-6/50 bg-gray-2/30 p-5">
<div>
<div className="text-sm font-medium text-gray-12">{t("settings.appearance_title")}</div>

View File

@@ -21,7 +21,7 @@ import {
import { t } from "../../../../i18n";
import type { ScheduledJob } from "../../../../app/types";
import { formatRelativeTime, isTauriRuntime } from "../../../../app/utils";
import { formatRelativeTime, isDesktopRuntime } from "../../../../app/utils";
type AutomationsFilter = "all" | "scheduled" | "templates";
type ScheduleMode = "daily" | "interval";
@@ -487,12 +487,12 @@ export function AutomationsView(props: AutomationsViewProps) {
setCreateError(null);
};
const supported = jobsSource === "remote" || (isTauriRuntime() && props.schedulerInstalled && !schedulerInstallRequested);
const schedulerGateActive = jobsSource === "local" && isTauriRuntime() && (!props.schedulerInstalled || schedulerInstallRequested);
const supported = jobsSource === "remote" || (isDesktopRuntime() && props.schedulerInstalled && !schedulerInstallRequested);
const schedulerGateActive = jobsSource === "local" && isDesktopRuntime() && (!props.schedulerInstalled || schedulerInstallRequested);
const automationDisabled = props.newTaskDisabled || schedulerGateActive || createBusy;
const sourceLabel = jobsSource === "remote" ? t("scheduled.source_remote") : t("scheduled.source_local");
const sourceDescription = jobsSource === "remote" ? t("scheduled.subtitle_remote") : t("scheduled.subtitle_local");
const supportNote = jobsSource === "remote" ? null : !isTauriRuntime() ? t("scheduled.desktop_required") : null;
const supportNote = jobsSource === "remote" ? null : !isDesktopRuntime() ? t("scheduled.desktop_required") : null;
const lastUpdatedLabel = useMemo(() => {
lastUpdatedNow;

View File

@@ -10,8 +10,8 @@ import {
type OpenworkServerSettings,
type OpenworkServerStatus,
} from "../../../../app/lib/openwork-server";
import type { OpenworkServerInfo } from "../../../../app/lib/tauri";
import { isTauriRuntime } from "../../../../app/utils";
import type { OpenworkServerInfo } from "../../../../app/lib/desktop";
import { isDesktopRuntime } from "../../../../app/utils";
import { t } from "../../../../i18n";
import { Button } from "../../../design-system/button";
import { TextInput } from "../../../design-system/text-input";
@@ -165,7 +165,7 @@ export function ConfigView(props: ConfigViewProps) {
const bundle = {
capturedAt: new Date().toISOString(),
runtime: {
tauri: isTauriRuntime(),
tauri: isDesktopRuntime(),
developerMode: props.developerMode,
},
workspace: {
@@ -608,7 +608,7 @@ export function ConfigView(props: ConfigViewProps) {
</div>
</div>
{!isTauriRuntime() ? (
{!isDesktopRuntime() ? (
<div className="text-xs text-gray-9">
{t("config.desktop_only_hint")}
</div>

View File

@@ -16,12 +16,12 @@ import type {
import type {
OrchestratorStatus,
SandboxDebugProbeResult,
} from "../../../../app/lib/tauri";
} from "../../../../app/lib/desktop";
import type {
OpencodeConnectStatus,
StartupPreference,
} from "../../../../app/types";
import { formatRelativeTime, isTauriRuntime } from "../../../../app/utils";
import { formatRelativeTime, isDesktopRuntime } from "../../../../app/utils";
import { t } from "../../../../i18n";
import { Button } from "../../../design-system/button";
@@ -173,7 +173,7 @@ function renderLines(lines: string[]) {
export function DebugView(props: DebugViewProps) {
if (!props.developerMode) return null;
const isDesktop = isTauriRuntime();
const isDesktop = isDesktopRuntime();
const isLocalPreference = props.startupPreference !== "server";
const sandboxProbeDisabled = !isDesktop || props.sandboxProbeBusy || props.anyActiveRuns;
const sandboxProbeTitle = !isDesktop

View File

@@ -21,7 +21,12 @@ import {
} from "lucide-react";
import { type McpDirectoryInfo } from "../../../../app/constants";
import { readOpencodeConfig, type OpencodeConfigFile } from "../../../../app/lib/tauri";
import {
openDesktopPath,
readOpencodeConfig,
revealDesktopItemInDir,
type OpencodeConfigFile,
} from "../../../../app/lib/desktop";
import {
buildChromeDevtoolsCommand,
getMcpIdentityKey,
@@ -30,7 +35,7 @@ import {
usesChromeDevtoolsAutoConnect,
} from "../../../../app/mcp";
import type { McpServerEntry, McpStatusMap } from "../../../../app/types";
import { formatRelativeTime, isTauriRuntime, isWindowsPlatform } from "../../../../app/utils";
import { formatRelativeTime, isDesktopRuntime, isWindowsPlatform } from "../../../../app/utils";
import { currentLocale, t, type Language } from "../../../../i18n";
import { Button } from "../../../design-system/button";
import { ConfirmModal } from "../../../design-system/modals/confirm-modal";
@@ -181,7 +186,7 @@ export function McpView(props: McpViewProps) {
configRequestId.current = nextId;
const readConfig = props.readConfigFile;
if (!readConfig && !isTauriRuntime()) {
if (!readConfig && !isDesktopRuntime()) {
setProjectConfig(null);
setGlobalConfig(null);
setConfigError(null);
@@ -220,7 +225,7 @@ export function McpView(props: McpViewProps) {
: tr("mcp.reveal_in_finder");
const canRevealConfig =
isTauriRuntime() &&
isDesktopRuntime() &&
!revealBusy &&
!(configScope === "project" && !props.selectedWorkspaceRoot.trim()) &&
Boolean(activeConfig?.exists);
@@ -302,7 +307,7 @@ export function McpView(props: McpViewProps) {
};
const revealConfig = async () => {
if (!isTauriRuntime() || revealBusy) return;
if (!isDesktopRuntime() || revealBusy) return;
const root = props.selectedWorkspaceRoot.trim();
if (configScope === "project" && !root) {
@@ -319,11 +324,10 @@ export function McpView(props: McpViewProps) {
if (!resolved) {
throw new Error(tr("mcp.config_load_failed"));
}
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
if (isWindowsPlatform()) {
await openPath(resolved.path);
await openDesktopPath(resolved.path);
} else {
await revealItemInDir(resolved.path);
await revealDesktopItemInDir(resolved.path);
}
} catch (error) {
setConfigError(

View File

@@ -1,7 +1,7 @@
/** @jsxImportSource react */
import { FolderOpen } from "lucide-react";
import { isTauriRuntime } from "../../../../app/utils";
import { isDesktopRuntime } from "../../../../app/utils";
import { t } from "../../../../i18n";
import { Button } from "../../../design-system/button";
@@ -37,8 +37,8 @@ export function RecoveryView(props: RecoveryViewProps) {
variant="outline"
className="h-8 px-3 py-0 text-xs"
onClick={() => void props.onRevealWorkspaceConfig()}
disabled={!isTauriRuntime() || props.revealConfigBusy || !props.workspaceConfigPath}
title={!isTauriRuntime() ? t("settings.reveal_config_requires_desktop") : ""}
disabled={!isDesktopRuntime() || props.revealConfigBusy || !props.workspaceConfigPath}
title={!isDesktopRuntime() ? t("settings.reveal_config_requires_desktop") : ""}
>
<FolderOpen size={13} className="mr-1.5" />
{props.revealConfigBusy ? t("settings.opening") : t("settings.reveal_config")}
@@ -70,8 +70,8 @@ export function RecoveryView(props: RecoveryViewProps) {
variant="secondary"
className="h-8 shrink-0 px-3 py-0 text-xs"
onClick={() => void props.onRepairOpencodeCache()}
disabled={props.cacheRepairBusy || !isTauriRuntime()}
title={isTauriRuntime() ? "" : t("settings.cache_repair_requires_desktop")}
disabled={props.cacheRepairBusy || !isDesktopRuntime()}
title={isDesktopRuntime() ? "" : t("settings.cache_repair_requires_desktop")}
>
{props.cacheRepairBusy ? t("settings.repairing_cache") : t("settings.repair_cache")}
</Button>
@@ -89,9 +89,9 @@ export function RecoveryView(props: RecoveryViewProps) {
variant="danger"
className="h-8 shrink-0 px-3 py-0 text-xs"
onClick={() => void props.onCleanupOpenworkDockerContainers()}
disabled={props.dockerCleanupBusy || props.anyActiveRuns || !isTauriRuntime()}
disabled={props.dockerCleanupBusy || props.anyActiveRuns || !isDesktopRuntime()}
title={
!isTauriRuntime()
!isDesktopRuntime()
? t("settings.docker_requires_desktop")
: props.anyActiveRuns
? t("settings.stop_runs_before_cleanup")

View File

@@ -9,9 +9,9 @@ import type {
OpenworkServerClient,
OpenworkServerStatus,
} from "../../../../app/lib/openwork-server";
import { pickDirectory } from "../../../../app/lib/tauri";
import { pickDirectory } from "../../../../app/lib/desktop";
import {
isTauriRuntime,
isDesktopRuntime,
normalizeDirectoryQueryPath,
safeStringify,
} from "../../../../app/utils";
@@ -129,7 +129,7 @@ export function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProps) {
}, [canReadConfig, canWriteConfig, openworkServerReady, openworkServerWorkspaceReady]);
const canPickAuthorizedFolder =
isTauriRuntime() && canWriteConfig && props.activeWorkspaceType === "local";
isDesktopRuntime() && canWriteConfig && props.activeWorkspaceType === "local";
const workspaceRootFolder = props.selectedWorkspaceRoot.trim();
const visibleAuthorizedFolders = useMemo(() => {
const root = workspaceRootFolder;
@@ -257,7 +257,7 @@ export function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProps) {
}, [authorizedFolders, persistAuthorizedFolders]);
const pickAuthorizedFolder = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
try {
const selection = await pickDirectory({
title: t("onboarding.authorize_folder"),

View File

@@ -1,9 +1,9 @@
import { useSyncExternalStore } from "react";
import { t } from "../../../../i18n";
import { schedulerDeleteJob, schedulerListJobs } from "../../../../app/lib/tauri";
import { schedulerDeleteJob, schedulerListJobs } from "../../../../app/lib/desktop";
import type { ScheduledJob } from "../../../../app/types";
import { isTauriRuntime, normalizeDirectoryPath } from "../../../../app/utils";
import { isDesktopRuntime, normalizeDirectoryPath } from "../../../../app/utils";
import type { OpenworkServerStore } from "../../connections/openwork-server-store";
export type AutomationActionPlan =
@@ -167,7 +167,7 @@ export function createAutomationsStore(options: CreateAutomationsStoreOptions) {
const getScheduledJobsPollingAvailable = () => {
if (getScheduledJobsSource() === "remote") return true;
return isTauriRuntime() && options.schedulerPluginInstalled();
return isDesktopRuntime() && options.schedulerPluginInstalled();
};
const maybeRefreshScheduledJobs = () => {
@@ -262,7 +262,7 @@ export function createAutomationsStore(options: CreateAutomationsStoreOptions) {
}
}
if (!isTauriRuntime() || !options.schedulerPluginInstalled()) {
if (!isDesktopRuntime() || !options.schedulerPluginInstalled()) {
if (getScheduledJobsContextKey() !== requestContextKey) return "skipped";
setStateField("scheduledJobsStatus", null);
return "unavailable";
@@ -311,7 +311,7 @@ export function createAutomationsStore(options: CreateAutomationsStoreOptions) {
return;
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
throw new Error(t("automations.desktop_required"));
}
const root = options.selectedWorkspaceRoot().trim();
@@ -336,7 +336,9 @@ export function createAutomationsStore(options: CreateAutomationsStoreOptions) {
};
const start = () => {
if (started || disposed) return;
if (started) return;
// StrictMode double-mount re-arms after dispose.
disposed = false;
started = true;
lastContextKey = getScheduledJobsContextKey();
openworkServerUnsubscribe = options.openworkServer.subscribe(() => {
@@ -349,6 +351,7 @@ export function createAutomationsStore(options: CreateAutomationsStoreOptions) {
const dispose = () => {
if (disposed) return;
disposed = true;
started = false;
openworkServerUnsubscribe?.();
openworkServerUnsubscribe = null;
listeners.clear();

View File

@@ -12,6 +12,7 @@ import {
opencodeRouterRestart as opencodeRouterRestartCmd,
opencodeRouterStop as opencodeRouterStopCmd,
orchestratorStatus as orchestratorStatusCmd,
pickFile,
resetOpenworkState,
sandboxDebugProbe as sandboxDebugProbeCmd,
workspaceBootstrap as workspaceBootstrapCmd,
@@ -21,13 +22,13 @@ import {
type OpenworkServerInfo,
type OrchestratorStatus,
type SandboxDebugProbeResult,
} from "../../../../app/lib/tauri";
} from "../../../../app/lib/desktop";
import {
writeOpenworkServerSettings,
} from "../../../../app/lib/openwork-server";
import {
clearStartupPreference,
isTauriRuntime,
isDesktopRuntime,
safeStringify,
} from "../../../../app/utils";
import { t } from "../../../../i18n";
@@ -272,7 +273,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
const [developerLogStatus, setDeveloperLogStatus] = useState<string | null>(null);
const refreshEngineInfo = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
try {
const info = await engineInfoCmd();
setEngineInfoState(info);
@@ -284,7 +285,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
useEffect(() => {
if (!developerMode) return;
void (async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
try {
const build = await appBuildInfoCmd();
setAppBuild(build);
@@ -438,7 +439,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
}, [developerLog]);
const onRunSandboxDebugProbe = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
setSandboxProbeBusy(true);
setSandboxProbeStatus(null);
try {
@@ -481,13 +482,12 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
);
const onPickEngineBinary = useCallback(async () => {
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
setServiceRestartError(t("settings.sandbox_requires_desktop"));
return;
}
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const target = await open({ title: t("settings.custom_binary_label"), multiple: false });
const target = await pickFile({ title: t("settings.custom_binary_label"), multiple: false });
if (typeof target === "string" && target.trim()) {
setEngineCustomBinPath(target);
writeStoredString(ENGINE_CUSTOM_BIN_KEY, target);
@@ -562,7 +562,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
}, [openworkServerStore, refreshEngineInfo]);
const onRestartLocalServer = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
setOpenworkRestartBusy(true);
setServiceRestartError(null);
setOpenworkRestartStatus(null);
@@ -578,7 +578,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
}, [bootFullEngineStack, pushDeveloperLog]);
const onRestartOpencode = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
setOpencodeRestarting(true);
setServiceRestartError(null);
setOpenworkRestartStatus(null);
@@ -594,7 +594,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
}, [bootFullEngineStack, pushDeveloperLog]);
const onRestartOpenworkServer = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
setOpenworkServerRestarting(true);
setServiceRestartError(null);
setOpenworkRestartStatus(null);
@@ -617,7 +617,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
]);
const onRestartOpencodeRouter = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
const workspacePath = optionsRef.current.selectedWorkspaceRoot.trim();
if (!workspacePath) {
setServiceRestartError("Select a workspace before restarting the OpenCode Router.");
@@ -642,7 +642,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
}, [pushDeveloperLog]);
const onStopOpencodeRouter = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
try {
await opencodeRouterStopCmd();
pushDeveloperLog("Stopped opencode-router");
@@ -653,7 +653,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
const onOpenResetModal = useCallback(
(mode: ResetModalMode) => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
const message =
mode === "all"
? "Reset ALL OpenWork app data? Open sessions and workspaces will be removed."
@@ -682,7 +682,7 @@ export function useDebugViewModel(options: UseDebugViewModelOptions) {
);
const onNukeOpenworkAndOpencodeConfig = useCallback(async () => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
const confirmed =
typeof window === "undefined"
? true

View File

@@ -1,7 +1,6 @@
import { useSyncExternalStore } from "react";
import { applyEdits, modify } from "jsonc-parser";
import { join } from "@tauri-apps/api/path";
import { currentLocale, t } from "../../../../i18n";
import type {
@@ -14,7 +13,7 @@ import type {
ReloadTrigger,
SkillCard,
} from "../../../../app/types";
import { addOpencodeCacheHint, isTauriRuntime, normalizeDirectoryPath } from "../../../../app/utils";
import { addOpencodeCacheHint, isDesktopRuntime, normalizeDirectoryPath } from "../../../../app/utils";
import skillCreatorTemplate from "../../../../app/data/skill-creator.md?raw";
import {
isPluginInstalled,
@@ -25,17 +24,20 @@ import {
import {
importSkill,
installSkillTemplate,
joinDesktopPath,
listLocalSkills,
openDesktopPath,
pickDirectory,
readLocalSkill,
readOpencodeConfig,
revealDesktopItemInDir,
uninstallSkill as uninstallSkillCommand,
workspaceOpenworkRead,
workspaceOpenworkWrite,
writeLocalSkill,
writeOpencodeConfig,
type OpencodeConfigFile,
} from "../../../../app/lib/tauri";
} from "../../../../app/lib/desktop";
import type { OpenworkHubRepo, OpenworkServerClient } from "../../../../app/lib/openwork-server";
import {
createDenClient,
@@ -333,7 +335,7 @@ export function createExtensionsStore(options: {
return config.openwork ?? {};
}
if (isLocalWorkspace && isTauriRuntime() && root) {
if (isLocalWorkspace && isDesktopRuntime() && root) {
return await workspaceOpenworkRead({ workspacePath: root }) as unknown as Record<string, unknown>;
}
@@ -357,7 +359,7 @@ export function createExtensionsStore(options: {
return true;
}
if (isLocalWorkspace && isTauriRuntime() && root) {
if (isLocalWorkspace && isDesktopRuntime() && root) {
const result = await workspaceOpenworkWrite({
workspacePath: root,
config: config as never,
@@ -467,7 +469,7 @@ export function createExtensionsStore(options: {
throw new Error("OpenWork server unavailable. Connect to import skills.");
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
throw new Error(translate("skills.desktop_required"));
}
@@ -537,7 +539,7 @@ export function createExtensionsStore(options: {
throw new Error("OpenWork server unavailable. Connect to remove skills.");
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
throw new Error(translate("skills.desktop_required"));
}
@@ -1161,7 +1163,7 @@ export function createExtensionsStore(options: {
return;
}
if (isLocalWorkspace && isTauriRuntime()) {
if (isLocalWorkspace && isDesktopRuntime()) {
if (root !== skillsRoot) skillsLoaded = false;
if (!optionsOverride?.force && skillsLoaded) return;
if (refreshSkillsInFlight) return;
@@ -1326,7 +1328,7 @@ export function createExtensionsStore(options: {
return;
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
mutateState((current) => ({
...current,
pluginStatus: translate("skills.plugin_management_host_only"),
@@ -1464,7 +1466,7 @@ export function createExtensionsStore(options: {
return;
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
setStateField("pluginStatus", translate("skills.plugin_management_host_only"));
return;
}
@@ -1547,7 +1549,7 @@ export function createExtensionsStore(options: {
return;
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
setStateField("pluginStatus", translate("skills.plugin_management_host_only"));
return;
}
@@ -1593,7 +1595,7 @@ export function createExtensionsStore(options: {
async function importLocalSkill() {
const isLocalWorkspace = options.workspaceType() === "local";
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
options.setError(translate("skills.desktop_required"));
return;
}
@@ -1670,7 +1672,7 @@ export function createExtensionsStore(options: {
setStateField("skillsStatus", message);
return { ok: false, message };
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
const message = translate("skills.desktop_required");
setStateField("skillsStatus", message);
return { ok: false, message };
@@ -1723,7 +1725,7 @@ export function createExtensionsStore(options: {
}
async function revealSkillsFolder() {
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
setStateField("skillsStatus", translate("skills.desktop_required"));
return;
}
@@ -1734,13 +1736,12 @@ export function createExtensionsStore(options: {
}
try {
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
const opencodeSkills = await join(root, ".opencode", "skills");
const claudeSkills = await join(root, ".claude", "skills");
const legacySkills = await join(root, ".opencode", "skill");
const opencodeSkills = await joinDesktopPath(root, ".opencode", "skills");
const claudeSkills = await joinDesktopPath(root, ".claude", "skills");
const legacySkills = await joinDesktopPath(root, ".opencode", "skill");
const tryOpen = async (target: string) => {
try {
await openPath(target);
await openDesktopPath(target);
return true;
} catch {
return false;
@@ -1749,7 +1750,7 @@ export function createExtensionsStore(options: {
if (await tryOpen(opencodeSkills)) return;
if (await tryOpen(claudeSkills)) return;
if (await tryOpen(legacySkills)) return;
await revealItemInDir(opencodeSkills);
await revealDesktopItemInDir(opencodeSkills);
} catch (error) {
setStateField("skillsStatus", error instanceof Error ? error.message : translate("skills.reveal_failed"));
}
@@ -1816,7 +1817,7 @@ export function createExtensionsStore(options: {
setStateField("skillsStatus", "OpenWork server unavailable. Connect to view skills.");
return null;
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
setStateField("skillsStatus", translate("skills.desktop_required"));
return null;
}
@@ -1881,7 +1882,7 @@ export function createExtensionsStore(options: {
setStateField("skillsStatus", "OpenWork server unavailable. Connect to edit skills.");
return;
}
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
setStateField("skillsStatus", translate("skills.desktop_required"));
return;
}
@@ -1990,7 +1991,9 @@ export function createExtensionsStore(options: {
};
const start = () => {
if (started || disposed) return;
if (started) return;
// StrictMode double-mount re-arms after dispose.
disposed = false;
started = true;
if (typeof window !== "undefined") {
@@ -2038,6 +2041,7 @@ export function createExtensionsStore(options: {
const dispose = () => {
if (disposed) return;
disposed = true;
started = false;
abortRefreshes();
stopOpenworkSubscription?.();
stopOpenworkSubscription = null;

View File

@@ -19,10 +19,10 @@ import type {
EngineInfo,
OpenworkServerInfo,
WorkspaceInfo,
} from "../../../app/lib/tauri";
} from "../../../app/lib/desktop";
import type { OpenworkServerSettings } from "../../../app/lib/openwork-server";
import { t } from "../../../i18n";
import { isTauriRuntime, normalizeDirectoryPath } from "../../../app/utils";
import { isDesktopRuntime, normalizeDirectoryPath } from "../../../app/utils";
export type ShareWorkspaceState = ReturnType<typeof useShareWorkspaceState>;
@@ -182,7 +182,7 @@ export function useShareWorkspaceState(options: UseShareWorkspaceStateOptions) {
{
label: t("session.share_worker_url"),
value: url,
placeholder: !isTauriRuntime()
placeholder: !isDesktopRuntime()
? t("session.share_desktop_app_required")
: t("session.share_starting_server"),
hint: mountedUrl
@@ -195,7 +195,7 @@ export function useShareWorkspaceState(options: UseShareWorkspaceStateOptions) {
label: t("session.share_password"),
value: ownerToken,
secret: true,
placeholder: isTauriRuntime() ? "-" : t("session.share_desktop_app_required"),
placeholder: isDesktopRuntime() ? "-" : t("session.share_desktop_app_required"),
hint: mountedUrl
? t("session.share_worker_url_phones_hint")
: t("session.share_owner_permission_hint"),
@@ -204,7 +204,7 @@ export function useShareWorkspaceState(options: UseShareWorkspaceStateOptions) {
label: t("session.share_collaborator_label"),
value: collaboratorToken,
secret: true,
placeholder: isTauriRuntime() ? "-" : t("session.share_desktop_app_required"),
placeholder: isDesktopRuntime() ? "-" : t("session.share_desktop_app_required"),
hint: mountedUrl
? t("session.share_collaborator_hint")
: t("session.share_collaborator_host_hint"),
@@ -549,7 +549,7 @@ export function useShareWorkspaceState(options: UseShareWorkspaceStateOptions) {
if (workspace.workspaceType === "remote") {
return t("session.export_local_only");
}
if (!isTauriRuntime()) return t("session.export_desktop_only");
if (!isDesktopRuntime()) return t("session.export_desktop_only");
if (options.exportWorkspaceBusy) return t("session.export_already_running");
return null;
}, [options.exportWorkspaceBusy, shareWorkspace]);

View File

@@ -1,7 +1,8 @@
/** @jsxImportSource react */
import { createContext, useContext, type ReactNode } from "react";
import { isTauriRuntime } from "../../app/utils";
import { openDesktopUrl, relaunchDesktopApp } from "../../app/lib/desktop";
import { isDesktopRuntime } from "../../app/utils";
export type SyncStorage = {
getItem(key: string): string | null;
@@ -59,12 +60,10 @@ function shouldOpenInCurrentTab(url: string) {
export function createDefaultPlatform(): Platform {
return {
platform: isTauriRuntime() ? "desktop" : "web",
platform: isDesktopRuntime() ? "desktop" : "web",
openLink(url: string) {
if (isTauriRuntime()) {
void import("@tauri-apps/plugin-opener")
.then(({ openUrl }) => openUrl(url))
.catch(() => undefined);
if (isDesktopRuntime()) {
void openDesktopUrl(url).catch(() => undefined);
return;
}
@@ -76,9 +75,8 @@ export function createDefaultPlatform(): Platform {
window.open(url, "_blank");
},
restart: async () => {
if (isTauriRuntime()) {
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
if (isDesktopRuntime()) {
await relaunchDesktopApp();
return;
}

View File

@@ -10,10 +10,10 @@ import {
type ReactNode,
} from "react";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { desktopFetch } from "../../app/lib/desktop";
import { isWebDeployment } from "../../app/lib/openwork-deployment";
import { isTauriRuntime } from "../../app/utils";
import { isDesktopRuntime } from "../../app/utils";
export function normalizeServerUrl(input: string): string | undefined {
const trimmed = input.trim();
@@ -78,7 +78,7 @@ async function checkHealth(url: string): Promise<boolean> {
baseUrl: url,
headers,
signal: AbortSignal.timeout(3000),
fetch: isTauriRuntime() ? tauriFetch : undefined,
fetch: isDesktopRuntime() ? desktopFetch : undefined,
});
return client.global
.health()
@@ -106,7 +106,7 @@ export function ServerProvider({ children, defaultUrl }: ServerProviderProps) {
// Hosted web deployments served by OpenWork must reuse the OpenCode proxy
// rather than any persisted localhost target.
const forceProxy =
!isTauriRuntime() &&
!isDesktopRuntime() &&
isWebDeployment() &&
(import.meta.env.PROD ||
(typeof import.meta.env?.VITE_OPENWORK_URL === "string" &&

View File

@@ -1,15 +1,14 @@
import { useCallback, useMemo, useState } from "react";
import { relaunch } from "@tauri-apps/plugin-process";
import type {
ReloadReason,
ReloadTrigger,
ResetOpenworkMode,
} from "../../app/types";
import { resetOpenworkState } from "../../app/lib/tauri";
import { relaunchDesktopApp, resetOpenworkState } from "../../app/lib/desktop";
import {
addOpencodeCacheHint,
isTauriRuntime,
isDesktopRuntime,
safeStringify,
} from "../../app/utils";
import { t } from "../../i18n";
@@ -145,12 +144,12 @@ export function useSystemState(
options.setError(null);
try {
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
await resetOpenworkState(resetModalMode);
}
clearOpenworkLocalStorage(resetModalMode);
if (isTauriRuntime()) {
await relaunch();
if (isDesktopRuntime()) {
await relaunchDesktopApp();
} else {
window.location.reload();
}

View File

@@ -3,7 +3,7 @@ import {
engineStart,
openworkServerInfo,
orchestratorWorkspaceActivate,
} from "../../app/lib/tauri";
} from "../../app/lib/desktop";
import { writeOpenworkServerSettings } from "../../app/lib/openwork-server";
import { safeStringify } from "../../app/utils";
import { recordInspectorEvent } from "./app-inspector";

View File

@@ -8,9 +8,9 @@ import {
orchestratorWorkspaceActivate,
resolveWorkspaceListSelectedId,
workspaceBootstrap,
} from "../../app/lib/tauri";
} from "../../app/lib/desktop";
import { hydrateOpenworkServerSettingsFromEnv, writeOpenworkServerSettings } from "../../app/lib/openwork-server";
import { isTauriRuntime, safeStringify } from "../../app/utils";
import { isDesktopRuntime, safeStringify } from "../../app/utils";
import { useServer } from "../kernel/server-provider";
import { useBootState } from "./boot-state";
@@ -35,7 +35,7 @@ export function useDesktopRuntimeBoot() {
const { setActive } = useServer();
useEffect(() => {
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
// Web/headless: nothing to spawn, we're instantly "ready".
markReady();
return;

View File

@@ -1,40 +1,37 @@
/** @jsxImportSource react */
import { useEffect } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import {
FONT_ZOOM_STEP,
applyFontZoom,
applyWebviewZoom,
normalizeFontZoom,
parseFontZoomShortcut,
persistFontZoom,
readStoredFontZoom,
} from "../../app/lib/font-zoom";
import { isTauriRuntime } from "../../app/utils";
import { setDesktopZoomFactor } from "../../app/lib/desktop";
import { isDesktopRuntime } from "../../app/utils";
export function useDesktopFontZoomBehavior() {
useEffect(() => {
if (typeof window === "undefined") return;
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
const applyAndPersistFontZoom = (value: number) => {
const next = normalizeFontZoom(value);
persistFontZoom(window.localStorage, next);
try {
const webview = getCurrentWebview();
void applyWebviewZoom(webview, next)
.then(() => {
void setDesktopZoomFactor(next)
.then((applied) => {
if (applied) {
document.documentElement.style.removeProperty("--openwork-font-size");
})
.catch(() => {
applyFontZoom(document.documentElement.style, next);
});
} catch {
applyFontZoom(document.documentElement.style, next);
}
return;
}
applyFontZoom(document.documentElement.style, next);
})
.catch(() => {
applyFontZoom(document.documentElement.style, next);
});
return next;
};

View File

@@ -3,7 +3,7 @@ import { useEffect, type ReactNode } from "react";
import { isWebDeployment } from "../../app/lib/openwork-deployment";
import { hydrateOpenworkServerSettingsFromEnv, readOpenworkServerSettings } from "../../app/lib/openwork-server";
import { isTauriRuntime } from "../../app/utils";
import { isDesktopRuntime } from "../../app/utils";
import { DenAuthProvider } from "../domains/cloud/den-auth-provider";
import { DesktopConfigProvider } from "../domains/cloud/desktop-config-provider";
import { RestrictionNoticeProvider } from "../domains/cloud/restriction-notice-provider";
@@ -14,7 +14,7 @@ import { DesktopRuntimeBoot } from "./desktop-runtime-boot";
import { startDebugLogger, stopDebugLogger } from "./debug-logger";
function resolveDefaultServerUrl(): string {
if (isTauriRuntime()) return "http://127.0.0.1:4096";
if (isDesktopRuntime()) return "http://127.0.0.1:4096";
const openworkUrl =
typeof import.meta.env?.VITE_OPENWORK_URL === "string"

View File

@@ -22,6 +22,7 @@ import {
} from "../../app/lib/openwork-server";
import {
engineInfo,
revealDesktopItemInDir,
openworkServerInfo,
openworkServerRestart,
pickDirectory,
@@ -38,7 +39,7 @@ import {
type OpenworkServerInfo,
type WorkspaceInfo,
type WorkspaceList,
} from "../../app/lib/tauri";
} from "../../app/lib/desktop";
import type {
ComposerAttachment,
ComposerDraft,
@@ -53,7 +54,7 @@ import type {
WorkspaceSessionGroup,
} from "../../app/types";
import { buildFeedbackUrl } from "../../app/lib/feedback";
import { isSandboxWorkspace, isTauriRuntime, normalizeDirectoryPath, safeStringify } from "../../app/utils";
import { isDesktopRuntime, isSandboxWorkspace, normalizeDirectoryPath, safeStringify } from "../../app/utils";
import { t } from "../../i18n";
import { useLocal } from "../kernel/local-provider";
import { usePlatform } from "../kernel/platform";
@@ -111,7 +112,7 @@ async function resolveRouteOpenworkConnection() {
let resolvedToken = settings.token?.trim() ?? "";
let hostInfo: OpenworkServerInfo | null = null;
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
try {
const info = await openworkServerInfo();
hostInfo = info;
@@ -352,7 +353,7 @@ export function SessionRoute() {
let desktopList = null as Awaited<ReturnType<typeof workspaceBootstrap>> | null;
let desktopWorkspaces = workspacesRef.current;
try {
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
try {
desktopList = await workspaceBootstrap();
desktopWorkspaces = (desktopList.workspaces ?? []).map(mapDesktopWorkspace);
@@ -519,7 +520,7 @@ export function SessionRoute() {
}, [refreshRouteState]);
useEffect(() => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
let cancelled = false;
void engineInfo()
.then((info) => {
@@ -612,7 +613,7 @@ export function SessionRoute() {
);
useEffect(() => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
if (loading) return;
if (client) {
reconnectAttemptedWorkspaceIdRef.current = "";
@@ -1014,7 +1015,7 @@ export function SessionRoute() {
// desktop-provided workspaceBootstrap results). Either call failing on
// its own should NOT block the other — the user's intent was "rename
// this workspace" and a soft failure in one store is recoverable.
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
await workspaceUpdateDisplayName({
workspaceId: renameWorkspaceId,
displayName: trimmed,
@@ -1036,10 +1037,9 @@ export function SessionRoute() {
const handleRevealWorkspace = useCallback(async (workspaceId: string) => {
const workspace = workspaces.find((item) => item.id === workspaceId);
const path = workspace?.path?.trim();
if (!path || !isTauriRuntime()) return;
if (!path || !isDesktopRuntime()) return;
try {
const { revealItemInDir } = await import("@tauri-apps/plugin-opener");
await revealItemInDir(path);
await revealDesktopItemInDir(path);
} catch {
// ignore
}
@@ -1051,7 +1051,7 @@ export function SessionRoute() {
const handleSaveShareRemoteAccess = useCallback(
async (enabled: boolean) => {
if (shareRemoteAccessBusy || !isTauriRuntime()) return;
if (shareRemoteAccessBusy || !isDesktopRuntime()) return;
const previous = readOpenworkServerSettings();
const next = { ...previous, remoteAccessEnabled: enabled };
setShareRemoteAccessBusy(true);
@@ -1075,7 +1075,7 @@ export function SessionRoute() {
const handleExportWorkspaceConfig = useCallback(
async (workspaceId: string) => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
const workspace = workspaces.find((item) => item.id === workspaceId) ?? null;
if (!workspace) return;
const outputPath = await pickDirectory({
@@ -1085,8 +1085,7 @@ export function SessionRoute() {
if (!targetPath) return;
await workspaceExportConfig({ workspaceId, outputPath: targetPath });
try {
const { revealItemInDir } = await import("@tauri-apps/plugin-opener");
await revealItemInDir(targetPath);
await revealDesktopItemInDir(targetPath);
} catch {
// ignore reveal failures
}
@@ -1104,7 +1103,7 @@ export function SessionRoute() {
}
// Remove from both stores so the next refresh can't resurrect the row
// from whichever list wins the merge.
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
await workspaceForget(workspaceId).catch(() => undefined);
}
if (client) {
@@ -1213,9 +1212,10 @@ export function SessionRoute() {
if (!folder) return;
setCreateWorkspaceBusy(true);
try {
const workspaceName = folderNameFromPath(folder);
const list = await workspaceCreate({
folderPath: folder,
name: folderNameFromPath(folder),
name: workspaceName,
preset,
});
const createdId = resolveWorkspaceListSelectedId(list) || list.workspaces[list.workspaces.length - 1]?.id || "";
@@ -1223,6 +1223,16 @@ export function SessionRoute() {
await workspaceSetSelected(createdId).catch(() => undefined);
await workspaceSetRuntimeActive(createdId).catch(() => undefined);
}
// Register the workspace with the running openwork-server so
// listWorkspaces() reflects it immediately. Without this the UI only
// picks up the new workspace after an app restart (because the server
// is launched with a fixed --workspace list at boot and the bridge
// write only updates desktop-side state).
if (client) {
await client
.createLocalWorkspace({ folderPath: folder, name: workspaceName, preset })
.catch(() => undefined);
}
setCreateWorkspaceOpen(false);
await refreshRouteState();
if (createdId) {
@@ -1231,7 +1241,7 @@ export function SessionRoute() {
} finally {
setCreateWorkspaceBusy(false);
}
}, [navigate, refreshRouteState]);
}, [client, navigate, refreshRouteState]);
const handleCreateRemoteWorkspace = useCallback(async (input: {
openworkHostUrl?: string | null;
@@ -1328,7 +1338,7 @@ export function SessionRoute() {
// Fire Tauri updates but don't await them — they're bookkeeping and
// awaiting 2 IPC roundtrips on every click used to stall rapid
// workspace switches behind a queue.
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
void workspaceSetSelected(workspaceId).catch(() => undefined);
void workspaceSetRuntimeActive(workspaceId).catch(() => undefined);
}
@@ -1405,7 +1415,7 @@ export function SessionRoute() {
workspaceDetail: shareWorkspaceState.shareWorkspaceDetail,
fields: shareWorkspaceState.shareFields,
remoteAccess:
isTauriRuntime() && shareWorkspaceState.shareWorkspace?.workspaceType === "local"
isDesktopRuntime() && shareWorkspaceState.shareWorkspace?.workspaceType === "local"
? {
enabled: openworkServerSettings.remoteAccessEnabled === true,
busy: shareRemoteAccessBusy,

View File

@@ -55,11 +55,11 @@ import {
workspaceSetRuntimeActive,
workspaceSetSelected,
type WorkspaceInfo,
} from "../../app/lib/tauri";
} from "../../app/lib/desktop";
import { isDesktopProviderBlocked } from "../../app/cloud/desktop-app-restrictions";
import { useCheckDesktopRestriction } from "../domains/cloud/desktop-config-provider";
import { useCloudProviderAutoSync } from "../domains/cloud/use-cloud-provider-auto-sync";
import { isMacPlatform, isTauriRuntime, normalizeDirectoryPath, safeStringify } from "../../app/utils";
import { isDesktopRuntime, isMacPlatform, normalizeDirectoryPath, safeStringify } from "../../app/utils";
import { CreateWorkspaceModal } from "../domains/workspace/create-workspace-modal";
import { ModelPickerModal } from "../domains/session/modals/model-picker-modal";
import type { ModelOption, ModelRef } from "../../app/types";
@@ -147,7 +147,7 @@ async function resolveRouteOpenworkConnection() {
let normalizedBaseUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? "";
let resolvedToken = settings.token?.trim() ?? "";
if ((!normalizedBaseUrl || !resolvedToken) && isTauriRuntime()) {
if ((!normalizedBaseUrl || !resolvedToken) && isDesktopRuntime()) {
try {
const info = await openworkServerInfo();
normalizedBaseUrl =
@@ -388,7 +388,7 @@ export function SettingsRoute() {
startupPreference: () => {
// In Tauri desktop mode, prefer the embedded host server (hostInfo.baseUrl)
// unless the user has explicitly pinned a remote urlOverride.
if (!isTauriRuntime()) return "server";
if (!isDesktopRuntime()) return "server";
const stored = readOpenworkServerSettings();
return stored.urlOverride?.trim() ? "server" : "local";
},
@@ -585,7 +585,7 @@ export function SettingsRoute() {
let desktopList = null as Awaited<ReturnType<typeof workspaceBootstrap>> | null;
let desktopWorkspaces = workspacesRef.current;
try {
if (isTauriRuntime()) {
if (isDesktopRuntime()) {
try {
desktopList = await workspaceBootstrap();
desktopWorkspaces = (desktopList.workspaces ?? []).map(mapDesktopWorkspace);
@@ -671,7 +671,7 @@ export function SettingsRoute() {
}, [workspaces]);
useEffect(() => {
if (!isTauriRuntime()) return;
if (!isDesktopRuntime()) return;
if (loading) return;
if (openworkClient) {
reconnectAttemptedWorkspaceIdRef.current = "";
@@ -804,9 +804,10 @@ export function SettingsRoute() {
if (!folder) return;
setCreateWorkspaceBusy(true);
try {
const workspaceName = folderNameFromPath(folder);
const list = await workspaceCreate({
folderPath: folder,
name: folderNameFromPath(folder),
name: workspaceName,
preset,
});
const createdId = resolveWorkspaceListSelectedId(list) || list.workspaces[list.workspaces.length - 1]?.id || "";
@@ -814,6 +815,16 @@ export function SettingsRoute() {
await workspaceSetSelected(createdId).catch(() => undefined);
await workspaceSetRuntimeActive(createdId).catch(() => undefined);
}
// Register the workspace with the running openwork-server so
// listWorkspaces() reflects it immediately. Without this the UI only
// picks up the new workspace after an app restart (because the server
// is launched with a fixed --workspace list at boot and the bridge
// write only updates desktop-side state).
if (openworkClient) {
await openworkClient
.createLocalWorkspace({ folderPath: folder, name: workspaceName, preset })
.catch(() => undefined);
}
setCreateWorkspaceOpen(false);
await refreshRouteState();
} finally {
@@ -1102,7 +1113,7 @@ export function SettingsRoute() {
onReleaseChannelChange={(next) =>
local.setPrefs((previous) => ({ ...previous, releaseChannel: next }))
}
alphaChannelSupported={isTauriRuntime() && isMacPlatform()}
alphaChannelSupported={isDesktopRuntime() && isMacPlatform()}
/>
);
case "recovery":

View File

@@ -1,8 +1,8 @@
import {
nativeDeepLinkEvent,
pushPendingDeepLinks,
} from "../../app/lib/deep-link-bridge";
import { isTauriRuntime } from "../../app/utils";
import { subscribeDesktopDeepLinks } from "../../app/lib/desktop";
import { isDesktopRuntime } from "../../app/utils";
let started = false;
@@ -10,32 +10,16 @@ export function startDeepLinkBridge(): void {
if (typeof window === "undefined" || started) return;
started = true;
if (!isTauriRuntime()) {
if (!isDesktopRuntime()) {
pushPendingDeepLinks(window, [window.location.href]);
return;
}
void (async () => {
try {
const [{ getCurrent, onOpenUrl }, { listen }] = await Promise.all([
import("@tauri-apps/plugin-deep-link"),
import("@tauri-apps/api/event"),
]);
const startUrls = await getCurrent().catch(() => null);
if (Array.isArray(startUrls)) {
pushPendingDeepLinks(window, startUrls);
}
await onOpenUrl((urls) => {
await subscribeDesktopDeepLinks((urls) => {
pushPendingDeepLinks(window, urls);
}).catch(() => undefined);
await listen<string[]>(nativeDeepLinkEvent, (event) => {
if (Array.isArray(event.payload)) {
pushPendingDeepLinks(window, event.payload);
}
}).catch(() => undefined);
});
} catch {
// ignore startup failures
}

View File

@@ -0,0 +1,29 @@
appId: com.differentai.openwork.electron
productName: OpenWork
directories:
output: dist-electron
files:
- electron/**/*
- package.json
extraResources:
- from: ../app/dist
to: app-dist
- from: src-tauri/sidecars
to: sidecars
asar: true
npmRebuild: false
mac:
icon: src-tauri/icons/icon.icns
target:
- dmg
- zip
linux:
icon: src-tauri/icons/icon.png
target:
- AppImage
- tar.gz
win:
icon: src-tauri/icons/icon.ico
target:
- nsis
artifactName: openwork-electron-${os}-${arch}-${version}.${ext}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
import { contextBridge, ipcRenderer } from "electron";
const NATIVE_DEEP_LINK_EVENT = "openwork:deep-link-native";
function normalizePlatform(value) {
if (value === "darwin" || value === "linux") return value;
if (value === "win32") return "windows";
return "linux";
}
contextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__", {
invokeDesktop(command, ...args) {
return ipcRenderer.invoke("openwork:desktop", command, ...args);
},
shell: {
openExternal(url) {
return ipcRenderer.invoke("openwork:shell:openExternal", url);
},
relaunch() {
return ipcRenderer.invoke("openwork:shell:relaunch");
},
},
meta: {
initialDeepLinks: [],
platform: normalizePlatform(process.platform),
version: process.versions.electron,
},
});
ipcRenderer.on(NATIVE_DEEP_LINK_EVENT, (_event, urls) => {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(NATIVE_DEEP_LINK_EVENT, { detail: urls }));
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,18 +2,32 @@
"name": "@openwork/desktop",
"private": true,
"version": "0.11.212",
"description": "OpenWork desktop shell",
"author": {
"name": "OpenWork",
"email": "support@openworklabs.com"
},
"opencodeRouterVersion": "0.11.212",
"main": "electron/main.mjs",
"type": "module",
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
"dev:react-session": "OPENWORK_DEV_MODE=1 VITE_OPENWORK_REACT_SESSION=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev-react\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
"dev:electron": "node ./scripts/electron-dev.mjs",
"dev:electron:react-session": "VITE_OPENWORK_REACT_SESSION=1 node ./scripts/electron-dev.mjs",
"dev:windows": "node ./scripts/dev-windows.mjs",
"dev:windows:x64": "node ./scripts/dev-windows.mjs x64",
"build": "tauri build",
"build:debug:react-session": "VITE_OPENWORK_REACT_SESSION=1 tauri build --debug",
"build:electron": "node ./scripts/electron-build.mjs",
"package:electron": "pnpm run build:electron && pnpm exec electron-builder --config electron-builder.yml",
"package:electron:dir": "pnpm run build:electron && pnpm exec electron-builder --config electron-builder.yml --dir",
"electron": "pnpm exec electron ./electron/main.mjs",
"prepare:sidecar": "node ./scripts/prepare-sidecar.mjs"
},
"devDependencies": {
"electron": "^35.0.0",
"electron-builder": "^25.1.8",
"@tauri-apps/cli": "^2.0.0"
},
"packageManager": "pnpm@10.27.0"

View File

@@ -0,0 +1,39 @@
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const desktopRoot = resolve(__dirname, "..");
const repoRoot = resolve(desktopRoot, "../..");
const pnpmCmd = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const nodeCmd = process.execPath;
function run(command, args, cwd) {
const result = spawnSync(command, args, {
cwd,
stdio: "inherit",
shell: process.platform === "win32",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
run(nodeCmd, [resolve(__dirname, "prepare-sidecar.mjs"), "--force"], desktopRoot);
run(pnpmCmd, ["--filter", "@openwork/app", "build"], repoRoot);
run(nodeCmd, ["--check", resolve(desktopRoot, "electron/main.mjs")], repoRoot);
run(nodeCmd, ["--check", resolve(desktopRoot, "electron/preload.mjs")], repoRoot);
process.stdout.write(
`${JSON.stringify(
{
ok: true,
renderer: "apps/app/dist",
electronMain: "apps/desktop/electron/main.mjs",
electronPreload: "apps/desktop/electron/preload.mjs",
},
null,
2,
)}\n`,
);

View File

@@ -0,0 +1,204 @@
import { spawn, spawnSync } from "node:child_process";
import net from "node:net";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const desktopRoot = resolve(__dirname, "..");
const repoRoot = resolve(desktopRoot, "../..");
const pnpmCmd = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const nodeCmd = process.execPath;
const portValue = Number.parseInt(process.env.PORT ?? "", 10);
const devPort = Number.isFinite(portValue) && portValue > 0 ? portValue : 5173;
const explicitStartUrl = process.env.OPENWORK_ELECTRON_START_URL?.trim() || "";
const startUrl = explicitStartUrl || `http://localhost:${devPort}`;
const viteProbeUrls = explicitStartUrl
? [explicitStartUrl]
: [
`http://127.0.0.1:${devPort}`,
`http://[::1]:${devPort}`,
`http://localhost:${devPort}`,
];
function run(command, args, options = {}) {
return spawn(command, args, {
stdio: "inherit",
shell: process.platform === "win32",
...options,
});
}
function runSync(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "inherit",
shell: process.platform === "win32",
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
async function fetchWithTimeout(url, timeoutMs = 4000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function probeHost(host, port) {
return new Promise((resolveCheck) => {
const socket = net.createConnection({ host, port });
const onDone = (ready) => {
socket.removeAllListeners();
socket.destroy();
resolveCheck(ready);
};
socket.setTimeout(1200);
socket.once("connect", () => onDone(true));
socket.once("timeout", () => onDone(false));
socket.once("error", () => onDone(false));
});
}
async function looksLikeVite(url) {
try {
const response = await fetchWithTimeout(`${url}/@vite/client`);
if (!response.ok) return false;
const body = await response.text();
return body.includes("@vite/client") || body.includes("import.meta.hot");
} catch {
return false;
}
}
async function portIsOpenForVite(url) {
try {
const parsed = new URL(url);
const host = parsed.hostname.replace(/^\[|\]$/g, "");
const port = Number.parseInt(parsed.port || (parsed.protocol === "https:" ? "443" : "80"), 10);
if (!Number.isFinite(port)) return false;
return probeHost(host, port);
} catch {
return false;
}
}
async function waitForVite(url, timeoutMs = 60_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
for (const candidate of [url, ...viteProbeUrls].filter(Boolean)) {
if (await looksLikeVite(candidate)) {
return candidate;
}
}
for (const candidate of [url, ...viteProbeUrls].filter(Boolean)) {
if (await portIsOpenForVite(candidate)) {
return candidate;
}
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
}
throw new Error(`Timed out waiting for Vite dev server at ${viteProbeUrls.join(", ")}`);
}
function killTree(child) {
if (!child?.pid) return;
if (process.platform === "win32") {
try {
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
} catch {
// ignore
}
return;
}
try {
process.kill(-child.pid, "SIGTERM");
} catch {
try {
child.kill("SIGTERM");
} catch {
// ignore
}
}
}
let uiChild = null;
let electronChild = null;
let stopping = false;
function stopAll(exitCode = 0) {
if (stopping) return;
stopping = true;
killTree(electronChild);
killTree(uiChild);
process.exit(exitCode);
}
process.once("SIGINT", () => stopAll(0));
process.once("SIGTERM", () => stopAll(0));
runSync(nodeCmd, [resolve(__dirname, "prepare-sidecar.mjs"), "--force"], { cwd: desktopRoot });
const initialProbeUrls = [startUrl, ...viteProbeUrls].filter(Boolean);
let viteReady = false;
for (const candidate of initialProbeUrls) {
if (await looksLikeVite(candidate)) {
viteReady = true;
break;
}
}
if (!viteReady) {
for (const candidate of initialProbeUrls) {
if (await portIsOpenForVite(candidate)) {
viteReady = true;
break;
}
}
}
if (!viteReady) {
uiChild = run(pnpmCmd, ["-w", "dev:ui"], {
cwd: repoRoot,
detached: process.platform !== "win32",
env: {
...process.env,
PORT: String(devPort),
OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE ?? "1",
},
});
}
const resolvedStartUrl = await waitForVite(startUrl);
// Default Electron CDP on a stable dev port so chrome-devtools MCP / raw CDP
// clients can attach without each launch picking a random port. Override with
// OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=<port> or set to "0" to disable.
const defaultCdpPort = "9823";
const cdpPortRaw = process.env.OPENWORK_ELECTRON_REMOTE_DEBUG_PORT?.trim() ?? defaultCdpPort;
const cdpPort = cdpPortRaw === "" || cdpPortRaw === "0" ? "" : cdpPortRaw;
electronChild = run(pnpmCmd, ["exec", "electron", "./electron/main.mjs"], {
cwd: desktopRoot,
detached: process.platform !== "win32",
env: {
...process.env,
OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE ?? "1",
OPENWORK_ELECTRON_START_URL: resolvedStartUrl,
...(cdpPort ? { OPENWORK_ELECTRON_REMOTE_DEBUG_PORT: cdpPort } : {}),
},
});
if (cdpPort) {
console.log(`[openwork] Electron CDP exposed at http://127.0.0.1:${cdpPort}`);
}
electronChild.on("exit", (code) => {
stopAll(code ?? 0);
});

View File

@@ -29,13 +29,6 @@
"@solid-primitives/storage": "^4.3.3",
"@solidjs/router": "^0.15.4",
"@tanstack/solid-virtual": "^3.13.19",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "~2.6.0",
"@tauri-apps/plugin-http": "~2.5.6",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "~2.3.1",
"@tauri-apps/plugin-updater": "~2.9.0",
"fuzzysort": "^3.1.0",
"jsonc-parser": "^3.2.1",
"lucide-solid": "^0.562.0",

View File

@@ -2,10 +2,11 @@
import { render } from "solid-js/web";
import "../../app/src/app/index.css";
import { openDesktopUrl, relaunchDesktopApp } from "../../app/src/app/lib/desktop";
import { ConnectionsProvider } from "../../app/src/app/connections/provider";
import { PlatformProvider, type Platform } from "../../app/src/app/context/platform";
import { bootstrapTheme } from "../../app/src/app/theme";
import { isTauriRuntime } from "../../app/src/app/utils";
import { isDesktopRuntime } from "../../app/src/app/utils";
import { initLocale } from "../../app/src/i18n";
import NewLayoutApp from "./new-layout";
@@ -19,20 +20,17 @@ if (!root) {
}
const platform: Platform = {
platform: isTauriRuntime() ? "desktop" : "web",
platform: isDesktopRuntime() ? "desktop" : "web",
openLink(url: string) {
if (isTauriRuntime()) {
void import("@tauri-apps/plugin-opener")
.then(({ openUrl }) => openUrl(url))
.catch(() => undefined);
if (isDesktopRuntime()) {
void openDesktopUrl(url).catch(() => undefined);
return;
}
window.open(url, "_blank");
},
restart: async () => {
if (isTauriRuntime()) {
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
if (isDesktopRuntime()) {
await relaunchDesktopApp();
return;
}
window.location.reload();

View File

@@ -1,7 +1,7 @@
import type { Part } from "@opencode-ai/sdk/v2/client";
import type { MessageWithParts } from "../../app/src/app/types";
import type { WorkspaceInfo } from "../../app/src/app/lib/tauri";
import type { WorkspaceInfo } from "../../app/src/app/lib/desktop";
export type StoryScreen = "session" | "settings" | "components" | "onboarding";

2335
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,434 @@
# Electron 1:1 Port Audit
## Goal
Port the current desktop product on `origin/dev` from Tauri 2.x to Electron without changing user-visible behavior.
Parity means keeping all of the following working:
- local desktop host mode
- orchestrator and direct runtime modes
- deep links: `openwork://` and `openwork-dev://`
- single-instance handoff behavior
- folder/file pickers and save dialogs
- open URL, open path, reveal in folder
- auto-update and release-channel behavior
- workspace file watching and reload-required events
- sidecar staging and version metadata
- desktop bootstrap config and forced sign-in flows
- window focus, relaunch, decorations, and zoom behavior
- renderer access to localhost/OpenWork/OpenCode HTTP surfaces
## Current State
- There is no shipping Electron implementation in the current tree. A repo-wide search for `electron` found no existing desktop shell to extend.
- Tauri is not isolated to `apps/desktop`; it leaks into the renderer, package manifests, CI, release scripts, docs, locales, and planning docs.
- The minimum realistic scope is: replace the native shell, replace the renderer/native bridge, then update every build/release/doc surface that still assumes Tauri.
## Hard Blockers
These capabilities need an Electron equivalent before the port can be called 1:1:
1. `invoke`-style IPC for the current native command surface.
2. Desktop-safe HTTP/fetch behavior currently handled by `@tauri-apps/plugin-http`.
3. Folder/file/save dialogs.
4. Open URL/open path/reveal path behavior.
5. Relaunch/restart behavior.
6. Single-instance + forwarded deep-link handling.
7. Updater integration and release-channel endpoint selection.
8. Webview zoom behavior.
9. Window decoration toggling.
10. Workspace file watching and native event emission.
11. Sidecar spawning, supervision, shutdown, and cleanup.
12. Desktop bootstrap config persistence.
## Native Contract That Must Be Recreated
The current Tauri bridge is not small.
- `apps/app/src/app/lib/tauri.ts` exports `63` wrappers.
- `apps/desktop/src-tauri/src/commands/*.rs` exposes `57` `#[tauri::command]` handlers.
Current command count by Rust module:
```text
apps/desktop/src-tauri/src/commands/workspace.rs: 14
apps/desktop/src-tauri/src/commands/orchestrator.rs: 8
apps/desktop/src-tauri/src/commands/engine.rs: 6
apps/desktop/src-tauri/src/commands/skills.rs: 6
apps/desktop/src-tauri/src/commands/misc.rs: 5
apps/desktop/src-tauri/src/commands/opencode_router.rs: 5
apps/desktop/src-tauri/src/commands/command_files.rs: 3
apps/desktop/src-tauri/src/commands/scheduler.rs: 2
apps/desktop/src-tauri/src/commands/openwork_server.rs: 2
apps/desktop/src-tauri/src/commands/desktop_bootstrap.rs: 2
apps/desktop/src-tauri/src/commands/config.rs: 2
apps/desktop/src-tauri/src/commands/window.rs: 1
apps/desktop/src-tauri/src/commands/updater.rs: 1
```
## Exact Change Inventory
### 1. Root Workspace Surface
These files change because the workspace entrypoints and dependency graph still expose Tauri directly.
```text
package.json
pnpm-lock.yaml
```
Notes:
- `package.json` currently exposes a root `tauri` script and routes desktop dev/build through the Tauri-based package.
- `pnpm-lock.yaml` will change when `@tauri-apps/*` packages are removed and Electron packages are added.
### 2. App Renderer And Shared Desktop Bridge
Everything below is in scope because it either imports `@tauri-apps/*`, imports `app/lib/tauri`, checks `isTauriRuntime()`, references `__TAURI_INTERNALS__`, or documents Tauri-specific renderer behavior.
Total files: `58`
#### Shared app modules
```text
apps/app/package.json
apps/app/scripts/bump-version.mjs
apps/app/src/app/bundles/apply.ts
apps/app/src/app/bundles/sources.ts
apps/app/src/app/lib/den.ts
apps/app/src/app/lib/opencode.ts
apps/app/src/app/lib/openwork-server.ts
apps/app/src/app/lib/release-channels.ts
apps/app/src/app/lib/tauri.ts
apps/app/src/app/mcp.ts
apps/app/src/app/types.ts
apps/app/src/app/utils/index.ts
apps/app/src/app/utils/plugins.ts
apps/app/src/index.react.tsx
apps/app/src/react-app/ARCHITECTURE.md
```
What changes here:
- Replace the Tauri-specific runtime boundary with an Electron preload/IPC boundary.
- Remove direct `@tauri-apps/*` dependencies from the renderer.
- Rename or replace `app/lib/tauri.ts`; a parity-first port should move this to a generic desktop bridge early.
- Rework update-channel copy and assumptions in `release-channels.ts`.
- Update version bump flow so it no longer edits `Cargo.toml`, `Cargo.lock`, or `tauri.conf.json`.
- Revisit router selection in `index.react.tsx`; it currently uses `HashRouter` when `isTauriRuntime()` is true.
#### React shell and kernel
```text
apps/app/src/react-app/kernel/platform.tsx
apps/app/src/react-app/kernel/server-provider.tsx
apps/app/src/react-app/kernel/system-state.ts
apps/app/src/react-app/shell/app-root.tsx
apps/app/src/react-app/shell/desktop-runtime-boot.ts
apps/app/src/react-app/shell/font-zoom.ts
apps/app/src/react-app/shell/providers.tsx
apps/app/src/react-app/shell/session-route.tsx
apps/app/src/react-app/shell/settings-route.tsx
apps/app/src/react-app/shell/startup-deep-links.ts
```
What changes here:
- `platform.tsx` currently hardcodes Tauri open-link and relaunch behavior.
- `server-provider.tsx`, `openwork-server.ts`, `opencode.ts`, `den.ts`, and `bundles/sources.ts` use Tauri HTTP fetch in desktop mode.
- `system-state.ts` uses Tauri relaunch and reset flow.
- `font-zoom.ts` uses `@tauri-apps/api/webview` directly.
- `startup-deep-links.ts` depends on Tauri deep-link and event APIs.
- `session-route.tsx` and `settings-route.tsx` assume Tauri-native workspace/bootstrap behavior.
#### React domain files
```text
apps/app/src/react-app/domains/bundles/skill-destination-modal.tsx
apps/app/src/react-app/domains/cloud/forced-signin-page.tsx
apps/app/src/react-app/domains/connections/mcp-auth-modal.tsx
apps/app/src/react-app/domains/connections/mcp-view.tsx
apps/app/src/react-app/domains/connections/openwork-server-store.ts
apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx
apps/app/src/react-app/domains/connections/provider-auth/store.ts
apps/app/src/react-app/domains/connections/store.ts
apps/app/src/react-app/domains/session/chat/session-page.tsx
apps/app/src/react-app/domains/session/sidebar/workspace-session-list.tsx
apps/app/src/react-app/domains/session/surface/message-list.tsx
apps/app/src/react-app/domains/settings/pages/advanced-view.tsx
apps/app/src/react-app/domains/settings/pages/appearance-view.tsx
apps/app/src/react-app/domains/settings/pages/automations-view.tsx
apps/app/src/react-app/domains/settings/pages/config-view.tsx
apps/app/src/react-app/domains/settings/pages/debug-view.tsx
apps/app/src/react-app/domains/settings/pages/mcp-view.tsx
apps/app/src/react-app/domains/settings/pages/recovery-view.tsx
apps/app/src/react-app/domains/settings/pages/updates-view.tsx
apps/app/src/react-app/domains/settings/panels/authorized-folders-panel.tsx
apps/app/src/react-app/domains/settings/state/automations-store.ts
apps/app/src/react-app/domains/settings/state/debug-view-model.ts
apps/app/src/react-app/domains/settings/state/extensions-store.ts
apps/app/src/react-app/domains/workspace/share-workspace-state.ts
```
What changes here:
- Replace direct bridge imports with the new Electron desktop bridge.
- Replace Tauri-only file/path/dialog helpers.
- Rework updater UI to target Electron update semantics.
- Keep forced-signin/deep-link auth flows working after the native deep-link layer changes.
- Keep settings pages working for filesystem-backed edits, scheduler access, local-skill management, and reveal/open actions.
#### Locale strings
These locale files all contain the current `app.error.tauri_required` copy and need wording changes once Tauri is gone.
```text
apps/app/src/i18n/locales/ca.ts
apps/app/src/i18n/locales/en.ts
apps/app/src/i18n/locales/es.ts
apps/app/src/i18n/locales/fr.ts
apps/app/src/i18n/locales/ja.ts
apps/app/src/i18n/locales/pt-BR.ts
apps/app/src/i18n/locales/th.ts
apps/app/src/i18n/locales/vi.ts
apps/app/src/i18n/locales/zh.ts
```
### 3. Story Book / Demo Runtime
These files still pull Tauri packages into the demo shell.
```text
apps/story-book/package.json
apps/story-book/src/index.tsx
```
What changes here:
- Remove `@tauri-apps/*` dependencies from Story Book.
- Replace the demo platform implementation so it no longer references Tauri opener/process APIs.
### 4. Native Desktop Shell Replacement
This is the core of the port. The current desktop package is Tauri/Rust-based and must be replaced or fully reimplemented behind Electron main/preload.
Total files: `64`
#### Package and build scripts
```text
apps/desktop/package.json
apps/desktop/scripts/chrome-devtools-mcp-shim.ts
apps/desktop/scripts/dev-windows.mjs
apps/desktop/scripts/prepare-sidecar.mjs
apps/desktop/scripts/tauri-before-build.mjs
apps/desktop/scripts/tauri-before-dev.mjs
```
What changes here:
- Replace `tauri dev` / `tauri build` entrypoints with Electron equivalents.
- Keep sidecar staging, but retarget output paths away from `src-tauri`.
- Rework Windows desktop dev launcher for Electron.
- `chrome-devtools-mcp-shim.ts` may stay logically the same, but its build/staging path changes with the shell.
#### Tauri manifests, build metadata, and generated capability artifacts
```text
apps/desktop/src-tauri/build.rs
apps/desktop/src-tauri/capabilities/default.json
apps/desktop/src-tauri/Cargo.lock
apps/desktop/src-tauri/Cargo.toml
apps/desktop/src-tauri/entitlements.plist
apps/desktop/src-tauri/gen/schemas/acl-manifests.json
apps/desktop/src-tauri/gen/schemas/capabilities.json
apps/desktop/src-tauri/gen/schemas/desktop-schema.json
apps/desktop/src-tauri/gen/schemas/macOS-schema.json
apps/desktop/src-tauri/gen/schemas/windows-schema.json
apps/desktop/src-tauri/Info.dev.plist
apps/desktop/src-tauri/tauri.conf.json
apps/desktop/src-tauri/tauri.dev.conf.json
```
What changes here:
- `Cargo.toml`/`Cargo.lock`/`build.rs` go away if the native shell is no longer Rust-based.
- Tauri config, capability JSON, and generated schemas are Tauri-specific and must be removed or replaced.
- macOS packaging metadata may still be needed, but not in current Tauri form.
#### Rust bootstrap, managers, helpers, and process orchestration
```text
apps/desktop/src-tauri/src/bun_env.rs
apps/desktop/src-tauri/src/config.rs
apps/desktop/src-tauri/src/desktop_bootstrap.rs
apps/desktop/src-tauri/src/fs.rs
apps/desktop/src-tauri/src/lib.rs
apps/desktop/src-tauri/src/main.rs
apps/desktop/src-tauri/src/paths.rs
apps/desktop/src-tauri/src/types.rs
apps/desktop/src-tauri/src/updater.rs
apps/desktop/src-tauri/src/utils.rs
apps/desktop/src-tauri/src/platform/mod.rs
apps/desktop/src-tauri/src/platform/unix.rs
apps/desktop/src-tauri/src/platform/windows.rs
apps/desktop/src-tauri/src/engine/doctor.rs
apps/desktop/src-tauri/src/engine/manager.rs
apps/desktop/src-tauri/src/engine/mod.rs
apps/desktop/src-tauri/src/engine/paths.rs
apps/desktop/src-tauri/src/engine/spawn.rs
apps/desktop/src-tauri/src/opencode_router/manager.rs
apps/desktop/src-tauri/src/opencode_router/mod.rs
apps/desktop/src-tauri/src/opencode_router/spawn.rs
apps/desktop/src-tauri/src/openwork_server/manager.rs
apps/desktop/src-tauri/src/openwork_server/mod.rs
apps/desktop/src-tauri/src/openwork_server/spawn.rs
apps/desktop/src-tauri/src/orchestrator/manager.rs
apps/desktop/src-tauri/src/orchestrator/mod.rs
apps/desktop/src-tauri/src/workspace/commands.rs
apps/desktop/src-tauri/src/workspace/files.rs
apps/desktop/src-tauri/src/workspace/mod.rs
apps/desktop/src-tauri/src/workspace/state.rs
apps/desktop/src-tauri/src/workspace/watch.rs
```
What changes here:
- `lib.rs` currently owns plugin registration, single-instance behavior, deep-link forwarding, shutdown cleanup, and command registration.
- `workspace/watch.rs` emits `openwork://reload-required` from native file-watch events.
- `desktop_bootstrap.rs` persists the external desktop bootstrap file and seeds Den startup behavior.
- `updater.rs` contains macOS DMG/translocation gating for updates.
- `engine/*`, `orchestrator/*`, `openwork_server/*`, and `opencode_router/*` currently spawn and supervise local child processes.
- `paths.rs` and related helpers contain native-side path resolution and sidecar discovery logic.
#### Rust command modules that the renderer depends on
```text
apps/desktop/src-tauri/src/commands/command_files.rs
apps/desktop/src-tauri/src/commands/config.rs
apps/desktop/src-tauri/src/commands/desktop_bootstrap.rs
apps/desktop/src-tauri/src/commands/engine.rs
apps/desktop/src-tauri/src/commands/misc.rs
apps/desktop/src-tauri/src/commands/mod.rs
apps/desktop/src-tauri/src/commands/opencode_router.rs
apps/desktop/src-tauri/src/commands/openwork_server.rs
apps/desktop/src-tauri/src/commands/orchestrator.rs
apps/desktop/src-tauri/src/commands/scheduler.rs
apps/desktop/src-tauri/src/commands/skills.rs
apps/desktop/src-tauri/src/commands/updater.rs
apps/desktop/src-tauri/src/commands/window.rs
apps/desktop/src-tauri/src/commands/workspace.rs
```
What changes here:
- Every renderer call currently funneled through `app/lib/tauri.ts` must be backed by Electron IPC instead.
- The desktop event contract also needs parity, not just request/response IPC.
### 5. Runtime / Sidecar Build Hooks Outside `apps/desktop`
These packages can mostly keep their product logic, but they still contain shell-coupled assumptions that must change for Electron packaging.
```text
apps/orchestrator/src/cli.ts
apps/server-v2/script/build.ts
```
What changes here:
- `apps/server-v2/script/build.ts` still supports `--bundle-dir` embedding for Tauri sidecar layouts.
- `apps/orchestrator/src/cli.ts` remains product-runtime logic, but any shell packaging, path, or supervision assumptions tied to the Tauri app layout must be reviewed during the cutover.
### 6. CI, Release, And Ops Scripts
These files will keep failing or generating the wrong artifacts until they are rewritten for Electron.
```text
.github/workflows/alpha-macos-aarch64.yml
.github/workflows/build-desktop.yml
.github/workflows/prerelease.yml
.github/workflows/release-macos-aarch64.yml
.github/workflows/windows-signed-artifacts.yml
scripts/find-unused.README.md
scripts/find-unused.sh
scripts/openwork-debug.sh
scripts/release/review.mjs
scripts/release/verify-tag.mjs
```
What changes here:
- CI currently caches Cargo, builds Tauri bundles, uploads Tauri artifacts, and signs/notarizes Tauri outputs.
- Release review/verify scripts compare desktop package version against `Cargo.toml` and `tauri.conf.json`.
- `openwork-debug.sh` kills Tauri dev processes.
- `find-unused.sh` and its README explicitly whitelist Tauri hooks/configs.
### 7. Live Product And Developer Docs
These are active docs, not just historical notes. They should be updated in the same implementation stream so the repo stops advertising Tauri as the current shell.
```text
AGENTS.md
ARCHITECTURE.md
README.md
translated_readmes/README_JA.md
translated_readmes/README_ZH_hk.md
translated_readmes/README_ZH.md
```
Why they change:
- `AGENTS.md` still says the desktop/mobile shell is Tauri 2.x and calls out Tauri commands/events as IPC.
- `ARCHITECTURE.md` explicitly frames native-shell fallback behavior around Tauri and documents Tauri updater/channel behavior.
- `README.md` and translated readmes currently require Rust + Tauri CLI and describe Tauri folder-picker and desktop build steps.
### 8. Planning Docs That Become Stale
These docs are not build blockers, but they will actively mislead future work if the Electron port lands and they still describe the desktop layer as Tauri-first.
```text
prds/openwork-desktop-bootstrap-config/desktop-bootstrap-and-org-runtime-config.md
prds/react-incremental-adoption.md
prds/server-v2-plan/app-audit.md
prds/server-v2-plan/distribution.md
prds/server-v2-plan/final-cutover-checklist.md
prds/server-v2-plan/ideal-flow.md
prds/server-v2-plan/plan.md
prds/server-v2-plan/tauri-audit.md
prds/server-v2-plan/ui-migration.md
```
## Recommended Cutover Order
1. Keep `apps/app` behavior frozen and define a generic desktop bridge that matches the current Tauri contract.
2. Stand up an Electron main/preload shell under `apps/desktop` that can satisfy the same bridge.
3. Port native process supervision, deep links, single-instance handling, updater logic, dialogs, open/reveal helpers, and file watching.
4. Swap renderer imports off direct `@tauri-apps/*` usage.
5. Replace Tauri build/release/versioning logic in scripts and GitHub Actions.
6. Update docs, translated readmes, and locale copy.
7. Delete the remaining Tauri-only codepaths and configs only after desktop parity is verified.
## Decisions To Make Before Implementation
1. Keep the package name `@openwork/desktop` and replace internals in place, or create a parallel Electron package and cut over later.
2. Keep a HashRouter-based desktop renderer, or move Electron to a different route/bootstrap strategy.
3. Keep GitHub-hosted updater artifacts/endpoints, or change updater infrastructure while doing the shell migration.
4. Reimplement native process/file-watch logic in Node/Electron only, or keep a small Rust helper binary for pieces like watcher/process supervision.
## Bottom Line
This is not just an `apps/desktop` rewrite.
A full 1:1 Electron port touches:
- root workspace scripts and lockfile
- `58` renderer/shared-app files
- `2` Story Book files
- `64` desktop shell files
- `2` runtime/build hook files outside the shell package
- `10` CI/release/ops script files
- `6` live product/developer docs
- `9` planning docs that become stale after cutover
If the goal is strict parity, the safest path is to port the current Tauri contract first, then simplify after the Electron app is feature-complete.

View File

@@ -0,0 +1,253 @@
# Migration plan: Tauri → Electron
Goal: every existing Tauri user ends up on the Electron build without
manual action, keeps all workspaces / tokens / sessions, and continues to
auto-update from Electron going forward — all through the update mechanism
users already trust.
## Where data lives today
### Tauri shell — `app_data_dir()` per OS
| OS | Path |
| -------- | -------------------------------------------------------- |
| macOS | `~/Library/Application Support/com.differentai.openwork/`|
| Windows | `%APPDATA%\com.differentai.openwork\` |
| Linux | `~/.config/com.differentai.openwork/` |
Contents (observed on a real machine):
- `openwork-workspaces.json` (Tauri's name)
- `openwork-server-state.json`
- `openwork-server-tokens.json`
- `workspaces/` subtree
### Electron shell — `app.getPath("userData")` default
| OS | Path |
| -------- | ---------------------------------------------- |
| macOS | `~/Library/Application Support/Electron/` |
| Windows | `%APPDATA%\Electron\` |
| Linux | `~/.config/Electron/` |
Contents written today:
- `workspace-state.json` (Electron's name — differs from Tauri's)
- `openwork-server-state.json`
- `openwork-server-tokens.json`
- `desktop-bootstrap.json`
### Shared state (already portable)
- `~/.openwork/openwork-orchestrator/` — orchestrator daemon data
- Each workspace's own `.opencode/` — sessions, messages, skills, MCP config
- Neither has to migrate.
## Tauri updater today
- `apps/desktop/src-tauri/tauri.conf.json`
`endpoints: ["https://github.com/different-ai/openwork/releases/latest/download/latest.json"]`
- minisign signature required (pubkey baked into config)
- installs a DMG/zip in place
A straight-swap to an Electron installer fails: the Tauri updater
won't accept an asset that isn't minisign-signed in the format it expects.
## Plan
### 1 — Make Electron read the same folder Tauri writes
Before any user-facing migration, flip two knobs in the current PR's Electron
shell:
```js
// apps/desktop/electron/main.mjs
app.setName("OpenWork");
app.setPath(
"userData",
path.join(app.getPath("appData"), "com.differentai.openwork"),
);
```
```yaml
# apps/desktop/electron-builder.yml
appId: com.differentai.openwork # (currently com.differentai.openwork.electron)
```
Effects:
- macOS Launchpad / Dock / notarization identity stay the same → Gatekeeper
doesn't re-prompt, the icon doesn't split into two slots.
- First Electron launch finds the Tauri-written `openwork-server-*.json`
already present → workspaces, tokens, orchestrator state survive with
zero copy. Same `workspaces/` subtree, same orchestrator data dir, same
workspace `.opencode/` dirs (they live inside user folders anyway).
Filename compatibility layer:
```js
// Electron runtime on load, once per launch
async function readWorkspaceState() {
const legacy = path.join(userData, "openwork-workspaces.json"); // Tauri
const current = path.join(userData, "workspace-state.json"); // Electron
if (existsSync(legacy) && !existsSync(current)) {
await rename(legacy, current); // idempotent migration
}
return existsSync(current) ? JSON.parse(await readFile(current)) : EMPTY;
}
```
### 2 — One final Tauri release: v0.12.0-migrate
This release uses the existing Tauri updater. Users click "Install update"
as they always do. What v0.12.0-migrate ships:
1. A single new command `migrate_to_electron()` in the Tauri shell that:
- Downloads the matching Electron installer from the same GitHub Release
(`OpenWork-0.12.0-mac-<arch>.dmg`, `.exe`, `.AppImage`).
- Verifies signature via OS-native tools (`codesign --verify --deep --strict`
on mac, Authenticode on Windows, minisign or GH attestations on Linux).
- Opens the installer and schedules Tauri quit.
2. A one-time prompt:
> OpenWork is moving to a new desktop engine. We'll install the new
> version and keep all your workspaces. ~30 seconds.
> [Install now] [Later]
"Later" defers 24h once, then force-installs on next launch — no
indefinite stragglers.
3. `tauri.conf.json.version``0.12.0`, `latest.json.version``0.12.0`,
minisign-signed as usual. Installed = still a Tauri binary, but whose
only remaining job is to launch the Electron installer.
This is the only new Tauri release required. After v0.12.0 we stop
publishing `latest.json` updates.
### 3 — Flow (ASCII)
```
Tauri v0.11.x
│ (normal Tauri updater poll)
latest.json says 0.12.0 is out → DMG installed in-place → Tauri v0.12.0-migrate
│ on first launch shows migration prompt
migrate_to_electron():
download OpenWork-0.12.0-electron-mac.dmg from same release
codesign --verify ✓
open installer, schedule Tauri quit
Installer replaces the .app bundle
appId = com.differentai.openwork (same as Tauri)
Launchpad slot + Dock pin preserved, no duplicate "OpenWork" icon
OpenWork Electron v0.12.0 first launch
app.setPath("userData", .../com.differentai.openwork) points at the
Tauri-written folder → tokens, workspaces, orchestrator state already there
rename openwork-workspaces.json → workspace-state.json (once)
electron-updater now owns the feed (latest-mac.yml, latest.yml, latest-linux.yml)
every future release is an Electron-only .dmg / .exe / .AppImage
```
### 4 — Post-migration auto-updater
Use `electron-updater` (ships with `electron-builder`) against the same
GitHub release stream:
```yaml
# apps/desktop/electron-builder.yml
publish:
- provider: github
owner: different-ai
repo: openwork
releaseType: release
mac:
notarize: true # reuse existing Apple Developer ID
icon: src-tauri/icons/icon.icns
win:
sign: ./scripts/sign-windows.mjs # reuse existing EV cert
icon: src-tauri/icons/icon.ico
```
Runtime:
```js
import { autoUpdater } from "electron-updater";
autoUpdater.channel = app.isPackaged ? (releaseChannel ?? "latest") : "latest";
autoUpdater.autoDownload = prefs.updateAutoDownload;
autoUpdater.checkForUpdatesAndNotify();
```
Alpha/beta channels reuse the existing alpha GitHub release (the current
`alpha-macos-aarch64.yml` workflow publishes to `alpha-macos-latest`;
switch its `generate-latest-json.mjs` step to emit `latest-mac.yml` instead).
Delta updates: electron-updater's block-map diffs drop a typical mac update
from ~120MB full bundle to ~5-20MB. A net win over Tauri's no-delta default.
### 5 — Release-engineering changes
- `Release App` workflow:
- Replaces `tauri build` with `pnpm --filter @openwork/desktop package:electron`.
- Uploads DMG + zip + `latest-mac.yml` + `latest.yml` + `latest-linux.yml`
to the same GitHub release asset list.
- Keeps publishing minisign-signed `latest.json` for the v0.12.0 release
only (so current Tauri users can pick up the migration update). After
that release, stop updating `latest.json`.
- `build-electron-desktop.yml` (already scaffolded in this PR): flip to a
required check once the migration release is in flight.
### 6 — Rollout
| Stage | Audience | What ships |
| ------- | ------------------- | --------------------------------------------------------------------------- |
| Week 0 | this PR merged | Electron co-exists, Tauri is default, no user impact |
| Week 1 | internal | Dogfood `pnpm dev:electron` on same data dir as Tauri |
| Week 2 | alpha channel | First real Electron release via alpha updater. Only opt-in alpha users get it. |
| Week 3 | stable — v0.12.0 | Migration release. Tauri prompt → Electron install → back online, same data.|
| Week 4+ | stable — v0.12.x | Electron-only. Tauri `latest.json` frozen. |
### 7 — Rollback
- Users already on Electron: ship `0.12.1` through `electron-updater`. Same
mechanism as any normal update.
- Users still on Tauri: they never received the migration prompt; they stay
on Tauri. Pull `latest.json` if there's a systemic issue.
- Users mid-migration: Tauri is only quit *after* the Electron installer
finishes writing the new bundle. If the installer aborts, Tauri remains
the working app until the user retries.
### 8 — Risks and mitigations
- **Bundle-identifier drift**. If Electron `appId` is different from Tauri,
macOS treats it as a separate app (new Launchpad icon, new Gatekeeper
prompt, new TCC permissions). Fixed in step 1 by unifying to
`com.differentai.openwork`.
- **Notarization / signing**. Electron builds need Apple Developer ID +
notarization for the same team. Reusing the existing Tauri CI secrets
(`APPLE_CERTIFICATE`, `APPLE_API_KEY`, etc.) makes this a config change
rather than a new credential story.
- **Electron bundle size**. First Electron update is ~120MB vs ~20MB today.
Mac universal build keeps it to one download per platform. Future deltas
via block-map diffs recover most of the gap.
- **Third-party integrations depending on the Tauri identifier** (Sparkle,
crash reporters, etc.): none in the current build, so zero action.
### 9 — Concrete next-step PRs (order matters)
1. **This PR** (#1522) — Electron shell lives side-by-side. No user impact.
2. **Follow-up PR**: "unify app identity with Tauri". Flips `appId` to
`com.differentai.openwork`, points `userData` at the Tauri folder, adds
the `openwork-workspaces.json``workspace-state.json` rename.
3. **Follow-up PR**: "electron-updater + release workflow". Wires
electron-builder `publish:` config, teaches `Release App` to emit the
electron-updater feed manifests.
4. **Final PR**: "last Tauri release v0.12.0". Ships the Tauri
`migrate_to_electron` command + prompt. Triggers migration for every
existing user on their next Tauri update.
After (4) lands and rolls out, flip the default
`apps/desktop/package.json` scripts so `dev` / `build` / `package` use
Electron, and delete `src-tauri/`.