mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
61
.github/workflows/build-electron-desktop.yml
vendored
Normal file
61
.github/workflows/build-electron-desktop.yml
vendored
Normal 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/**
|
||||
@@ -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): {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
1010
apps/app/src/app/lib/desktop-tauri.ts
Normal file
1010
apps/app/src/app/lib/desktop-tauri.ts
Normal file
File diff suppressed because it is too large
Load Diff
311
apps/app/src/app/lib/desktop.ts
Normal file
311
apps/app/src/app/lib/desktop.ts
Normal 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,
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 l’URL 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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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サーバーワークスペースでのみ変更できます。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 ที่เขียนได้",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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服务器工作区。",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
29
apps/desktop/electron-builder.yml
Normal file
29
apps/desktop/electron-builder.yml
Normal 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}
|
||||
1215
apps/desktop/electron/main.mjs
Normal file
1215
apps/desktop/electron/main.mjs
Normal file
File diff suppressed because it is too large
Load Diff
33
apps/desktop/electron/preload.mjs
Normal file
33
apps/desktop/electron/preload.mjs
Normal 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 }));
|
||||
});
|
||||
1550
apps/desktop/electron/runtime.mjs
Normal file
1550
apps/desktop/electron/runtime.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
39
apps/desktop/scripts/electron-build.mjs
Normal file
39
apps/desktop/scripts/electron-build.mjs
Normal 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`,
|
||||
);
|
||||
204
apps/desktop/scripts/electron-dev.mjs
Normal file
204
apps/desktop/scripts/electron-dev.mjs
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
2335
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
434
prds/electron-1-1-port-audit.md
Normal file
434
prds/electron-1-1-port-audit.md
Normal 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.
|
||||
253
prds/electron-migration-plan.md
Normal file
253
prds/electron-migration-plan.md
Normal 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/`.
|
||||
Reference in New Issue
Block a user