refactor: remove legacy Tauri desktop support
Electron updater now uses Electron release metadata only Removed legacy Tauri package and migration workflow Replaced Tauri shim usage with the desktop bridge
This commit is contained in:
@@ -171,7 +171,7 @@ For any shared UI call to `/api/*`, decide the VS Code behavior explicitly:
|
||||
Electron exposes API base and shell identity broadly, but privileged local capabilities stay local-only.
|
||||
|
||||
- `__OPENCHAMBER_API_BASE_URL__` and `__OPENCHAMBER_LOCAL_ORIGIN__` route requests.
|
||||
- `__OPENCHAMBER_CLIENT_TOKEN__`, `__OPENCHAMBER_HOME__`, and `__TAURI__`-style IPC are local-page gated.
|
||||
- `__OPENCHAMBER_CLIENT_TOKEN__`, `__OPENCHAMBER_HOME__`, and privileged desktop IPC are local-page gated.
|
||||
- Do not expose filesystem, shell, or host secrets to remote pages for UI convenience.
|
||||
- Do not trust arbitrary loopback, `file://`, or `about:blank` origins as local UI. Gate privileged preload/IPC/token access to the packaged UI origin and exact runtime origins.
|
||||
- Deep-links that add or switch remote runtimes are trust-boundary changes. Confirm before storing tokens or switching hosts.
|
||||
@@ -274,9 +274,9 @@ If an OpenChamber route is consumed directly by the browser with `oc_url_token`,
|
||||
|
||||
`packages/electron/main.mjs` starts the web server in-process, resolves local/remote runtime target, tracks `apiBaseUrl` and `clientToken`, injects init scripts, confirms remote connect deep-links before storing tokens, and handles host switching.
|
||||
|
||||
`packages/electron/preload.mjs` exposes runtime globals. API base and local origin are broadly available for routing. Client token, home directory, and `__TAURI__` IPC stay local-page gated so remote pages cannot access local host capabilities.
|
||||
`packages/electron/preload.mjs` exposes runtime globals. API base and local origin are broadly available for routing. Client token, home directory, and privileged desktop IPC stay local-page gated so remote pages cannot access local host capabilities.
|
||||
|
||||
Shared UI should not branch on Electron for backend behavior. Prefer web runtime APIs and the preload-provided `__TAURI__` compatibility shim only for shell capabilities that already exist in the shared runtime contract.
|
||||
Shared UI should not branch on Electron for backend behavior. Prefer web runtime APIs and the `__OPENCHAMBER_DESKTOP__` bridge only for shell capabilities that already exist in the shared runtime contract.
|
||||
|
||||
### Runtime Switch Flow
|
||||
|
||||
|
||||
@@ -242,32 +242,6 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Stage signed Electron app for Tauri updater repackage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="packages/electron/dist/mac"
|
||||
[ -d "packages/electron/dist/mac-arm64" ] && APP_DIR="packages/electron/dist/mac-arm64"
|
||||
|
||||
APP_PATH=$(find "$APP_DIR" -maxdepth 2 -name "*.app" -print -quit)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "Error: .app not found under packages/electron/dist/mac*"
|
||||
ls -la packages/electron/dist/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf electron-app-artifact
|
||||
mkdir -p electron-app-artifact
|
||||
cp -R "$APP_PATH" electron-app-artifact/OpenChamber.app
|
||||
tar -C electron-app-artifact -czf electron-app-${{ matrix.arch }}.tar.gz OpenChamber.app
|
||||
|
||||
- name: Upload signed Electron app for Tauri updater repackage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: electron-app-${{ matrix.arch }}
|
||||
path: electron-app-${{ matrix.arch }}.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload per-arch latest-mac.yml for merge
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -338,179 +312,6 @@ jobs:
|
||||
path: packages/electron/dist/latest.yml
|
||||
retention-days: 1
|
||||
|
||||
repackage-electron-as-tauri-update:
|
||||
needs: [create-release, build-desktop-electron-macos]
|
||||
runs-on: macos-26
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
platform: darwin-aarch64
|
||||
- arch: x64
|
||||
platform: darwin-x86_64
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Download signed Electron app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: electron-app-${{ matrix.arch }}
|
||||
path: staged
|
||||
|
||||
- name: Tar and sign Electron app as Tauri update payload
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -f staged/electron-app-${{ matrix.arch }}.tar.gz ]; then
|
||||
tar -C staged -xzf staged/electron-app-${{ matrix.arch }}.tar.gz
|
||||
elif [ ! -d staged/OpenChamber.app ] && [ -d staged/Contents ]; then
|
||||
mkdir -p staged/OpenChamber.app
|
||||
mv staged/Contents staged/OpenChamber.app/Contents
|
||||
fi
|
||||
|
||||
if [ ! -d staged/OpenChamber.app ]; then
|
||||
echo "Error: staged/OpenChamber.app not found"
|
||||
ls -la staged
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_EXECUTABLE=$(find staged/OpenChamber.app/Contents/MacOS -type f -maxdepth 1 -print -quit)
|
||||
if [ -z "$APP_EXECUTABLE" ] || [ ! -x "$APP_EXECUTABLE" ]; then
|
||||
echo "Error: Electron app executable is missing or not executable"
|
||||
ls -la staged/OpenChamber.app/Contents/MacOS
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd staged
|
||||
TARBALL="OpenChamber.app.tar.gz"
|
||||
tar -czf "$TARBALL" OpenChamber.app
|
||||
|
||||
bun run --cwd ../packages/desktop tauri signer sign "$PWD/$TARBALL"
|
||||
|
||||
mv "$TARBALL" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz"
|
||||
mv "${TARBALL}.sig" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig"
|
||||
|
||||
- name: Generate Tauri latest platform manifest
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
SIG=$(cat staged/OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig)
|
||||
TAR="OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz"
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg notes "OpenChamber has moved to Electron. This update replaces the Tauri shell with the Electron build. Subsequent updates will be delivered via the Electron auto-updater." \
|
||||
--arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg platform "${{ matrix.platform }}" \
|
||||
--arg signature "$SIG" \
|
||||
--arg url "https://github.com/${REPO}/releases/download/v${VERSION}/${TAR}" \
|
||||
'{ version: $version, notes: $notes, pub_date: $pub_date, platforms: { ($platform): { signature: $signature, url: $url } } }' \
|
||||
> staged/latest-${{ matrix.platform }}.json
|
||||
|
||||
- name: Upload tarball and signature to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.create-release.outputs.version }}
|
||||
files: |
|
||||
staged/*.app.tar.gz
|
||||
staged/*.app.tar.gz.sig
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload per-platform Tauri manifest for merge
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: tauri-manifest-${{ matrix.platform }}
|
||||
path: staged/latest-${{ matrix.platform }}.json
|
||||
retention-days: 1
|
||||
|
||||
combine-manifests:
|
||||
needs: [create-release, repackage-electron-as-tauri-update]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Tauri updater manifests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: tauri-manifest-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Combine manifests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${{ needs.create-release.outputs.version }}"
|
||||
PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
if [ ! -f artifacts/latest-darwin-aarch64.json ]; then
|
||||
echo "Error: aarch64 manifest not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f artifacts/latest-darwin-x86_64.json ]; then
|
||||
echo "Error: x86_64 manifest not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq empty artifacts/latest-darwin-aarch64.json 2>/dev/null; then
|
||||
echo "Error: aarch64 manifest is not valid JSON"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq empty artifacts/latest-darwin-x86_64.json 2>/dev/null; then
|
||||
echo "Error: x86_64 manifest is not valid JSON"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.platforms["darwin-aarch64"]' artifacts/latest-darwin-aarch64.json > /dev/null; then
|
||||
echo "Error: darwin-aarch64 platform data not found in manifest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.platforms["darwin-x86_64"]' artifacts/latest-darwin-x86_64.json > /dev/null; then
|
||||
echo "Error: darwin-x86_64 platform data not found in manifest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg notes "OpenChamber has moved to Electron. This update replaces the Tauri shell with the Electron build. Subsequent updates will be delivered via the Electron auto-updater." \
|
||||
--arg pub_date "$PUB_DATE" \
|
||||
--slurpfile aarch64 artifacts/latest-darwin-aarch64.json \
|
||||
--slurpfile x86_64 artifacts/latest-darwin-x86_64.json \
|
||||
'{
|
||||
version: $version,
|
||||
notes: $notes,
|
||||
pub_date: $pub_date,
|
||||
platforms: {
|
||||
"darwin-aarch64": $aarch64[0].platforms["darwin-aarch64"],
|
||||
"darwin-x86_64": $x86_64[0].platforms["darwin-x86_64"]
|
||||
}
|
||||
}' > artifacts/latest.json
|
||||
|
||||
cat artifacts/latest.json
|
||||
|
||||
- name: Upload combined Tauri updater manifest
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.create-release.outputs.version }}
|
||||
files: artifacts/latest.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
combine-electron-manifests:
|
||||
needs: [create-release, build-desktop-electron-macos]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -545,7 +346,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
finalize-release:
|
||||
needs: [create-release, build-desktop-electron-macos, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests]
|
||||
needs: [create-release, build-desktop-electron-macos, publish-npm, combine-electron-manifests]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
|
||||
@@ -49,7 +49,7 @@ Only use labels that already exist in this repository. Do not create labels.
|
||||
| `area:sync` | State sync, cross-runtime consistency |
|
||||
| `area:auth` | Authentication, passwords, OAuth, tunnels |
|
||||
| `area:installation` | Install, Docker, Nix, deployment |
|
||||
| `area:desktop` | Desktop shell (Electron/Tauri), window management |
|
||||
| `area:desktop` | Desktop shell (Electron), window management |
|
||||
| `area:keyboard` | Keyboard shortcuts, keybinds, input handling |
|
||||
| `area:permissions` | Permission prompts, allow/deny flows |
|
||||
| `area:compact` | Context compaction, /compact command |
|
||||
@@ -63,7 +63,7 @@ Only use labels that already exist in this repository. Do not create labels.
|
||||
| Label | Covers |
|
||||
|---|---|
|
||||
| `platform:web` | Desktop web browser (incl. CLI serve) |
|
||||
| `platform:macos` | macOS desktop (Electron/Tauri) |
|
||||
| `platform:macos` | macOS desktop (Electron) |
|
||||
| `platform:linux` | Linux desktop |
|
||||
| `platform:windows` | Windows desktop / WSL |
|
||||
| `platform:mobile` | Mobile web/PWA (iOS/Android) |
|
||||
@@ -105,4 +105,3 @@ For each issue:
|
||||
- Add a small set of accurate existing labels following the steps above.
|
||||
- In a single comment summarize the issue and ask the reporter for any additional information needed to complete the request.
|
||||
- Keep the comment friendly and concise.
|
||||
|
||||
|
||||
@@ -7,18 +7,15 @@ OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an O
|
||||
## Runtime architecture (IMPORTANT)
|
||||
|
||||
- `Desktop` (Electron) boots the web server **in the same Node process** as the Electron main, then loads the web UI from `http://127.0.0.1:<port>`. No sidecar subprocess.
|
||||
- `Desktop` (Tauri, legacy) still spawns `openchamber-server` as a bun-compiled sidecar binary. Kept only for auto-update compatibility with existing Tauri installs.
|
||||
- Backend/domain logic lives in `packages/web/server/*` (and `packages/vscode/*` for VS Code bridge/runtime parity). Electron owns the desktop shell/security boundary: windows, menus, dialogs, notifications, updater, deep-links, runtime host switching, local IPC gates, and SSH/tunnel management.
|
||||
- Do not add OpenCode feature backends to the native shell. Shared UI features should remain server/runtime APIs unless the capability is inherently native.
|
||||
|
||||
### Desktop shell: Electron is the target, Tauri is legacy
|
||||
### Desktop Shell
|
||||
|
||||
- **New desktop work goes into `packages/electron/`.** This is the forward path.
|
||||
- `packages/desktop/` (Tauri) is kept running in parallel only to preserve auto-update for existing installs until the cutover. Do **not** add features to it; do **not** port bug fixes back unless they actually affect currently-released Tauri users.
|
||||
- Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in `packages/electron/main.mjs` + `packages/electron/preload.mjs`. The `__TAURI__` shim exposed by the preload keeps the shared UI working against both shells, so renderer-side code should not branch on shell type.
|
||||
- **Desktop work goes into `packages/electron/`.**
|
||||
- Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in `packages/electron/main.mjs` + `packages/electron/preload.mjs`.
|
||||
- Electron imports the server via `@openchamber/web/server/index.js` (workspace dep) and calls `startWebUiServer({...})`. The returned handle has `getPort()` / `stop()`. Notifications flow via an `onDesktopNotification` callback injected at startup — no stdout-parsing IPC.
|
||||
- Build/release: Electron is the release target. The release workflow also repackages the signed Electron app as a Tauri updater payload for the one-shot migration path documented in `docs/TAURI_TO_ELECTRON_CUTOVER.md`.
|
||||
- After the cutover ships and stabilises, `packages/desktop/` is deleted; this note collapses back to "Desktop is Electron".
|
||||
- Build/release: Electron is the desktop release target.
|
||||
|
||||
## Tech stack (source of truth: `package.json`, resolved: `bun.lock`)
|
||||
|
||||
@@ -27,8 +24,7 @@ OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an O
|
||||
- State: Zustand stores and sync layer (`packages/ui/src/stores/`, `packages/ui/src/sync/`)
|
||||
- UI primitives: Base UI (`@base-ui/react`, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in `packages/ui/src/components/ui/`), Radix UI (`package.json` deps, legacy usages being migrated), HeroUI (`package.json` deps), Remixicon as SVG sprite source only (use shared `Icon`, never direct `@remixicon/react` imports)
|
||||
- Server: Express (`packages/web/server/index.js`)
|
||||
- Desktop (forward): Electron 41 (`packages/electron/`)
|
||||
- Desktop (legacy, maintenance-only): Tauri v2 (`packages/desktop/src-tauri/`)
|
||||
- Desktop: Electron 41 (`packages/electron/`)
|
||||
- VS Code: extension + webview (`packages/vscode/`)
|
||||
|
||||
## Monorepo layout
|
||||
@@ -37,8 +33,7 @@ Workspaces are `packages/*` (see `package.json`).
|
||||
|
||||
- Shared UI: `packages/ui`
|
||||
- Web app + server + CLI: `packages/web`
|
||||
- Desktop shell (Electron — forward): `packages/electron`
|
||||
- Desktop shell (Tauri — legacy, maintenance-only): `packages/desktop`
|
||||
- Desktop shell: `packages/electron`
|
||||
- VS Code extension: `packages/vscode`
|
||||
|
||||
## Documentation map
|
||||
@@ -173,7 +168,6 @@ All scripts are in `package.json`.
|
||||
- Build all: `bun run build`
|
||||
- Desktop build (Electron — primary): `bun run electron:build`
|
||||
- Desktop dev (Electron): `bun run electron:dev`
|
||||
- Desktop build (Tauri — legacy): `bun run desktop:build`
|
||||
- VS Code build: `bun run vscode:build`
|
||||
- Release smoke build: `bun run release:test` (shell script: `scripts/test-release-build.sh`)
|
||||
|
||||
@@ -182,8 +176,7 @@ All scripts are in `package.json`.
|
||||
- Web bootstrap: `packages/web/src/main.tsx`
|
||||
- Web server: `packages/web/server/index.js`
|
||||
- Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`)
|
||||
- Desktop (Electron — primary): `packages/electron/main.mjs` (boots the web server in-process via `startWebUiServer`, loads web UI over loopback; preload at `packages/electron/preload.mjs` exposes the `__TAURI__` IPC shim so shared UI code is shell-agnostic)
|
||||
- Desktop (Tauri — legacy): `packages/desktop/src-tauri/src/main.rs`
|
||||
- Desktop: `packages/electron/main.mjs` (boots the web server in-process via `startWebUiServer`, loads web UI over loopback; preload at `packages/electron/preload.mjs` exposes the desktop IPC bridge)
|
||||
- VS Code extension host: `packages/vscode/src/extension.ts`
|
||||
- VS Code webview bootstrap: `packages/vscode/webview/main.tsx`
|
||||
|
||||
|
||||
+4
-4
@@ -19,13 +19,13 @@ bun install
|
||||
|
||||
Both are configurable via env vars: `OPENCHAMBER_PORT`, `OPENCHAMBER_HMR_UI_PORT`, `OPENCHAMBER_HMR_API_PORT`.
|
||||
|
||||
### Desktop (Tauri)
|
||||
### Desktop (Electron)
|
||||
|
||||
```bash
|
||||
bun run desktop:dev
|
||||
bun run electron:dev
|
||||
```
|
||||
|
||||
Launches Tauri in dev mode with WebView devtools enabled and a distinct dev icon.
|
||||
Launches the Electron desktop shell in dev mode.
|
||||
|
||||
### VS Code Extension
|
||||
|
||||
@@ -72,7 +72,7 @@ bun run build # Must succeed
|
||||
packages/
|
||||
ui/ Shared React components, hooks, stores, and theme system
|
||||
web/ Web server (Express) + frontend (Vite) + CLI
|
||||
desktop/ Tauri macOS app (thin shell around the web UI)
|
||||
electron/ Electron desktop shell
|
||||
vscode/ VS Code extension (extension host + webview)
|
||||
```
|
||||
|
||||
|
||||
@@ -419,7 +419,6 @@ Independent project, not affiliated with the OpenCode team.
|
||||
- [OpenCode](https://opencode.ai) - For the excellent API and extensible architecture.
|
||||
- [Flexoki](https://github.com/kepano/flexoki) - Beautiful color scheme by [Steph Ango](https://stephango.com/flexoki).
|
||||
- [Pierre](https://pierrejs-docs.vercel.app/) - Fast, beautiful diff viewer with syntax highlighting.
|
||||
- [Tauri](https://github.com/tauri-apps/tauri) - Desktop application framework.
|
||||
- [Ghostty-web](https://github.com/coder/ghostty-web) - Great implementation of a Ghostty web renderer.
|
||||
- [David Hill](https://x.com/iamdavidhill) - Who inspired me to release this without [overthinking](https://x.com/iamdavidhill/status/1993648326450020746).
|
||||
- [My wife](https://github.com/yulia-ivashko), who - with zero AI background - sat down with the app for the first time and built the firework celebration that plays on every successful push.
|
||||
|
||||
@@ -96,18 +96,9 @@
|
||||
"vite": "^7.1.2",
|
||||
},
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@openchamber/desktop",
|
||||
"version": "1.11.7",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^24.3.1",
|
||||
"typescript": "~5.8.3",
|
||||
},
|
||||
},
|
||||
"packages/electron": {
|
||||
"name": "@openchamber/electron",
|
||||
"version": "1.11.7",
|
||||
"version": "1.12.0",
|
||||
"dependencies": {
|
||||
"@openchamber/web": "workspace:*",
|
||||
"electron-context-menu": "^4.1.2",
|
||||
@@ -122,7 +113,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.11.7",
|
||||
"version": "1.12.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
@@ -197,7 +188,6 @@
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@remixicon/react": "^4.7.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -223,7 +213,7 @@
|
||||
},
|
||||
"packages/vscode": {
|
||||
"name": "openchamber",
|
||||
"version": "1.11.7",
|
||||
"version": "1.12.0",
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.15.10",
|
||||
@@ -246,7 +236,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.11.7",
|
||||
"version": "1.12.0",
|
||||
"bin": {
|
||||
"openchamber": "./bin/cli.js",
|
||||
},
|
||||
@@ -942,8 +932,6 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@openchamber/desktop": ["@openchamber/desktop@workspace:packages/desktop"],
|
||||
|
||||
"@openchamber/electron": ["@openchamber/electron@workspace:packages/electron"],
|
||||
|
||||
"@openchamber/ui": ["@openchamber/ui@workspace:packages/ui"],
|
||||
@@ -1254,32 +1242,6 @@
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-x64-msvc": "2.10.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.0", "", { "os": "linux", "cpu": "arm" }, "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.0", "", { "os": "linux", "cpu": "none" }, "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.0", "", { "os": "win32", "cpu": "x64" }, "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ=="],
|
||||
|
||||
"@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="],
|
||||
|
||||
"@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="],
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
# Tauri → Electron auto-update cutover
|
||||
|
||||
> Self-contained playbook. The branch and conversation where this plan was
|
||||
> designed will not be around when the cutover happens — read this file top to
|
||||
> bottom and execute; do not assume prior context.
|
||||
|
||||
> Current status: the release workflow cutover is implemented. Desktop releases
|
||||
> now build Electron and repackage that Electron `.app` into the old Tauri
|
||||
> updater format for existing Tauri installs. The next safe engineering step is
|
||||
> [Step 5 — Remove Tauri-specific code](#step-5--remove-tauri-specific-code),
|
||||
> but only after the transition release has shipped and lived for at least 2
|
||||
> weeks with no rollback.
|
||||
|
||||
## What this is
|
||||
|
||||
OpenChamber historically shipped as a Tauri app. A parallel Electron shell was
|
||||
added on branch `electron-app` (merged to `main` as part of a larger migration).
|
||||
Since then, both desktop shells have been released in the same GitHub release
|
||||
and each has its own auto-update channel:
|
||||
|
||||
| Shell | Manifest | Update format | Secret used to sign |
|
||||
|----------|-------------------|---------------------|---------------------|
|
||||
| Tauri | `latest.json` | `.tar.gz` + `.sig` | `TAURI_SIGNING_PRIVATE_KEY` (Tauri signer / minisign format) |
|
||||
| Electron | `latest-mac.yml` | `.zip` + `blockmap` | Developer ID codesign (APPLE_* secrets) |
|
||||
|
||||
Existing Tauri installs keep their own auto-update path (`latest.json`).
|
||||
Electron installs auto-update through `latest-mac.yml`. They coexist without
|
||||
conflict because filenames and manifests differ.
|
||||
|
||||
At some point the user wants to **stop maintaining the Tauri build** and make
|
||||
the Tauri installs migrate themselves into Electron via auto-update. This
|
||||
document describes how to do that in a single "transition release".
|
||||
|
||||
## The core trick
|
||||
|
||||
Tauri's updater downloads whatever `.tar.gz` the `latest.json` points at,
|
||||
verifies the minisign signature, unpacks the contents **over** the existing
|
||||
`.app` directory, and restarts. It does **not** introspect the payload — it
|
||||
just replaces files.
|
||||
|
||||
So: produce a `.tar.gz` of the Electron `.app`, sign it with the existing
|
||||
Tauri minisign key, point `latest.json` at it. Tauri users receive the update,
|
||||
their `OpenChamber.app` becomes the Electron bundle in-place, and next launch
|
||||
starts Electron. Subsequent updates go through `latest-mac.yml`
|
||||
(electron-updater). One-way migration, one-shot workflow change.
|
||||
|
||||
## Prerequisites before running the cutover
|
||||
|
||||
Check all of these before making any release:
|
||||
|
||||
1. **Electron has shipped stable through its own `latest-mac.yml` path for at
|
||||
least 2 releases.** Verify:
|
||||
```
|
||||
gh release list --repo btriapitsyn/openchamber
|
||||
gh release view vX.Y.Z --repo btriapitsyn/openchamber \
|
||||
| grep -E 'OpenChamber-.*\.zip|latest-mac\.yml'
|
||||
```
|
||||
A user on Electron should have successfully auto-updated at least once.
|
||||
If not, pause and stabilise that path first — don't stack risk.
|
||||
|
||||
2. **`~/.config/openchamber/settings.json` is still the shared state path.**
|
||||
Tauri `src-tauri/src/main.rs:settings_file_path` and Electron
|
||||
`packages/electron/main.mjs:settingsFilePath` must both resolve to
|
||||
`$HOME/.config/openchamber/settings.json`. If either has moved, data parity
|
||||
breaks and this migration loses user data. Audit both paths, update the
|
||||
non-migrated shell to match before proceeding.
|
||||
|
||||
3. **Electron `appId` is `dev.openchamber.desktop`** (check
|
||||
`packages/electron/package.json` `build.appId`). Tauri identifier is
|
||||
`ai.opencode.openchamber`. These differ intentionally — it means macOS
|
||||
LaunchServices will re-register after the in-place replace. That's fine but
|
||||
see "Risks" below.
|
||||
|
||||
4. **All GitHub secrets still valid:** `APPLE_CERTIFICATE`,
|
||||
`APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`,
|
||||
`TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. A
|
||||
workflow_dispatch dry-run should succeed before the real tag.
|
||||
|
||||
5. **`minisign` CLI is available on the macOS runner** (or installable via
|
||||
brew). Used to sign the Electron tarball with the Tauri key.
|
||||
|
||||
## Current release workflow
|
||||
|
||||
The release workflow no longer builds a Tauri desktop app. It now does this:
|
||||
|
||||
```text
|
||||
create-release
|
||||
├── build-desktop-electron-macos (Electron .dmg/.zip/blockmap/latest-mac.yml)
|
||||
├── repackage-electron-as-tauri-update (Electron .app -> Tauri .app.tar.gz/.sig)
|
||||
├── publish-npm
|
||||
├── combine-manifests (Tauri latest.json for migration only)
|
||||
├── combine-electron-manifests (Electron latest-mac.yml)
|
||||
└── finalize-release
|
||||
```
|
||||
|
||||
The transition works like this:
|
||||
|
||||
1. `build-desktop-electron-macos` builds, signs, and notarizes the Electron app.
|
||||
2. It wraps the signed `OpenChamber.app` in a tarball and uploads that tarball
|
||||
as a short-lived Actions artifact.
|
||||
3. `repackage-electron-as-tauri-update` downloads that Electron `.app`.
|
||||
4. It packs it into `OpenChamber-<version>-darwin-*.app.tar.gz`.
|
||||
5. It signs that tarball with `tauri signer sign` and the existing Tauri signing key.
|
||||
6. It uploads the tarball and `.sig` to the GitHub release.
|
||||
7. It generates Tauri-compatible manifests and `combine-manifests` merges them into `latest.json`.
|
||||
|
||||
So old Tauri installs still see the update contract they expect:
|
||||
|
||||
```text
|
||||
latest.json -> .app.tar.gz -> .sig
|
||||
```
|
||||
|
||||
But the payload inside the `.app.tar.gz` is Electron, not Tauri. Tauri's updater
|
||||
only verifies the signature and extracts the bundle over the existing
|
||||
`/Applications/OpenChamber.app`. After restart, the app is Electron and future
|
||||
updates use `latest-mac.yml` through `electron-updater`.
|
||||
|
||||
Note: do not upload the `.app` directory directly with `actions/upload-artifact`.
|
||||
That action can flatten the app to its inner `Contents/` folder and can also
|
||||
normalize file modes. Losing the executable bit on `Contents/MacOS/*` makes the
|
||||
updated app fail to launch with a permissions/package error. The workflow wraps
|
||||
the `.app` in a tarball before upload so permissions survive the handoff between
|
||||
jobs, then verifies the app executable is still executable before creating the
|
||||
Tauri updater tarball.
|
||||
|
||||
## Historical release workflow changes
|
||||
|
||||
The file edited for the cutover was `.github/workflows/release.yml`.
|
||||
|
||||
Before the cutover it had these jobs (simplified):
|
||||
|
||||
```
|
||||
create-release
|
||||
├── build-desktop-macos (Tauri .dmg/.tar.gz/.tar.gz.sig)
|
||||
├── build-desktop-electron-macos (Electron .dmg/.zip/blockmap/latest-mac.yml)
|
||||
├── publish-npm
|
||||
├── combine-manifests (merges Tauri per-arch JSONs → latest.json)
|
||||
├── combine-electron-manifests (merges Electron per-arch YMLs → latest-mac.yml)
|
||||
└── finalize-release
|
||||
```
|
||||
|
||||
### Step 1 — Remove the Tauri build
|
||||
|
||||
Status: done.
|
||||
|
||||
Delete these jobs entirely:
|
||||
- `build-desktop-macos`
|
||||
- `combine-manifests`
|
||||
|
||||
They are replaced by the repackage job (below). `finalize-release` `needs:`
|
||||
list must be updated to drop both.
|
||||
|
||||
### Step 2 — Add a repackage job
|
||||
|
||||
Status: done.
|
||||
|
||||
Insert after `build-desktop-electron-macos`:
|
||||
|
||||
```yaml
|
||||
repackage-electron-as-tauri-update:
|
||||
needs: [create-release, build-desktop-electron-macos]
|
||||
runs-on: macos-26
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
platform: darwin-aarch64
|
||||
- arch: x64
|
||||
platform: darwin-x86_64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# Pull the signed+notarized Electron .app that build-desktop-electron-macos
|
||||
# already produced. Either re-download the dmg and mount+copy the .app, or
|
||||
# (cleaner) modify build-desktop-electron-macos to upload the .app itself
|
||||
# as an artifact so this job can download it. Prefer the latter — adds one
|
||||
# `actions/upload-artifact@v4` step uploading `packages/electron/dist/mac-<arch>/OpenChamber.app`.
|
||||
|
||||
- name: Download signed Electron .app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: electron-app-${{ matrix.arch }}
|
||||
path: staged
|
||||
|
||||
- name: Tar and sign Electron .app as Tauri update payload
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd staged
|
||||
# The tarball name convention Tauri's updater expects. Must end in
|
||||
# `.app.tar.gz`. Name stays stable — Tauri updater does not care about
|
||||
# the inner .app name.
|
||||
TARBALL="OpenChamber.app.tar.gz"
|
||||
tar -czf "$TARBALL" OpenChamber.app
|
||||
|
||||
# Use Tauri's signer instead of minisign directly. The CI secret is in
|
||||
# the format consumed by TAURI_SIGNING_PRIVATE_KEY.
|
||||
bun run --cwd ../packages/desktop tauri signer sign "$PWD/$TARBALL"
|
||||
|
||||
# Rename per platform so the release has distinct names for arm64/x64.
|
||||
mv "$TARBALL" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz"
|
||||
mv "${TARBALL}.sig" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig"
|
||||
|
||||
- name: Generate Tauri latest-<platform>.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
SIG=$(cat staged/OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig)
|
||||
TAR=OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz
|
||||
cat > staged/latest-${{ matrix.platform }}.json <<EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "OpenChamber has moved to Electron. This update replaces the Tauri shell with the Electron build. Subsequent updates will be delivered via the Electron auto-updater.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"${{ matrix.platform }}": {
|
||||
"signature": "${SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/v${VERSION}/${TAR}"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Upload tarball + sig to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.create-release.outputs.version }}
|
||||
files: |
|
||||
staged/*.app.tar.gz
|
||||
staged/*.app.tar.gz.sig
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload per-platform manifest as artifact for merge
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tauri-manifest-${{ matrix.platform }}
|
||||
path: staged/latest-${{ matrix.platform }}.json
|
||||
retention-days: 1
|
||||
```
|
||||
|
||||
### Step 3 — Re-add the `combine-manifests` job
|
||||
|
||||
Status: done.
|
||||
|
||||
Bring it back (it was deleted in Step 1) but sourcing artifacts from the
|
||||
repackage job instead of the old Tauri build. The merging logic is identical
|
||||
to what the old job did. Minimum job shape:
|
||||
|
||||
```yaml
|
||||
combine-manifests:
|
||||
needs: [create-release, repackage-electron-as-tauri-update]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: tauri-manifest-*
|
||||
path: artifacts
|
||||
- name: Merge
|
||||
run: |
|
||||
# Copy the original merge logic from git history. It takes the two
|
||||
# per-platform JSONs and produces a single `latest.json` with both
|
||||
# platform entries. Upload as a release asset.
|
||||
# Search git history: git log --all --diff-filter=D -- .github/workflows/release.yml
|
||||
# Find the commit that deleted the old merge step and copy its shell block.
|
||||
...
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.create-release.outputs.version }}
|
||||
files: artifacts/latest.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
### Step 4 — Update `finalize-release.needs`
|
||||
|
||||
Status: done.
|
||||
|
||||
```yaml
|
||||
finalize-release:
|
||||
needs: [create-release, build-desktop-electron-macos, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests]
|
||||
```
|
||||
|
||||
### Step 5 — Remove Tauri-specific code
|
||||
|
||||
Status: next safe refactoring step, after the transition release ships and has
|
||||
been out at least 2 weeks with no rollback.
|
||||
|
||||
Do not do this in the same release as the migration. Once the transition release
|
||||
has proved stable, remove:
|
||||
|
||||
- `packages/desktop/` (entire package — Tauri Rust + UI glue)
|
||||
- Any `isTauriShell()` branches that are now dead code in
|
||||
`packages/ui/src/` (search for the symbol; most call sites already fall
|
||||
through to the Electron path because our preload exposes a `__TAURI__` shim;
|
||||
audit each before removing).
|
||||
- This file (`docs/TAURI_TO_ELECTRON_CUTOVER.md`) — mission accomplished.
|
||||
|
||||
Do this in a separate PR. Keep the transition release workflow intact until the
|
||||
cleanup lands; rolling the cleanup into the transition release itself makes
|
||||
debugging much harder if the migration misbehaves for a user.
|
||||
|
||||
The manual arm64 macOS DMG workflow has already been changed to build Electron
|
||||
only, so there should be no GitHub Actions path that accidentally produces a new
|
||||
Tauri DMG.
|
||||
|
||||
When rerunning the same release version with `workflow_dispatch`, use
|
||||
`dry_run=true` if npm and marketplace packages are already published. In that
|
||||
mode the workflow still rebuilds release assets, but skips publishing to npm and
|
||||
skips re-uploading the npm tarball asset.
|
||||
|
||||
## Validation before tagging the transition release
|
||||
|
||||
You must manually validate with a real Tauri install. Do NOT skip this.
|
||||
|
||||
1. Have the previous Tauri release installed locally
|
||||
(`/Applications/OpenChamber.app` with `Contents/Info.plist` showing
|
||||
`CFBundleIdentifier = ai.opencode.openchamber`).
|
||||
2. Tag the transition release to a test tag
|
||||
(e.g. `v2.0.0-migration-test`) and push.
|
||||
3. Let the workflow complete. Do not merge cleanup PR yet.
|
||||
4. In the running Tauri app, use the built-in "Check for updates".
|
||||
5. Accept the update. The app should download, verify, extract, restart.
|
||||
6. After restart, `Info.plist` under `/Applications/OpenChamber.app/` should
|
||||
now show `CFBundleIdentifier = dev.openchamber.desktop`.
|
||||
7. Settings should be intact: hosts list, default host, sessions history.
|
||||
8. In the new Electron app, "Check for updates" should report no update
|
||||
available (it's now at the transition version, which is the latest).
|
||||
9. Produce a dummy v2.0.1 Electron-only release to prove the subsequent
|
||||
Electron-path update works. Accept it. App relaunches into v2.0.1.
|
||||
|
||||
If any step fails:
|
||||
- Delete the test tag and GitHub release.
|
||||
- Do not delete yet-shipped artifacts from a real tag until rollback below.
|
||||
|
||||
## Rollback if the transition release misbehaves
|
||||
|
||||
If users report the Tauri → Electron update bricks their install:
|
||||
|
||||
1. **Immediately** delete the latest release asset
|
||||
`OpenChamber-*.app.tar.gz` and `latest.json` from the GitHub release
|
||||
(keep the DMGs so manual download still works).
|
||||
2. Re-upload the previous version's `latest.json` as the current latest so
|
||||
Tauri updaters see "up to date" instead of a broken update on next check.
|
||||
3. Post a support note: users who already applied the broken update can
|
||||
download a fresh Electron `.dmg` manually and drag-replace. Their
|
||||
`~/.config/openchamber/settings.json` survives.
|
||||
4. Investigate, fix the workflow, retry with a new version number.
|
||||
|
||||
## Risks & edge cases
|
||||
|
||||
### Different `CFBundleIdentifier` at same path
|
||||
macOS LaunchServices caches identifier ↔ path mappings. When we replace
|
||||
`ai.opencode.openchamber` with `dev.openchamber.desktop` at the same `.app`
|
||||
path, LaunchServices will rebuild on next launch (automatic). Usually fine.
|
||||
If a user's system is in a weird state, a `killall Dock` or logout/login
|
||||
fixes it. Worth noting in the release notes.
|
||||
|
||||
### macOS notification permissions
|
||||
Notification permission is per-bundle-id. After migration, the app has a new
|
||||
bundle-id, so the first notification will re-prompt the user. Unavoidable.
|
||||
Mention in release notes.
|
||||
|
||||
### Deep-link protocol registration
|
||||
The `openchamber://` protocol was registered for `ai.opencode.openchamber`.
|
||||
After migration, `dev.openchamber.desktop` registers itself on first launch.
|
||||
LaunchServices updates the handler. Usually seamless. Test with
|
||||
`open 'openchamber://session/test'` post-migration.
|
||||
|
||||
### Gatekeeper "damaged app" dialog
|
||||
Rare. Triggered if the replaced `.app` fails a mid-extract codesign check.
|
||||
Can happen if Tauri's extractor corrupts xattrs. Mitigation: test on a
|
||||
pristine macOS install before tagging production.
|
||||
|
||||
### Users on unsupported old Tauri versions
|
||||
If a user is on a very old Tauri build that doesn't know how to do the
|
||||
fetch-verify-extract flow, they're stuck. Expected: negligibly few users;
|
||||
they'll just stay on their old version forever until they manually download.
|
||||
Acceptable.
|
||||
|
||||
### Rollback-after-migration-accepted is impossible per-user
|
||||
Once a user is on Electron, the Tauri updater is gone. If they want to go
|
||||
back to a Tauri build, they must manually download. We don't support this.
|
||||
|
||||
## Relevant files to understand before making changes
|
||||
|
||||
- `.github/workflows/release.yml` — the release workflow.
|
||||
- `packages/electron/package.json` — electron-builder config (appId,
|
||||
mac/dmg, publish, artifactName).
|
||||
- `packages/electron/main.mjs` — autoUpdater setup (`setupAutoUpdater`,
|
||||
`desktop_check_for_updates`, `desktop_download_and_install_update`,
|
||||
`desktop_restart`). Understand this flow before touching the CI.
|
||||
- `packages/electron/scripts/finalize-latest-yml.mjs` — per-arch
|
||||
`latest-mac.yml` merger. Already wired in `combine-electron-manifests`.
|
||||
- `packages/desktop/src-tauri/tauri.conf.json` — legacy Tauri identifier,
|
||||
minisign pubkey embedded for updater verification. Don't modify; just
|
||||
reference for context.
|
||||
|
||||
## Working protocol
|
||||
|
||||
Default to a dry-run (test tag like `vX.Y.Z-migration-test` on a workflow_dispatch
|
||||
run) before the real tag. Surface only business-level decisions —
|
||||
"cutover this release, or hold one more cycle?" — and make technical calls
|
||||
(minisign invocation flags, YAML layout, job dependency order) yourself,
|
||||
documenting each one in the PR description.
|
||||
@@ -25,17 +25,14 @@
|
||||
"build": "bun run --filter '*' build",
|
||||
"build:web": "bun run --cwd packages/web build",
|
||||
"build:ui": "bun run --cwd packages/ui build",
|
||||
"build:desktop": "bun run --cwd packages/desktop build",
|
||||
"build:electron": "bun run --cwd packages/electron build",
|
||||
"type-check": "bun run --filter '*' type-check",
|
||||
"type-check:web": "bun run --cwd packages/web type-check",
|
||||
"type-check:ui": "bun run --cwd packages/ui type-check",
|
||||
"type-check:desktop": "bun run --cwd packages/desktop type-check",
|
||||
"type-check:electron": "bun run --cwd packages/electron type-check",
|
||||
"lint": "bun run --filter '*' lint",
|
||||
"lint:web": "bun run --cwd packages/web lint",
|
||||
"lint:ui": "bun run --cwd packages/ui lint",
|
||||
"lint:desktop": "bun run --cwd packages/desktop lint",
|
||||
"lint:electron": "bun run --cwd packages/electron lint",
|
||||
"clean": "bun run --filter '*' clean",
|
||||
"changelog-card": "node scripts/changelog-card/generate.mjs",
|
||||
@@ -46,15 +43,9 @@
|
||||
"dev:web:hmr": "node ./scripts/dev-web-hmr.mjs",
|
||||
"start:web": "bun run --cwd packages/web start",
|
||||
"pack:web": "bun pm pack --cwd packages/web",
|
||||
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
||||
"desktop:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop",
|
||||
"desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs",
|
||||
"desktop:build": "bun run --cwd packages/desktop build:sidecar && bun run --cwd packages/desktop tauri build",
|
||||
"electron:dev": "node ./packages/electron/scripts/electron-dev.mjs",
|
||||
"electron:dev:bundled": "OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1 node ./packages/electron/scripts/electron-dev.mjs",
|
||||
"electron:build": "bun run --cwd packages/electron package",
|
||||
"desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||
"desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||
"vscode:dev": "node ./scripts/dev-vscode.mjs",
|
||||
"vscode:build": "bun run --cwd packages/vscode build",
|
||||
"vscode:package": "bun run --cwd packages/vscode package",
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Vite build output
|
||||
dist/
|
||||
|
||||
# Tauri build artifacts
|
||||
src-tauri/target/
|
||||
|
||||
# Tauri generated code
|
||||
src-tauri/gen/
|
||||
|
||||
# Desktop sidecar + bundled web assets (generated)
|
||||
src-tauri/resources/web-dist/
|
||||
src-tauri/sidecars/openchamber-server-*
|
||||
src-tauri/sidecars/*.exe
|
||||
!src-tauri/resources/.gitkeep
|
||||
!src-tauri/sidecars/.gitkeep
|
||||
|
||||
# OpenCode CLI state tracking
|
||||
.opencode-cli-state.json
|
||||
|
||||
# OS-specific
|
||||
.DS_Store
|
||||
@@ -1,68 +0,0 @@
|
||||
# <picture><source media="(prefers-color-scheme: dark)" srcset="https://github.com/btriapitsyn/openchamber/raw/HEAD/docs/references/badges/openchamber-logo-dark.svg"><img src="https://github.com/btriapitsyn/openchamber/raw/HEAD/docs/references/badges/openchamber-logo-light.svg" width="32" height="32" align="absmiddle" /></picture> OpenChamber Desktop
|
||||
|
||||
[](https://github.com/btriapitsyn/openchamber/stargazers)
|
||||
[](https://github.com/btriapitsyn/openchamber/releases/latest)
|
||||
[](https://discord.gg/ZYRSdnwwKA)
|
||||
|
||||
A native macOS app for [OpenCode](https://opencode.ai). Feels like home - multiple windows, SSH remotes, project actions, and everything running locally.
|
||||
|
||||
Full project overview, screenshots, and all features: [github.com/btriapitsyn/openchamber](https://github.com/btriapitsyn/openchamber)
|
||||
|
||||
## Install
|
||||
|
||||
Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). Available for macOS (Apple Silicon and Intel).
|
||||
|
||||
> **Prerequisite:** [OpenCode CLI](https://opencode.ai) installed.
|
||||
|
||||
## What makes the desktop app special
|
||||
|
||||
- **Remote instances over SSH** - connect to remote OpenChamber servers with dedicated lifecycle and UX flows
|
||||
- **Project Actions** - run dev servers, configure SSH port forwarding, open remote URLs locally
|
||||
- **Multi-window** - work on several projects in parallel, each in its own window
|
||||
- **"Open In" shortcuts** - open workspace in Finder, Terminal, or your editor of choice
|
||||
- **Local + remote switching** - jump between local and remote OpenChamber instances
|
||||
- **Native macOS integration** - menus, deep-links, auto-update, and polished window management
|
||||
|
||||
Plus everything from the shared OpenChamber UI: branchable timeline, Git sidebar, terminal, voice mode, and more.
|
||||
|
||||
## Features
|
||||
|
||||
### Core UI
|
||||
|
||||
- Branchable chat timeline with `/undo`, `/redo`, and one-click forks from earlier turns
|
||||
- Smart tool UIs for diffs, file operations, permissions, and long-running task progress
|
||||
- Multi-agent runs from one prompt with isolated worktrees for safe comparisons
|
||||
- Git workflows in-app: identities, commits, PR creation, checks, and merge actions
|
||||
- Context visibility tools (token/cost breakdowns, raw message inspection, and activity summaries)
|
||||
- Integrated terminal with per-directory sessions and stable performance on heavy output
|
||||
|
||||
### Desktop (macOS)
|
||||
|
||||
- Native macOS menu integration with polished app actions and deep-link handling
|
||||
- Multi-window support for parallel project/session workflows
|
||||
- "Open In" shortcuts for Finder, Terminal, and your preferred editor
|
||||
- Fast switching between local and remote instances
|
||||
- Workspace-first startup flow with directory picker and steadier window restore behavior
|
||||
|
||||
### Remote Tunnel (Desktop)
|
||||
|
||||
- Configure in **Settings -> OpenChamber -> Remote Tunnel**.
|
||||
- Supported Cloudflare modes: **Quick**, **Managed Remote**, **Managed Local**.
|
||||
- One active tunnel per Desktop instance. Starting a different mode replaces the current tunnel.
|
||||
- Replacing or stopping a tunnel revokes existing connect links and invalidates remote tunnel sessions.
|
||||
- Connect links are one-time tokens; generate a new link for each new connection attempt.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/btriapitsyn/openchamber.git
|
||||
cd openchamber
|
||||
bun install
|
||||
bun run desktop:dev
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,110 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenChamber</title>
|
||||
<style>
|
||||
:root {
|
||||
--splash-background-dark: #151313;
|
||||
--splash-stroke-dark: #CECDC3;
|
||||
--splash-background-light: #FFFCF0;
|
||||
--splash-stroke-light: #100F0F;
|
||||
|
||||
--splash-background: var(--splash-background-dark);
|
||||
--splash-stroke: var(--splash-stroke-dark);
|
||||
--splash-face-fill: rgba(255, 255, 255, 0.15);
|
||||
--splash-cell-fill: rgba(255, 255, 255, 0.35);
|
||||
--splash-logo-fill: var(--splash-stroke);
|
||||
}
|
||||
|
||||
html[data-splash-variant='light'] {
|
||||
--splash-background: var(--splash-background-light);
|
||||
--splash-stroke: var(--splash-stroke-light);
|
||||
--splash-face-fill: rgba(0, 0, 0, 0.15);
|
||||
--splash-cell-fill: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
html[data-splash-variant='dark'] {
|
||||
--splash-background: var(--splash-background-dark);
|
||||
--splash-stroke: var(--splash-stroke-dark);
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, white 50%, transparent)) {
|
||||
:root {
|
||||
--splash-face-fill: color-mix(in srgb, var(--splash-stroke) 15%, transparent);
|
||||
--splash-cell-fill: color-mix(in srgb, var(--splash-stroke) 35%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--splash-background: var(--splash-background-light);
|
||||
--splash-stroke: var(--splash-stroke-light);
|
||||
--splash-face-fill: rgba(0, 0, 0, 0.15);
|
||||
--splash-cell-fill: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--splash-background);
|
||||
color: var(--splash-stroke);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>OpenChamber requires JavaScript.</noscript>
|
||||
<svg width="120" height="120" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading icon">
|
||||
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M50 50 L39.608 44 L39.608 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||
<path d="M39.608 44 L29.216 38 L29.216 50 L39.608 56 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||
<path d="M29.216 38 L18.824 32 L18.824 44 L29.216 50 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||
<path d="M18.824 32 L8.432 26 L8.432 38 L18.824 44 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
||||
<path d="M50 62 L39.608 56 L39.608 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
||||
<path d="M39.608 56 L29.216 50 L29.216 62 L39.608 68 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
||||
<path d="M29.216 50 L18.824 44 L18.824 56 L29.216 62 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
|
||||
<path d="M18.824 44 L8.432 38 L8.432 50 L18.824 56 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
||||
<path d="M50 74 L39.608 68 L39.608 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
||||
<path d="M39.608 68 L29.216 62 L29.216 74 L39.608 80 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
||||
<path d="M29.216 62 L18.824 56 L18.824 68 L29.216 74 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||
<path d="M18.824 56 L8.432 50 L8.432 62 L18.824 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||
<path d="M50 86 L39.608 80 L39.608 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
||||
<path d="M39.608 80 L29.216 74 L29.216 86 L39.608 92 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||
<path d="M29.216 74 L18.824 68 L18.824 80 L29.216 86 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
||||
<path d="M18.824 68 L8.432 62 L8.432 74 L18.824 80 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
||||
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
||||
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||
<path d="M70.784 38 L81.176 32 L81.176 44 L70.784 50 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||
<path d="M81.176 32 L91.568 26 L91.568 38 L81.176 44 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
||||
<path d="M50 62 L60.392 56 L60.392 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
|
||||
<path d="M60.392 56 L70.784 50 L70.784 62 L60.392 68 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
||||
<path d="M70.784 50 L81.176 44 L81.176 56 L70.784 62 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
||||
<path d="M81.176 44 L91.568 38 L91.568 50 L81.176 56 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
||||
<path d="M50 74 L60.392 68 L60.392 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||
<path d="M60.392 68 L70.784 62 L70.784 74 L60.392 80 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
||||
<path d="M70.784 62 L81.176 56 L81.176 68 L70.784 74 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
||||
<path d="M81.176 56 L91.568 50 L91.568 62 L81.176 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
||||
<path d="M50 86 L60.392 80 L60.392 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
||||
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
||||
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
||||
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
||||
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="var(--splash-logo-fill)"/>
|
||||
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="var(--splash-logo-fill)" fill-opacity="0.4"/>
|
||||
</g>
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@openchamber/desktop",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"desktopPrerequisites": [
|
||||
"Rust stable toolchain (via rustup)",
|
||||
"Xcode Command Line Tools installed",
|
||||
"Tauri CLI installed (cargo install tauri-cli@^2)"
|
||||
],
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev --features devtools",
|
||||
"tauri:build": "tauri build",
|
||||
"build:sidecar": "node ./scripts/build-sidecar.mjs",
|
||||
"build": "bun -e \"process.exit(0)\"",
|
||||
"type-check": "bun -e \"process.exit(0)\"",
|
||||
"lint": "bun -e \"process.exit(0)\""
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^24.3.1",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,135 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
const webDir = path.join(repoRoot, 'packages', 'web');
|
||||
const desktopTauriDir = path.join(repoRoot, 'packages', 'desktop', 'src-tauri');
|
||||
|
||||
const resourcesDir = path.join(desktopTauriDir, 'resources');
|
||||
const resourcesWebDistDir = path.join(resourcesDir, 'web-dist');
|
||||
const webDistDir = path.join(webDir, 'dist');
|
||||
|
||||
const sidecarsDir = path.join(desktopTauriDir, 'sidecars');
|
||||
|
||||
const inferTargetTriple = () => {
|
||||
if (typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' && process.env.TAURI_ENV_TARGET_TRIPLE.trim()) {
|
||||
return process.env.TAURI_ENV_TARGET_TRIPLE.trim();
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return 'x86_64-pc-windows-msvc';
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu';
|
||||
}
|
||||
|
||||
return `${process.arch}-${process.platform}`;
|
||||
};
|
||||
|
||||
const targetTriple = inferTargetTriple();
|
||||
|
||||
const bunCompileTargetByTriple = {
|
||||
'aarch64-apple-darwin': 'bun-darwin-arm64',
|
||||
'x86_64-apple-darwin': 'bun-darwin-x64',
|
||||
'aarch64-unknown-linux-gnu': 'bun-linux-arm64',
|
||||
'x86_64-unknown-linux-gnu': 'bun-linux-x64',
|
||||
'x86_64-pc-windows-msvc': 'bun-windows-x64',
|
||||
};
|
||||
|
||||
const compileTarget = bunCompileTargetByTriple[targetTriple];
|
||||
|
||||
if (!compileTarget) {
|
||||
console.warn(
|
||||
`[desktop] unknown target triple '${targetTriple}', falling back to host-arch sidecar build`
|
||||
);
|
||||
}
|
||||
|
||||
const sidecarBaseName = process.platform === 'win32'
|
||||
? `openchamber-server-${targetTriple}.exe`
|
||||
: `openchamber-server-${targetTriple}`;
|
||||
const sidecarOutPath = path.join(sidecarsDir, sidecarBaseName);
|
||||
|
||||
|
||||
const run = (cmd, args, cwd) => {
|
||||
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveBun = () => {
|
||||
if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) {
|
||||
return process.env.BUN.trim();
|
||||
}
|
||||
|
||||
const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' });
|
||||
const resolved = (result.stdout || '').trim();
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return 'bun';
|
||||
};
|
||||
|
||||
const bunExe = resolveBun();
|
||||
|
||||
|
||||
const copyDir = async (src, dst) => {
|
||||
await fs.mkdir(dst, { recursive: true });
|
||||
const entries = await fs.readdir(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const from = path.join(src, entry.name);
|
||||
const to = path.join(dst, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(from, to);
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
const link = await fs.readlink(from);
|
||||
await fs.symlink(link, to);
|
||||
} else {
|
||||
await fs.copyFile(from, to);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[desktop] building web UI dist...');
|
||||
run(bunExe, ['run', 'build'], webDir);
|
||||
|
||||
console.log('[desktop] preparing tauri resources...');
|
||||
await fs.mkdir(resourcesDir, { recursive: true });
|
||||
await fs.rm(resourcesWebDistDir, { recursive: true, force: true });
|
||||
await copyDir(webDistDir, resourcesWebDistDir);
|
||||
|
||||
console.log('[desktop] building openchamber-server sidecar...');
|
||||
await fs.mkdir(sidecarsDir, { recursive: true });
|
||||
|
||||
const buildArgs = [
|
||||
'build',
|
||||
'--compile',
|
||||
path.join(webDir, 'server', 'index.js'),
|
||||
'--outfile',
|
||||
sidecarOutPath,
|
||||
];
|
||||
|
||||
if (compileTarget) {
|
||||
buildArgs.push('--target', compileTarget);
|
||||
}
|
||||
|
||||
run(bunExe, buildArgs, repoRoot);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.chmod(sidecarOutPath, 0o755);
|
||||
}
|
||||
|
||||
console.log(`[desktop] sidecar ready: ${sidecarOutPath}`);
|
||||
console.log(`[desktop] web assets ready: ${resourcesWebDistDir}`);
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const desktopDir = path.join(repoRoot, 'packages/desktop');
|
||||
|
||||
function spawnProcess(command, args, opts = {}) {
|
||||
return spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env },
|
||||
stdio: 'inherit',
|
||||
detached: process.platform !== 'win32',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function signalChild(child, signal) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopChildTree(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
signalChild(child, 'SIGINT');
|
||||
await waitForExit(child, 2500);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGTERM');
|
||||
await waitForExit(child, 2500);
|
||||
}
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGKILL');
|
||||
await waitForExit(child, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tauriProcess = spawnProcess('bun', [
|
||||
'--cwd',
|
||||
desktopDir,
|
||||
'tauri',
|
||||
'dev',
|
||||
'--features',
|
||||
'devtools',
|
||||
'--config',
|
||||
'./src-tauri/tauri.dev.conf.json',
|
||||
]);
|
||||
|
||||
let cleaning = false;
|
||||
|
||||
const teardown = async (code) => {
|
||||
if (cleaning) {
|
||||
return;
|
||||
}
|
||||
cleaning = true;
|
||||
|
||||
await stopChildTree(tauriProcess);
|
||||
process.exit(typeof code === 'number' ? code : 0);
|
||||
};
|
||||
|
||||
const handleChildExit = (childName) => (code, signal) => {
|
||||
if (code !== 0 || signal) {
|
||||
console.warn(`[desktop:dev] ${childName} exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`);
|
||||
}
|
||||
teardown(code).catch((error) => {
|
||||
console.error('[desktop:dev] Cleanup error:', error);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
};
|
||||
|
||||
tauriProcess.on('exit', handleChildExit('Tauri dev process'));
|
||||
const errorHandler = (label) => (error) => {
|
||||
console.error(`[desktop:dev] Failed to start ${label}:`, error);
|
||||
teardown(1).catch(() => process.exit(1));
|
||||
};
|
||||
|
||||
tauriProcess.on('error', errorHandler('Tauri dev process'));
|
||||
|
||||
const signalExitCodes = {
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
SIGQUIT: 131,
|
||||
};
|
||||
|
||||
Object.entries(signalExitCodes).forEach(([signal, exitCode]) => {
|
||||
process.on(signal, () => {
|
||||
teardown(exitCode).catch(() => process.exit(exitCode));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[desktop:dev] Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DESKTOP_DEV_PORT = 3901;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
const desktopDir = path.join(repoRoot, 'packages', 'desktop');
|
||||
const tauriDir = path.join(desktopDir, 'src-tauri');
|
||||
|
||||
const inferTargetTriple = () => {
|
||||
const fromEnv = typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' ? process.env.TAURI_ENV_TARGET_TRIPLE.trim() : '';
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return 'x86_64-pc-windows-msvc';
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu';
|
||||
}
|
||||
|
||||
return `${process.arch}-${process.platform}`;
|
||||
};
|
||||
|
||||
const targetTriple = inferTargetTriple();
|
||||
const sidecarName = process.platform === 'win32'
|
||||
? `openchamber-server-${targetTriple}.exe`
|
||||
: `openchamber-server-${targetTriple}`;
|
||||
|
||||
const sidecarPath = path.join(tauriDir, 'sidecars', sidecarName);
|
||||
const distDir = path.join(tauriDir, 'resources', 'web-dist');
|
||||
const webDir = path.join(repoRoot, 'packages', 'web');
|
||||
|
||||
const run = (cmd, args, cwd) => {
|
||||
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[desktop] ensuring sidecar + web-dist...');
|
||||
run('node', ['./scripts/build-sidecar.mjs'], desktopDir);
|
||||
|
||||
console.log(`[desktop] starting API server on http://127.0.0.1:${DESKTOP_DEV_PORT} ...`);
|
||||
|
||||
const apiChild = spawn(sidecarPath, ['--port', String(DESKTOP_DEV_PORT)], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCHAMBER_HOST: '127.0.0.1',
|
||||
OPENCHAMBER_DIST_DIR: distDir,
|
||||
NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1',
|
||||
no_proxy: process.env.no_proxy || 'localhost,127.0.0.1',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[desktop] starting Vite HMR server on http://127.0.0.1:5173 ...');
|
||||
|
||||
const webChild = spawn('bun', ['x', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], {
|
||||
cwd: webDir,
|
||||
stdio: 'inherit',
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCHAMBER_PORT: String(DESKTOP_DEV_PORT),
|
||||
NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1',
|
||||
no_proxy: process.env.no_proxy || 'localhost,127.0.0.1',
|
||||
},
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function signalChild(child, signal) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestApiShutdown() {
|
||||
const url = `http://127.0.0.1:${DESKTOP_DEV_PORT}/api/system/shutdown`;
|
||||
try {
|
||||
await fetch(url, { method: 'POST' });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopChildTree(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
signalChild(child, 'SIGINT');
|
||||
await waitForExit(child, 2500);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGTERM');
|
||||
await waitForExit(child, 2500);
|
||||
}
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGKILL');
|
||||
await waitForExit(child, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const shutdown = async (exitCode = 0) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
await requestApiShutdown();
|
||||
await Promise.all([stopChildTree(webChild), stopChildTree(apiChild)]);
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
const handleExit = (label) => (code, signal) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0 || signal) {
|
||||
console.error(`[desktop] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
|
||||
}
|
||||
|
||||
shutdown(typeof code === 'number' ? code : 1).catch((error) => {
|
||||
console.error('[desktop] shutdown failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
apiChild.on('exit', handleExit('API server'));
|
||||
webChild.on('exit', handleExit('Vite server'));
|
||||
|
||||
const handleError = (label) => (error) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
console.error(`[desktop] failed to start ${label}:`, error);
|
||||
shutdown(1).catch(() => process.exit(1));
|
||||
};
|
||||
|
||||
apiChild.on('error', handleError('API server'));
|
||||
webChild.on('error', handleError('Vite server'));
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
shutdown(130).catch(() => process.exit(130));
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown(143).catch(() => process.exit(143));
|
||||
});
|
||||
process.on('SIGHUP', () => {
|
||||
shutdown(129).catch(() => process.exit(129));
|
||||
});
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { access, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const desktopDir = path.resolve(__dirname, '..');
|
||||
const stateFile = path.join(desktopDir, '.opencode-cli-state.json');
|
||||
const DEFAULT_BIN_CANDIDATES = [
|
||||
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||
process.env.OPENCODE_PATH,
|
||||
process.env.OPENCODE_BINARY,
|
||||
'/opt/homebrew/bin/opencode',
|
||||
'/usr/local/bin/opencode',
|
||||
'/usr/bin/opencode',
|
||||
path.join(os.homedir(), '.local/bin/opencode'),
|
||||
].filter(Boolean);
|
||||
const CLI_ARGS_ENV = process.env.OPENCHAMBER_OPENCODE_ARGS;
|
||||
const DEFAULT_ARGS = CLI_ARGS_ENV
|
||||
? parseArgs(CLI_ARGS_ENV)
|
||||
: ['api'];
|
||||
|
||||
function parseArgs(raw) {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return [];
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// fall through to whitespace split
|
||||
}
|
||||
}
|
||||
return trimmed.split(/\s+/g);
|
||||
}
|
||||
|
||||
async function fileExists(targetPath) {
|
||||
try {
|
||||
await access(targetPath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCliPath() {
|
||||
for (const candidate of DEFAULT_BIN_CANDIDATES) {
|
||||
if (candidate && await fileExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const envPath = process.env.PATH || '';
|
||||
for (const segment of envPath.split(path.delimiter)) {
|
||||
const candidate = path.join(segment, 'opencode');
|
||||
if (await fileExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to locate the OpenCode CLI. Set OPENCHAMBER_OPENCODE_PATH to the executable.');
|
||||
}
|
||||
|
||||
async function readState() {
|
||||
try {
|
||||
const raw = await readFile(stateFile, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
if (typeof data?.pid === 'number') {
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
if (!pid || typeof pid !== 'number') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(pid) {
|
||||
await writeFile(stateFile, JSON.stringify({ pid }), 'utf8');
|
||||
}
|
||||
|
||||
async function removeStateFile() {
|
||||
try {
|
||||
await unlink(stateFile);
|
||||
} catch {
|
||||
// already removed
|
||||
}
|
||||
}
|
||||
|
||||
function spawnCli(cliPath, args) {
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_OPENCODE_PORT: process.env.OPENCHAMBER_OPENCODE_PORT || process.env.OPENCODE_PORT || process.env.OPENCHAMBER_INTERNAL_PORT || '0',
|
||||
};
|
||||
const cwd = process.env.OPENCHAMBER_OPENCODE_CWD || process.cwd();
|
||||
|
||||
const child = spawn(cliPath, args.length > 0 ? args : DEFAULT_ARGS, {
|
||||
cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function startCli({ silent = false } = {}) {
|
||||
const existing = await readState();
|
||||
if (existing?.pid && isProcessAlive(existing.pid)) {
|
||||
if (!silent) {
|
||||
console.log(`[desktop:start-cli] OpenCode CLI already running (pid ${existing.pid}).`);
|
||||
}
|
||||
return existing.pid;
|
||||
}
|
||||
|
||||
const cliPath = await resolveCliPath();
|
||||
const child = spawnCli(cliPath, DEFAULT_ARGS);
|
||||
await writeState(child.pid);
|
||||
if (!silent) {
|
||||
console.log(`[desktop:start-cli] OpenCode CLI started (${cliPath}) pid ${child.pid}.`);
|
||||
}
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
export async function stopCli({ silent = false } = {}) {
|
||||
const state = await readState();
|
||||
if (!state?.pid) {
|
||||
if (!silent) {
|
||||
console.log('[desktop:stop-cli] No OpenCode CLI PID recorded.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { pid } = state;
|
||||
if (!isProcessAlive(pid)) {
|
||||
await removeStateFile();
|
||||
if (!silent) {
|
||||
console.log('[desktop:stop-cli] CLI already stopped.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error(`[desktop:stop-cli] Failed to send SIGTERM to pid ${pid}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutMs = 5000;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
await removeStateFile();
|
||||
if (!silent) {
|
||||
console.log('[desktop:stop-cli] OpenCode CLI stopped.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
if (!silent) {
|
||||
console.warn(`[desktop:stop-cli] Forced termination sent to pid ${pid}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error(`[desktop:stop-cli] Unable to terminate pid ${pid}:`, error);
|
||||
}
|
||||
} finally {
|
||||
await removeStateFile();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , command] = process.argv;
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
console.log('Usage: node opencode-cli.mjs <start|stop|status>');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === 'start') {
|
||||
await startCli();
|
||||
return;
|
||||
}
|
||||
if (command === 'stop') {
|
||||
await stopCli();
|
||||
return;
|
||||
}
|
||||
if (command === 'status') {
|
||||
const state = await readState();
|
||||
if (state?.pid && isProcessAlive(state.pid)) {
|
||||
console.log(`OpenCode CLI running (pid ${state.pid}).`);
|
||||
} else {
|
||||
console.log('OpenCode CLI not running.');
|
||||
}
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Unknown command: ${command}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
main().catch((error) => {
|
||||
console.error('[desktop:opencode-cli] Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Generated
-6688
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
[package]
|
||||
name = "openchamber-desktop"
|
||||
version = "1.12.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "openchamber-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
devtools = ["tauri/devtools"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
base64 = "0.22.1"
|
||||
log = "0.4.28"
|
||||
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "json"] }
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
serde_json = "1.0.143"
|
||||
tauri = { version = "2.10.3", features = ["macos-private-api"] }
|
||||
tauri-plugin-dialog = "2.6.0"
|
||||
tauri-plugin-log = "2.8.0"
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-updater = "2.10.0"
|
||||
tokio = { version = "1.38", features = ["rt-multi-thread", "time", "macros", "sync"] }
|
||||
url = "2.5"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.6", features = [] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-web-kit = "0.3"
|
||||
rfd = "0.15"
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>OpenChamber needs to run the OpenCode CLI to provide AI coding assistance.</string>
|
||||
<key>NSDesktopFolderUsageDescription</key>
|
||||
<string>OpenChamber needs access to work with your projects.</string>
|
||||
<key>NSDocumentsFolderUsageDescription</key>
|
||||
<string>OpenChamber needs access to work with your projects.</string>
|
||||
<key>NSDownloadsFolderUsageDescription</key>
|
||||
<string>OpenChamber needs access to work with your projects.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OpenChamber needs microphone access for voice input.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OpenChamber needs speech recognition to transcribe voice input.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for OpenChamber desktop runtime",
|
||||
"remote": {
|
||||
"urls": [
|
||||
"http://127.0.0.1:*/*",
|
||||
"http://localhost:*/*",
|
||||
"http://*",
|
||||
"http://*/*",
|
||||
"https://*",
|
||||
"https://*/*"
|
||||
]
|
||||
},
|
||||
"windows": ["main", "main-*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:default",
|
||||
"core:webview:allow-webview-close",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-ask",
|
||||
"dialog:allow-confirm",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download-and-install"
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
Intentionally NOT sandboxed. This app is distributed outside the Mac App Store.
|
||||
Do not add com.apple.security.app-sandbox.
|
||||
|
||||
These entitlements are commonly required for WKWebView/WebKit JIT behavior
|
||||
under hardened runtime.
|
||||
-->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,33 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="iconShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-opacity="0.5" flood-color="#000000"/>
|
||||
</filter>
|
||||
<linearGradient id="bgGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#303030"/>
|
||||
<stop offset="100%" stop-color="#141414"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Icon with Apple standard padding (100px on each side) -->
|
||||
<g transform="translate(100, 100)">
|
||||
<!-- Background rounded square - 824x824 (Apple standard) - dark gradient -->
|
||||
<rect x="0" y="0" width="824" height="824" rx="185" ry="185" fill="url(#bgGradient)" filter="url(#iconShadow)"/>
|
||||
|
||||
<!-- OpenChamber logo centered - simplified for dock visibility -->
|
||||
<g transform="translate(412, 412) scale(6.5)">
|
||||
<!-- Left face - simplified, no grid cells -->
|
||||
<path d="M0 0 L-41.568 -24 L-41.568 24 L0 48 Z" fill="white" fill-opacity="0.2" stroke="white" stroke-width="3" stroke-linejoin="round"/>
|
||||
<!-- Right face - simplified, no grid cells -->
|
||||
<path d="M0 0 L41.568 -24 L41.568 24 L0 48 Z" fill="white" fill-opacity="0.35" stroke="white" stroke-width="3" stroke-linejoin="round"/>
|
||||
<!-- Top face - open -->
|
||||
<path d="M0 -48 L-41.568 -24 L0 0 L41.568 -24 Z" fill="none" stroke="white" stroke-width="3" stroke-linejoin="round"/>
|
||||
<!-- OpenCode logo on top face -->
|
||||
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, -24) scale(0.75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="white"/>
|
||||
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="white" fill-opacity="0.4"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"productName": "OpenChamber",
|
||||
"version": "1.12.0",
|
||||
"identifier": "ai.opencode.openchamber",
|
||||
"build": {
|
||||
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
|
||||
"beforeBuildCommand": "bun run build:sidecar",
|
||||
"devUrl": "http://127.0.0.1:3901",
|
||||
"frontendDist": "../noop-dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"create": false,
|
||||
"title": "OpenChamber",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"hiddenTitle": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"trafficLightPosition": {
|
||||
"x": 17,
|
||||
"y": 26
|
||||
},
|
||||
"dragDropEnabled": false,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"withGlobalTauri": true,
|
||||
"macOSPrivateApi": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"externalBin": [
|
||||
"sidecars/openchamber-server"
|
||||
],
|
||||
"resources": [
|
||||
"resources/web-dist/**/*"
|
||||
],
|
||||
"icon": [
|
||||
"icons/icon.icns",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"macOS": {
|
||||
"exceptionDomain": "localhost",
|
||||
"minimumSystemVersion": "13.0",
|
||||
"signingIdentity": null,
|
||||
"entitlements": "./entitlements.plist",
|
||||
"infoPlist": "Info.plist",
|
||||
"dmg": {
|
||||
"appPosition": {
|
||||
"x": 180,
|
||||
"y": 170
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 480,
|
||||
"y": 170
|
||||
},
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
"height": 400
|
||||
}
|
||||
}
|
||||
},
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://github.com/btriapitsyn/openchamber/releases/latest/download/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwRDMyQUQzNjNFRTc1ODIKUldTQ2RlNWoweXJUSUdpVWVWNm84R1pHamYzNVFhYWgyWmlpWFVzem5nUTlHd1dlRlNTV0FFc3IK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"bundle": {
|
||||
"icon": [
|
||||
"icons/dev-icon.icns",
|
||||
"icons/dev-icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,6 @@ const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local';
|
||||
const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local';
|
||||
const ENV_OVERRIDE_HOST_ID = '__env';
|
||||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md';
|
||||
const UPDATE_METADATA_URL = 'https://github.com/openchamber/openchamber/releases/latest/download/latest.json';
|
||||
const GITHUB_BUG_REPORT_URL = 'https://github.com/openchamber/openchamber/issues/new?template=bug_report.yml';
|
||||
const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/issues/new?template=feature_request.yml';
|
||||
const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA';
|
||||
@@ -1681,7 +1680,6 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
backgroundColor: '#151313',
|
||||
frame: process.platform === 'win32' ? false : undefined,
|
||||
autoHideMenuBar: autoHidesNativeMenuBar,
|
||||
// Tauri used an overlay title bar with explicit traffic-light placement.
|
||||
// Electron's hiddenInset adds its own extra inset, which leaves the controls
|
||||
// visibly lower than the app header. Use a plain hidden title bar instead.
|
||||
titleBarStyle: usesCustomTitleBar ? 'hidden' : 'default',
|
||||
@@ -1704,7 +1702,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
// sandbox must stay off: the preload uses contextBridge + ipcRenderer
|
||||
// from Electron's Node layer. contextIsolation + nodeIntegration:false
|
||||
// keep the renderer world walled off from Node. Do NOT flip to true —
|
||||
// the preload would fail to load and __TAURI__ would go undefined.
|
||||
// the preload would fail to load and the desktop bridge would be unavailable.
|
||||
sandbox: false,
|
||||
},
|
||||
};
|
||||
@@ -3028,9 +3026,8 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
}
|
||||
|
||||
case 'desktop_set_vibrancy': {
|
||||
// Vibrancy (macOS blur) is not supported in the Electron shell — the
|
||||
// Tauri build used NSVisualEffectView via Tauri plugin, Electron has
|
||||
// no equivalent for our titleBarStyle:'hidden' setup. Persist the
|
||||
// Vibrancy (macOS blur) is not supported in the Electron shell for our
|
||||
// titleBarStyle:'hidden' setup. Persist the
|
||||
// disabled state so settings UI reflects it; args.enabled is ignored.
|
||||
await mutateSettingsRoot((root) => {
|
||||
root.desktopVibrancy = false;
|
||||
@@ -3040,13 +3037,6 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
|
||||
case 'desktop_check_for_updates': {
|
||||
const currentVersion = APP_VERSION;
|
||||
let payload = null;
|
||||
try {
|
||||
const response = await fetch(UPDATE_METADATA_URL, { signal: AbortSignal.timeout(10_000) });
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
}
|
||||
|
||||
let updateResult = null;
|
||||
try {
|
||||
updateResult = await autoUpdater.checkForUpdates();
|
||||
@@ -3056,14 +3046,12 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
const updateInfo = updateResult?.updateInfo;
|
||||
const nextVersion =
|
||||
(typeof updateInfo?.version === 'string' && updateInfo.version) ||
|
||||
(typeof payload?.version === 'string' && payload.version) ||
|
||||
currentVersion;
|
||||
const available = compareSemver(nextVersion, currentVersion) > 0;
|
||||
const body =
|
||||
(typeof payload?.notes === 'string' && payload.notes.trim() ? payload.notes : null) ||
|
||||
(typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) ||
|
||||
await parseRelevantChangelogNotes(currentVersion, nextVersion);
|
||||
state.pendingUpdate = available ? { version: nextVersion, metadata: payload, electronUpdate: updateResult } : null;
|
||||
state.pendingUpdate = available ? { version: nextVersion, electronUpdate: updateResult } : null;
|
||||
return {
|
||||
available,
|
||||
currentVersion,
|
||||
@@ -3071,7 +3059,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
body: body || null,
|
||||
date:
|
||||
(typeof updateInfo?.releaseDate === 'string' && updateInfo.releaseDate) ||
|
||||
(typeof payload?.pub_date === 'string' ? payload.pub_date : null),
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ const macosMajor = Number.parseInt(macosMajorRaw, 10);
|
||||
// Remote UIs still need it so isDesktopShell() returns true and the
|
||||
// window renders with desktop affordances (DesktopHostSwitcher,
|
||||
// title bar offsets, etc.). Expose unconditionally.
|
||||
// - __TAURI__ is the IPC channel to the main process. The compatibility
|
||||
// shim is exposed broadly, but privileged commands are gated in main.mjs.
|
||||
// - __OPENCHAMBER_DESKTOP__ is the IPC channel to the main process. It is
|
||||
// exposed broadly, but privileged commands are gated in main.mjs.
|
||||
// Local-only globals below stay limited to packaged UI / exact localOrigin.
|
||||
// Everything driven by localOrigin (home dir, macOS hints) also stays
|
||||
// local-only since it leaks info about the Electron host machine.
|
||||
@@ -136,21 +136,13 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => {
|
||||
dispatchNativeEvent(event, payload.detail);
|
||||
});
|
||||
|
||||
// __TAURI__ is exposed on all pages; the main-process gate in
|
||||
// The desktop bridge is exposed on all pages; the main-process gate in
|
||||
// ipcMain.handle('openchamber:invoke') decides per-command what is safe
|
||||
// for non-local callers (window/host-switcher ops yes, file/shell ops
|
||||
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
|
||||
contextBridge.exposeInMainWorld('__TAURI__', {
|
||||
core: {
|
||||
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
|
||||
},
|
||||
dialog: {
|
||||
open: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
|
||||
},
|
||||
shell: {
|
||||
open: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
|
||||
},
|
||||
event: {
|
||||
listen: async (event, handler) => addListener(event, handler),
|
||||
},
|
||||
contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', {
|
||||
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
|
||||
openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
|
||||
openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
|
||||
listen: async (event, handler) => addListener(event, handler),
|
||||
});
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
@@ -17,7 +17,7 @@ import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import {
|
||||
getInjectedBootOutcome,
|
||||
getBootInjectionStatus,
|
||||
@@ -758,7 +758,7 @@ function App({ apis }: AppProps) {
|
||||
|
||||
const handleDesktopBootDismiss = React.useCallback(async () => {
|
||||
if (shouldRestartDesktopBootFlow({
|
||||
isTauriShell: isTauriShell(),
|
||||
isDesktopShell: isDesktopShell(),
|
||||
isDesktopLocalOriginActive: isDesktopLocalOriginActive(),
|
||||
})) {
|
||||
await restartDesktopApp();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { invokeDesktop, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
||||
@@ -129,11 +129,7 @@ const issueDesktopClientTokenViaShell = async (password: string, trustDevice: bo
|
||||
if (!isDesktopShell() || typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const invoke = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__?.core?.invoke;
|
||||
if (typeof invoke !== 'function') {
|
||||
return '';
|
||||
}
|
||||
const response = await invoke('desktop_remote_password_login', {
|
||||
const response = await invokeDesktop('desktop_remote_password_login', {
|
||||
url: getRuntimeApiBaseUrl(),
|
||||
password,
|
||||
trustDevice,
|
||||
|
||||
@@ -36,8 +36,7 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
// useMessageStore removed — messages now come from sync system
|
||||
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { StopIcon } from '@/components/icons/StopIcon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -968,7 +967,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
|
||||
const canAcceptDropRef = React.useRef(false);
|
||||
const nativeDragInsideDropZoneRef = React.useRef(false);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
||||
@@ -3407,121 +3405,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
};
|
||||
|
||||
// Tauri desktop: handle native file drops via onDragDropEvent
|
||||
React.useEffect(() => {
|
||||
if (!isTauriShell()) return;
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||
const webviewWindow = getCurrentWebviewWindow();
|
||||
const removeListener = await webviewWindow.onDragDropEvent(async (event) => {
|
||||
if (!canAcceptDropRef.current) return;
|
||||
|
||||
const payload = (event as { payload?: unknown }).payload;
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
|
||||
const typed = payload as { type?: string; paths?: string[]; position?: { x?: number; y?: number } };
|
||||
const type = typed.type;
|
||||
const x = typed.position?.x;
|
||||
const y = typed.position?.y;
|
||||
|
||||
// Check if drop is inside the chat input area
|
||||
const zone = dropZoneRef.current;
|
||||
let inZone: boolean | null = null;
|
||||
if (zone && typeof x === 'number' && typeof y === 'number') {
|
||||
const rect = zone.getBoundingClientRect();
|
||||
inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
// Handle retina displays where Tauri might report physical pixels
|
||||
if (!inZone && window.devicePixelRatio > 1) {
|
||||
const sx = x / window.devicePixelRatio;
|
||||
const sy = y / window.devicePixelRatio;
|
||||
inZone = sx >= rect.left && sx <= rect.right && sy >= rect.top && sy <= rect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'enter' || type === 'over') {
|
||||
if (inZone !== null) {
|
||||
nativeDragInsideDropZoneRef.current = inZone;
|
||||
}
|
||||
setIsDragging(nativeDragInsideDropZoneRef.current);
|
||||
return;
|
||||
}
|
||||
if (type === 'leave') {
|
||||
nativeDragInsideDropZoneRef.current = false;
|
||||
setIsDragging(false);
|
||||
return;
|
||||
}
|
||||
if (type === 'drop') {
|
||||
const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef.current;
|
||||
nativeDragInsideDropZoneRef.current = false;
|
||||
setIsDragging(false);
|
||||
if (!shouldHandleDrop) return;
|
||||
|
||||
const paths = Array.isArray(typed.paths)
|
||||
? typed.paths.filter((p): p is string => typeof p === 'string')
|
||||
: [];
|
||||
if (paths.length === 0) return;
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const normalizedPath = normalizeDroppedPath(path);
|
||||
const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath;
|
||||
let file: File;
|
||||
|
||||
// In Tauri shell, dropped paths are local machine paths.
|
||||
// Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions.
|
||||
if (isTauriShell()) {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath });
|
||||
const byteCharacters = atob(result.base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: result.mime || 'application/octet-stream' });
|
||||
file = new File([blob], fileName, { type: result.mime || 'application/octet-stream' });
|
||||
} else {
|
||||
const response = await runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read dropped file (${response.status})`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
file = new File([blob], fileName, { type: blob.type || 'application/octet-stream' });
|
||||
}
|
||||
|
||||
await addAttachedFile(file);
|
||||
} catch (error) {
|
||||
console.error('Failed to attach dropped file:', path, error);
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', {
|
||||
name: path.split(/[\\/]/).pop() || t('chat.chatInput.fileFallback'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
removeListener();
|
||||
return;
|
||||
}
|
||||
unlisten = removeListener;
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to register Tauri drag-drop listener:', error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [addAttachedFile, normalizeDroppedPath, t]);
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isElectronShell, isTauriShell, isDesktopShell } from '@/lib/desktop';
|
||||
import { isElectronShell, isDesktopShell } from '@/lib/desktop';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -354,7 +354,7 @@ export function DesktopHostSwitcherDialog({
|
||||
}, [allHosts, defaultHostId, t]);
|
||||
|
||||
const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => {
|
||||
if (!isTauriShell()) return;
|
||||
if (!isDesktopShell()) return;
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -376,7 +376,7 @@ export function DesktopHostSwitcherDialog({
|
||||
}, [onOpenChange, setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!isTauriShell()) return;
|
||||
if (!isDesktopShell()) return;
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -408,7 +408,7 @@ export function DesktopHostSwitcherDialog({
|
||||
}, [t]);
|
||||
|
||||
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
|
||||
if (!isTauriShell()) return;
|
||||
if (!isDesktopShell()) return;
|
||||
setIsProbing(true);
|
||||
const nextProbingHostIds: Record<string, true> = {};
|
||||
for (const host of hosts) {
|
||||
@@ -458,7 +458,7 @@ export function DesktopHostSwitcherDialog({
|
||||
}, [open, allHosts, probeAll]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !isTauriShell()) {
|
||||
if (!open || !isDesktopShell()) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -512,7 +512,7 @@ export function DesktopHostSwitcherDialog({
|
||||
|
||||
const isSshHost = Boolean(sshHostIds[host.id]);
|
||||
|
||||
if (host.id !== LOCAL_HOST_ID && isSshHost && isTauriShell()) {
|
||||
if (host.id !== LOCAL_HOST_ID && isSshHost && isDesktopShell()) {
|
||||
let existingStatus = sshStatusesById[host.id];
|
||||
const latestStatus = await desktopSshStatus(host.id)
|
||||
.then((items) => items.find((item) => item.id === host.id) || null)
|
||||
@@ -596,7 +596,7 @@ export function DesktopHostSwitcherDialog({
|
||||
}
|
||||
}
|
||||
|
||||
if (host.id !== LOCAL_HOST_ID && isTauriShell()) {
|
||||
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
|
||||
setSwitchingHostId(host.id);
|
||||
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
setStatusById((prev) => ({
|
||||
@@ -705,7 +705,7 @@ export function DesktopHostSwitcherDialog({
|
||||
error: null,
|
||||
});
|
||||
|
||||
if (!hostId || hostId === LOCAL_HOST_ID || !isTauriShell()) {
|
||||
if (!hostId || hostId === LOCAL_HOST_ID || !isDesktopShell()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -721,7 +721,7 @@ export function DesktopHostSwitcherDialog({
|
||||
}, [allHosts, handleSwitch, sshSwitchModal.hostId]);
|
||||
|
||||
const connectSshHostInPlace = React.useCallback(async (host: DesktopHost) => {
|
||||
if (!isTauriShell()) return;
|
||||
if (!isDesktopShell()) return;
|
||||
setSwitchingHostId(host.id);
|
||||
try {
|
||||
await desktopSshConnect(host.id);
|
||||
@@ -750,7 +750,7 @@ export function DesktopHostSwitcherDialog({
|
||||
return null;
|
||||
}
|
||||
|
||||
const tauriAvailable = isTauriShell();
|
||||
const desktopAvailable = isDesktopShell();
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -772,7 +772,7 @@ export function DesktopHostSwitcherDialog({
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||
)}
|
||||
onClick={() => void probeAll(allHosts)}
|
||||
disabled={!tauriAvailable || isLoading || isProbing}
|
||||
disabled={!desktopAvailable || isLoading || isProbing}
|
||||
aria-label={t('desktopHostSwitcher.actions.refreshInstancesAria')}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-4 w-4', isProbing && 'animate-spin')} />
|
||||
@@ -805,7 +805,7 @@ export function DesktopHostSwitcherDialog({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void probeAll(allHosts)}
|
||||
disabled={!tauriAvailable || isLoading || isProbing}
|
||||
disabled={!desktopAvailable || isLoading || isProbing}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-4 w-4', isProbing && 'animate-spin')} />
|
||||
{t('desktopHostSwitcher.actions.refresh')}
|
||||
@@ -814,7 +814,7 @@ export function DesktopHostSwitcherDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!tauriAvailable && (
|
||||
{!desktopAvailable && (
|
||||
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{t('desktopHostSwitcher.state.limitedOnPage')}
|
||||
@@ -974,7 +974,7 @@ export function DesktopHostSwitcherDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && (
|
||||
{desktopAvailable && editingId && editingId !== LOCAL_HOST_ID && (
|
||||
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">{t('desktopHostSwitcher.edit.title')}</div>
|
||||
@@ -1202,7 +1202,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
|
||||
}, [connectDefaultSshInstance, startupSshModal.hostId, startupSshModal.hostLabel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTauriShell()) return;
|
||||
if (!isDesktopShell()) return;
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isDesktopLocalOriginActive, isTauriShell, openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop';
|
||||
import { DEFAULT_OPEN_IN_APP_ID, OPEN_IN_APPS } from '@/lib/openInApps';
|
||||
import { useOpenInAppsStore, type OpenInAppOption } from '@/stores/useOpenInAppsStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -87,7 +87,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
const isDesktopLocal = isTauriShell() && isDesktopLocalOriginActive();
|
||||
const isDesktopLocal = isDesktopShell() && isDesktopLocalOriginActive();
|
||||
|
||||
const selectedApp = React.useMemo(() => {
|
||||
const known = availableApps.find((app) => app.id === selectedAppId)
|
||||
|
||||
@@ -1660,15 +1660,12 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let unlistenResize: (() => void) | null = null;
|
||||
|
||||
const syncFullscreenState = async () => {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
const fullscreen = await currentWindow.isFullscreen();
|
||||
const fullscreen = await invokeDesktop<boolean>('desktop_is_window_fullscreen');
|
||||
if (!disposed) {
|
||||
setIsDesktopWindowFullscreen(fullscreen);
|
||||
setIsDesktopWindowFullscreen(fullscreen === true);
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
@@ -1677,26 +1674,16 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const attach = async () => {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
unlistenResize = await currentWindow.onResized(() => {
|
||||
void syncFullscreenState();
|
||||
});
|
||||
} catch {
|
||||
// Ignore listener setup failures; fallback state remains false.
|
||||
}
|
||||
const onResize = () => {
|
||||
void syncFullscreenState();
|
||||
};
|
||||
|
||||
void syncFullscreenState();
|
||||
void attach();
|
||||
window.addEventListener('openchamber:window-resized', onResize);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (unlistenResize) {
|
||||
unlistenResize();
|
||||
}
|
||||
window.removeEventListener('openchamber:window-resized', onResize);
|
||||
};
|
||||
}, [isDesktopApp, isMacPlatform]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { isDesktopShell, isTauriShell, startDesktopWindowDrag } from '@/lib/desktop';
|
||||
import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib/desktop';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -116,7 +116,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
}, []);
|
||||
|
||||
const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
|
||||
if (!isTauriShell()) return;
|
||||
if (!isDesktopApp) return;
|
||||
|
||||
const config = await desktopHostsGet();
|
||||
await desktopHostsSet({
|
||||
@@ -124,14 +124,14 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
...(choice === 'local' ? { defaultHostId: 'local' } : {}),
|
||||
initialHostChoiceCompleted: true,
|
||||
});
|
||||
}, []);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const announceAvailable = React.useCallback(async () => {
|
||||
if (isTauriShell()) {
|
||||
if (isDesktopApp) {
|
||||
await persistFirstChoice('local');
|
||||
}
|
||||
onCliAvailable?.();
|
||||
}, [onCliAvailable, persistFirstChoice]);
|
||||
}, [isDesktopApp, onCliAvailable, persistFirstChoice]);
|
||||
|
||||
// Background polling: while the local tab is visible, periodically check
|
||||
// whether the OpenCode CLI is reachable. As soon as it is, transition
|
||||
@@ -179,30 +179,23 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
|
||||
const handleBrowse = React.useCallback(async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!isDesktopApp || !isTauriShell()) return;
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
if (!tauri?.dialog?.open) return;
|
||||
if (!isDesktopApp) return;
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
title: t('onboarding.localSetup.dialog.selectOpencodeBinary'),
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (typeof selected === 'string' && selected.trim().length > 0) {
|
||||
setOpencodeBinary(selected.trim());
|
||||
const selected = await requestFileAccess();
|
||||
if (selected.success && selected.path && selected.path.trim().length > 0) {
|
||||
setOpencodeBinary(selected.path.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isDesktopApp, t]);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const handleApplyPath = React.useCallback(async () => {
|
||||
setIsApplyingPath(true);
|
||||
try {
|
||||
await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
|
||||
if (isTauriShell()) {
|
||||
if (isDesktopApp) {
|
||||
await persistFirstChoice('local');
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
@@ -211,7 +204,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
} finally {
|
||||
setTimeout(() => setIsApplyingPath(false), 1000);
|
||||
}
|
||||
}, [opencodeBinary, persistFirstChoice]);
|
||||
}, [isDesktopApp, opencodeBinary, persistFirstChoice]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
const result = await copyTextToClipboard(INSTALL_COMMAND);
|
||||
@@ -231,7 +224,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
? '/home/you/.bun/bin/opencode'
|
||||
: '/Users/you/.bun/bin/opencode';
|
||||
|
||||
const showLocal = !isDesktopApp || !isTauriShell() || activeTab === 'local';
|
||||
const showLocal = !isDesktopApp || activeTab === 'local';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -248,7 +241,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isDesktopApp && isTauriShell() && (
|
||||
{isDesktopApp && (
|
||||
<div className="app-region-no-drag flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -277,7 +270,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDesktopApp && isTauriShell() && activeTab === 'remote' ? (
|
||||
{isDesktopApp && activeTab === 'remote' ? (
|
||||
<div className="app-region-no-drag">
|
||||
<RemoteConnectionForm
|
||||
onBack={() => setActiveTab('local')}
|
||||
@@ -394,7 +387,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBrowse}
|
||||
disabled={isApplyingPath || !isDesktopApp || !isTauriShell()}
|
||||
disabled={isApplyingPath || !isDesktopApp}
|
||||
>
|
||||
{t('onboarding.localSetup.actions.browse')}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib/desktop';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -122,14 +122,8 @@ export function LocalSetupScreen({
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (isDesktopApp && isTauriShell()) {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const window = getCurrentWindow();
|
||||
await window.startDragging();
|
||||
} catch (error) {
|
||||
console.error('Failed to start window dragging:', error);
|
||||
}
|
||||
if (isDesktopApp) {
|
||||
await startDesktopWindowDrag();
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
@@ -148,37 +142,28 @@ export function LocalSetupScreen({
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopApp || !isTauriShell()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
if (!tauri?.dialog?.open) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
title: t('onboarding.localSetup.dialog.selectOpencodeBinary'),
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (typeof selected === 'string' && selected.trim().length > 0) {
|
||||
setOpencodeBinary(selected.trim());
|
||||
const selected = await requestFileAccess();
|
||||
if (selected.success && selected.path && selected.path.trim().length > 0) {
|
||||
setOpencodeBinary(selected.path.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isDesktopApp, t]);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const handleApplyPath = React.useCallback(async () => {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
|
||||
|
||||
// In desktop boot flow, always restart the entire Tauri app so Rust
|
||||
// can re-evaluate the boot outcome with the updated binary path.
|
||||
if (isTauriShell()) {
|
||||
// In desktop boot flow, restart the app so the native host can
|
||||
// re-evaluate the boot outcome with the updated binary path.
|
||||
if (isDesktopApp) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +172,7 @@ export function LocalSetupScreen({
|
||||
} finally {
|
||||
setTimeout(() => setIsRetrying(false), 1000);
|
||||
}
|
||||
}, [opencodeBinary]);
|
||||
}, [isDesktopApp, opencodeBinary]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
const result = await copyTextToClipboard(INSTALL_COMMAND);
|
||||
@@ -321,7 +306,7 @@ export function LocalSetupScreen({
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleBrowse}
|
||||
disabled={isRetrying || !isDesktopApp || !isTauriShell()}
|
||||
disabled={isRetrying || !isDesktopApp}
|
||||
>
|
||||
{t('onboarding.localSetup.actions.browse')}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { isTauriShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import { isDesktopShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import { DesktopConnectionRecovery, type RecoveryVariant } from './DesktopConnectionRecovery';
|
||||
import { RemoteConnectionForm } from './RemoteConnectionForm';
|
||||
import { resolveRecoveryNextStep } from './desktopRecoveryRouting';
|
||||
@@ -43,7 +43,7 @@ export function RecoveryScreen({
|
||||
}: RecoveryScreenProps) {
|
||||
// Persist the user's first choice (local or remote)
|
||||
const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
|
||||
if (!isTauriShell()) return;
|
||||
if (!isDesktopShell()) return;
|
||||
|
||||
const config = await desktopHostsGet();
|
||||
await desktopHostsSet({
|
||||
@@ -56,9 +56,9 @@ export function RecoveryScreen({
|
||||
}, []);
|
||||
|
||||
const handleRecoveryRetry = React.useCallback(async () => {
|
||||
// In desktop boot flow, always restart the entire Tauri app so Rust
|
||||
// can re-evaluate the boot outcome.
|
||||
if (isTauriShell()) {
|
||||
// In desktop boot flow, restart the app so the native host can
|
||||
// re-evaluate the boot outcome.
|
||||
if (isDesktopShell()) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function RecoveryScreen({
|
||||
// switch-default-to-local → persist local choice and restart
|
||||
await persistFirstChoice('local');
|
||||
|
||||
if (isTauriShell()) {
|
||||
if (isDesktopShell()) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export function RecoveryScreen({
|
||||
isRecoveryMode={true}
|
||||
onSwitchToLocal={onSwitchToLocalFromRemote || (() => {
|
||||
persistFirstChoice('local').then(() => {
|
||||
if (isTauriShell()) {
|
||||
if (isDesktopShell()) {
|
||||
restartDesktopApp();
|
||||
} else {
|
||||
onEnterLocalSetup?.();
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/lib/desktopHosts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { isTauriShell } from '@/lib/desktop';
|
||||
import { isDesktopShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type ConnectionState = 'idle' | 'testing' | 'success' | 'error';
|
||||
@@ -153,9 +153,8 @@ export function RemoteConnectionForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTauriShell()) {
|
||||
const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_restart');
|
||||
if (isDesktopShell()) {
|
||||
await restartDesktopApp();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.failedToSaveConnection'));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { isDesktopShell, requestFileAccess } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -54,28 +54,19 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDesktopShell() || !isTauriShell()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
if (!tauri?.dialog?.open) {
|
||||
if (!isDesktopShell()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
title: t('settings.openchamber.opencodeCli.dialog.selectBinaryTitle'),
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (typeof selected === 'string' && selected.trim().length > 0) {
|
||||
setValue(selected.trim());
|
||||
const selected = await requestFileAccess();
|
||||
if (selected.success && selected.path && selected.path.trim().length > 0) {
|
||||
setValue(selected.path.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const handleSaveAndReload = React.useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -130,7 +121,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleBrowse}
|
||||
disabled={isLoading || isSaving || !isDesktopShell() || !isTauriShell()}
|
||||
disabled={isLoading || isSaving || !isDesktopShell()}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={t('settings.openchamber.opencodeCli.actions.browseAria')}
|
||||
title={t('settings.openchamber.opencodeCli.actions.browse')}
|
||||
|
||||
@@ -42,7 +42,6 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
@@ -2629,7 +2628,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
const srcPromise = files.readFileBinary
|
||||
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
|
||||
: Promise.resolve(convertFileSrc(selectedFile.path, 'asset'));
|
||||
: Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
|
||||
path: selectedFile.path,
|
||||
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||
}));
|
||||
|
||||
await srcPromise
|
||||
.then((src) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
import { flushSync } from 'react-dom';
|
||||
import type { Theme, ThemeMode } from '@/types/theme';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell as detectDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { setDesktopWindowTheme } from '@/lib/desktopNative';
|
||||
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
@@ -220,7 +220,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
});
|
||||
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
|
||||
const isLocalDesktopOrigin = useMemo(() => isDesktopLocalOriginActive(), []);
|
||||
const isDesktopShell = useMemo(() => isTauriShell(), []);
|
||||
const isDesktopShell = useMemo(() => detectDesktopShell(), []);
|
||||
|
||||
const availableThemes = useMemo(() => {
|
||||
const merged: Theme[] = [];
|
||||
|
||||
@@ -56,17 +56,13 @@ const copyCurrentSelectionFallback = async (): Promise<boolean> => {
|
||||
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
||||
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
||||
|
||||
type TauriEventApi = {
|
||||
type DesktopBridgeGlobal = {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (evt: { payload?: unknown }) => void
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
|
||||
type TauriGlobal = {
|
||||
event?: TauriEventApi;
|
||||
};
|
||||
|
||||
type MenuAction =
|
||||
| 'about'
|
||||
| 'settings'
|
||||
@@ -344,8 +340,8 @@ export const useMenuActions = (
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const listen = tauri?.event?.listen;
|
||||
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
||||
const listen = desktop?.listen;
|
||||
if (typeof listen !== 'function') return;
|
||||
|
||||
let unlistenMenu: null | (() => void | Promise<void>) = null;
|
||||
|
||||
@@ -27,7 +27,7 @@ function isVSCodeContext(): boolean {
|
||||
*
|
||||
* Works in:
|
||||
* - Web: Full bidirectional sync
|
||||
* - Desktop (Tauri): Full bidirectional sync
|
||||
* - Desktop: Full bidirectional sync
|
||||
* - VS Code: State-only (no URL updates, reads initial params)
|
||||
*/
|
||||
export function useRouter(): void {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
|
||||
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
import { setDesktopWindowTitle } from '@/lib/desktopNative';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
@@ -110,7 +110,7 @@ export const useWindowTitle = () => {
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
if (!isTauriShell()) {
|
||||
if (!isDesktopShell()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ export const debugUtils = {
|
||||
})();
|
||||
|
||||
const runtimeApis = getRegisteredRuntimeAPIs();
|
||||
const isTauriShell = typeof window !== 'undefined' && Boolean((window as any).__TAURI__);
|
||||
const isDesktopRuntime = typeof window !== 'undefined' && Boolean((window as { __OPENCHAMBER_ELECTRON__?: unknown }).__OPENCHAMBER_ELECTRON__);
|
||||
|
||||
const safeJson = async (resp: Response) => {
|
||||
try {
|
||||
@@ -327,7 +327,7 @@ export const debugUtils = {
|
||||
const report = {
|
||||
runtime: {
|
||||
platform: runtimeApis?.runtime?.platform ?? null,
|
||||
isDesktop: isTauriShell,
|
||||
isDesktop: isDesktopRuntime,
|
||||
isVSCode: Boolean(runtimeApis?.runtime?.isVSCode),
|
||||
hasRuntimeApis: Boolean(runtimeApis),
|
||||
desktopServerOrigin: null,
|
||||
|
||||
@@ -185,19 +185,14 @@ export type DesktopSettings = {
|
||||
draftStarters?: DraftStarterRef[];
|
||||
};
|
||||
|
||||
type TauriGlobal = {
|
||||
core?: {
|
||||
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
dialog?: {
|
||||
open?: (options: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
event?: {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (evt: { payload?: unknown }) => void,
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
type DesktopBridgeGlobal = {
|
||||
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
openDialog?: (options: Record<string, unknown>) => Promise<unknown>;
|
||||
openExternal?: (url: string) => Promise<unknown>;
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (evt: { payload?: unknown }) => void,
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
|
||||
type ElectronRuntimeGlobal = {
|
||||
@@ -209,27 +204,23 @@ const getElectronRuntime = (): ElectronRuntimeGlobal | null => {
|
||||
return (window as unknown as { __OPENCHAMBER_ELECTRON__?: ElectronRuntimeGlobal }).__OPENCHAMBER_ELECTRON__ ?? null;
|
||||
};
|
||||
|
||||
export const isTauriShell = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
return typeof tauri?.core?.invoke === 'function';
|
||||
const getDesktopBridge = (): DesktopBridgeGlobal | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__ ?? null;
|
||||
};
|
||||
|
||||
export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron';
|
||||
|
||||
export const hasDesktopInvoke = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
return typeof tauri?.core?.invoke === 'function';
|
||||
return typeof getDesktopBridge()?.invoke === 'function';
|
||||
};
|
||||
|
||||
export const canUseElectronDesktopIPC = (): boolean => isElectronShell() && hasDesktopInvoke();
|
||||
|
||||
export const invokeDesktop = async <T = unknown>(command: string, args?: Record<string, unknown>): Promise<T | null> => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
if (typeof tauri?.core?.invoke !== 'function') return null;
|
||||
return tauri.core.invoke(command, args ?? {}) as Promise<T>;
|
||||
const bridge = getDesktopBridge();
|
||||
if (typeof bridge?.invoke !== 'function') return null;
|
||||
return bridge.invoke(command, args ?? {}) as Promise<T>;
|
||||
};
|
||||
|
||||
type LaunchAtLoginStatus = {
|
||||
@@ -367,18 +358,16 @@ export const isDesktopLocalOriginActive = (): boolean => {
|
||||
|
||||
export const isDesktopShell = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return isTauriShell() || isElectronShell();
|
||||
return isElectronShell();
|
||||
};
|
||||
|
||||
export const startDesktopWindowDrag = async (): Promise<boolean> => {
|
||||
if (!isDesktopShell() || !isTauriShell()) {
|
||||
if (!isDesktopShell()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const appWindow = getCurrentWindow();
|
||||
await appWindow.startDragging();
|
||||
await invokeDesktop('desktop_start_window_drag');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -418,10 +407,9 @@ export const requestDirectoryAccess = async (
|
||||
directoryPath: string
|
||||
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||
// Desktop shell on local instance: use native folder picker.
|
||||
if (isTauriShell() && isDesktopLocalOriginActive()) {
|
||||
if (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const selected = await tauri?.dialog?.open?.({
|
||||
const selected = await getDesktopBridge()?.openDialog?.({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: 'Select Working Directory',
|
||||
@@ -431,7 +419,7 @@ export const requestDirectoryAccess = async (
|
||||
}
|
||||
return { success: true, path: selected };
|
||||
} catch (error) {
|
||||
console.warn('Failed to request directory access (tauri)', error);
|
||||
console.warn('Failed to request directory access', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
@@ -442,10 +430,9 @@ export const requestDirectoryAccess = async (
|
||||
export const requestFileAccess = async (
|
||||
options?: { filters?: Array<{ name: string; extensions: string[] }>; defaultPath?: string }
|
||||
): Promise<{ success: boolean; path?: string; error?: string }> => {
|
||||
if (isTauriShell() && isDesktopLocalOriginActive()) {
|
||||
if (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const selected = await tauri?.dialog?.open?.({
|
||||
const selected = await getDesktopBridge()?.openDialog?.({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
title: 'Select File',
|
||||
@@ -457,7 +444,7 @@ export const requestFileAccess = async (
|
||||
}
|
||||
return { success: true, path: selected };
|
||||
} catch (error) {
|
||||
console.warn('Failed to request file access (tauri)', error);
|
||||
console.warn('Failed to request file access', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
@@ -482,10 +469,9 @@ export const stopAccessingDirectory = async (
|
||||
export const sendAssistantCompletionNotification = async (
|
||||
payload?: AssistantNotificationPayload
|
||||
): Promise<boolean> => {
|
||||
if (isTauriShell()) {
|
||||
if (hasDesktopInvoke()) {
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_notify', {
|
||||
await invokeDesktop('desktop_notify', {
|
||||
payload: {
|
||||
title: payload?.title,
|
||||
body: payload?.body,
|
||||
@@ -494,7 +480,7 @@ export const sendAssistantCompletionNotification = async (
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to send assistant completion notification (tauri)', error);
|
||||
console.warn('Failed to send assistant completion notification', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -503,16 +489,15 @@ export const sendAssistantCompletionNotification = async (
|
||||
};
|
||||
|
||||
export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
||||
if (!isTauriShell()) {
|
||||
if (!hasDesktopInvoke()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const info = await tauri?.core?.invoke?.('desktop_check_for_updates');
|
||||
const info = await invokeDesktop<UpdateInfo>('desktop_check_for_updates');
|
||||
return info as UpdateInfo;
|
||||
} catch (error) {
|
||||
console.warn('Failed to check for updates (tauri)', error);
|
||||
console.warn('Failed to check for updates', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -520,18 +505,18 @@ export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
|
||||
export const downloadDesktopUpdate = async (
|
||||
onProgress?: (progress: UpdateProgress) => void
|
||||
): Promise<boolean> => {
|
||||
if (!isTauriShell()) {
|
||||
if (!hasDesktopInvoke()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const bridge = getDesktopBridge();
|
||||
let unlisten: null | (() => void | Promise<void>) = null;
|
||||
let downloaded = 0;
|
||||
let total: number | undefined;
|
||||
|
||||
try {
|
||||
if (typeof onProgress === 'function' && tauri?.event?.listen) {
|
||||
unlisten = await tauri.event.listen('openchamber:update-progress', (evt) => {
|
||||
if (typeof onProgress === 'function' && bridge?.listen) {
|
||||
unlisten = await bridge.listen('openchamber:update-progress', (evt) => {
|
||||
const payload = evt?.payload;
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const data = payload as { event?: unknown; data?: unknown };
|
||||
@@ -560,10 +545,10 @@ export const downloadDesktopUpdate = async (
|
||||
});
|
||||
}
|
||||
|
||||
await tauri?.core?.invoke?.('desktop_download_and_install_update');
|
||||
await invokeDesktop('desktop_download_and_install_update');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to download update (tauri)', error);
|
||||
console.warn('Failed to download update', error);
|
||||
return false;
|
||||
} finally {
|
||||
if (unlisten) {
|
||||
@@ -580,7 +565,7 @@ export const downloadDesktopUpdate = async (
|
||||
};
|
||||
|
||||
export const restartToApplyUpdate = async (): Promise<boolean> => {
|
||||
if (!isTauriShell()) {
|
||||
if (!hasDesktopInvoke()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -588,37 +573,35 @@ export const restartToApplyUpdate = async (): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const restartDesktopApp = async (): Promise<boolean> => {
|
||||
if (!isTauriShell()) {
|
||||
if (!hasDesktopInvoke()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_restart');
|
||||
await invokeDesktop('desktop_restart');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to restart desktop app (tauri)', error);
|
||||
console.warn('Failed to restart desktop app', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDesktopLanAddress = async (): Promise<string | null> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const result = await tauri?.core?.invoke?.('desktop_get_lan_address');
|
||||
const result = await invokeDesktop<string>('desktop_get_lan_address');
|
||||
return typeof result === 'string' && result.trim().length > 0 ? result.trim() : null;
|
||||
} catch (error) {
|
||||
console.warn('Failed to get desktop LAN address (tauri)', error);
|
||||
console.warn('Failed to get desktop LAN address', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const openDesktopPath = async (path: string, app?: string | null): Promise<boolean> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -628,20 +611,19 @@ export const openDesktopPath = async (path: string, app?: string | null): Promis
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_open_path', {
|
||||
await invokeDesktop('desktop_open_path', {
|
||||
path: trimmed,
|
||||
app: typeof app === 'string' && app.trim().length > 0 ? app.trim() : undefined,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to open path (tauri)', error);
|
||||
console.warn('Failed to open path', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const revealDesktopPath = async (path: string): Promise<boolean> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -651,8 +633,7 @@ export const revealDesktopPath = async (path: string): Promise<boolean> => {
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_reveal_path', {
|
||||
await invokeDesktop('desktop_reveal_path', {
|
||||
path: trimmed,
|
||||
});
|
||||
return true;
|
||||
@@ -665,7 +646,7 @@ export const saveDesktopMarkdownFile = async (
|
||||
defaultFileName: string,
|
||||
content: string,
|
||||
): Promise<string | null> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -675,14 +656,13 @@ export const saveDesktopMarkdownFile = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const result = await tauri?.core?.invoke?.('desktop_save_markdown_file', {
|
||||
const result = await invokeDesktop<string>('desktop_save_markdown_file', {
|
||||
defaultFileName: trimmedFileName,
|
||||
content,
|
||||
});
|
||||
return typeof result === 'string' && result.trim().length > 0 ? result : null;
|
||||
} catch (error) {
|
||||
console.warn('Failed to save markdown file (tauri)', error);
|
||||
console.warn('Failed to save markdown file', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -692,7 +672,7 @@ export const openDesktopProjectInApp = async (
|
||||
appId: string,
|
||||
appName: string,
|
||||
): Promise<boolean> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -705,8 +685,7 @@ export const openDesktopProjectInApp = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_open_in_app', {
|
||||
await invokeDesktop('desktop_open_in_app', {
|
||||
projectPath: trimmedProjectPath,
|
||||
appId: trimmedAppId,
|
||||
appName: trimmedAppName,
|
||||
@@ -723,7 +702,7 @@ export const openDesktopFileInApp = async (
|
||||
appId: string,
|
||||
appName: string,
|
||||
): Promise<boolean> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -736,8 +715,7 @@ export const openDesktopFileInApp = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_open_file_in_app', {
|
||||
await invokeDesktop('desktop_open_file_in_app', {
|
||||
filePath: trimmedFilePath,
|
||||
appId: trimmedAppId,
|
||||
appName: trimmedAppName,
|
||||
@@ -750,7 +728,7 @@ export const openDesktopFileInApp = async (
|
||||
};
|
||||
|
||||
export const filterInstalledDesktopApps = async (apps: string[]): Promise<string[]> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -760,19 +738,18 @@ export const filterInstalledDesktopApps = async (apps: string[]): Promise<string
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const result = await tauri?.core?.invoke?.('desktop_filter_installed_apps', {
|
||||
const result = await invokeDesktop<string[]>('desktop_filter_installed_apps', {
|
||||
apps: candidate,
|
||||
});
|
||||
return Array.isArray(result) ? result.filter((value) => typeof value === 'string') : [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to check installed apps (tauri)', error);
|
||||
console.warn('Failed to check installed apps', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchDesktopAppIcons = async (apps: string[]): Promise<Record<string, string>> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -782,8 +759,7 @@ export const fetchDesktopAppIcons = async (apps: string[]): Promise<Record<strin
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const result = await tauri?.core?.invoke?.('desktop_fetch_app_icons', {
|
||||
const result = await invokeDesktop<unknown[]>('desktop_fetch_app_icons', {
|
||||
apps: candidate,
|
||||
});
|
||||
if (!Array.isArray(result)) {
|
||||
@@ -798,7 +774,7 @@ export const fetchDesktopAppIcons = async (apps: string[]): Promise<Record<strin
|
||||
}
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch installed app icons (tauri)', error);
|
||||
console.warn('Failed to fetch installed app icons', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
@@ -819,7 +795,7 @@ export const fetchDesktopInstalledApps = async (
|
||||
apps: string[],
|
||||
force?: boolean
|
||||
): Promise<FetchDesktopInstalledAppsResult> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return { apps: [], success: false, hasCache: false, isCacheStale: false };
|
||||
}
|
||||
|
||||
@@ -829,8 +805,7 @@ export const fetchDesktopInstalledApps = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const result = await tauri?.core?.invoke?.('desktop_get_installed_apps', {
|
||||
const result = await invokeDesktop<unknown>('desktop_get_installed_apps', {
|
||||
apps: candidate,
|
||||
force: force === true ? true : undefined,
|
||||
});
|
||||
@@ -858,19 +833,18 @@ export const fetchDesktopInstalledApps = async (
|
||||
isCacheStale: payload.isCacheStale === true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch installed apps (tauri)', error);
|
||||
console.warn('Failed to fetch installed apps', error);
|
||||
return { apps: [], success: false, hasCache: false, isCacheStale: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const clearDesktopCache = async (): Promise<boolean> => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_clear_cache');
|
||||
await invokeDesktop('desktop_clear_cache');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear cache', error);
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('shouldRestartDesktopBootFlow', () => {
|
||||
test('restarts the desktop app when boot UI is running in the startup window', () => {
|
||||
expect(
|
||||
shouldRestartDesktopBootFlow({
|
||||
isTauriShell: true,
|
||||
isDesktopShell: true,
|
||||
isDesktopLocalOriginActive: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -203,16 +203,16 @@ describe('shouldRestartDesktopBootFlow', () => {
|
||||
test('does not restart when the local desktop origin is already active', () => {
|
||||
expect(
|
||||
shouldRestartDesktopBootFlow({
|
||||
isTauriShell: true,
|
||||
isDesktopShell: true,
|
||||
isDesktopLocalOriginActive: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('does not restart outside the tauri shell', () => {
|
||||
test('does not restart outside the desktop shell', () => {
|
||||
expect(
|
||||
shouldRestartDesktopBootFlow({
|
||||
isTauriShell: false,
|
||||
isDesktopShell: false,
|
||||
isDesktopLocalOriginActive: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -222,14 +222,14 @@ export type InitialLoadingState = {
|
||||
};
|
||||
|
||||
export type DesktopBootFlowRestartInput = {
|
||||
isTauriShell: boolean;
|
||||
isDesktopShell: boolean;
|
||||
isDesktopLocalOriginActive: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the initial loading screen can be dismissed.
|
||||
*
|
||||
* Desktop shells must wait until a valid boot outcome is injected by Rust.
|
||||
* Desktop shells must wait until a valid boot outcome is injected by the native host.
|
||||
* For non-main views (chooser, recovery), the splash can dismiss as soon as
|
||||
* the outcome is known — `isInitialized` is not required because OpenCode
|
||||
* may not be available in those flows.
|
||||
@@ -254,16 +254,16 @@ export function canDismissInitialLoading(state: InitialLoadingState): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot/recovery UI can render in the Tauri startup window before the local
|
||||
* Boot/recovery UI can render in the desktop startup window before the local
|
||||
* desktop HTTP origin is active. In that state, same-origin reloads and
|
||||
* `/api/*` requests cannot recover the app, so callers must restart Tauri.
|
||||
* `/api/*` requests cannot recover the app, so callers must restart desktop.
|
||||
*/
|
||||
export function shouldRestartDesktopBootFlow(input: DesktopBootFlowRestartInput): boolean {
|
||||
return input.isTauriShell && !input.isDesktopLocalOriginActive;
|
||||
return input.isDesktopShell && !input.isDesktopLocalOriginActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the boot outcome injected by the Rust backend.
|
||||
* Read the boot outcome injected by the native desktop host.
|
||||
* Returns `null` when not in desktop, when the outcome has not been set yet,
|
||||
* or when the injected payload is malformed.
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { isTauriShell } from '@/lib/desktop';
|
||||
import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
||||
|
||||
type TauriInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
type TauriGlobal = {
|
||||
core?: {
|
||||
invoke?: TauriInvoke;
|
||||
};
|
||||
};
|
||||
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
export type DesktopHost = {
|
||||
id: string;
|
||||
@@ -176,10 +170,9 @@ export const getDesktopHostApiUrl = (host: DesktopHost): string => {
|
||||
return normalizeHostUrl(host.apiUrl || host.url) || host.apiUrl || host.url;
|
||||
};
|
||||
|
||||
const getInvoke = (): TauriInvoke | null => {
|
||||
if (!isTauriShell()) return null;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null;
|
||||
const getInvoke = (): DesktopInvoke | null => {
|
||||
if (!hasDesktopInvoke()) return null;
|
||||
return (command, args) => invokeDesktop(command, args) as Promise<unknown>;
|
||||
};
|
||||
|
||||
export const desktopHostsGet = async (): Promise<DesktopHostsConfig> => {
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
type InvokeArgs = Record<string, unknown>;
|
||||
|
||||
const isElectronDesktop = (): boolean => {
|
||||
return typeof window !== 'undefined' && Boolean((window as { __OPENCHAMBER_ELECTRON__?: unknown }).__OPENCHAMBER_ELECTRON__);
|
||||
};
|
||||
|
||||
const getInvoke = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const tauri = (window as unknown as {
|
||||
__TAURI__?: { core?: { invoke?: (cmd: string, args?: InvokeArgs) => Promise<unknown> } };
|
||||
}).__TAURI__;
|
||||
return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null;
|
||||
};
|
||||
|
||||
export const invokeDesktopCommand = async <TValue = unknown>(
|
||||
command: string,
|
||||
args?: InvokeArgs,
|
||||
): Promise<TValue> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
if (!hasDesktopInvoke()) {
|
||||
throw new Error('Desktop runtime is not available');
|
||||
}
|
||||
return invoke(command, args) as Promise<TValue>;
|
||||
return invokeDesktop<TValue>(command, args) as Promise<TValue>;
|
||||
};
|
||||
|
||||
export const startDesktopWindowDrag = async (): Promise<void> => {
|
||||
@@ -33,13 +18,7 @@ export const startDesktopWindowDrag = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (isElectronDesktop()) {
|
||||
await invokeDesktopCommand('desktop_start_window_drag');
|
||||
return;
|
||||
}
|
||||
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().startDragging();
|
||||
await invokeDesktopCommand('desktop_start_window_drag');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -51,11 +30,6 @@ export const isDesktopWindowFullscreen = async (): Promise<boolean> => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isElectronDesktop()) {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
return await getCurrentWindow().isFullscreen();
|
||||
}
|
||||
|
||||
return Boolean(await invokeDesktopCommand('desktop_is_window_fullscreen'));
|
||||
} catch {
|
||||
return false;
|
||||
@@ -77,12 +51,6 @@ export const setDesktopWindowTitle = async (title: string): Promise<void> => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isElectronDesktop()) {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().setTitle(title);
|
||||
return;
|
||||
}
|
||||
|
||||
await invokeDesktopCommand('desktop_set_window_title', { title });
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -98,12 +66,6 @@ export const setDesktopWindowTheme = async (
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isElectronDesktop()) {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('desktop_set_window_theme', { themeMode, themeVariant });
|
||||
return;
|
||||
}
|
||||
|
||||
await invokeDesktopCommand('desktop_set_window_theme', { themeMode, themeVariant });
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -116,11 +78,6 @@ export const getDesktopAppVersion = async (): Promise<string | null> => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isElectronDesktop()) {
|
||||
const { getVersion } = await import('@tauri-apps/api/app');
|
||||
return await getVersion();
|
||||
}
|
||||
|
||||
const version = await invokeDesktopCommand('desktop_get_app_version');
|
||||
return typeof version === 'string' && version.trim().length > 0 ? version : null;
|
||||
} catch {
|
||||
@@ -146,17 +103,6 @@ export const listenDesktopNativeDragDrop = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
// Electron uses the renderer's native DOM drag/drop events instead of a
|
||||
// separate webview drag listener.
|
||||
if (isElectronDesktop()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||
const webviewWindow = getCurrentWebviewWindow();
|
||||
return await webviewWindow.onDragDropEvent(handler as never);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
void handler;
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { isTauriShell } from '@/lib/desktop';
|
||||
import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
||||
|
||||
type TauriInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
type TauriGlobal = {
|
||||
core?: {
|
||||
invoke?: TauriInvoke;
|
||||
};
|
||||
event?: {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (evt: { payload?: unknown }) => void,
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
type DesktopBridgeGlobal = {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (evt: { payload?: unknown }) => void,
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
|
||||
export type DesktopSshRemoteMode = 'managed' | 'external';
|
||||
@@ -126,10 +121,9 @@ const asStringArray = (value: unknown): string[] => {
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
};
|
||||
|
||||
const getInvoke = (): TauriInvoke | null => {
|
||||
if (!isTauriShell()) return null;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null;
|
||||
const getInvoke = (): DesktopInvoke | null => {
|
||||
if (!hasDesktopInvoke()) return null;
|
||||
return (command, args) => invokeDesktop(command, args) as Promise<unknown>;
|
||||
};
|
||||
|
||||
const parseStoredSecret = (value: unknown): DesktopSshStoredSecret | undefined => {
|
||||
@@ -432,12 +426,12 @@ export const desktopSshLogsClear = async (id: string): Promise<void> => {
|
||||
export const listenDesktopSshStatus = async (
|
||||
listener: (status: DesktopSshInstanceStatus) => void,
|
||||
): Promise<() => Promise<void>> => {
|
||||
if (!isTauriShell()) {
|
||||
if (!hasDesktopInvoke()) {
|
||||
return async () => {};
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const listen = tauri?.event?.listen;
|
||||
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
||||
const listen = desktop?.listen;
|
||||
if (typeof listen !== 'function') {
|
||||
return async () => {};
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const getNavigatorDeviceHints = (maxTouchPoints: number) => {
|
||||
};
|
||||
|
||||
const setRootDeviceAttributes = (
|
||||
isTauriShellRuntime: boolean,
|
||||
isDesktopShellRuntime: boolean,
|
||||
deviceType: DeviceType,
|
||||
hasTouchInput: boolean,
|
||||
) => {
|
||||
@@ -66,7 +66,7 @@ const setRootDeviceAttributes = (
|
||||
: 'device-desktop'
|
||||
);
|
||||
|
||||
if (isTauriShellRuntime) {
|
||||
if (isDesktopShellRuntime) {
|
||||
root.classList.add('desktop-runtime');
|
||||
root.style.setProperty('--is-mobile', '0');
|
||||
root.style.setProperty('--device-type', 'desktop');
|
||||
|
||||
@@ -5,10 +5,9 @@ import type React from 'react';
|
||||
* Uses both `isComposing` and the `keyCode === 229` fallback.
|
||||
*
|
||||
* Note: `keyCode` is deprecated, but `229` remains a practical fallback for
|
||||
* some WebKit-based environments (including Tauri WebView) where composition
|
||||
* some WebKit-based environments where composition
|
||||
* events can be ordered unexpectedly.
|
||||
*/
|
||||
export const isIMECompositionEvent = (e: React.KeyboardEvent): boolean => {
|
||||
return e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { snapdom } from '@zumer/snapdom';
|
||||
import { getFontEmbedCSS, toJpeg } from 'html-to-image';
|
||||
import { invokeDesktop } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type PreviewElementMetadata = {
|
||||
@@ -97,18 +98,16 @@ export const renderPreviewScreenshot = async (
|
||||
iframe: HTMLIFrameElement,
|
||||
target: PreviewElementMetadata,
|
||||
): Promise<File | null> => {
|
||||
const tauri = typeof window !== 'undefined'
|
||||
? (window as unknown as { __TAURI__?: { core?: { invoke?: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> } } }).__TAURI__
|
||||
: undefined;
|
||||
if (typeof tauri?.core?.invoke === 'function') {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const rect = iframe.getBoundingClientRect();
|
||||
const capture = await tauri.core.invoke<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', {
|
||||
const capture = await invokeDesktop<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
if (!capture) throw new Error('Desktop screenshot capture is not available');
|
||||
const image = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Router module for URL-based navigation in OpenChamber.
|
||||
*
|
||||
* Provides bidirectional sync between URL query parameters and application state.
|
||||
* Works across web, desktop (Tauri), and VS Code (state-only mode).
|
||||
* Works across web, desktop, and VS Code (state-only mode).
|
||||
*
|
||||
* URL Schema:
|
||||
* - `?session=<id>` - Navigate to specific session
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isMacOS } from '@/lib/utils';
|
||||
import { isTauriShell } from '@/lib/desktop';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
export type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl';
|
||||
export type ShortcutKey = string;
|
||||
@@ -32,7 +32,7 @@ const MODIFIER_KEY_MAP: Record<string, ShortcutModifier> = {
|
||||
};
|
||||
|
||||
const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
|
||||
'mod': isMacOS() && isTauriShell() ? '⌘' : 'Ctrl',
|
||||
'mod': isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl',
|
||||
'shift': '⇧',
|
||||
'alt': '⌥',
|
||||
'option': '⌥',
|
||||
@@ -555,7 +555,7 @@ export function eventMatchesShortcut(
|
||||
const expectedShift = parsed.modifiers.has('shift');
|
||||
const expectedAlt = parsed.modifiers.has('alt');
|
||||
const expectedCtrl = parsed.modifiers.has('ctrl');
|
||||
const isDesktopMac = isMacOS() && isTauriShell();
|
||||
const isDesktopMac = isMacOS() && isDesktopShell();
|
||||
const isMac = isMacOS();
|
||||
|
||||
const modMatches = isDesktopMac
|
||||
@@ -615,5 +615,5 @@ export function getShortcutLabel(id: string): string {
|
||||
}
|
||||
|
||||
export function getModifierLabel(): string {
|
||||
return isMacOS() && isTauriShell() ? '⌘' : 'Ctrl';
|
||||
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
/**
|
||||
* Utility for opening external URLs with Tauri shell support.
|
||||
* In desktop runtime, uses tauri.shell.open() for proper system browser handling.
|
||||
* Falls back to window.open() for web runtime.
|
||||
*/
|
||||
|
||||
type TauriShell = {
|
||||
shell?: {
|
||||
open?: (url: string) => Promise<unknown>;
|
||||
};
|
||||
type DesktopBridgeGlobal = {
|
||||
openExternal?: (url: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const parseUrlSafely = (value: string): URL | null => {
|
||||
@@ -90,7 +82,7 @@ export const extractLoopbackUrls = (text: string): string[] => {
|
||||
|
||||
/**
|
||||
* Opens an external URL in the system browser.
|
||||
* In Tauri desktop runtime, uses tauri.shell.open() for proper handling.
|
||||
* In desktop runtime, uses the native shell for proper handling.
|
||||
* Falls back to window.open() for web runtime.
|
||||
*
|
||||
* @param url - The URL to open
|
||||
@@ -127,10 +119,10 @@ export const openExternalUrl = async (url: string): Promise<boolean> => {
|
||||
}
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
|
||||
if (tauri?.shell?.open) {
|
||||
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
||||
if (desktop?.openExternal) {
|
||||
try {
|
||||
await tauri.shell.open(normalizedTarget);
|
||||
await desktop.openExternal(normalizedTarget);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to window.open
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { isTauriShell } from "@/lib/desktop";
|
||||
import { isDesktopShell } from "@/lib/desktop";
|
||||
import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch";
|
||||
import type { I18nKey } from "@/lib/i18n";
|
||||
|
||||
@@ -31,19 +31,19 @@ export const getRevealLabelKey = (): I18nKey => {
|
||||
/**
|
||||
* Checks if the platform-appropriate modifier key is pressed.
|
||||
* On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey).
|
||||
* Browser intercepts Cmd shortcuts, so we only use Cmd in Tauri desktop app.
|
||||
* Browser intercepts Cmd shortcuts, so we only use Cmd in the desktop app.
|
||||
*/
|
||||
export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
|
||||
return isMacOS() && isTauriShell() ? e.metaKey : e.ctrlKey;
|
||||
return isMacOS() && isDesktopShell() ? e.metaKey : e.ctrlKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the platform-appropriate modifier key label.
|
||||
* On macOS desktop app: "⌘", on other platforms or web: "Ctrl"
|
||||
* Browser intercepts Cmd shortcuts, so we only show Cmd in Tauri desktop app.
|
||||
* Browser intercepts Cmd shortcuts, so we only show Cmd in the desktop app.
|
||||
*/
|
||||
export const getModifierLabel = (): string => {
|
||||
return isMacOS() && isTauriShell() ? '⌘' : 'Ctrl';
|
||||
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
|
||||
};
|
||||
|
||||
export const truncatePathMiddle = (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
|
||||
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
|
||||
import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
@@ -86,7 +86,7 @@ export const useOpenInAppsStore = create<OpenInAppsState>()((set, get) => ({
|
||||
};
|
||||
|
||||
const loadInstalledApps = async (force?: boolean) => {
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ export const useOpenInAppsStore = create<OpenInAppsState>()((set, get) => ({
|
||||
get().initialize();
|
||||
}
|
||||
|
||||
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
restartToApplyUpdate,
|
||||
isDesktopLocalOriginActive,
|
||||
isElectronShell,
|
||||
isTauriShell,
|
||||
isVSCodeRuntime,
|
||||
isWebRuntime,
|
||||
} from '@/lib/desktop';
|
||||
@@ -83,7 +82,7 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
|
||||
params.set('arch', detectArch());
|
||||
params.set('platform', detectPlatform());
|
||||
if (runtime === 'desktop') {
|
||||
params.set('appType', isElectronShell() ? 'desktop-electron' : 'desktop-tauri');
|
||||
params.set('appType', 'desktop-electron');
|
||||
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
|
||||
return params;
|
||||
}
|
||||
@@ -136,7 +135,7 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin
|
||||
}
|
||||
|
||||
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
|
||||
if (isTauriShell()) {
|
||||
if (isElectronShell()) {
|
||||
return 'desktop';
|
||||
}
|
||||
if (isVSCodeRuntime()) return 'vscode';
|
||||
|
||||
@@ -51,8 +51,8 @@ const getOpenChamberConfigDir = (): string => {
|
||||
return path.join(os.homedir(), '.config', 'openchamber');
|
||||
};
|
||||
|
||||
const sanitizeInstallScope = (scope: string): 'desktop-tauri' | 'vscode' | 'web' => {
|
||||
if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
|
||||
const sanitizeInstallScope = (scope: string): 'vscode' | 'web' => {
|
||||
if (scope === 'vscode' || scope === 'web') return scope;
|
||||
return 'web';
|
||||
};
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
sessionEditorProvider?.updateConnectionStatus(status, error);
|
||||
|
||||
// Start/stop global event watcher based on connection status
|
||||
// Mirrors web server and desktop Tauri behavior
|
||||
// Mirrors web server and desktop behavior
|
||||
if (status === 'connected' && chatViewProvider && openCodeManager) {
|
||||
setChatViewProvider(chatViewProvider);
|
||||
void startGlobalEventWatcher(openCodeManager, chatViewProvider);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
|
||||
// Session activity tracking (mirrors web server and desktop Tauri behavior)
|
||||
// Session activity tracking (mirrors web server and desktop behavior)
|
||||
type ActivityPhase = 'idle' | 'busy' | 'cooldown';
|
||||
|
||||
interface SessionActivity {
|
||||
|
||||
@@ -46,8 +46,7 @@ export const createNotificationEmitterRuntime = (dependencies) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// stdout IPC: Tauri shell spawns this process as a sidecar and parses
|
||||
// its stdout for the one-line `${prefix}{json}` protocol.
|
||||
// stdout fallback for runtimes that parse the one-line `${prefix}{json}` protocol.
|
||||
process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -64,9 +63,9 @@ export const createNotificationEmitterRuntime = (dependencies) => {
|
||||
type: 'openchamber:notification',
|
||||
properties: {
|
||||
...payload,
|
||||
// Tell the UI whether the sidecar stdout notification channel is active.
|
||||
// Tell the UI whether the stdout notification channel is active.
|
||||
// When true, the desktop UI should skip this SSE notification to avoid duplicates.
|
||||
// When false (e.g. tauri dev), the UI must handle this SSE notification itself.
|
||||
// When false, the UI must handle this SSE notification itself.
|
||||
desktopStdoutActive: desktopNotifyEnabled,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ function getOpenChamberConfigDir() {
|
||||
}
|
||||
|
||||
function sanitizeInstallScope(scope) {
|
||||
if (scope === 'desktop-electron' || scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
|
||||
if (scope === 'desktop-electron' || scope === 'vscode' || scope === 'web') return scope;
|
||||
return 'web';
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ function mapArch(value) {
|
||||
}
|
||||
|
||||
function normalizeAppType(value) {
|
||||
if (value === 'web' || value === 'desktop-electron' || value === 'desktop-tauri' || value === 'vscode') return value;
|
||||
if (value === 'web' || value === 'desktop-electron' || value === 'vscode') return value;
|
||||
return 'web';
|
||||
}
|
||||
|
||||
|
||||
@@ -115,27 +115,6 @@ describe('checkForUpdates', () => {
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
it('does not cross-check desktop update claims against npm', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.10.0',
|
||||
updateAvailable: true,
|
||||
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({
|
||||
appType: 'desktop-tauri',
|
||||
currentVersion: '1.9.10',
|
||||
});
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe('1.10.0');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('accepts electron desktop update claims without npm cross-checking', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
|
||||
@@ -184,13 +184,13 @@ const notifyWithDesktop = async (payload?: NotificationPayload): Promise<boolean
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
if (!tauri?.core?.invoke) {
|
||||
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
||||
if (!desktop?.invoke) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await tauri.core.invoke('desktop_notify', {
|
||||
await desktop.invoke('desktop_notify', {
|
||||
payload: {
|
||||
title: payload?.title,
|
||||
body: payload?.body,
|
||||
@@ -214,16 +214,14 @@ export const createWebNotificationsAPI = (): NotificationsAPI => ({
|
||||
},
|
||||
canNotify: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
if (tauri?.core?.invoke) {
|
||||
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
||||
if (desktop?.invoke) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false;
|
||||
},
|
||||
});
|
||||
type TauriGlobal = {
|
||||
core?: {
|
||||
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type DesktopBridgeGlobal = {
|
||||
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
Regular → Executable
+1
-63
@@ -10,15 +10,10 @@ const PACKAGES = [
|
||||
'package.json',
|
||||
'packages/ui/package.json',
|
||||
'packages/web/package.json',
|
||||
'packages/desktop/package.json',
|
||||
'packages/electron/package.json',
|
||||
'packages/vscode/package.json',
|
||||
];
|
||||
|
||||
const TAURI_CONF = 'packages/desktop/src-tauri/tauri.conf.json';
|
||||
const CARGO_TOML = 'packages/desktop/src-tauri/Cargo.toml';
|
||||
const CARGO_LOCK = 'packages/desktop/src-tauri/Cargo.lock';
|
||||
|
||||
const newVersion = process.argv[2];
|
||||
if (!newVersion || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(newVersion)) {
|
||||
console.error('Usage: node scripts/bump-version.mjs <version>');
|
||||
@@ -29,7 +24,6 @@ if (!newVersion || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(newVersion)) {
|
||||
|
||||
console.log(`Bumping version to ${newVersion}\n`);
|
||||
|
||||
// Update package.json files
|
||||
for (const pkgPath of PACKAGES) {
|
||||
const fullPath = path.join(ROOT, pkgPath);
|
||||
const pkg = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
@@ -39,60 +33,4 @@ for (const pkgPath of PACKAGES) {
|
||||
console.log(` ${pkgPath}: ${oldVersion} -> ${newVersion}`);
|
||||
}
|
||||
|
||||
// Update tauri.conf.json
|
||||
const tauriConfPath = path.join(ROOT, TAURI_CONF);
|
||||
const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf8'));
|
||||
const oldTauriVersion = tauriConf.version;
|
||||
tauriConf.version = newVersion;
|
||||
fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n');
|
||||
console.log(` ${TAURI_CONF}: ${oldTauriVersion} -> ${newVersion}`);
|
||||
|
||||
// Update Cargo.toml
|
||||
const cargoPath = path.join(ROOT, CARGO_TOML);
|
||||
let cargoContent = fs.readFileSync(cargoPath, 'utf8');
|
||||
const cargoMatch = cargoContent.match(/^version = "(.*)"/m);
|
||||
const oldCargoVersion = cargoMatch ? cargoMatch[1] : 'unknown';
|
||||
cargoContent = cargoContent.replace(
|
||||
/^version = ".*"$/m,
|
||||
`version = "${newVersion}"`
|
||||
);
|
||||
fs.writeFileSync(cargoPath, cargoContent);
|
||||
console.log(` ${CARGO_TOML}: ${oldCargoVersion} -> ${newVersion}`);
|
||||
|
||||
// Update Cargo.lock for openchamber-desktop, if present
|
||||
const cargoLockPath = path.join(ROOT, CARGO_LOCK);
|
||||
if (fs.existsSync(cargoLockPath)) {
|
||||
try {
|
||||
let lockContent = fs.readFileSync(cargoLockPath, 'utf8');
|
||||
const anchor = 'name = "openchamber-desktop"';
|
||||
const anchorIndex = lockContent.indexOf(anchor);
|
||||
if (anchorIndex !== -1) {
|
||||
// find the next version line after the anchor
|
||||
const verIndex = lockContent.indexOf('version', anchorIndex);
|
||||
if (verIndex !== -1) {
|
||||
const q1 = lockContent.indexOf('"', verIndex);
|
||||
const q2 = lockContent.indexOf('"', q1 + 1);
|
||||
const oldLockVersion = lockContent.substring(q1 + 1, q2);
|
||||
lockContent = lockContent.substring(0, q1 + 1) + newVersion + lockContent.substring(q2);
|
||||
fs.writeFileSync(cargoLockPath, lockContent);
|
||||
console.log(` ${CARGO_LOCK}: ${oldLockVersion} -> ${newVersion}`);
|
||||
} else {
|
||||
console.warn(`Warning: could not locate version line in ${CARGO_LOCK}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Warning: could not find openchamber-desktop entry in ${CARGO_LOCK}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to update ${CARGO_LOCK}:`, e);
|
||||
}
|
||||
} else {
|
||||
// No lock file to update; ignore gracefully
|
||||
console.log(`Cargo.lock not found at ${CARGO_LOCK}, skipping lock update`);
|
||||
}
|
||||
|
||||
console.log(`\nVersion bumped to ${newVersion}`);
|
||||
console.log('\nNext steps:');
|
||||
console.log(` git add -A`);
|
||||
console.log(` git commit -m "release v${newVersion}"`);
|
||||
console.log(` git tag v${newVersion}`);
|
||||
console.log(` git push origin main --tags`);
|
||||
console.log('\nVersion bump complete. Review changes, then commit and tag.');
|
||||
|
||||
+2
-3
@@ -3,7 +3,7 @@
|
||||
"references": [
|
||||
{ "path": "./packages/ui/tsconfig.json" },
|
||||
{ "path": "./packages/web/tsconfig.json" },
|
||||
{ "path": "./packages/desktop/tsconfig.json" }
|
||||
{ "path": "./packages/electron/tsconfig.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
@@ -14,8 +14,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@openchamber/ui/*": ["./packages/ui/src/*"],
|
||||
"@openchamber/web/*": ["./packages/web/src/*"],
|
||||
"@openchamber/desktop/*": ["./packages/desktop/src/*"]
|
||||
"@openchamber/web/*": ["./packages/web/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user