summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop-electron
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-03-04 15:12:34 +0800
committerGitHub <[email protected]>2026-03-04 15:12:34 +0800
commit5cf235fa6cf7b4c890c68f8ff68a96fcae992abf (patch)
tree25fdfd8ce95ad048fb097822995dcf060e8d6d8b /packages/desktop-electron
parente4f0825c56300286ec0aa82b1006e4006a17e1e1 (diff)
downloadopencode-5cf235fa6cf7b4c890c68f8ff68a96fcae992abf.tar.gz
opencode-5cf235fa6cf7b4c890c68f8ff68a96fcae992abf.zip
desktop: add electron version (#15663)
Diffstat (limited to 'packages/desktop-electron')
-rw-r--r--packages/desktop-electron/.gitignore28
-rw-r--r--packages/desktop-electron/AGENTS.md4
-rw-r--r--packages/desktop-electron/README.md32
-rw-r--r--packages/desktop-electron/electron-builder.config.ts97
-rw-r--r--packages/desktop-electron/electron.vite.config.ts41
-rw-r--r--packages/desktop-electron/icons/README.md11
-rw-r--r--packages/desktop-electron/icons/beta/128x128.pngbin0 -> 10186 bytes
-rw-r--r--packages/desktop-electron/icons/beta/[email protected]bin0 -> 36252 bytes
-rw-r--r--packages/desktop-electron/icons/beta/32x32.pngbin0 -> 1309 bytes
-rw-r--r--packages/desktop-electron/icons/beta/64x64.pngbin0 -> 3587 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square107x107Logo.pngbin0 -> 7562 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square142x142Logo.pngbin0 -> 12279 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square150x150Logo.pngbin0 -> 13445 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square284x284Logo.pngbin0 -> 45201 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square30x30Logo.pngbin0 -> 1281 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square310x310Logo.pngbin0 -> 54725 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square44x44Logo.pngbin0 -> 2167 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square71x71Logo.pngbin0 -> 4121 bytes
-rw-r--r--packages/desktop-electron/icons/beta/Square89x89Logo.pngbin0 -> 5782 bytes
-rw-r--r--packages/desktop-electron/icons/beta/StoreLogo.pngbin0 -> 2559 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.pngbin0 -> 2077 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.pngbin0 -> 15269 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.pngbin0 -> 1887 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.pngbin0 -> 2083 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.pngbin0 -> 7845 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.pngbin0 -> 1792 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.pngbin0 -> 5778 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.pngbin0 -> 25523 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.pngbin0 -> 5026 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.pngbin0 -> 10758 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.pngbin0 -> 60763 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.pngbin0 -> 9312 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.pngbin0 -> 17122 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.pngbin0 -> 116520 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.pngbin0 -> 14941 bytes
-rw-r--r--packages/desktop-electron/icons/beta/android/values/ic_launcher_background.xml4
-rw-r--r--packages/desktop-electron/icons/beta/icon.icnsbin0 -> 882048 bytes
-rw-r--r--packages/desktop-electron/icons/beta/icon.icobin0 -> 49612 bytes
-rw-r--r--packages/desktop-electron/icons/beta/icon.pngbin0 -> 172485 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 687 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 1660 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 1660 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 2950 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 1072 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 2834 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 2834 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 5048 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 1660 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 4396 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 4396 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 8452 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 596205 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 8452 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 16916 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 4193 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 12523 bytes
-rw-r--r--packages/desktop-electron/icons/beta/ios/[email protected]bin0 -> 14760 bytes
-rw-r--r--packages/desktop-electron/icons/dev/128x128.pngbin0 -> 16568 bytes
-rw-r--r--packages/desktop-electron/icons/dev/[email protected]bin0 -> 59884 bytes
-rw-r--r--packages/desktop-electron/icons/dev/32x32.pngbin0 -> 1973 bytes
-rw-r--r--packages/desktop-electron/icons/dev/64x64.pngbin0 -> 5469 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square107x107Logo.pngbin0 -> 12116 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square142x142Logo.pngbin0 -> 19936 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square150x150Logo.pngbin0 -> 21988 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square284x284Logo.pngbin0 -> 74022 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square30x30Logo.pngbin0 -> 1786 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square310x310Logo.pngbin0 -> 89075 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square44x44Logo.pngbin0 -> 3211 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square71x71Logo.pngbin0 -> 6370 bytes
-rw-r--r--packages/desktop-electron/icons/dev/Square89x89Logo.pngbin0 -> 9316 bytes
-rw-r--r--packages/desktop-electron/icons/dev/StoreLogo.pngbin0 -> 3862 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.pngbin0 -> 3076 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.pngbin0 -> 24987 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.pngbin0 -> 2853 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.pngbin0 -> 3016 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.pngbin0 -> 12682 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_round.pngbin0 -> 2702 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher.pngbin0 -> 8701 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.pngbin0 -> 42285 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_round.pngbin0 -> 7640 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher.pngbin0 -> 16970 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.pngbin0 -> 97586 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.pngbin0 -> 14939 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher.pngbin0 -> 27316 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.pngbin0 -> 180625 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.pngbin0 -> 24066 bytes
-rw-r--r--packages/desktop-electron/icons/dev/android/values/ic_launcher_background.xml4
-rw-r--r--packages/desktop-electron/icons/dev/icon.icnsbin0 -> 1187792 bytes
-rw-r--r--packages/desktop-electron/icons/dev/icon.icobin0 -> 73182 bytes
-rw-r--r--packages/desktop-electron/icons/dev/icon.pngbin0 -> 264014 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 955 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 2695 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 2695 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 4932 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 1640 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 4684 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 4684 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 8781 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 2695 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 7529 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 7529 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 14557 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 980713 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 14557 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 29995 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 7093 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 22066 bytes
-rw-r--r--packages/desktop-electron/icons/dev/ios/[email protected]bin0 -> 25898 bytes
-rw-r--r--packages/desktop-electron/icons/prod/128x128.pngbin0 -> 9013 bytes
-rw-r--r--packages/desktop-electron/icons/prod/[email protected]bin0 -> 36840 bytes
-rw-r--r--packages/desktop-electron/icons/prod/32x32.pngbin0 -> 1255 bytes
-rw-r--r--packages/desktop-electron/icons/prod/64x64.pngbin0 -> 2971 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square107x107Logo.pngbin0 -> 6441 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square142x142Logo.pngbin0 -> 10850 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square150x150Logo.pngbin0 -> 12036 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square284x284Logo.pngbin0 -> 47137 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square30x30Logo.pngbin0 -> 1109 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square310x310Logo.pngbin0 -> 58165 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square44x44Logo.pngbin0 -> 1827 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square71x71Logo.pngbin0 -> 3405 bytes
-rw-r--r--packages/desktop-electron/icons/prod/Square89x89Logo.pngbin0 -> 4760 bytes
-rw-r--r--packages/desktop-electron/icons/prod/StoreLogo.pngbin0 -> 2186 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher.pngbin0 -> 1886 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.pngbin0 -> 13918 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_round.pngbin0 -> 1811 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher.pngbin0 -> 1873 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.pngbin0 -> 6540 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_round.pngbin0 -> 1751 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher.pngbin0 -> 4726 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.pngbin0 -> 25393 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_round.pngbin0 -> 4101 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher.pngbin0 -> 9156 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.pngbin0 -> 64829 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.pngbin0 -> 8270 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher.pngbin0 -> 15359 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.pngbin0 -> 127895 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.pngbin0 -> 14064 bytes
-rw-r--r--packages/desktop-electron/icons/prod/android/values/ic_launcher_background.xml4
-rw-r--r--packages/desktop-electron/icons/prod/icon.icnsbin0 -> 1010901 bytes
-rw-r--r--packages/desktop-electron/icons/prod/icon.icobin0 -> 47600 bytes
-rw-r--r--packages/desktop-electron/icons/prod/icon.pngbin0 -> 190179 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 728 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 1607 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 1607 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 2648 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 1094 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 2542 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 2542 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 4709 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 1607 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 4058 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 4058 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 7828 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 681769 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 7828 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 17106 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 3730 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 12166 bytes
-rw-r--r--packages/desktop-electron/icons/prod/ios/[email protected]bin0 -> 14705 bytes
-rw-r--r--packages/desktop-electron/package.json52
-rw-r--r--packages/desktop-electron/resources/entitlements.plist30
-rw-r--r--packages/desktop-electron/scripts/copy-bundles.ts12
-rw-r--r--packages/desktop-electron/scripts/copy-icons.ts12
-rw-r--r--packages/desktop-electron/scripts/finalize-latest-yml.ts116
-rw-r--r--packages/desktop-electron/scripts/predev.ts17
-rwxr-xr-xpackages/desktop-electron/scripts/prepare.ts24
-rw-r--r--packages/desktop-electron/scripts/utils.ts69
-rw-r--r--packages/desktop-electron/src/main/apps.ts148
-rw-r--r--packages/desktop-electron/src/main/cli.ts279
-rw-r--r--packages/desktop-electron/src/main/constants.ts10
-rw-r--r--packages/desktop-electron/src/main/env.d.ts7
-rw-r--r--packages/desktop-electron/src/main/index.ts449
-rw-r--r--packages/desktop-electron/src/main/ipc.ts176
-rw-r--r--packages/desktop-electron/src/main/logging.ts40
-rw-r--r--packages/desktop-electron/src/main/markdown.ts16
-rw-r--r--packages/desktop-electron/src/main/menu.ts116
-rw-r--r--packages/desktop-electron/src/main/migrate.ts91
-rw-r--r--packages/desktop-electron/src/main/server.ts129
-rw-r--r--packages/desktop-electron/src/main/store.ts15
-rw-r--r--packages/desktop-electron/src/main/windows.ts135
-rw-r--r--packages/desktop-electron/src/preload/index.ts66
-rw-r--r--packages/desktop-electron/src/preload/types.ts64
-rw-r--r--packages/desktop-electron/src/renderer/cli.ts12
-rw-r--r--packages/desktop-electron/src/renderer/env.d.ts12
-rw-r--r--packages/desktop-electron/src/renderer/i18n/ar.ts26
-rw-r--r--packages/desktop-electron/src/renderer/i18n/br.ts27
-rw-r--r--packages/desktop-electron/src/renderer/i18n/bs.ts28
-rw-r--r--packages/desktop-electron/src/renderer/i18n/da.ts28
-rw-r--r--packages/desktop-electron/src/renderer/i18n/de.ts28
-rw-r--r--packages/desktop-electron/src/renderer/i18n/en.ts27
-rw-r--r--packages/desktop-electron/src/renderer/i18n/es.ts27
-rw-r--r--packages/desktop-electron/src/renderer/i18n/fr.ts28
-rw-r--r--packages/desktop-electron/src/renderer/i18n/index.ts187
-rw-r--r--packages/desktop-electron/src/renderer/i18n/ja.ts28
-rw-r--r--packages/desktop-electron/src/renderer/i18n/ko.ts27
-rw-r--r--packages/desktop-electron/src/renderer/i18n/no.ts28
-rw-r--r--packages/desktop-electron/src/renderer/i18n/pl.ts28
-rw-r--r--packages/desktop-electron/src/renderer/i18n/ru.ts27
-rw-r--r--packages/desktop-electron/src/renderer/i18n/zh.ts26
-rw-r--r--packages/desktop-electron/src/renderer/i18n/zht.ts26
-rw-r--r--packages/desktop-electron/src/renderer/index.html23
-rw-r--r--packages/desktop-electron/src/renderer/index.tsx312
-rw-r--r--packages/desktop-electron/src/renderer/loading.html23
-rw-r--r--packages/desktop-electron/src/renderer/loading.tsx80
-rw-r--r--packages/desktop-electron/src/renderer/styles.css0
-rw-r--r--packages/desktop-electron/src/renderer/updater.ts14
-rw-r--r--packages/desktop-electron/src/renderer/webview-zoom.ts38
-rw-r--r--packages/desktop-electron/sst-env.d.ts10
-rw-r--r--packages/desktop-electron/tsconfig.json22
212 files changed, 3455 insertions, 0 deletions
diff --git a/packages/desktop-electron/.gitignore b/packages/desktop-electron/.gitignore
new file mode 100644
index 000000000..ac9d8db96
--- /dev/null
+++ b/packages/desktop-electron/.gitignore
@@ -0,0 +1,28 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+out/
+
+resources/opencode-cli*
+resources/icons
diff --git a/packages/desktop-electron/AGENTS.md b/packages/desktop-electron/AGENTS.md
new file mode 100644
index 000000000..7805ea835
--- /dev/null
+++ b/packages/desktop-electron/AGENTS.md
@@ -0,0 +1,4 @@
+# Desktop package notes
+
+- Renderer process should only call `window.api` from `src/preload`.
+- Main process should register IPC handlers in `src/main/ipc.ts`.
diff --git a/packages/desktop-electron/README.md b/packages/desktop-electron/README.md
new file mode 100644
index 000000000..ebaf48822
--- /dev/null
+++ b/packages/desktop-electron/README.md
@@ -0,0 +1,32 @@
+# OpenCode Desktop
+
+Native OpenCode desktop app, built with Tauri v2.
+
+## Development
+
+From the repo root:
+
+```bash
+bun install
+bun run --cwd packages/desktop tauri dev
+```
+
+This starts the Vite dev server on http://localhost:1420 and opens the native window.
+
+If you only want the web dev server (no native shell):
+
+```bash
+bun run --cwd packages/desktop dev
+```
+
+## Build
+
+To create a production `dist/` and build the native app bundle:
+
+```bash
+bun run --cwd packages/desktop tauri build
+```
+
+## Prerequisites
+
+Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts
new file mode 100644
index 000000000..e6b4bcd2b
--- /dev/null
+++ b/packages/desktop-electron/electron-builder.config.ts
@@ -0,0 +1,97 @@
+import type { Configuration } from "electron-builder"
+
+const channel = (() => {
+ const raw = process.env.OPENCODE_CHANNEL
+ if (raw === "dev" || raw === "beta" || raw === "prod") return raw
+ return "dev"
+})()
+
+const getBase = (): Configuration => ({
+ artifactName: "opencode-electron-${os}-${arch}.${ext}",
+ directories: {
+ output: "dist",
+ buildResources: "resources",
+ },
+ files: ["out/**/*", "resources/**/*"],
+ extraResources: [
+ {
+ from: "resources/",
+ to: "",
+ filter: ["opencode-cli*"],
+ },
+ {
+ from: "native/",
+ to: "native/",
+ filter: ["index.js", "index.d.ts", "build/Release/mac_window.node", "swift-build/**"],
+ },
+ ],
+ mac: {
+ category: "public.app-category.developer-tools",
+ icon: `resources/icons/icon.icns`,
+ hardenedRuntime: true,
+ gatekeeperAssess: false,
+ entitlements: "resources/entitlements.plist",
+ entitlementsInherit: "resources/entitlements.plist",
+ notarize: true,
+ target: ["dmg", "zip"],
+ },
+ dmg: {
+ sign: true,
+ },
+ protocols: {
+ name: "OpenCode",
+ schemes: ["opencode"],
+ },
+ win: {
+ icon: `resources/icons/icon.ico`,
+ target: ["nsis"],
+ },
+ nsis: {
+ oneClick: false,
+ allowToChangeInstallationDirectory: true,
+ installerIcon: `resources/icons/icon.ico`,
+ installerHeaderIcon: `resources/icons/icon.ico`,
+ },
+ linux: {
+ icon: `resources/icons`,
+ category: "Development",
+ target: ["AppImage", "deb", "rpm"],
+ },
+})
+
+function getConfig() {
+ const base = getBase()
+
+ switch (channel) {
+ case "dev": {
+ return {
+ ...base,
+ appId: "ai.opencode.desktop.dev",
+ productName: "OpenCode Dev",
+ rpm: { packageName: "opencode-dev" },
+ }
+ }
+ case "beta": {
+ return {
+ ...base,
+ appId: "ai.opencode.desktop.beta",
+ productName: "OpenCode Beta",
+ protocols: { name: "OpenCode Beta", schemes: ["opencode"] },
+ publish: { provider: "github", owner: "anomalyco", repo: "opencode-beta", channel: "latest" },
+ rpm: { packageName: "opencode-beta" },
+ }
+ }
+ case "prod": {
+ return {
+ ...base,
+ appId: "ai.opencode.desktop",
+ productName: "OpenCode",
+ protocols: { name: "OpenCode", schemes: ["opencode"] },
+ publish: { provider: "github", owner: "anomalyco", repo: "opencode", channel: "latest" },
+ rpm: { packageName: "opencode" },
+ }
+ }
+ }
+}
+
+export default getConfig()
diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts
new file mode 100644
index 000000000..80c1d6b70
--- /dev/null
+++ b/packages/desktop-electron/electron.vite.config.ts
@@ -0,0 +1,41 @@
+import { defineConfig } from "electron-vite"
+import appPlugin from "@opencode-ai/app/vite"
+
+const channel = (() => {
+ const raw = process.env.OPENCODE_CHANNEL
+ if (raw === "dev" || raw === "beta" || raw === "prod") return raw
+ return "dev"
+})()
+
+export default defineConfig({
+ main: {
+ define: {
+ "import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
+ },
+ build: {
+ rollupOptions: {
+ input: { index: "src/main/index.ts" },
+ },
+ },
+ },
+ preload: {
+ build: {
+ rollupOptions: {
+ input: { index: "src/preload/index.ts" },
+ },
+ },
+ },
+ renderer: {
+ plugins: [appPlugin],
+ publicDir: "../app/public",
+ root: "src/renderer",
+ build: {
+ rollupOptions: {
+ input: {
+ main: "src/renderer/index.html",
+ loading: "src/renderer/loading.html",
+ },
+ },
+ },
+ },
+})
diff --git a/packages/desktop-electron/icons/README.md b/packages/desktop-electron/icons/README.md
new file mode 100644
index 000000000..fa219a77e
--- /dev/null
+++ b/packages/desktop-electron/icons/README.md
@@ -0,0 +1,11 @@
+# Tauri Icons
+
+Here's the process I've been using to create icons:
+
+- Save source image as `app-icon.png` in `packages/desktop`
+- `cd` to `packages/desktop`
+- Run `bun tauri icon -o src-tauri/icons/{environment}`
+- Use [Image2Icon](https://img2icnsapp.com/)'s 'Big Sur Icon' preset to generate an `icon.icns` file and place it in the appropriate icons folder
+
+The Image2Icon step is necessary as the `icon.icns` generated by `app-icon.png` does not apply the shadow/padding expected by macOS,
+so app icons appear larger than expected.
diff --git a/packages/desktop-electron/icons/beta/128x128.png b/packages/desktop-electron/icons/beta/128x128.png
new file mode 100644
index 000000000..751e80f1f
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/128x128.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/[email protected] b/packages/desktop-electron/icons/beta/[email protected]
new file mode 100644
index 000000000..fe330df41
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/32x32.png b/packages/desktop-electron/icons/beta/32x32.png
new file mode 100644
index 000000000..2703048ee
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/32x32.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/64x64.png b/packages/desktop-electron/icons/beta/64x64.png
new file mode 100644
index 000000000..ecd7fe314
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/64x64.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square107x107Logo.png b/packages/desktop-electron/icons/beta/Square107x107Logo.png
new file mode 100644
index 000000000..e6ea73f4d
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square107x107Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square142x142Logo.png b/packages/desktop-electron/icons/beta/Square142x142Logo.png
new file mode 100644
index 000000000..74ae729c4
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square142x142Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square150x150Logo.png b/packages/desktop-electron/icons/beta/Square150x150Logo.png
new file mode 100644
index 000000000..0b109b8f4
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square150x150Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square284x284Logo.png b/packages/desktop-electron/icons/beta/Square284x284Logo.png
new file mode 100644
index 000000000..0261ded42
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square284x284Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square30x30Logo.png b/packages/desktop-electron/icons/beta/Square30x30Logo.png
new file mode 100644
index 000000000..34158f10a
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square30x30Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square310x310Logo.png b/packages/desktop-electron/icons/beta/Square310x310Logo.png
new file mode 100644
index 000000000..f18bfada4
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square310x310Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square44x44Logo.png b/packages/desktop-electron/icons/beta/Square44x44Logo.png
new file mode 100644
index 000000000..6d1cc06c0
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square44x44Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square71x71Logo.png b/packages/desktop-electron/icons/beta/Square71x71Logo.png
new file mode 100644
index 000000000..a26084dc2
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square71x71Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/Square89x89Logo.png b/packages/desktop-electron/icons/beta/Square89x89Logo.png
new file mode 100644
index 000000000..58b0eb605
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/Square89x89Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/StoreLogo.png b/packages/desktop-electron/icons/beta/StoreLogo.png
new file mode 100644
index 000000000..648fd2114
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/StoreLogo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop-electron/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..2ffbf24b6
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+ <background android:drawable="@color/ic_launcher_background"/>
+</adaptive-icon> \ No newline at end of file
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.png b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..39d1dd0d5
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..84908e71c
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..a6b8cb616
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.png b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..6522e0fba
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..b3449bd4f
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..7aa97d827
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..82bc9d22a
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..6b031ce85
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..34859de5e
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..4cdb71d62
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..a64be6ada
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..2de3c2734
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..0ead28866
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..bdd174825
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..69f74758e
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/android/values/ic_launcher_background.xml b/packages/desktop-electron/icons/beta/android/values/ic_launcher_background.xml
new file mode 100644
index 000000000..ea9c223a6
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/android/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_launcher_background">#fff</color>
+</resources> \ No newline at end of file
diff --git a/packages/desktop-electron/icons/beta/icon.icns b/packages/desktop-electron/icons/beta/icon.icns
new file mode 100644
index 000000000..f98de5da8
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/icon.icns
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/icon.ico b/packages/desktop-electron/icons/beta/icon.ico
new file mode 100644
index 000000000..df8588c8e
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/icon.ico
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/icon.png b/packages/desktop-electron/icons/beta/icon.png
new file mode 100644
index 000000000..531304956
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/icon.png
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..e8ebb28ef
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..50c8015de
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..50c8015de
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..6e290dbc6
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..4ef554b4d
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..b9ddfd47c
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..b9ddfd47c
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..052322d68
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..50c8015de
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..9317b2500
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..9317b2500
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..6b921a17e
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..b83131d64
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..6b921a17e
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..685004995
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..1ffceb752
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..81c4178c9
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected]
new file mode 100644
index 000000000..d5453adff
--- /dev/null
+++ b/packages/desktop-electron/icons/beta/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/128x128.png b/packages/desktop-electron/icons/dev/128x128.png
new file mode 100644
index 000000000..d7fc4db14
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/128x128.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/[email protected] b/packages/desktop-electron/icons/dev/[email protected]
new file mode 100644
index 000000000..591882306
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/32x32.png b/packages/desktop-electron/icons/dev/32x32.png
new file mode 100644
index 000000000..53925cc4f
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/32x32.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/64x64.png b/packages/desktop-electron/icons/dev/64x64.png
new file mode 100644
index 000000000..a88ef15c6
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/64x64.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square107x107Logo.png b/packages/desktop-electron/icons/dev/Square107x107Logo.png
new file mode 100644
index 000000000..0de29ec82
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square107x107Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square142x142Logo.png b/packages/desktop-electron/icons/dev/Square142x142Logo.png
new file mode 100644
index 000000000..af62e8e1e
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square142x142Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square150x150Logo.png b/packages/desktop-electron/icons/dev/Square150x150Logo.png
new file mode 100644
index 000000000..2b19dc39c
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square150x150Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square284x284Logo.png b/packages/desktop-electron/icons/dev/Square284x284Logo.png
new file mode 100644
index 000000000..eda6d9901
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square284x284Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square30x30Logo.png b/packages/desktop-electron/icons/dev/Square30x30Logo.png
new file mode 100644
index 000000000..dad821ba8
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square30x30Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square310x310Logo.png b/packages/desktop-electron/icons/dev/Square310x310Logo.png
new file mode 100644
index 000000000..555b3b197
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square310x310Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square44x44Logo.png b/packages/desktop-electron/icons/dev/Square44x44Logo.png
new file mode 100644
index 000000000..9f8ad001f
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square44x44Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square71x71Logo.png b/packages/desktop-electron/icons/dev/Square71x71Logo.png
new file mode 100644
index 000000000..43feb7848
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square71x71Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/Square89x89Logo.png b/packages/desktop-electron/icons/dev/Square89x89Logo.png
new file mode 100644
index 000000000..628cc597f
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/Square89x89Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/StoreLogo.png b/packages/desktop-electron/icons/dev/StoreLogo.png
new file mode 100644
index 000000000..8d3aa53cf
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/StoreLogo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop-electron/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..2ffbf24b6
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+ <background android:drawable="@color/ic_launcher_background"/>
+</adaptive-icon> \ No newline at end of file
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.png b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..b355e37fe
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..c33f8713b
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..04e37aa65
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.png b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..98e53cd22
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..40fe6e378
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..4814f1ddf
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..608493283
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..898066a3f
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..64035c0f3
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..f47691bf4
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..dba6f5635
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..764702604
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..2e8430a60
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..db953d128
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..d5c9ba6a8
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/android/values/ic_launcher_background.xml b/packages/desktop-electron/icons/dev/android/values/ic_launcher_background.xml
new file mode 100644
index 000000000..ea9c223a6
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/android/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_launcher_background">#fff</color>
+</resources> \ No newline at end of file
diff --git a/packages/desktop-electron/icons/dev/icon.icns b/packages/desktop-electron/icons/dev/icon.icns
new file mode 100644
index 000000000..d73a94904
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/icon.icns
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/icon.ico b/packages/desktop-electron/icons/dev/icon.ico
new file mode 100644
index 000000000..bec385d9a
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/icon.ico
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/icon.png b/packages/desktop-electron/icons/dev/icon.png
new file mode 100644
index 000000000..6de37ea29
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/icon.png
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..0e823043e
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..54e4b2aac
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..54e4b2aac
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..645b01561
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..054225c6e
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..0b1b2e0b7
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..0b1b2e0b7
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..d2c42592b
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..54e4b2aac
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..471ed2eec
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..471ed2eec
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..1a490cbf1
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..f53b404e5
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..1a490cbf1
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..bdc759eef
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..d22096a2d
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..d675773d1
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected]
new file mode 100644
index 000000000..31698afce
--- /dev/null
+++ b/packages/desktop-electron/icons/dev/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/128x128.png b/packages/desktop-electron/icons/prod/128x128.png
new file mode 100644
index 000000000..caf7b02eb
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/128x128.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/[email protected] b/packages/desktop-electron/icons/prod/[email protected]
new file mode 100644
index 000000000..47fe4c61e
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/32x32.png b/packages/desktop-electron/icons/prod/32x32.png
new file mode 100644
index 000000000..5868bcc93
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/32x32.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/64x64.png b/packages/desktop-electron/icons/prod/64x64.png
new file mode 100644
index 000000000..1ed7425d8
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/64x64.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square107x107Logo.png b/packages/desktop-electron/icons/prod/Square107x107Logo.png
new file mode 100644
index 000000000..1db249bf7
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square107x107Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square142x142Logo.png b/packages/desktop-electron/icons/prod/Square142x142Logo.png
new file mode 100644
index 000000000..1961c3408
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square142x142Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square150x150Logo.png b/packages/desktop-electron/icons/prod/Square150x150Logo.png
new file mode 100644
index 000000000..abc507347
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square150x150Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square284x284Logo.png b/packages/desktop-electron/icons/prod/Square284x284Logo.png
new file mode 100644
index 000000000..51e2a1b9f
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square284x284Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square30x30Logo.png b/packages/desktop-electron/icons/prod/Square30x30Logo.png
new file mode 100644
index 000000000..066a1fd0c
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square30x30Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square310x310Logo.png b/packages/desktop-electron/icons/prod/Square310x310Logo.png
new file mode 100644
index 000000000..2a85c8e95
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square310x310Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square44x44Logo.png b/packages/desktop-electron/icons/prod/Square44x44Logo.png
new file mode 100644
index 000000000..c855b8063
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square44x44Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square71x71Logo.png b/packages/desktop-electron/icons/prod/Square71x71Logo.png
new file mode 100644
index 000000000..c8168f711
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square71x71Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/Square89x89Logo.png b/packages/desktop-electron/icons/prod/Square89x89Logo.png
new file mode 100644
index 000000000..19ec1777d
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/Square89x89Logo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/StoreLogo.png b/packages/desktop-electron/icons/prod/StoreLogo.png
new file mode 100644
index 000000000..3fd053d34
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/StoreLogo.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop-electron/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..2ffbf24b6
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+ <background android:drawable="@color/ic_launcher_background"/>
+</adaptive-icon> \ No newline at end of file
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher.png b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..4f3ea0e36
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..7db80699b
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..a54ebe652
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher.png b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..9337ccfa3
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..0bfc1082e
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..5b02ec732
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..322aeaeaa
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..ca1e336cc
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..f71110799
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..287a6b500
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..9d3d06a86
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..d4b6fde1b
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..bde8d7596
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..03df7809d
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..62363be04
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/android/values/ic_launcher_background.xml b/packages/desktop-electron/icons/prod/android/values/ic_launcher_background.xml
new file mode 100644
index 000000000..ea9c223a6
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/android/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_launcher_background">#fff</color>
+</resources> \ No newline at end of file
diff --git a/packages/desktop-electron/icons/prod/icon.icns b/packages/desktop-electron/icons/prod/icon.icns
new file mode 100644
index 000000000..be910ad5f
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/icon.icns
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/icon.ico b/packages/desktop-electron/icons/prod/icon.ico
new file mode 100644
index 000000000..ff88d21e4
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/icon.ico
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/icon.png b/packages/desktop-electron/icons/prod/icon.png
new file mode 100644
index 000000000..0ecbb6d5f
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/icon.png
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..eb137e164
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..aa76ab10b
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..aa76ab10b
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..c58ea3d49
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..0eeb4d9bf
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..32601c70a
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..32601c70a
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..a372c4a11
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..aa76ab10b
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..e82ce2765
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..e82ce2765
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..15ad59362
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..2260671c0
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..15ad59362
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..5c66bd3b1
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..a5b05f3b5
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..9c0615d41
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected]
new file mode 100644
index 000000000..6b792b36a
--- /dev/null
+++ b/packages/desktop-electron/icons/prod/ios/[email protected]
Binary files differ
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
new file mode 100644
index 000000000..c4a64aff7
--- /dev/null
+++ b/packages/desktop-electron/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@opencode-ai/desktop-electron",
+ "private": true,
+ "version": "1.2.6",
+ "type": "module",
+ "license": "MIT",
+ "homepage": "https://opencode.ai",
+ "author": {
+ "name": "OpenCode",
+ "email": "[email protected]"
+ },
+ "scripts": {
+ "typecheck": "tsgo -b",
+ "predev": "bun ./scripts/predev.ts",
+ "dev": "electron-vite dev",
+ "prebuild": "bun ./scripts/copy-icons.ts",
+ "build": "electron-vite build",
+ "preview": "electron-vite preview",
+ "package": "electron-builder --config electron-builder.config.ts",
+ "package:mac": "electron-builder --mac --config electron-builder.config.ts",
+ "package:win": "electron-builder --win --config electron-builder.config.ts",
+ "package:linux": "electron-builder --linux --config electron-builder.config.ts",
+ "native:build": "bun install --cwd native"
+ },
+ "main": "./out/main/index.js",
+ "dependencies": {
+ "@opencode-ai/app": "workspace:*",
+ "@opencode-ai/ui": "workspace:*",
+ "@solid-primitives/i18n": "2.2.1",
+ "@solid-primitives/storage": "catalog:",
+ "@solidjs/meta": "catalog:",
+ "@solidjs/router": "0.15.4",
+ "electron-log": "^5",
+ "electron-store": "^10",
+ "electron-updater": "^6",
+ "electron-window-state": "^5.0.3",
+ "marked": "^15",
+ "solid-js": "catalog:",
+ "tree-kill": "^1.2.2"
+ },
+ "devDependencies": {
+ "@actions/artifact": "4.0.0",
+ "@types/bun": "catalog:",
+ "@types/node": "catalog:",
+ "@typescript/native-preview": "catalog:",
+ "electron": "40.4.1",
+ "electron-builder": "^26",
+ "electron-vite": "^5",
+ "typescript": "~5.6.2",
+ "vite": "catalog:"
+ }
+}
diff --git a/packages/desktop-electron/resources/entitlements.plist b/packages/desktop-electron/resources/entitlements.plist
new file mode 100644
index 000000000..61d6c38ce
--- /dev/null
+++ b/packages/desktop-electron/resources/entitlements.plist
@@ -0,0 +1,30 @@
+<?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>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.allow-dyld-environment-variables</key>
+ <true/>
+ <key>com.apple.security.cs.disable-library-validation</key>
+ <true/>
+ <key>com.apple.security.automation.apple-events</key>
+ <true/>
+ <key>com.apple.security.device.audio-input</key>
+ <true/>
+ <key>com.apple.security.device.camera</key>
+ <true/>
+ <key>com.apple.security.personal-information.addressbook</key>
+ <true/>
+ <key>com.apple.security.personal-information.calendars</key>
+ <true/>
+ <key>com.apple.security.personal-information.location</key>
+ <true/>
+ <key>com.apple.security.personal-information.photos-library</key>
+ <true/>
+</dict>
+</plist>
diff --git a/packages/desktop-electron/scripts/copy-bundles.ts b/packages/desktop-electron/scripts/copy-bundles.ts
new file mode 100644
index 000000000..6ef3335eb
--- /dev/null
+++ b/packages/desktop-electron/scripts/copy-bundles.ts
@@ -0,0 +1,12 @@
+import { $ } from "bun"
+import * as path from "node:path"
+
+import { RUST_TARGET } from "./utils"
+
+if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
+
+const BUNDLE_DIR = "dist"
+const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
+
+await $`mkdir -p ${BUNDLES_OUT_DIR}`
+await $`cp -r ${BUNDLE_DIR}/* ${BUNDLES_OUT_DIR}`
diff --git a/packages/desktop-electron/scripts/copy-icons.ts b/packages/desktop-electron/scripts/copy-icons.ts
new file mode 100644
index 000000000..400f42757
--- /dev/null
+++ b/packages/desktop-electron/scripts/copy-icons.ts
@@ -0,0 +1,12 @@
+import { $ } from "bun"
+import { resolveChannel } from "./utils"
+
+const arg = process.argv[2]
+const channel = arg === "dev" || arg === "beta" || arg === "prod" ? arg : resolveChannel()
+
+const src = `./icons/${channel}`
+const dest = "resources/icons"
+
+await $`rm -rf ${dest}`
+await $`cp -R ${src} ${dest}`
+console.log(`Copied ${channel} icons from ${src} to ${dest}`)
diff --git a/packages/desktop-electron/scripts/finalize-latest-yml.ts b/packages/desktop-electron/scripts/finalize-latest-yml.ts
new file mode 100644
index 000000000..42ec23b64
--- /dev/null
+++ b/packages/desktop-electron/scripts/finalize-latest-yml.ts
@@ -0,0 +1,116 @@
+#!/usr/bin/env bun
+
+import { $ } from "bun"
+import path from "path"
+
+const dir = process.env.LATEST_YML_DIR!
+if (!dir) throw new Error("LATEST_YML_DIR is required")
+
+const repo = process.env.GH_REPO
+if (!repo) throw new Error("GH_REPO is required")
+
+const version = process.env.OPENCODE_VERSION
+if (!version) throw new Error("OPENCODE_VERSION is required")
+
+type FileEntry = {
+ url: string
+ sha512: string
+ size: number
+ blockMapSize?: number
+}
+
+type LatestYml = {
+ version: string
+ files: FileEntry[]
+ releaseDate: string
+}
+
+function parse(content: string): LatestYml {
+ const lines = content.split("\n")
+ let version = ""
+ let releaseDate = ""
+ const files: FileEntry[] = []
+ let current: Partial<FileEntry> | undefined
+
+ const flush = () => {
+ if (current?.url && current.sha512 && current.size) files.push(current as FileEntry)
+ current = undefined
+ }
+
+ for (const line of lines) {
+ const indented = line.startsWith(" ") || line.startsWith(" -")
+ if (line.startsWith("version:")) version = line.slice("version:".length).trim()
+ else if (line.startsWith("releaseDate:"))
+ releaseDate = line.slice("releaseDate:".length).trim().replace(/^'|'$/g, "")
+ else if (line.trim().startsWith("- url:")) {
+ flush()
+ current = { url: line.trim().slice("- url:".length).trim() }
+ } else if (indented && current && line.trim().startsWith("sha512:"))
+ current.sha512 = line.trim().slice("sha512:".length).trim()
+ else if (indented && current && line.trim().startsWith("size:"))
+ current.size = Number(line.trim().slice("size:".length).trim())
+ else if (indented && current && line.trim().startsWith("blockMapSize:"))
+ current.blockMapSize = Number(line.trim().slice("blockMapSize:".length).trim())
+ else if (!indented && current) flush()
+ }
+ flush()
+
+ return { version, files, releaseDate }
+}
+
+function serialize(data: LatestYml) {
+ const lines = [`version: ${data.version}`, "files:"]
+ for (const file of data.files) {
+ lines.push(` - url: ${file.url}`)
+ lines.push(` sha512: ${file.sha512}`)
+ lines.push(` size: ${file.size}`)
+ if (file.blockMapSize) lines.push(` blockMapSize: ${file.blockMapSize}`)
+ }
+ lines.push(`releaseDate: '${data.releaseDate}'`)
+ return lines.join("\n") + "\n"
+}
+
+async function read(subdir: string, filename: string): Promise<LatestYml | undefined> {
+ const file = Bun.file(path.join(dir, subdir, filename))
+ if (!(await file.exists())) return undefined
+ return parse(await file.text())
+}
+
+const output: Record<string, string> = {}
+
+// Windows: single arch, pass through
+const win = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
+if (win) output["latest.yml"] = serialize(win)
+
+// Linux x64: pass through
+const linuxX64 = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
+if (linuxX64) output["latest-linux.yml"] = serialize(linuxX64)
+
+// Linux arm64: pass through
+const linuxArm64 = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
+if (linuxArm64) output["latest-linux-arm64.yml"] = serialize(linuxArm64)
+
+// macOS: merge arm64 + x64 into single file
+const macX64 = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
+const macArm64 = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
+if (macX64 || macArm64) {
+ const base = macArm64 ?? macX64!
+ output["latest-mac.yml"] = serialize({
+ version: base.version,
+ files: [...(macArm64?.files ?? []), ...(macX64?.files ?? [])],
+ releaseDate: base.releaseDate,
+ })
+}
+
+// Upload to release
+const tag = `v${version}`
+const tmp = process.env.RUNNER_TEMP ?? "/tmp"
+
+for (const [filename, content] of Object.entries(output)) {
+ const filepath = path.join(tmp, filename)
+ await Bun.write(filepath, content)
+ await $`gh release upload ${tag} ${filepath} --clobber --repo ${repo}`
+ console.log(`uploaded ${filename}`)
+}
+
+console.log("finalized latest yml files")
diff --git a/packages/desktop-electron/scripts/predev.ts b/packages/desktop-electron/scripts/predev.ts
new file mode 100644
index 000000000..a688d0e7f
--- /dev/null
+++ b/packages/desktop-electron/scripts/predev.ts
@@ -0,0 +1,17 @@
+import { $ } from "bun"
+
+import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
+
+await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
+
+const RUST_TARGET = Bun.env.RUST_TARGET
+
+const sidecarConfig = getCurrentSidecar(RUST_TARGET)
+
+const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
+
+await (sidecarConfig.ocBinary.includes("-baseline")
+ ? $`cd ../opencode && bun run build --single --baseline`
+ : $`cd ../opencode && bun run build --single`)
+
+await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
diff --git a/packages/desktop-electron/scripts/prepare.ts b/packages/desktop-electron/scripts/prepare.ts
new file mode 100755
index 000000000..3764db921
--- /dev/null
+++ b/packages/desktop-electron/scripts/prepare.ts
@@ -0,0 +1,24 @@
+#!/usr/bin/env bun
+import { $ } from "bun"
+
+import { Script } from "@opencode-ai/script"
+import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
+
+const channel = resolveChannel()
+await $`bun ./scripts/copy-icons.ts ${channel}`
+
+const pkg = await Bun.file("./package.json").json()
+pkg.version = Script.version
+await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
+console.log(`Updated package.json version to ${Script.version}`)
+
+const sidecarConfig = getCurrentSidecar()
+
+const dir = "resources/opencode-binaries"
+
+await $`mkdir -p ${dir}`
+await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
+
+await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
+
+await $`rm -rf ${dir}`
diff --git a/packages/desktop-electron/scripts/utils.ts b/packages/desktop-electron/scripts/utils.ts
new file mode 100644
index 000000000..4c9af1fc7
--- /dev/null
+++ b/packages/desktop-electron/scripts/utils.ts
@@ -0,0 +1,69 @@
+import { $ } from "bun"
+
+export type Channel = "dev" | "beta" | "prod"
+
+export function resolveChannel(): Channel {
+ const raw = Bun.env.OPENCODE_CHANNEL
+ if (raw === "dev" || raw === "beta" || raw === "prod") return raw
+ return "dev"
+}
+
+export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [
+ {
+ rustTarget: "aarch64-apple-darwin",
+ ocBinary: "opencode-darwin-arm64",
+ assetExt: "zip",
+ },
+ {
+ rustTarget: "x86_64-apple-darwin",
+ ocBinary: "opencode-darwin-x64-baseline",
+ assetExt: "zip",
+ },
+ {
+ rustTarget: "x86_64-pc-windows-msvc",
+ ocBinary: "opencode-windows-x64-baseline",
+ assetExt: "zip",
+ },
+ {
+ rustTarget: "x86_64-unknown-linux-gnu",
+ ocBinary: "opencode-linux-x64-baseline",
+ assetExt: "tar.gz",
+ },
+ {
+ rustTarget: "aarch64-unknown-linux-gnu",
+ ocBinary: "opencode-linux-arm64",
+ assetExt: "tar.gz",
+ },
+]
+
+export const RUST_TARGET = Bun.env.RUST_TARGET
+
+function nativeTarget() {
+ const { platform, arch } = process
+ if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
+ if (platform === "win32") return "x86_64-pc-windows-msvc"
+ if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
+ throw new Error(`Unsupported platform: ${platform}/${arch}`)
+}
+
+export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) {
+ const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target)
+ if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`)
+
+ return binaryConfig
+}
+
+export async function copyBinaryToSidecarFolder(source: string) {
+ const dir = `resources`
+ await $`mkdir -p ${dir}`
+ const dest = windowsify(`${dir}/opencode-cli`)
+ await $`cp ${source} ${dest}`
+ if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
+
+ console.log(`Copied ${source} to ${dest}`)
+}
+
+export function windowsify(path: string) {
+ if (path.endsWith(".exe")) return path
+ return `${path}${process.platform === "win32" ? ".exe" : ""}`
+}
diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts
new file mode 100644
index 000000000..2b4603789
--- /dev/null
+++ b/packages/desktop-electron/src/main/apps.ts
@@ -0,0 +1,148 @@
+import { execFileSync } from "node:child_process"
+import { existsSync, readFileSync, readdirSync } from "node:fs"
+import { dirname, extname, join } from "node:path"
+
+export function checkAppExists(appName: string): boolean {
+ if (process.platform === "win32") return true
+ if (process.platform === "linux") return true
+ return checkMacosApp(appName)
+}
+
+export function resolveAppPath(appName: string): string | null {
+ if (process.platform !== "win32") return appName
+ return resolveWindowsAppPath(appName)
+}
+
+export function wslPath(path: string, mode: "windows" | "linux" | null): string {
+ if (process.platform !== "win32") return path
+
+ const flag = mode === "windows" ? "-w" : "-u"
+ try {
+ if (path.startsWith("~")) {
+ const suffix = path.slice(1)
+ const cmd = `wslpath ${flag} \"$HOME${suffix.replace(/\"/g, '\\"')}\"`
+ const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
+ return output.toString().trim()
+ }
+
+ const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
+ return output.toString().trim()
+ } catch (error) {
+ throw new Error(`Failed to run wslpath: ${String(error)}`)
+ }
+}
+
+function checkMacosApp(appName: string) {
+ const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
+
+ const home = process.env.HOME
+ if (home) locations.push(`${home}/Applications/${appName}.app`)
+
+ if (locations.some((location) => existsSync(location))) return true
+
+ try {
+ execFileSync("which", [appName])
+ return true
+ } catch {
+ return false
+ }
+}
+
+function resolveWindowsAppPath(appName: string): string | null {
+ let output: string
+ try {
+ output = execFileSync("where", [appName]).toString()
+ } catch {
+ return null
+ }
+
+ const paths = output
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+
+ const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}`
+
+ const exe = paths.find((path) => hasExt(path, "exe"))
+ if (exe) return exe
+
+ const resolveCmd = (path: string) => {
+ const content = readFileSync(path, "utf8")
+ for (const token of content.split('"').map((value: string) => value.trim())) {
+ const lower = token.toLowerCase()
+ if (!lower.includes(".exe")) continue
+
+ const index = lower.indexOf("%~dp0")
+ if (index >= 0) {
+ const base = dirname(path)
+ const suffix = token.slice(index + 5)
+ const resolved = suffix
+ .replace(/\//g, "\\")
+ .split("\\")
+ .filter((part: string) => part && part !== ".")
+ .reduce((current: string, part: string) => {
+ if (part === "..") return dirname(current)
+ return join(current, part)
+ }, base)
+
+ if (existsSync(resolved)) return resolved
+ }
+
+ if (existsSync(token)) return token
+ }
+
+ return null
+ }
+
+ for (const path of paths) {
+ if (hasExt(path, "cmd") || hasExt(path, "bat")) {
+ const resolved = resolveCmd(path)
+ if (resolved) return resolved
+ }
+
+ if (!extname(path)) {
+ const cmd = `${path}.cmd`
+ if (existsSync(cmd)) {
+ const resolved = resolveCmd(cmd)
+ if (resolved) return resolved
+ }
+
+ const bat = `${path}.bat`
+ if (existsSync(bat)) {
+ const resolved = resolveCmd(bat)
+ if (resolved) return resolved
+ }
+ }
+ }
+
+ const key = appName
+ .split("")
+ .filter((value: string) => /[a-z0-9]/i.test(value))
+ .map((value: string) => value.toLowerCase())
+ .join("")
+
+ if (key) {
+ for (const path of paths) {
+ const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
+ for (const dir of dirs) {
+ try {
+ for (const entry of readdirSync(dir)) {
+ const candidate = join(dir, entry)
+ if (!hasExt(candidate, "exe")) continue
+ const stem = entry.replace(/\.exe$/i, "")
+ const name = stem
+ .split("")
+ .filter((value: string) => /[a-z0-9]/i.test(value))
+ .map((value: string) => value.toLowerCase())
+ .join("")
+ if (name.includes(key) || key.includes(name)) return candidate
+ }
+ } catch {
+ continue
+ }
+ }
+ }
+ }
+
+ return paths[0] ?? null
+}
diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts
new file mode 100644
index 000000000..e338d3913
--- /dev/null
+++ b/packages/desktop-electron/src/main/cli.ts
@@ -0,0 +1,279 @@
+import { execFileSync, spawn } from "node:child_process"
+import { EventEmitter } from "node:events"
+import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { dirname, join } from "node:path"
+import readline from "node:readline"
+import { fileURLToPath } from "node:url"
+import { app } from "electron"
+import treeKill from "tree-kill"
+
+import { WSL_ENABLED_KEY } from "./constants"
+import { store } from "./store"
+
+const CLI_INSTALL_DIR = ".opencode/bin"
+const CLI_BINARY_NAME = "opencode"
+
+export type ServerConfig = {
+ hostname?: string
+ port?: number
+}
+
+export type Config = {
+ server?: ServerConfig
+}
+
+export type TerminatedPayload = { code: number | null; signal: number | null }
+
+export type CommandEvent =
+ | { type: "stdout"; value: string }
+ | { type: "stderr"; value: string }
+ | { type: "error"; value: string }
+ | { type: "terminated"; value: TerminatedPayload }
+ | { type: "sqlite"; value: SqliteMigrationProgress }
+
+export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
+
+export type CommandChild = {
+ kill: () => void
+}
+
+const root = dirname(fileURLToPath(import.meta.url))
+
+export function getSidecarPath() {
+ const suffix = process.platform === "win32" ? ".exe" : ""
+ const path = app.isPackaged
+ ? join(process.resourcesPath, `opencode-cli${suffix}`)
+ : join(root, "../../resources", `opencode-cli${suffix}`)
+ console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
+ return path
+}
+
+export async function getConfig(): Promise<Config | null> {
+ const { events } = spawnCommand("debug config", {})
+ let output = ""
+
+ await new Promise<void>((resolve) => {
+ events.on("stdout", (line: string) => {
+ output += line
+ })
+ events.on("stderr", (line: string) => {
+ output += line
+ })
+ events.on("terminated", () => resolve())
+ events.on("error", () => resolve())
+ })
+
+ try {
+ return JSON.parse(output) as Config
+ } catch {
+ return null
+ }
+}
+
+export async function installCli(): Promise<string> {
+ if (process.platform === "win32") {
+ throw new Error("CLI installation is only supported on macOS & Linux")
+ }
+
+ const sidecar = getSidecarPath()
+ const scriptPath = join(app.getAppPath(), "install")
+ const script = readFileSync(scriptPath, "utf8")
+ const tempScript = join(tmpdir(), "opencode-install.sh")
+
+ writeFileSync(tempScript, script, "utf8")
+ chmodSync(tempScript, 0o755)
+
+ const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
+ return await new Promise<string>((resolve, reject) => {
+ cmd.on("exit", (code: number | null) => {
+ try {
+ unlinkSync(tempScript)
+ } catch {}
+ if (code === 0) {
+ const installPath = getCliInstallPath()
+ if (installPath) return resolve(installPath)
+ return reject(new Error("Could not determine install path"))
+ }
+ reject(new Error("Install script failed"))
+ })
+ })
+}
+
+export function syncCli() {
+ if (!app.isPackaged) return
+ const installPath = getCliInstallPath()
+ if (!installPath) return
+
+ let version = ""
+ try {
+ version = execFileSync(installPath, ["--version"]).toString().trim()
+ } catch {
+ return
+ }
+
+ const cli = parseVersion(version)
+ const appVersion = parseVersion(app.getVersion())
+ if (!cli || !appVersion) return
+ if (compareVersions(cli, appVersion) >= 0) return
+ void installCli().catch(() => undefined)
+}
+
+export function serve(hostname: string, port: number, password: string) {
+ const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
+ const env = {
+ OPENCODE_SERVER_USERNAME: "opencode",
+ OPENCODE_SERVER_PASSWORD: password,
+ }
+
+ return spawnCommand(args, env)
+}
+
+export function spawnCommand(args: string, extraEnv: Record<string, string>) {
+ console.log(`[cli] Spawning command with args: ${args}`)
+ const base = Object.fromEntries(
+ Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
+ )
+ const envs = {
+ ...base,
+ OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
+ OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
+ OPENCODE_CLIENT: "desktop",
+ XDG_STATE_HOME: app.getPath("userData"),
+ ...extraEnv,
+ }
+
+ const { cmd, cmdArgs } = buildCommand(args, envs)
+ console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
+ const child = spawn(cmd, cmdArgs, {
+ env: envs,
+ detached: true,
+ windowsHide: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ })
+ console.log(`[cli] Spawned process with PID: ${child.pid}`)
+
+ const events = new EventEmitter()
+ const exit = new Promise<TerminatedPayload>((resolve) => {
+ child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
+ console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
+ resolve({ code: code ?? null, signal: null })
+ })
+ child.on("error", (error: Error) => {
+ console.error(`[cli] Process error: ${error.message}`)
+ events.emit("error", error.message)
+ })
+ })
+
+ const stdout = child.stdout
+ const stderr = child.stderr
+
+ if (stdout) {
+ readline.createInterface({ input: stdout }).on("line", (line: string) => {
+ if (handleSqliteProgress(events, line)) return
+ events.emit("stdout", `${line}\n`)
+ })
+ }
+
+ if (stderr) {
+ readline.createInterface({ input: stderr }).on("line", (line: string) => {
+ if (handleSqliteProgress(events, line)) return
+ events.emit("stderr", `${line}\n`)
+ })
+ }
+
+ exit.then((payload) => {
+ events.emit("terminated", payload)
+ })
+
+ const kill = () => {
+ if (!child.pid) return
+ treeKill(child.pid)
+ }
+
+ return { events, child: { kill }, exit }
+}
+
+function handleSqliteProgress(events: EventEmitter, line: string) {
+ const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
+ if (!stripped) return false
+ if (stripped === "done") {
+ events.emit("sqlite", { type: "Done" })
+ return true
+ }
+ const value = Number.parseInt(stripped, 10)
+ if (!Number.isNaN(value)) {
+ events.emit("sqlite", { type: "InProgress", value })
+ return true
+ }
+ return false
+}
+
+function buildCommand(args: string, env: Record<string, string>) {
+ if (process.platform === "win32" && isWslEnabled()) {
+ console.log(`[cli] Using WSL mode`)
+ const version = app.getVersion()
+ const script = [
+ "set -e",
+ 'BIN="$HOME/.opencode/bin/opencode"',
+ 'if [ ! -x "$BIN" ]; then',
+ ` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
+ "fi",
+ `${envPrefix(env)} exec "$BIN" ${args}`,
+ ].join("\n")
+
+ return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
+ }
+
+ if (process.platform === "win32") {
+ const sidecar = getSidecarPath()
+ console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
+ return { cmd: sidecar, cmdArgs: args.split(" ") }
+ }
+
+ const sidecar = getSidecarPath()
+ const shell = process.env.SHELL || "/bin/sh"
+ const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
+ console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
+ return { cmd: shell, cmdArgs: ["-l", "-c", line] }
+}
+
+function envPrefix(env: Record<string, string>) {
+ const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
+ return entries.join(" ")
+}
+
+function shellEscape(input: string) {
+ if (!input) return "''"
+ return `'${input.replace(/'/g, `'"'"'`)}'`
+}
+
+function getCliInstallPath() {
+ const home = process.env.HOME
+ if (!home) return null
+ return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
+}
+
+function isWslEnabled() {
+ return store.get(WSL_ENABLED_KEY) === true
+}
+
+function parseVersion(value: string) {
+ const parts = value
+ .replace(/^v/, "")
+ .split(".")
+ .map((part) => Number.parseInt(part, 10))
+ if (parts.some((part) => Number.isNaN(part))) return null
+ return parts
+}
+
+function compareVersions(a: number[], b: number[]) {
+ const len = Math.max(a.length, b.length)
+ for (let i = 0; i < len; i += 1) {
+ const left = a[i] ?? 0
+ const right = b[i] ?? 0
+ if (left > right) return 1
+ if (left < right) return -1
+ }
+ return 0
+}
diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts
new file mode 100644
index 000000000..1e21661c1
--- /dev/null
+++ b/packages/desktop-electron/src/main/constants.ts
@@ -0,0 +1,10 @@
+import { app } from "electron"
+
+type Channel = "dev" | "beta" | "prod"
+const raw = import.meta.env.OPENCODE_CHANNEL
+export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
+
+export const SETTINGS_STORE = "opencode.settings"
+export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
+export const WSL_ENABLED_KEY = "wslEnabled"
+export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
diff --git a/packages/desktop-electron/src/main/env.d.ts b/packages/desktop-electron/src/main/env.d.ts
new file mode 100644
index 000000000..0ee0c551d
--- /dev/null
+++ b/packages/desktop-electron/src/main/env.d.ts
@@ -0,0 +1,7 @@
+interface ImportMetaEnv {
+ readonly OPENCODE_CHANNEL: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
new file mode 100644
index 000000000..03c1e128e
--- /dev/null
+++ b/packages/desktop-electron/src/main/index.ts
@@ -0,0 +1,449 @@
+import { app, BrowserWindow, dialog } from "electron"
+import type { Event } from "electron"
+import pkg from "electron-updater"
+import { randomUUID } from "node:crypto"
+import { EventEmitter } from "node:events"
+import { existsSync } from "node:fs"
+import { homedir } from "node:os"
+import { join } from "node:path"
+import { createServer } from "node:net"
+
+const APP_NAMES: Record<string, string> = { dev: "OpenCode Dev", beta: "OpenCode Beta", prod: "OpenCode" }
+const APP_IDS: Record<string, string> = {
+ dev: "ai.opencode.desktop.dev",
+ beta: "ai.opencode.desktop.beta",
+ prod: "ai.opencode.desktop",
+}
+app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
+app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"))
+const { autoUpdater } = pkg
+
+import { checkAppExists, resolveAppPath, wslPath } from "./apps"
+import { installCli, syncCli } from "./cli"
+import { CHANNEL, UPDATER_ENABLED } from "./constants"
+import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
+import { initLogging } from "./logging"
+import { parseMarkdown } from "./markdown"
+import { createMenu } from "./menu"
+import {
+ checkHealth,
+ checkHealthOrAskRetry,
+ getDefaultServerUrl,
+ getSavedServerUrl,
+ getWslConfig,
+ setDefaultServerUrl,
+ setWslConfig,
+ spawnLocalServer,
+} from "./server"
+import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
+
+import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
+import type { CommandChild } from "./cli"
+
+type ServerConnection =
+ | { variant: "existing"; url: string }
+ | {
+ variant: "cli"
+ url: string
+ password: null | string
+ health: {
+ wait: Promise<void>
+ }
+ events: any
+ }
+
+const initEmitter = new EventEmitter()
+let initStep: InitStep = { phase: "server_waiting" }
+
+let mainWindow: BrowserWindow | null = null
+let loadingWindow: BrowserWindow | null = null
+let sidecar: CommandChild | null = null
+let loadingComplete = defer<void>()
+
+const pendingDeepLinks: string[] = []
+
+const serverReady = defer<ServerReadyData>()
+const logger = initLogging()
+
+logger.log("app starting", { version: app.getVersion(), packaged: app.isPackaged })
+
+setupApp()
+
+function setupApp() {
+ ensureLoopbackNoProxy()
+ app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
+
+ if (!app.requestSingleInstanceLock()) {
+ app.quit()
+ return
+ }
+
+ app.on("second-instance", (_event: Event, argv: string[]) => {
+ const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
+ if (urls.length) {
+ logger.log("deep link received via second-instance", { urls })
+ emitDeepLinks(urls)
+ }
+ focusMainWindow()
+ })
+
+ app.on("open-url", (event: Event, url: string) => {
+ event.preventDefault()
+ logger.log("deep link received via open-url", { url })
+ emitDeepLinks([url])
+ })
+
+ app.on("before-quit", () => {
+ killSidecar()
+ })
+
+ void app.whenReady().then(async () => {
+ // migrate()
+ app.setAsDefaultProtocolClient("opencode")
+ setDockIcon()
+ setupAutoUpdater()
+ syncCli()
+ await initialize()
+ })
+}
+
+function emitDeepLinks(urls: string[]) {
+ if (urls.length === 0) return
+ pendingDeepLinks.push(...urls)
+ if (mainWindow) sendDeepLinks(mainWindow, urls)
+}
+
+function focusMainWindow() {
+ if (!mainWindow) return
+ mainWindow.show()
+ mainWindow.focus()
+}
+
+function setInitStep(step: InitStep) {
+ initStep = step
+ logger.log("init step", { step })
+ initEmitter.emit("step", step)
+}
+
+async function setupServerConnection(): Promise<ServerConnection> {
+ const customUrl = await getSavedServerUrl()
+
+ if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
+ serverReady.resolve({ url: customUrl, password: null })
+ return { variant: "existing", url: customUrl }
+ }
+
+ const port = await getSidecarPort()
+ const hostname = "127.0.0.1"
+ const localUrl = `http://${hostname}:${port}`
+
+ if (await checkHealth(localUrl)) {
+ serverReady.resolve({ url: localUrl, password: null })
+ return { variant: "existing", url: localUrl }
+ }
+
+ const password = randomUUID()
+ const { child, health, events } = spawnLocalServer(hostname, port, password)
+ sidecar = child
+
+ return {
+ variant: "cli",
+ url: localUrl,
+ password,
+ health,
+ events,
+ }
+}
+
+async function initialize() {
+ const needsMigration = !sqliteFileExists()
+ const sqliteDone = needsMigration ? defer<void>() : undefined
+
+ const loadingTask = (async () => {
+ logger.log("setting up server connection")
+ const serverConnection = await setupServerConnection()
+ logger.log("server connection ready", { variant: serverConnection.variant, url: serverConnection.url })
+
+ const cliHealthCheck = (() => {
+ if (serverConnection.variant == "cli") {
+ return async () => {
+ const { events, health } = serverConnection
+ events.on("sqlite", (progress: SqliteMigrationProgress) => {
+ setInitStep({ phase: "sqlite_waiting" })
+ if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
+ if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
+ if (progress.type === "Done") sqliteDone?.resolve()
+ })
+ await health.wait
+ serverReady.resolve({ url: serverConnection.url, password: serverConnection.password })
+ }
+ } else {
+ serverReady.resolve({ url: serverConnection.url, password: null })
+ return null
+ }
+ })()
+
+ logger.log("server connection started")
+
+ if (cliHealthCheck) {
+ if (needsMigration) await sqliteDone?.promise
+ cliHealthCheck?.()
+ }
+
+ logger.log("loading task finished")
+ })()
+
+ const globals = {
+ updaterEnabled: UPDATER_ENABLED,
+ wsl: getWslConfig().enabled,
+ deepLinks: pendingDeepLinks,
+ }
+
+ const loadingWindow = await (async () => {
+ if (needsMigration /** TOOD: 1 second timeout */) {
+ // showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
+ const loadingWindow = createLoadingWindow(globals)
+ await delay(1000)
+ return loadingWindow
+ } else {
+ logger.log("showing main window without loading window")
+ mainWindow = createMainWindow(globals)
+ wireMenu()
+ }
+ })()
+
+ await loadingTask
+ setInitStep({ phase: "done" })
+
+ if (loadingWindow) {
+ await loadingComplete.promise
+ }
+
+ if (!mainWindow) {
+ mainWindow = createMainWindow(globals)
+ wireMenu()
+ }
+
+ loadingWindow?.close()
+}
+
+function wireMenu() {
+ if (!mainWindow) return
+ createMenu({
+ trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
+ installCli: () => {
+ void installCli()
+ },
+ checkForUpdates: () => {
+ void checkForUpdates(true)
+ },
+ reload: () => mainWindow?.reload(),
+ relaunch: () => {
+ killSidecar()
+ app.relaunch()
+ app.exit(0)
+ },
+ })
+}
+
+registerIpcHandlers({
+ killSidecar: () => killSidecar(),
+ installCli: async () => installCli(),
+ awaitInitialization: async (sendStep) => {
+ sendStep(initStep)
+ const listener = (step: InitStep) => sendStep(step)
+ initEmitter.on("step", listener)
+ try {
+ logger.log("awaiting server ready")
+ const res = await serverReady.promise
+ logger.log("server ready", { url: res.url })
+ return res
+ } finally {
+ initEmitter.off("step", listener)
+ }
+ },
+ getDefaultServerUrl: () => getDefaultServerUrl(),
+ setDefaultServerUrl: (url) => setDefaultServerUrl(url),
+ getWslConfig: () => Promise.resolve(getWslConfig()),
+ setWslConfig: (config: WslConfig) => setWslConfig(config),
+ getDisplayBackend: async () => null,
+ setDisplayBackend: async () => undefined,
+ parseMarkdown: async (markdown) => parseMarkdown(markdown),
+ checkAppExists: async (appName) => checkAppExists(appName),
+ wslPath: async (path, mode) => wslPath(path, mode),
+ resolveAppPath: async (appName) => resolveAppPath(appName),
+ loadingWindowComplete: () => loadingComplete.resolve(),
+ runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
+ checkUpdate: async () => checkUpdate(),
+ installUpdate: async () => installUpdate(),
+})
+
+function killSidecar() {
+ if (!sidecar) return
+ sidecar.kill()
+ sidecar = null
+}
+
+function ensureLoopbackNoProxy() {
+ const loopback = ["127.0.0.1", "localhost", "::1"]
+ const upsert = (key: string) => {
+ const items = (process.env[key] ?? "")
+ .split(",")
+ .map((value: string) => value.trim())
+ .filter((value: string) => Boolean(value))
+
+ for (const host of loopback) {
+ if (items.some((value: string) => value.toLowerCase() === host)) continue
+ items.push(host)
+ }
+
+ process.env[key] = items.join(",")
+ }
+
+ upsert("NO_PROXY")
+ upsert("no_proxy")
+}
+
+async function getSidecarPort() {
+ const fromEnv = process.env.OPENCODE_PORT
+ if (fromEnv) {
+ const parsed = Number.parseInt(fromEnv, 10)
+ if (!Number.isNaN(parsed)) return parsed
+ }
+
+ return await new Promise<number>((resolve, reject) => {
+ const server = createServer()
+ server.on("error", reject)
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address()
+ if (typeof address !== "object" || !address) {
+ server.close()
+ reject(new Error("Failed to get port"))
+ return
+ }
+ const port = address.port
+ server.close(() => resolve(port))
+ })
+ })
+}
+
+function sqliteFileExists() {
+ const xdg = process.env.XDG_DATA_HOME
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
+ return existsSync(join(base, "opencode", "opencode.db"))
+}
+
+function setupAutoUpdater() {
+ if (!UPDATER_ENABLED) return
+ autoUpdater.logger = logger
+ autoUpdater.channel = "latest"
+ autoUpdater.allowPrerelease = false
+ autoUpdater.allowDowngrade = true
+ autoUpdater.autoDownload = false
+ autoUpdater.autoInstallOnAppQuit = true
+ logger.log("auto updater configured", {
+ channel: autoUpdater.channel,
+ allowPrerelease: autoUpdater.allowPrerelease,
+ allowDowngrade: autoUpdater.allowDowngrade,
+ currentVersion: app.getVersion(),
+ })
+}
+
+let updateReady = false
+
+async function checkUpdate() {
+ if (!UPDATER_ENABLED) return { updateAvailable: false }
+ updateReady = false
+ logger.log("checking for updates", {
+ currentVersion: app.getVersion(),
+ channel: autoUpdater.channel,
+ allowPrerelease: autoUpdater.allowPrerelease,
+ allowDowngrade: autoUpdater.allowDowngrade,
+ })
+ try {
+ const result = await autoUpdater.checkForUpdates()
+ const updateInfo = result?.updateInfo
+ logger.log("update metadata fetched", {
+ releaseVersion: updateInfo?.version ?? null,
+ releaseDate: updateInfo?.releaseDate ?? null,
+ releaseName: updateInfo?.releaseName ?? null,
+ files: updateInfo?.files?.map((file) => file.url) ?? [],
+ })
+ const version = result?.updateInfo?.version
+ if (!version) {
+ logger.log("no update available", { reason: "provider returned no newer version" })
+ return { updateAvailable: false }
+ }
+ logger.log("update available", { version })
+ await autoUpdater.downloadUpdate()
+ logger.log("update download completed", { version })
+ updateReady = true
+ return { updateAvailable: true, version }
+ } catch (error) {
+ logger.error("update check failed", error)
+ return { updateAvailable: false, failed: true }
+ }
+}
+
+async function installUpdate() {
+ if (!updateReady) return
+ killSidecar()
+ autoUpdater.quitAndInstall()
+}
+
+async function checkForUpdates(alertOnFail: boolean) {
+ if (!UPDATER_ENABLED) return
+ logger.log("checkForUpdates invoked", { alertOnFail })
+ const result = await checkUpdate()
+ if (!result.updateAvailable) {
+ if (result.failed) {
+ logger.log("no update decision", { reason: "update check failed" })
+ if (!alertOnFail) return
+ await dialog.showMessageBox({
+ type: "error",
+ message: "Update check failed.",
+ title: "Update Error",
+ })
+ return
+ }
+
+ logger.log("no update decision", { reason: "already up to date" })
+ if (!alertOnFail) return
+ await dialog.showMessageBox({
+ type: "info",
+ message: "You're up to date.",
+ title: "No Updates",
+ })
+ return
+ }
+
+ const response = await dialog.showMessageBox({
+ type: "info",
+ message: `Update ${result.version ?? ""} downloaded. Restart now?`,
+ title: "Update Ready",
+ buttons: ["Restart", "Later"],
+ defaultId: 0,
+ cancelId: 1,
+ })
+ logger.log("update prompt response", {
+ version: result.version ?? null,
+ restartNow: response.response === 0,
+ })
+ if (response.response === 0) {
+ await installUpdate()
+ }
+}
+
+function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function defer<T>() {
+ let resolve!: (value: T) => void
+ let reject!: (error: Error) => void
+ const promise = new Promise<T>((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+}
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
new file mode 100644
index 000000000..bbb5379bb
--- /dev/null
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -0,0 +1,176 @@
+import { execFile } from "node:child_process"
+import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
+import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
+
+import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
+import { getStore } from "./store"
+
+type Deps = {
+ killSidecar: () => void
+ installCli: () => Promise<string>
+ awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
+ getDefaultServerUrl: () => Promise<string | null> | string | null
+ setDefaultServerUrl: (url: string | null) => Promise<void> | void
+ getWslConfig: () => Promise<WslConfig>
+ setWslConfig: (config: WslConfig) => Promise<void> | void
+ getDisplayBackend: () => Promise<string | null>
+ setDisplayBackend: (backend: string | null) => Promise<void> | void
+ parseMarkdown: (markdown: string) => Promise<string> | string
+ checkAppExists: (appName: string) => Promise<boolean> | boolean
+ wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
+ resolveAppPath: (appName: string) => Promise<string | null>
+ loadingWindowComplete: () => void
+ runUpdater: (alertOnFail: boolean) => Promise<void> | void
+ checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
+ installUpdate: () => Promise<void> | void
+}
+
+export function registerIpcHandlers(deps: Deps) {
+ ipcMain.handle("kill-sidecar", () => deps.killSidecar())
+ ipcMain.handle("install-cli", () => deps.installCli())
+ ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
+ const send = (step: InitStep) => event.sender.send("init-step", step)
+ return deps.awaitInitialization(send)
+ })
+ ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
+ ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
+ deps.setDefaultServerUrl(url),
+ )
+ ipcMain.handle("get-wsl-config", () => deps.getWslConfig())
+ ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
+ ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
+ ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
+ deps.setDisplayBackend(backend),
+ )
+ ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
+ ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
+ ipcMain.handle("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
+ deps.wslPath(path, mode),
+ )
+ ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
+ ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
+ ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
+ ipcMain.handle("check-update", () => deps.checkUpdate())
+ ipcMain.handle("install-update", () => deps.installUpdate())
+ ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
+ const store = getStore(name)
+ const value = store.get(key)
+ if (value === undefined || value === null) return null
+ return typeof value === "string" ? value : JSON.stringify(value)
+ })
+ ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
+ getStore(name).set(key, value)
+ })
+ ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
+ getStore(name).delete(key)
+ })
+ ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
+ getStore(name).clear()
+ })
+ ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
+ const store = getStore(name)
+ return Object.keys(store.store)
+ })
+ ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
+ const store = getStore(name)
+ return Object.keys(store.store).length
+ })
+
+ ipcMain.handle(
+ "open-directory-picker",
+ async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
+ const result = await dialog.showOpenDialog({
+ properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
+ title: opts?.title ?? "Choose a folder",
+ defaultPath: opts?.defaultPath,
+ })
+ if (result.canceled) return null
+ return opts?.multiple ? result.filePaths : result.filePaths[0]
+ },
+ )
+
+ ipcMain.handle(
+ "open-file-picker",
+ async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
+ const result = await dialog.showOpenDialog({
+ properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
+ title: opts?.title ?? "Choose a file",
+ defaultPath: opts?.defaultPath,
+ })
+ if (result.canceled) return null
+ return opts?.multiple ? result.filePaths : result.filePaths[0]
+ },
+ )
+
+ ipcMain.handle(
+ "save-file-picker",
+ async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
+ const result = await dialog.showSaveDialog({
+ title: opts?.title ?? "Save file",
+ defaultPath: opts?.defaultPath,
+ })
+ if (result.canceled) return null
+ return result.filePath ?? null
+ },
+ )
+
+ ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
+ void shell.openExternal(url)
+ })
+
+ ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
+ if (!app) return shell.openPath(path)
+ await new Promise<void>((resolve, reject) => {
+ const [cmd, args] =
+ process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
+ execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
+ })
+ })
+
+ ipcMain.handle("read-clipboard-image", () => {
+ const image = clipboard.readImage()
+ if (image.isEmpty()) return null
+ const buffer = image.toPNG().buffer
+ const size = image.getSize()
+ return { buffer, width: size.width, height: size.height }
+ })
+
+ ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
+ new Notification({ title, body }).show()
+ })
+
+ ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
+ const win = BrowserWindow.fromWebContents(event.sender)
+ return win?.isFocused() ?? false
+ })
+
+ ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
+ const win = BrowserWindow.fromWebContents(event.sender)
+ win?.focus()
+ })
+
+ ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
+ const win = BrowserWindow.fromWebContents(event.sender)
+ win?.show()
+ })
+
+ ipcMain.on("relaunch", () => {
+ app.relaunch()
+ app.exit(0)
+ })
+
+ ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
+ ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
+}
+
+export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
+ win.webContents.send("sqlite-migration-progress", progress)
+}
+
+export function sendMenuCommand(win: BrowserWindow, id: string) {
+ win.webContents.send("menu-command", id)
+}
+
+export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
+ win.webContents.send("deep-link", urls)
+}
diff --git a/packages/desktop-electron/src/main/logging.ts b/packages/desktop-electron/src/main/logging.ts
new file mode 100644
index 000000000..d315b2d34
--- /dev/null
+++ b/packages/desktop-electron/src/main/logging.ts
@@ -0,0 +1,40 @@
+import log from "electron-log/main.js"
+import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"
+import { dirname, join } from "node:path"
+
+const MAX_LOG_AGE_DAYS = 7
+const TAIL_LINES = 1000
+
+export function initLogging() {
+ log.transports.file.maxSize = 5 * 1024 * 1024
+ cleanup()
+ return log
+}
+
+export function tail(): string {
+ try {
+ const path = log.transports.file.getFile().path
+ const contents = readFileSync(path, "utf8")
+ const lines = contents.split("\n")
+ return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
+ } catch {
+ return ""
+ }
+}
+
+function cleanup() {
+ const path = log.transports.file.getFile().path
+ const dir = dirname(path)
+ const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000
+
+ for (const entry of readdirSync(dir)) {
+ const file = join(dir, entry)
+ try {
+ const info = statSync(file)
+ if (!info.isFile()) continue
+ if (info.mtimeMs < cutoff) unlinkSync(file)
+ } catch {
+ continue
+ }
+ }
+}
diff --git a/packages/desktop-electron/src/main/markdown.ts b/packages/desktop-electron/src/main/markdown.ts
new file mode 100644
index 000000000..b956f4876
--- /dev/null
+++ b/packages/desktop-electron/src/main/markdown.ts
@@ -0,0 +1,16 @@
+import { marked, type Tokens } from "marked"
+
+const renderer = new marked.Renderer()
+
+renderer.link = ({ href, title, text }: Tokens.Link) => {
+ const titleAttr = title ? ` title="${title}"` : ""
+ return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
+}
+
+export function parseMarkdown(input: string) {
+ return marked(input, {
+ renderer,
+ breaks: false,
+ gfm: true,
+ })
+}
diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts
new file mode 100644
index 000000000..53707ba7f
--- /dev/null
+++ b/packages/desktop-electron/src/main/menu.ts
@@ -0,0 +1,116 @@
+import { BrowserWindow, Menu, shell } from "electron"
+
+import { UPDATER_ENABLED } from "./constants"
+
+type Deps = {
+ trigger: (id: string) => void
+ installCli: () => void
+ checkForUpdates: () => void
+ reload: () => void
+ relaunch: () => void
+}
+
+export function createMenu(deps: Deps) {
+ if (process.platform !== "darwin") return
+
+ const template: Electron.MenuItemConstructorOptions[] = [
+ {
+ label: "OpenCode",
+ submenu: [
+ { role: "about" },
+ {
+ label: "Check for Updates...",
+ enabled: UPDATER_ENABLED,
+ click: () => deps.checkForUpdates(),
+ },
+ {
+ label: "Install CLI...",
+ click: () => deps.installCli(),
+ },
+ {
+ label: "Reload Webview",
+ click: () => deps.reload(),
+ },
+ {
+ label: "Restart",
+ click: () => deps.relaunch(),
+ },
+ { type: "separator" },
+ { role: "hide" },
+ { role: "hideOthers" },
+ { role: "unhide" },
+ { type: "separator" },
+ { role: "quit" },
+ ],
+ },
+ {
+ label: "File",
+ submenu: [
+ { label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
+ { label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
+ { type: "separator" },
+ { role: "close" },
+ ],
+ },
+ {
+ label: "Edit",
+ submenu: [
+ { role: "undo" },
+ { role: "redo" },
+ { type: "separator" },
+ { role: "cut" },
+ { role: "copy" },
+ { role: "paste" },
+ { role: "selectAll" },
+ ],
+ },
+ {
+ label: "View",
+ submenu: [
+ { label: "Toggle Sidebar", accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") },
+ { label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") },
+ { label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") },
+ { type: "separator" },
+ { label: "Back", click: () => deps.trigger("common.goBack") },
+ { label: "Forward", click: () => deps.trigger("common.goForward") },
+ { type: "separator" },
+ {
+ label: "Previous Session",
+ accelerator: "Option+ArrowUp",
+ click: () => deps.trigger("session.previous"),
+ },
+ {
+ label: "Next Session",
+ accelerator: "Option+ArrowDown",
+ click: () => deps.trigger("session.next"),
+ },
+ { type: "separator" },
+ {
+ label: "Toggle Developer Tools",
+ accelerator: "Alt+Cmd+I",
+ click: () => BrowserWindow.getFocusedWindow()?.webContents.toggleDevTools(),
+ },
+ ],
+ },
+ {
+ label: "Help",
+ submenu: [
+ { label: "OpenCode Documentation", click: () => shell.openExternal("https://opencode.ai/docs") },
+ { label: "Support Forum", click: () => shell.openExternal("https://discord.com/invite/opencode") },
+ { type: "separator" },
+ { type: "separator" },
+ {
+ label: "Share Feedback",
+ click: () =>
+ shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"),
+ },
+ {
+ label: "Report a Bug",
+ click: () => shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"),
+ },
+ ],
+ },
+ ]
+
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template))
+}
diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts
new file mode 100644
index 000000000..bad1349ee
--- /dev/null
+++ b/packages/desktop-electron/src/main/migrate.ts
@@ -0,0 +1,91 @@
+import { app } from "electron"
+import log from "electron-log/main.js"
+import { existsSync, readdirSync, readFileSync } from "node:fs"
+import { homedir } from "node:os"
+import { join } from "node:path"
+import { CHANNEL } from "./constants"
+import { getStore, store } from "./store"
+
+const TAURI_MIGRATED_KEY = "tauriMigrated"
+
+// Resolve the directory where Tauri stored its .dat files for the given app identifier.
+// Mirrors Tauri's AppLocalData / AppData resolution per OS.
+function tauriDir(id: string) {
+ switch (process.platform) {
+ case "darwin":
+ return join(homedir(), "Library", "Application Support", id)
+ case "win32":
+ return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id)
+ default:
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id)
+ }
+}
+
+// The Tauri app identifier changes between dev/beta/prod builds.
+const TAURI_APP_IDS: Record<string, string> = {
+ dev: "ai.opencode.desktop.dev",
+ beta: "ai.opencode.desktop.beta",
+ prod: "ai.opencode.desktop",
+}
+function tauriAppId() {
+ return app.isPackaged ? TAURI_APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
+}
+
+// Migrate a single Tauri .dat file into the corresponding electron-store.
+// `opencode.settings.dat` is special: it maps to the `opencode.settings` store
+// (the electron-store name without the `.dat` extension). All other .dat files
+// keep their full filename as the electron-store name so they match what the
+// renderer already passes via IPC (e.g. `"default.dat"`, `"opencode.global.dat"`).
+function migrateFile(datPath: string, filename: string) {
+ let data: Record<string, unknown>
+ try {
+ data = JSON.parse(readFileSync(datPath, "utf-8"))
+ } catch (err) {
+ log.warn("tauri migration: failed to parse", filename, err)
+ return
+ }
+
+ // opencode.settings.dat → the electron settings store ("opencode.settings").
+ // All other .dat files keep their full filename as the store name so they match
+ // what the renderer passes via IPC (e.g. "default.dat", "opencode.global.dat").
+ const storeName = filename === "opencode.settings.dat" ? "opencode.settings" : filename
+ const target = getStore(storeName)
+ const migrated: string[] = []
+ const skipped: string[] = []
+
+ for (const [key, value] of Object.entries(data)) {
+ // Don't overwrite values the user has already set in the Electron app.
+ if (target.has(key)) {
+ skipped.push(key)
+ continue
+ }
+ target.set(key, value)
+ migrated.push(key)
+ }
+
+ log.log("tauri migration: migrated", filename, "→", storeName, { migrated, skipped })
+}
+
+export function migrate() {
+ if (store.get(TAURI_MIGRATED_KEY)) {
+ log.log("tauri migration: already done, skipping")
+ return
+ }
+
+ const dir = tauriDir(tauriAppId())
+ log.log("tauri migration: starting", { dir })
+
+ if (!existsSync(dir)) {
+ log.log("tauri migration: no tauri data directory found, nothing to migrate")
+ store.set(TAURI_MIGRATED_KEY, true)
+ return
+ }
+
+ for (const filename of readdirSync(dir)) {
+ if (!filename.endsWith(".dat")) continue
+ migrateFile(join(dir, filename), filename)
+ }
+
+ log.log("tauri migration: complete")
+ store.set(TAURI_MIGRATED_KEY, true)
+}
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
new file mode 100644
index 000000000..92018e72e
--- /dev/null
+++ b/packages/desktop-electron/src/main/server.ts
@@ -0,0 +1,129 @@
+import { dialog } from "electron"
+
+import { getConfig, serve, type CommandChild, type Config } from "./cli"
+import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
+import { store } from "./store"
+
+export type WslConfig = { enabled: boolean }
+
+export type HealthCheck = { wait: Promise<void> }
+
+export function getDefaultServerUrl(): string | null {
+ const value = store.get(DEFAULT_SERVER_URL_KEY)
+ return typeof value === "string" ? value : null
+}
+
+export function setDefaultServerUrl(url: string | null) {
+ if (url) {
+ store.set(DEFAULT_SERVER_URL_KEY, url)
+ return
+ }
+
+ store.delete(DEFAULT_SERVER_URL_KEY)
+}
+
+export function getWslConfig(): WslConfig {
+ const value = store.get(WSL_ENABLED_KEY)
+ return { enabled: typeof value === "boolean" ? value : false }
+}
+
+export function setWslConfig(config: WslConfig) {
+ store.set(WSL_ENABLED_KEY, config.enabled)
+}
+
+export async function getSavedServerUrl(): Promise<string | null> {
+ const direct = getDefaultServerUrl()
+ if (direct) return direct
+
+ const config = await getConfig().catch(() => null)
+ if (!config) return null
+ return getServerUrlFromConfig(config)
+}
+
+export function spawnLocalServer(hostname: string, port: number, password: string) {
+ const { child, exit, events } = serve(hostname, port, password)
+
+ const wait = (async () => {
+ const url = `http://${hostname}:${port}`
+
+ const ready = async () => {
+ while (true) {
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ if (await checkHealth(url, password)) return
+ }
+ }
+
+ const terminated = async () => {
+ const payload = await exit
+ throw new Error(
+ `Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
+ payload.signal ?? "unknown"
+ })`,
+ )
+ }
+
+ await Promise.race([ready(), terminated()])
+ })()
+
+ return { child, health: { wait }, events }
+}
+
+export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
+ let healthUrl: URL
+ try {
+ healthUrl = new URL("/global/health", url)
+ } catch {
+ return false
+ }
+
+ const headers = new Headers()
+ if (password) {
+ const auth = Buffer.from(`opencode:${password}`).toString("base64")
+ headers.set("authorization", `Basic ${auth}`)
+ }
+
+ try {
+ const res = await fetch(healthUrl, {
+ method: "GET",
+ headers,
+ signal: AbortSignal.timeout(3000),
+ })
+ return res.ok
+ } catch {
+ return false
+ }
+}
+
+export async function checkHealthOrAskRetry(url: string): Promise<boolean> {
+ while (true) {
+ if (await checkHealth(url)) return true
+
+ const result = await dialog.showMessageBox({
+ type: "warning",
+ message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
+ title: "Connection Failed",
+ buttons: ["Retry", "Start Local"],
+ defaultId: 0,
+ cancelId: 1,
+ })
+
+ if (result.response === 0) continue
+ return false
+ }
+}
+
+export function normalizeHostnameForUrl(hostname: string) {
+ if (hostname === "0.0.0.0") return "127.0.0.1"
+ if (hostname === "::") return "[::1]"
+ if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
+ return hostname
+}
+
+export function getServerUrlFromConfig(config: Config) {
+ const server = config.server
+ if (!server?.port) return null
+ const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
+ return `http://${host}:${server.port}`
+}
+
+export type { CommandChild }
diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts
new file mode 100644
index 000000000..fa1c5682e
--- /dev/null
+++ b/packages/desktop-electron/src/main/store.ts
@@ -0,0 +1,15 @@
+import Store from "electron-store"
+
+import { SETTINGS_STORE } from "./constants"
+
+const cache = new Map<string, Store>()
+
+export function getStore(name = SETTINGS_STORE) {
+ const cached = cache.get(name)
+ if (cached) return cached
+ const next = new Store({ name })
+ cache.set(name, next)
+ return next
+}
+
+export const store = getStore(SETTINGS_STORE)
diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts
new file mode 100644
index 000000000..9178457f8
--- /dev/null
+++ b/packages/desktop-electron/src/main/windows.ts
@@ -0,0 +1,135 @@
+import windowState from "electron-window-state"
+import { app, BrowserWindow, nativeImage } from "electron"
+import { dirname, join } from "node:path"
+import { fileURLToPath } from "node:url"
+
+type Globals = {
+ updaterEnabled: boolean
+ wsl: boolean
+ deepLinks?: string[]
+}
+
+const root = dirname(fileURLToPath(import.meta.url))
+
+function iconsDir() {
+ return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
+}
+
+function iconPath() {
+ const ext = process.platform === "win32" ? "ico" : "png"
+ return join(iconsDir(), `icon.${ext}`)
+}
+
+export function setDockIcon() {
+ if (process.platform !== "darwin") return
+ app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "[email protected]")))
+}
+
+export function createMainWindow(globals: Globals) {
+ const state = windowState({
+ defaultWidth: 1280,
+ defaultHeight: 800,
+ })
+
+ const win = new BrowserWindow({
+ x: state.x,
+ y: state.y,
+ width: state.width,
+ height: state.height,
+ show: true,
+ title: "OpenCode",
+ icon: iconPath(),
+ ...(process.platform === "darwin"
+ ? {
+ titleBarStyle: "hidden" as const,
+ trafficLightPosition: { x: 12, y: 14 },
+ }
+ : {}),
+ ...(process.platform === "win32"
+ ? {
+ frame: false,
+ titleBarStyle: "hidden" as const,
+ titleBarOverlay: {
+ color: "transparent",
+ symbolColor: "#999",
+ height: 40,
+ },
+ }
+ : {}),
+ webPreferences: {
+ preload: join(root, "../preload/index.mjs"),
+ sandbox: false,
+ },
+ })
+
+ state.manage(win)
+ loadWindow(win, "index.html")
+ wireZoom(win)
+ injectGlobals(win, globals)
+
+ return win
+}
+
+export function createLoadingWindow(globals: Globals) {
+ const win = new BrowserWindow({
+ width: 640,
+ height: 480,
+ resizable: false,
+ center: true,
+ show: true,
+ icon: iconPath(),
+ ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
+ ...(process.platform === "win32"
+ ? {
+ frame: false,
+ titleBarStyle: "hidden" as const,
+ titleBarOverlay: {
+ color: "transparent",
+ symbolColor: "#999",
+ height: 40,
+ },
+ }
+ : {}),
+ webPreferences: {
+ preload: join(root, "../preload/index.mjs"),
+ sandbox: false,
+ },
+ })
+
+ loadWindow(win, "loading.html")
+ injectGlobals(win, globals)
+
+ return win
+}
+
+function loadWindow(win: BrowserWindow, html: string) {
+ const devUrl = process.env.ELECTRON_RENDERER_URL
+ if (devUrl) {
+ const url = new URL(html, devUrl)
+ void win.loadURL(url.toString())
+ return
+ }
+
+ void win.loadFile(join(root, `../renderer/${html}`))
+}
+
+function injectGlobals(win: BrowserWindow, globals: Globals) {
+ win.webContents.on("dom-ready", () => {
+ const deepLinks = globals.deepLinks ?? []
+ const data = {
+ updaterEnabled: globals.updaterEnabled,
+ wsl: globals.wsl,
+ deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
+ }
+ void win.webContents.executeJavaScript(
+ `window.__OPENCODE__ = Object.assign(window.__OPENCODE__ ?? {}, ${JSON.stringify(data)})`,
+ )
+ })
+}
+
+function wireZoom(win: BrowserWindow) {
+ win.webContents.setZoomFactor(1)
+ win.webContents.on("zoom-changed", () => {
+ win.webContents.setZoomFactor(1)
+ })
+}
diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts
new file mode 100644
index 000000000..a6520ab42
--- /dev/null
+++ b/packages/desktop-electron/src/preload/index.ts
@@ -0,0 +1,66 @@
+import { contextBridge, ipcRenderer } from "electron"
+import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
+
+const api: ElectronAPI = {
+ killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
+ installCli: () => ipcRenderer.invoke("install-cli"),
+ awaitInitialization: (onStep) => {
+ const handler = (_: unknown, step: InitStep) => onStep(step)
+ ipcRenderer.on("init-step", handler)
+ return ipcRenderer.invoke("await-initialization").finally(() => {
+ ipcRenderer.removeListener("init-step", handler)
+ })
+ },
+ getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
+ setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
+ getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
+ setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config),
+ getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
+ setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
+ parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
+ checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
+ wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode),
+ resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
+ storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
+ storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
+ storeDelete: (name, key) => ipcRenderer.invoke("store-delete", name, key),
+ storeClear: (name) => ipcRenderer.invoke("store-clear", name),
+ storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
+ storeLength: (name) => ipcRenderer.invoke("store-length", name),
+
+ onSqliteMigrationProgress: (cb) => {
+ const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
+ ipcRenderer.on("sqlite-migration-progress", handler)
+ return () => ipcRenderer.removeListener("sqlite-migration-progress", handler)
+ },
+ onMenuCommand: (cb) => {
+ const handler = (_: unknown, id: string) => cb(id)
+ ipcRenderer.on("menu-command", handler)
+ return () => ipcRenderer.removeListener("menu-command", handler)
+ },
+ onDeepLink: (cb) => {
+ const handler = (_: unknown, urls: string[]) => cb(urls)
+ ipcRenderer.on("deep-link", handler)
+ return () => ipcRenderer.removeListener("deep-link", handler)
+ },
+
+ openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
+ openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
+ saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
+ openLink: (url) => ipcRenderer.send("open-link", url),
+ openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
+ readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
+ showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
+ getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
+ setWindowFocus: () => ipcRenderer.invoke("set-window-focus"),
+ showWindow: () => ipcRenderer.invoke("show-window"),
+ relaunch: () => ipcRenderer.send("relaunch"),
+ getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
+ setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
+ loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
+ runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
+ checkUpdate: () => ipcRenderer.invoke("check-update"),
+ installUpdate: () => ipcRenderer.invoke("install-update"),
+}
+
+contextBridge.exposeInMainWorld("api", api)
diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts
new file mode 100644
index 000000000..af5410f5f
--- /dev/null
+++ b/packages/desktop-electron/src/preload/types.ts
@@ -0,0 +1,64 @@
+export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
+
+export type ServerReadyData = {
+ url: string
+ password: string | null
+}
+
+export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
+
+export type WslConfig = { enabled: boolean }
+
+export type LinuxDisplayBackend = "wayland" | "auto"
+
+export type ElectronAPI = {
+ killSidecar: () => Promise<void>
+ installCli: () => Promise<string>
+ awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
+ getDefaultServerUrl: () => Promise<string | null>
+ setDefaultServerUrl: (url: string | null) => Promise<void>
+ getWslConfig: () => Promise<WslConfig>
+ setWslConfig: (config: WslConfig) => Promise<void>
+ getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
+ setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
+ parseMarkdownCommand: (markdown: string) => Promise<string>
+ checkAppExists: (appName: string) => Promise<boolean>
+ wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
+ resolveAppPath: (appName: string) => Promise<string | null>
+ storeGet: (name: string, key: string) => Promise<string | null>
+ storeSet: (name: string, key: string, value: string) => Promise<void>
+ storeDelete: (name: string, key: string) => Promise<void>
+ storeClear: (name: string) => Promise<void>
+ storeKeys: (name: string) => Promise<string[]>
+ storeLength: (name: string) => Promise<number>
+
+ onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
+ onMenuCommand: (cb: (id: string) => void) => () => void
+ onDeepLink: (cb: (urls: string[]) => void) => () => void
+
+ openDirectoryPicker: (opts?: {
+ multiple?: boolean
+ title?: string
+ defaultPath?: string
+ }) => Promise<string | string[] | null>
+ openFilePicker: (opts?: {
+ multiple?: boolean
+ title?: string
+ defaultPath?: string
+ }) => Promise<string | string[] | null>
+ saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
+ openLink: (url: string) => void
+ openPath: (path: string, app?: string) => Promise<void>
+ readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
+ showNotification: (title: string, body?: string) => void
+ getWindowFocused: () => Promise<boolean>
+ setWindowFocus: () => Promise<void>
+ showWindow: () => Promise<void>
+ relaunch: () => void
+ getZoomFactor: () => Promise<number>
+ setZoomFactor: (factor: number) => Promise<void>
+ loadingWindowComplete: () => void
+ runUpdater: (alertOnFail: boolean) => Promise<void>
+ checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
+ installUpdate: () => Promise<void>
+}
diff --git a/packages/desktop-electron/src/renderer/cli.ts b/packages/desktop-electron/src/renderer/cli.ts
new file mode 100644
index 000000000..11d3c1f1b
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/cli.ts
@@ -0,0 +1,12 @@
+import { initI18n, t } from "./i18n"
+
+export async function installCli(): Promise<void> {
+ await initI18n()
+
+ try {
+ const path = await window.api.installCli()
+ window.alert(t("desktop.cli.installed.message", { path }))
+ } catch (e) {
+ window.alert(t("desktop.cli.failed.message", { error: String(e) }))
+ }
+}
diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts
new file mode 100644
index 000000000..d1590ff04
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/env.d.ts
@@ -0,0 +1,12 @@
+import type { ElectronAPI } from "../preload/types"
+
+declare global {
+ interface Window {
+ api: ElectronAPI
+ __OPENCODE__?: {
+ updaterEnabled?: boolean
+ wsl?: boolean
+ deepLinks?: string[]
+ }
+ }
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/ar.ts b/packages/desktop-electron/src/renderer/i18n/ar.ts
new file mode 100644
index 000000000..fdbf0a804
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/ar.ts
@@ -0,0 +1,26 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
+ "desktop.menu.installCli": "تثبيت CLI...",
+ "desktop.menu.reloadWebview": "إعادة تحميل Webview",
+ "desktop.menu.restart": "إعادة تشغيل",
+
+ "desktop.dialog.chooseFolder": "اختر مجلدًا",
+ "desktop.dialog.chooseFile": "اختر ملفًا",
+ "desktop.dialog.saveFile": "حفظ ملف",
+
+ "desktop.updater.checkFailed.title": "فشل التحقق من التحديثات",
+ "desktop.updater.checkFailed.message": "فشل التحقق من وجود تحديثات",
+ "desktop.updater.none.title": "لا توجد تحديثات متاحة",
+ "desktop.updater.none.message": "أنت تستخدم بالفعل أحدث إصدار من OpenCode",
+ "desktop.updater.downloadFailed.title": "فشل التحديث",
+ "desktop.updater.downloadFailed.message": "فشل تنزيل التحديث",
+ "desktop.updater.downloaded.title": "تم تنزيل التحديث",
+ "desktop.updater.downloaded.prompt": "تم تنزيل إصدار {{version}} من OpenCode، هل ترغب في تثبيته وإعادة تشغيله؟",
+ "desktop.updater.installFailed.title": "فشل التحديث",
+ "desktop.updater.installFailed.message": "فشل تثبيت التحديث",
+
+ "desktop.cli.installed.title": "تم تثبيت CLI",
+ "desktop.cli.installed.message": "تم تثبيت CLI في {{path}}\n\nأعد تشغيل الطرفية لاستخدام الأمر 'opencode'.",
+ "desktop.cli.failed.title": "فشل التثبيت",
+ "desktop.cli.failed.message": "فشل تثبيت CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/br.ts b/packages/desktop-electron/src/renderer/i18n/br.ts
new file mode 100644
index 000000000..75fe2dc32
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/br.ts
@@ -0,0 +1,27 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Verificar atualizações...",
+ "desktop.menu.installCli": "Instalar CLI...",
+ "desktop.menu.reloadWebview": "Recarregar Webview",
+ "desktop.menu.restart": "Reiniciar",
+
+ "desktop.dialog.chooseFolder": "Escolher uma pasta",
+ "desktop.dialog.chooseFile": "Escolher um arquivo",
+ "desktop.dialog.saveFile": "Salvar arquivo",
+
+ "desktop.updater.checkFailed.title": "Falha ao verificar atualizações",
+ "desktop.updater.checkFailed.message": "Falha ao verificar atualizações",
+ "desktop.updater.none.title": "Nenhuma atualização disponível",
+ "desktop.updater.none.message": "Você já está usando a versão mais recente do OpenCode",
+ "desktop.updater.downloadFailed.title": "Falha na atualização",
+ "desktop.updater.downloadFailed.message": "Falha ao baixar a atualização",
+ "desktop.updater.downloaded.title": "Atualização baixada",
+ "desktop.updater.downloaded.prompt":
+ "A versão {{version}} do OpenCode foi baixada. Você gostaria de instalá-la e reiniciar?",
+ "desktop.updater.installFailed.title": "Falha na atualização",
+ "desktop.updater.installFailed.message": "Falha ao instalar a atualização",
+
+ "desktop.cli.installed.title": "CLI instalada",
+ "desktop.cli.installed.message": "CLI instalada em {{path}}\n\nReinicie seu terminal para usar o comando 'opencode'.",
+ "desktop.cli.failed.title": "Falha na instalação",
+ "desktop.cli.failed.message": "Falha ao instalar a CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/bs.ts b/packages/desktop-electron/src/renderer/i18n/bs.ts
new file mode 100644
index 000000000..58c266f53
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/bs.ts
@@ -0,0 +1,28 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Provjeri ažuriranja...",
+ "desktop.menu.installCli": "Instaliraj CLI...",
+ "desktop.menu.reloadWebview": "Ponovo učitavanje webview-a",
+ "desktop.menu.restart": "Restartuj",
+
+ "desktop.dialog.chooseFolder": "Odaberi folder",
+ "desktop.dialog.chooseFile": "Odaberi datoteku",
+ "desktop.dialog.saveFile": "Sačuvaj datoteku",
+
+ "desktop.updater.checkFailed.title": "Provjera ažuriranja nije uspjela",
+ "desktop.updater.checkFailed.message": "Nije moguće provjeriti ažuriranja",
+ "desktop.updater.none.title": "Nema dostupnog ažuriranja",
+ "desktop.updater.none.message": "Već koristiš najnoviju verziju OpenCode-a",
+ "desktop.updater.downloadFailed.title": "Ažuriranje nije uspjelo",
+ "desktop.updater.downloadFailed.message": "Neuspjelo preuzimanje ažuriranja",
+ "desktop.updater.downloaded.title": "Ažuriranje preuzeto",
+ "desktop.updater.downloaded.prompt":
+ "Verzija {{version}} OpenCode-a je preuzeta. Želiš li da je instaliraš i ponovo pokreneš aplikaciju?",
+ "desktop.updater.installFailed.title": "Ažuriranje nije uspjelo",
+ "desktop.updater.installFailed.message": "Neuspjela instalacija ažuriranja",
+
+ "desktop.cli.installed.title": "CLI instaliran",
+ "desktop.cli.installed.message":
+ "CLI je instaliran u {{path}}\n\nRestartuj terminal da bi koristio komandu 'opencode'.",
+ "desktop.cli.failed.title": "Instalacija nije uspjela",
+ "desktop.cli.failed.message": "Neuspjela instalacija CLI-a: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/da.ts b/packages/desktop-electron/src/renderer/i18n/da.ts
new file mode 100644
index 000000000..2109495f7
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/da.ts
@@ -0,0 +1,28 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Tjek for opdateringer...",
+ "desktop.menu.installCli": "Installer CLI...",
+ "desktop.menu.reloadWebview": "Genindlæs Webview",
+ "desktop.menu.restart": "Genstart",
+
+ "desktop.dialog.chooseFolder": "Vælg en mappe",
+ "desktop.dialog.chooseFile": "Vælg en fil",
+ "desktop.dialog.saveFile": "Gem fil",
+
+ "desktop.updater.checkFailed.title": "Opdateringstjek mislykkedes",
+ "desktop.updater.checkFailed.message": "Kunne ikke tjekke for opdateringer",
+ "desktop.updater.none.title": "Ingen opdatering tilgængelig",
+ "desktop.updater.none.message": "Du bruger allerede den nyeste version af OpenCode",
+ "desktop.updater.downloadFailed.title": "Opdatering mislykkedes",
+ "desktop.updater.downloadFailed.message": "Kunne ikke downloade opdateringen",
+ "desktop.updater.downloaded.title": "Opdatering downloadet",
+ "desktop.updater.downloaded.prompt":
+ "Version {{version}} af OpenCode er blevet downloadet. Vil du installere den og genstarte?",
+ "desktop.updater.installFailed.title": "Opdatering mislykkedes",
+ "desktop.updater.installFailed.message": "Kunne ikke installere opdateringen",
+
+ "desktop.cli.installed.title": "CLI installeret",
+ "desktop.cli.installed.message":
+ "CLI installeret i {{path}}\n\nGenstart din terminal for at bruge 'opencode'-kommandoen.",
+ "desktop.cli.failed.title": "Installation mislykkedes",
+ "desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/de.ts b/packages/desktop-electron/src/renderer/i18n/de.ts
new file mode 100644
index 000000000..38ad8096e
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/de.ts
@@ -0,0 +1,28 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Nach Updates suchen...",
+ "desktop.menu.installCli": "CLI installieren...",
+ "desktop.menu.reloadWebview": "Webview neu laden",
+ "desktop.menu.restart": "Neustart",
+
+ "desktop.dialog.chooseFolder": "Ordner auswählen",
+ "desktop.dialog.chooseFile": "Datei auswählen",
+ "desktop.dialog.saveFile": "Datei speichern",
+
+ "desktop.updater.checkFailed.title": "Updateprüfung fehlgeschlagen",
+ "desktop.updater.checkFailed.message": "Updates konnten nicht geprüft werden",
+ "desktop.updater.none.title": "Kein Update verfügbar",
+ "desktop.updater.none.message": "Sie verwenden bereits die neueste Version von OpenCode",
+ "desktop.updater.downloadFailed.title": "Update fehlgeschlagen",
+ "desktop.updater.downloadFailed.message": "Update konnte nicht heruntergeladen werden",
+ "desktop.updater.downloaded.title": "Update heruntergeladen",
+ "desktop.updater.downloaded.prompt":
+ "Version {{version}} von OpenCode wurde heruntergeladen. Möchten Sie sie installieren und neu starten?",
+ "desktop.updater.installFailed.title": "Update fehlgeschlagen",
+ "desktop.updater.installFailed.message": "Update konnte nicht installiert werden",
+
+ "desktop.cli.installed.title": "CLI installiert",
+ "desktop.cli.installed.message":
+ "CLI wurde in {{path}} installiert\n\nStarten Sie Ihr Terminal neu, um den Befehl 'opencode' zu verwenden.",
+ "desktop.cli.failed.title": "Installation fehlgeschlagen",
+ "desktop.cli.failed.message": "CLI konnte nicht installiert werden: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/en.ts b/packages/desktop-electron/src/renderer/i18n/en.ts
new file mode 100644
index 000000000..4c30380d5
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/en.ts
@@ -0,0 +1,27 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Check for Updates...",
+ "desktop.menu.installCli": "Install CLI...",
+ "desktop.menu.reloadWebview": "Reload Webview",
+ "desktop.menu.restart": "Restart",
+
+ "desktop.dialog.chooseFolder": "Choose a folder",
+ "desktop.dialog.chooseFile": "Choose a file",
+ "desktop.dialog.saveFile": "Save file",
+
+ "desktop.updater.checkFailed.title": "Update Check Failed",
+ "desktop.updater.checkFailed.message": "Failed to check for updates",
+ "desktop.updater.none.title": "No Update Available",
+ "desktop.updater.none.message": "You are already using the latest version of OpenCode",
+ "desktop.updater.downloadFailed.title": "Update Failed",
+ "desktop.updater.downloadFailed.message": "Failed to download update",
+ "desktop.updater.downloaded.title": "Update Downloaded",
+ "desktop.updater.downloaded.prompt":
+ "Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?",
+ "desktop.updater.installFailed.title": "Update Failed",
+ "desktop.updater.installFailed.message": "Failed to install update",
+
+ "desktop.cli.installed.title": "CLI Installed",
+ "desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.",
+ "desktop.cli.failed.title": "Installation Failed",
+ "desktop.cli.failed.message": "Failed to install CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/es.ts b/packages/desktop-electron/src/renderer/i18n/es.ts
new file mode 100644
index 000000000..80504a8f2
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/es.ts
@@ -0,0 +1,27 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Buscar actualizaciones...",
+ "desktop.menu.installCli": "Instalar CLI...",
+ "desktop.menu.reloadWebview": "Recargar Webview",
+ "desktop.menu.restart": "Reiniciar",
+
+ "desktop.dialog.chooseFolder": "Elegir una carpeta",
+ "desktop.dialog.chooseFile": "Elegir un archivo",
+ "desktop.dialog.saveFile": "Guardar archivo",
+
+ "desktop.updater.checkFailed.title": "Comprobación de actualizaciones fallida",
+ "desktop.updater.checkFailed.message": "No se pudieron buscar actualizaciones",
+ "desktop.updater.none.title": "No hay actualizaciones disponibles",
+ "desktop.updater.none.message": "Ya estás usando la versión más reciente de OpenCode",
+ "desktop.updater.downloadFailed.title": "Actualización fallida",
+ "desktop.updater.downloadFailed.message": "No se pudo descargar la actualización",
+ "desktop.updater.downloaded.title": "Actualización descargada",
+ "desktop.updater.downloaded.prompt":
+ "Se ha descargado la versión {{version}} de OpenCode. ¿Quieres instalarla y reiniciar?",
+ "desktop.updater.installFailed.title": "Actualización fallida",
+ "desktop.updater.installFailed.message": "No se pudo instalar la actualización",
+
+ "desktop.cli.installed.title": "CLI instalada",
+ "desktop.cli.installed.message": "CLI instalada en {{path}}\n\nReinicia tu terminal para usar el comando 'opencode'.",
+ "desktop.cli.failed.title": "Instalación fallida",
+ "desktop.cli.failed.message": "No se pudo instalar la CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/fr.ts b/packages/desktop-electron/src/renderer/i18n/fr.ts
new file mode 100644
index 000000000..4f0bb2b16
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/fr.ts
@@ -0,0 +1,28 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Vérifier les mises à jour...",
+ "desktop.menu.installCli": "Installer la CLI...",
+ "desktop.menu.reloadWebview": "Recharger la Webview",
+ "desktop.menu.restart": "Redémarrer",
+
+ "desktop.dialog.chooseFolder": "Choisir un dossier",
+ "desktop.dialog.chooseFile": "Choisir un fichier",
+ "desktop.dialog.saveFile": "Enregistrer le fichier",
+
+ "desktop.updater.checkFailed.title": "Échec de la vérification des mises à jour",
+ "desktop.updater.checkFailed.message": "Impossible de vérifier les mises à jour",
+ "desktop.updater.none.title": "Aucune mise à jour disponible",
+ "desktop.updater.none.message": "Vous utilisez déjà la dernière version d'OpenCode",
+ "desktop.updater.downloadFailed.title": "Échec de la mise à jour",
+ "desktop.updater.downloadFailed.message": "Impossible de télécharger la mise à jour",
+ "desktop.updater.downloaded.title": "Mise à jour téléchargée",
+ "desktop.updater.downloaded.prompt":
+ "La version {{version}} d'OpenCode a été téléchargée. Voulez-vous l'installer et redémarrer ?",
+ "desktop.updater.installFailed.title": "Échec de la mise à jour",
+ "desktop.updater.installFailed.message": "Impossible d'installer la mise à jour",
+
+ "desktop.cli.installed.title": "CLI installée",
+ "desktop.cli.installed.message":
+ "CLI installée dans {{path}}\n\nRedémarrez votre terminal pour utiliser la commande 'opencode'.",
+ "desktop.cli.failed.title": "Échec de l'installation",
+ "desktop.cli.failed.message": "Impossible d'installer la CLI : {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/index.ts b/packages/desktop-electron/src/renderer/i18n/index.ts
new file mode 100644
index 000000000..81158ad24
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/index.ts
@@ -0,0 +1,187 @@
+import * as i18n from "@solid-primitives/i18n"
+
+import { dict as desktopEn } from "./en"
+import { dict as desktopZh } from "./zh"
+import { dict as desktopZht } from "./zht"
+import { dict as desktopKo } from "./ko"
+import { dict as desktopDe } from "./de"
+import { dict as desktopEs } from "./es"
+import { dict as desktopFr } from "./fr"
+import { dict as desktopDa } from "./da"
+import { dict as desktopJa } from "./ja"
+import { dict as desktopPl } from "./pl"
+import { dict as desktopRu } from "./ru"
+import { dict as desktopAr } from "./ar"
+import { dict as desktopNo } from "./no"
+import { dict as desktopBr } from "./br"
+import { dict as desktopBs } from "./bs"
+
+import { dict as appEn } from "../../../../app/src/i18n/en"
+import { dict as appZh } from "../../../../app/src/i18n/zh"
+import { dict as appZht } from "../../../../app/src/i18n/zht"
+import { dict as appKo } from "../../../../app/src/i18n/ko"
+import { dict as appDe } from "../../../../app/src/i18n/de"
+import { dict as appEs } from "../../../../app/src/i18n/es"
+import { dict as appFr } from "../../../../app/src/i18n/fr"
+import { dict as appDa } from "../../../../app/src/i18n/da"
+import { dict as appJa } from "../../../../app/src/i18n/ja"
+import { dict as appPl } from "../../../../app/src/i18n/pl"
+import { dict as appRu } from "../../../../app/src/i18n/ru"
+import { dict as appAr } from "../../../../app/src/i18n/ar"
+import { dict as appNo } from "../../../../app/src/i18n/no"
+import { dict as appBr } from "../../../../app/src/i18n/br"
+import { dict as appBs } from "../../../../app/src/i18n/bs"
+
+export type Locale =
+ | "en"
+ | "zh"
+ | "zht"
+ | "ko"
+ | "de"
+ | "es"
+ | "fr"
+ | "da"
+ | "ja"
+ | "pl"
+ | "ru"
+ | "ar"
+ | "no"
+ | "br"
+ | "bs"
+
+type RawDictionary = typeof appEn & typeof desktopEn
+type Dictionary = i18n.Flatten<RawDictionary>
+
+const LOCALES: readonly Locale[] = [
+ "en",
+ "zh",
+ "zht",
+ "ko",
+ "de",
+ "es",
+ "fr",
+ "da",
+ "ja",
+ "pl",
+ "ru",
+ "bs",
+ "ar",
+ "no",
+ "br",
+]
+
+function detectLocale(): Locale {
+ if (typeof navigator !== "object") return "en"
+
+ const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
+ for (const language of languages) {
+ if (!language) continue
+ if (language.toLowerCase().startsWith("zh")) {
+ if (language.toLowerCase().includes("hant")) return "zht"
+ return "zh"
+ }
+ if (language.toLowerCase().startsWith("ko")) return "ko"
+ if (language.toLowerCase().startsWith("de")) return "de"
+ if (language.toLowerCase().startsWith("es")) return "es"
+ if (language.toLowerCase().startsWith("fr")) return "fr"
+ if (language.toLowerCase().startsWith("da")) return "da"
+ if (language.toLowerCase().startsWith("ja")) return "ja"
+ if (language.toLowerCase().startsWith("pl")) return "pl"
+ if (language.toLowerCase().startsWith("ru")) return "ru"
+ if (language.toLowerCase().startsWith("ar")) return "ar"
+ if (
+ language.toLowerCase().startsWith("no") ||
+ language.toLowerCase().startsWith("nb") ||
+ language.toLowerCase().startsWith("nn")
+ )
+ return "no"
+ if (language.toLowerCase().startsWith("pt")) return "br"
+ if (language.toLowerCase().startsWith("bs")) return "bs"
+ }
+
+ return "en"
+}
+
+function parseLocale(value: unknown): Locale | null {
+ if (!value) return null
+ if (typeof value !== "string") return null
+ if ((LOCALES as readonly string[]).includes(value)) return value as Locale
+ return null
+}
+
+function parseRecord(value: unknown) {
+ if (!value || typeof value !== "object") return null
+ if (Array.isArray(value)) return null
+ return value as Record<string, unknown>
+}
+
+function parseStored(value: unknown) {
+ if (typeof value !== "string") return value
+ try {
+ return JSON.parse(value) as unknown
+ } catch {
+ return value
+ }
+}
+
+function pickLocale(value: unknown): Locale | null {
+ const direct = parseLocale(value)
+ if (direct) return direct
+
+ const record = parseRecord(value)
+ if (!record) return null
+
+ return parseLocale(record.locale)
+}
+
+const base = i18n.flatten({ ...appEn, ...desktopEn })
+
+function build(locale: Locale): Dictionary {
+ if (locale === "en") return base
+ if (locale === "zh") return { ...base, ...i18n.flatten(appZh), ...i18n.flatten(desktopZh) }
+ if (locale === "zht") return { ...base, ...i18n.flatten(appZht), ...i18n.flatten(desktopZht) }
+ if (locale === "de") return { ...base, ...i18n.flatten(appDe), ...i18n.flatten(desktopDe) }
+ if (locale === "es") return { ...base, ...i18n.flatten(appEs), ...i18n.flatten(desktopEs) }
+ if (locale === "fr") return { ...base, ...i18n.flatten(appFr), ...i18n.flatten(desktopFr) }
+ if (locale === "da") return { ...base, ...i18n.flatten(appDa), ...i18n.flatten(desktopDa) }
+ if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) }
+ if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) }
+ if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) }
+ if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) }
+ if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) }
+ if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) }
+ if (locale === "bs") return { ...base, ...i18n.flatten(appBs), ...i18n.flatten(desktopBs) }
+ return { ...base, ...i18n.flatten(appKo), ...i18n.flatten(desktopKo) }
+}
+
+const state = {
+ locale: detectLocale(),
+ dict: base as Dictionary,
+ init: undefined as Promise<Locale> | undefined,
+}
+
+state.dict = build(state.locale)
+
+const translate = i18n.translator(() => state.dict, i18n.resolveTemplate)
+
+export function t(key: keyof Dictionary, params?: Record<string, string | number>) {
+ return translate(key, params)
+}
+
+export function initI18n(): Promise<Locale> {
+ const cached = state.init
+ if (cached) return cached
+
+ const promise = (async () => {
+ const raw = await window.api.storeGet("opencode.global.dat", "language").catch(() => null)
+ const value = parseStored(raw)
+ const next = pickLocale(value) ?? state.locale
+
+ state.locale = next
+ state.dict = build(next)
+ return next
+ })().catch(() => state.locale)
+
+ state.init = promise
+ return promise
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/ja.ts b/packages/desktop-electron/src/renderer/i18n/ja.ts
new file mode 100644
index 000000000..fc485c6f4
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/ja.ts
@@ -0,0 +1,28 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "アップデートを確認...",
+ "desktop.menu.installCli": "CLI をインストール...",
+ "desktop.menu.reloadWebview": "Webview を再読み込み",
+ "desktop.menu.restart": "再起動",
+
+ "desktop.dialog.chooseFolder": "フォルダーを選択",
+ "desktop.dialog.chooseFile": "ファイルを選択",
+ "desktop.dialog.saveFile": "ファイルを保存",
+
+ "desktop.updater.checkFailed.title": "アップデートの確認に失敗しました",
+ "desktop.updater.checkFailed.message": "アップデートを確認できませんでした",
+ "desktop.updater.none.title": "利用可能なアップデートはありません",
+ "desktop.updater.none.message": "すでに最新バージョンの OpenCode を使用しています",
+ "desktop.updater.downloadFailed.title": "アップデートに失敗しました",
+ "desktop.updater.downloadFailed.message": "アップデートをダウンロードできませんでした",
+ "desktop.updater.downloaded.title": "アップデートをダウンロードしました",
+ "desktop.updater.downloaded.prompt":
+ "OpenCode のバージョン {{version}} がダウンロードされました。インストールして再起動しますか?",
+ "desktop.updater.installFailed.title": "アップデートに失敗しました",
+ "desktop.updater.installFailed.message": "アップデートをインストールできませんでした",
+
+ "desktop.cli.installed.title": "CLI をインストールしました",
+ "desktop.cli.installed.message":
+ "CLI を {{path}} にインストールしました\n\nターミナルを再起動して 'opencode' コマンドを使用してください。",
+ "desktop.cli.failed.title": "インストールに失敗しました",
+ "desktop.cli.failed.message": "CLI のインストールに失敗しました: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/ko.ts b/packages/desktop-electron/src/renderer/i18n/ko.ts
new file mode 100644
index 000000000..be27cec86
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/ko.ts
@@ -0,0 +1,27 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "업데이트 확인...",
+ "desktop.menu.installCli": "CLI 설치...",
+ "desktop.menu.reloadWebview": "Webview 새로고침",
+ "desktop.menu.restart": "다시 시작",
+
+ "desktop.dialog.chooseFolder": "폴더 선택",
+ "desktop.dialog.chooseFile": "파일 선택",
+ "desktop.dialog.saveFile": "파일 저장",
+
+ "desktop.updater.checkFailed.title": "업데이트 확인 실패",
+ "desktop.updater.checkFailed.message": "업데이트를 확인하지 못했습니다",
+ "desktop.updater.none.title": "사용 가능한 업데이트 없음",
+ "desktop.updater.none.message": "이미 최신 버전의 OpenCode를 사용하고 있습니다",
+ "desktop.updater.downloadFailed.title": "업데이트 실패",
+ "desktop.updater.downloadFailed.message": "업데이트를 다운로드하지 못했습니다",
+ "desktop.updater.downloaded.title": "업데이트 다운로드 완료",
+ "desktop.updater.downloaded.prompt": "OpenCode {{version}} 버전을 다운로드했습니다. 설치하고 다시 실행할까요?",
+ "desktop.updater.installFailed.title": "업데이트 실패",
+ "desktop.updater.installFailed.message": "업데이트를 설치하지 못했습니다",
+
+ "desktop.cli.installed.title": "CLI 설치됨",
+ "desktop.cli.installed.message":
+ "CLI가 {{path}}에 설치되었습니다\n\n터미널을 다시 시작하여 'opencode' 명령을 사용하세요.",
+ "desktop.cli.failed.title": "설치 실패",
+ "desktop.cli.failed.message": "CLI 설치 실패: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/no.ts b/packages/desktop-electron/src/renderer/i18n/no.ts
new file mode 100644
index 000000000..e39bd7f3b
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/no.ts
@@ -0,0 +1,28 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Se etter oppdateringer...",
+ "desktop.menu.installCli": "Installer CLI...",
+ "desktop.menu.reloadWebview": "Last inn Webview på nytt",
+ "desktop.menu.restart": "Start på nytt",
+
+ "desktop.dialog.chooseFolder": "Velg en mappe",
+ "desktop.dialog.chooseFile": "Velg en fil",
+ "desktop.dialog.saveFile": "Lagre fil",
+
+ "desktop.updater.checkFailed.title": "Oppdateringssjekk mislyktes",
+ "desktop.updater.checkFailed.message": "Kunne ikke se etter oppdateringer",
+ "desktop.updater.none.title": "Ingen oppdatering tilgjengelig",
+ "desktop.updater.none.message": "Du bruker allerede den nyeste versjonen av OpenCode",
+ "desktop.updater.downloadFailed.title": "Oppdatering mislyktes",
+ "desktop.updater.downloadFailed.message": "Kunne ikke laste ned oppdateringen",
+ "desktop.updater.downloaded.title": "Oppdatering lastet ned",
+ "desktop.updater.downloaded.prompt":
+ "Versjon {{version}} av OpenCode er lastet ned. Vil du installere den og starte på nytt?",
+ "desktop.updater.installFailed.title": "Oppdatering mislyktes",
+ "desktop.updater.installFailed.message": "Kunne ikke installere oppdateringen",
+
+ "desktop.cli.installed.title": "CLI installert",
+ "desktop.cli.installed.message":
+ "CLI installert til {{path}}\n\nStart terminalen på nytt for å bruke 'opencode'-kommandoen.",
+ "desktop.cli.failed.title": "Installasjon mislyktes",
+ "desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/pl.ts b/packages/desktop-electron/src/renderer/i18n/pl.ts
new file mode 100644
index 000000000..d3ad7ce64
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/pl.ts
@@ -0,0 +1,28 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Sprawdź aktualizacje...",
+ "desktop.menu.installCli": "Zainstaluj CLI...",
+ "desktop.menu.reloadWebview": "Przeładuj Webview",
+ "desktop.menu.restart": "Restartuj",
+
+ "desktop.dialog.chooseFolder": "Wybierz folder",
+ "desktop.dialog.chooseFile": "Wybierz plik",
+ "desktop.dialog.saveFile": "Zapisz plik",
+
+ "desktop.updater.checkFailed.title": "Nie udało się sprawdzić aktualizacji",
+ "desktop.updater.checkFailed.message": "Nie udało się sprawdzić aktualizacji",
+ "desktop.updater.none.title": "Brak dostępnych aktualizacji",
+ "desktop.updater.none.message": "Korzystasz już z najnowszej wersji OpenCode",
+ "desktop.updater.downloadFailed.title": "Aktualizacja nie powiodła się",
+ "desktop.updater.downloadFailed.message": "Nie udało się pobrać aktualizacji",
+ "desktop.updater.downloaded.title": "Aktualizacja pobrana",
+ "desktop.updater.downloaded.prompt":
+ "Pobrano wersję {{version}} OpenCode. Czy chcesz ją zainstalować i uruchomić ponownie?",
+ "desktop.updater.installFailed.title": "Aktualizacja nie powiodła się",
+ "desktop.updater.installFailed.message": "Nie udało się zainstalować aktualizacji",
+
+ "desktop.cli.installed.title": "CLI zainstalowane",
+ "desktop.cli.installed.message":
+ "CLI zainstalowane w {{path}}\n\nUruchom ponownie terminal, aby użyć polecenia 'opencode'.",
+ "desktop.cli.failed.title": "Instalacja nie powiodła się",
+ "desktop.cli.failed.message": "Nie udało się zainstalować CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/ru.ts b/packages/desktop-electron/src/renderer/i18n/ru.ts
new file mode 100644
index 000000000..8e09cc45b
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/ru.ts
@@ -0,0 +1,27 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "Проверить обновления...",
+ "desktop.menu.installCli": "Установить CLI...",
+ "desktop.menu.reloadWebview": "Перезагрузить Webview",
+ "desktop.menu.restart": "Перезапустить",
+
+ "desktop.dialog.chooseFolder": "Выберите папку",
+ "desktop.dialog.chooseFile": "Выберите файл",
+ "desktop.dialog.saveFile": "Сохранить файл",
+
+ "desktop.updater.checkFailed.title": "Не удалось проверить обновления",
+ "desktop.updater.checkFailed.message": "Не удалось проверить обновления",
+ "desktop.updater.none.title": "Обновлений нет",
+ "desktop.updater.none.message": "Вы уже используете последнюю версию OpenCode",
+ "desktop.updater.downloadFailed.title": "Обновление не удалось",
+ "desktop.updater.downloadFailed.message": "Не удалось скачать обновление",
+ "desktop.updater.downloaded.title": "Обновление загружено",
+ "desktop.updater.downloaded.prompt": "Версия OpenCode {{version}} загружена. Хотите установить и перезапустить?",
+ "desktop.updater.installFailed.title": "Обновление не удалось",
+ "desktop.updater.installFailed.message": "Не удалось установить обновление",
+
+ "desktop.cli.installed.title": "CLI установлен",
+ "desktop.cli.installed.message":
+ "CLI установлен в {{path}}\n\nПерезапустите терминал, чтобы использовать команду 'opencode'.",
+ "desktop.cli.failed.title": "Ошибка установки",
+ "desktop.cli.failed.message": "Не удалось установить CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/zh.ts b/packages/desktop-electron/src/renderer/i18n/zh.ts
new file mode 100644
index 000000000..aeb3a54e0
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/zh.ts
@@ -0,0 +1,26 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "检查更新...",
+ "desktop.menu.installCli": "安装 CLI...",
+ "desktop.menu.reloadWebview": "重新加载 Webview",
+ "desktop.menu.restart": "重启",
+
+ "desktop.dialog.chooseFolder": "选择文件夹",
+ "desktop.dialog.chooseFile": "选择文件",
+ "desktop.dialog.saveFile": "保存文件",
+
+ "desktop.updater.checkFailed.title": "检查更新失败",
+ "desktop.updater.checkFailed.message": "无法检查更新",
+ "desktop.updater.none.title": "没有可用更新",
+ "desktop.updater.none.message": "你已经在使用最新版本的 OpenCode",
+ "desktop.updater.downloadFailed.title": "更新失败",
+ "desktop.updater.downloadFailed.message": "无法下载更新",
+ "desktop.updater.downloaded.title": "更新已下载",
+ "desktop.updater.downloaded.prompt": "已下载 OpenCode {{version}} 版本,是否安装并重启?",
+ "desktop.updater.installFailed.title": "更新失败",
+ "desktop.updater.installFailed.message": "无法安装更新",
+
+ "desktop.cli.installed.title": "CLI 已安装",
+ "desktop.cli.installed.message": "CLI 已安装到 {{path}}\n\n重启终端以使用 'opencode' 命令。",
+ "desktop.cli.failed.title": "安装失败",
+ "desktop.cli.failed.message": "无法安装 CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/i18n/zht.ts b/packages/desktop-electron/src/renderer/i18n/zht.ts
new file mode 100644
index 000000000..7fd677aca
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/i18n/zht.ts
@@ -0,0 +1,26 @@
+export const dict = {
+ "desktop.menu.checkForUpdates": "檢查更新...",
+ "desktop.menu.installCli": "安裝 CLI...",
+ "desktop.menu.reloadWebview": "重新載入 Webview",
+ "desktop.menu.restart": "重新啟動",
+
+ "desktop.dialog.chooseFolder": "選擇資料夾",
+ "desktop.dialog.chooseFile": "選擇檔案",
+ "desktop.dialog.saveFile": "儲存檔案",
+
+ "desktop.updater.checkFailed.title": "檢查更新失敗",
+ "desktop.updater.checkFailed.message": "無法檢查更新",
+ "desktop.updater.none.title": "沒有可用更新",
+ "desktop.updater.none.message": "你已在使用最新版的 OpenCode",
+ "desktop.updater.downloadFailed.title": "更新失敗",
+ "desktop.updater.downloadFailed.message": "無法下載更新",
+ "desktop.updater.downloaded.title": "更新已下載",
+ "desktop.updater.downloaded.prompt": "已下載 OpenCode {{version}} 版本,是否安裝並重新啟動?",
+ "desktop.updater.installFailed.title": "更新失敗",
+ "desktop.updater.installFailed.message": "無法安裝更新",
+
+ "desktop.cli.installed.title": "CLI 已安裝",
+ "desktop.cli.installed.message": "CLI 已安裝到 {{path}}\n\n重新啟動終端機以使用 'opencode' 命令。",
+ "desktop.cli.failed.title": "安裝失敗",
+ "desktop.cli.failed.message": "無法安裝 CLI: {{error}}",
+}
diff --git a/packages/desktop-electron/src/renderer/index.html b/packages/desktop-electron/src/renderer/index.html
new file mode 100644
index 000000000..175640819
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/index.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en" style="background-color: var(--background-base)">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>OpenCode</title>
+ <link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
+ <link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
+ <link rel="shortcut icon" href="/favicon-v3.ico" />
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
+ <link rel="manifest" href="/site.webmanifest" />
+ <meta name="theme-color" content="#F8F7F7" />
+ <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
+ <meta property="og:image" content="/social-share.png" />
+ <meta property="twitter:image" content="/social-share.png" />
+ <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
+ </head>
+ <body class="antialiased overscroll-none text-12-regular overflow-hidden">
+ <noscript>You need to enable JavaScript to run this app.</noscript>
+ <div id="root" class="flex flex-col h-dvh"></div>
+ <script src="/index.tsx" type="module"></script>
+ </body>
+</html>
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
new file mode 100644
index 000000000..b5193d626
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -0,0 +1,312 @@
+// @refresh reload
+
+import {
+ AppBaseProviders,
+ AppInterface,
+ handleNotificationClick,
+ type Platform,
+ PlatformProvider,
+ ServerConnection,
+ useCommand,
+} from "@opencode-ai/app"
+import { Splash } from "@opencode-ai/ui/logo"
+import type { AsyncStorage } from "@solid-primitives/storage"
+import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
+import { render } from "solid-js/web"
+import { MemoryRouter } from "@solidjs/router"
+import pkg from "../../package.json"
+import { initI18n, t } from "./i18n"
+import { UPDATER_ENABLED } from "./updater"
+import { webviewZoom } from "./webview-zoom"
+import "./styles.css"
+import type { ServerReadyData } from "../preload/types"
+
+const root = document.getElementById("root")
+if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
+ throw new Error(t("error.dev.rootNotFound"))
+}
+
+void initI18n()
+
+const deepLinkEvent = "opencode:deep-link"
+
+const emitDeepLinks = (urls: string[]) => {
+ if (urls.length === 0) return
+ window.__OPENCODE__ ??= {}
+ const pending = window.__OPENCODE__.deepLinks ?? []
+ window.__OPENCODE__.deepLinks = [...pending, ...urls]
+ window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
+}
+
+const listenForDeepLinks = () => {
+ const startUrls = window.__OPENCODE__?.deepLinks ?? []
+ if (startUrls.length) emitDeepLinks(startUrls)
+ return window.api.onDeepLink((urls) => emitDeepLinks(urls))
+}
+
+const createPlatform = (): Platform => {
+ const os = (() => {
+ const ua = navigator.userAgent
+ if (ua.includes("Mac")) return "macos"
+ if (ua.includes("Windows")) return "windows"
+ if (ua.includes("Linux")) return "linux"
+ return undefined
+ })()
+
+ const wslHome = async () => {
+ if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
+ return window.api.wslPath("~", "windows").catch(() => undefined)
+ }
+
+ const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
+ if (!result || !window.__OPENCODE__?.wsl) return result
+ if (Array.isArray(result)) {
+ return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
+ }
+ return window.api.wslPath(result, "linux").catch(() => result) as any
+ }
+
+ const storage = (() => {
+ const cache = new Map<string, AsyncStorage>()
+
+ const createStorage = (name: string) => {
+ const api: AsyncStorage = {
+ getItem: (key: string) => window.api.storeGet(name, key),
+ setItem: (key: string, value: string) => window.api.storeSet(name, key, value),
+ removeItem: (key: string) => window.api.storeDelete(name, key),
+ clear: () => window.api.storeClear(name),
+ key: async (index: number) => (await window.api.storeKeys(name))[index],
+ getLength: () => window.api.storeLength(name),
+ get length() {
+ return api.getLength()
+ },
+ }
+ return api
+ }
+
+ return (name = "default.dat") => {
+ const cached = cache.get(name)
+ if (cached) return cached
+ const api = createStorage(name)
+ cache.set(name, api)
+ return api
+ }
+ })()
+
+ return {
+ platform: "desktop",
+ os,
+ version: pkg.version,
+
+ async openDirectoryPickerDialog(opts) {
+ const defaultPath = await wslHome()
+ const result = await window.api.openDirectoryPicker({
+ multiple: opts?.multiple ?? false,
+ title: opts?.title ?? t("desktop.dialog.chooseFolder"),
+ defaultPath,
+ })
+ return await handleWslPicker(result)
+ },
+
+ async openFilePickerDialog(opts) {
+ const result = await window.api.openFilePicker({
+ multiple: opts?.multiple ?? false,
+ title: opts?.title ?? t("desktop.dialog.chooseFile"),
+ })
+ return handleWslPicker(result)
+ },
+
+ async saveFilePickerDialog(opts) {
+ const result = await window.api.saveFilePicker({
+ title: opts?.title ?? t("desktop.dialog.saveFile"),
+ defaultPath: opts?.defaultPath,
+ })
+ return handleWslPicker(result)
+ },
+
+ openLink(url: string) {
+ window.api.openLink(url)
+ },
+ async openPath(path: string, app?: string) {
+ if (os === "windows") {
+ const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
+ const resolvedPath = await (async () => {
+ if (window.__OPENCODE__?.wsl) {
+ const converted = await window.api.wslPath(path, "windows").catch(() => null)
+ if (converted) return converted
+ }
+ return path
+ })()
+ return window.api.openPath(resolvedPath, resolvedApp ?? undefined)
+ }
+ return window.api.openPath(path, app)
+ },
+
+ back() {
+ window.history.back()
+ },
+
+ forward() {
+ window.history.forward()
+ },
+
+ storage,
+
+ checkUpdate: async () => {
+ if (!UPDATER_ENABLED) return { updateAvailable: false }
+ return window.api.checkUpdate()
+ },
+
+ update: async () => {
+ if (!UPDATER_ENABLED) return
+ await window.api.installUpdate()
+ },
+
+ restart: async () => {
+ await window.api.killSidecar().catch(() => undefined)
+ window.api.relaunch()
+ },
+
+ notify: async (title, description, href) => {
+ const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
+ if (focused) return
+
+ const notification = new Notification(title, {
+ body: description ?? "",
+ icon: "https://opencode.ai/favicon-96x96-v3.png",
+ })
+ notification.onclick = () => {
+ void window.api.showWindow()
+ void window.api.setWindowFocus()
+ handleNotificationClick(href)
+ notification.close()
+ }
+ },
+
+ fetch: (input, init) => {
+ if (input instanceof Request) return fetch(input)
+ return fetch(input, init)
+ },
+
+ getWslEnabled: async () => {
+ const next = await window.api.getWslConfig().catch(() => null)
+ if (next) return next.enabled
+ return window.__OPENCODE__!.wsl ?? false
+ },
+
+ setWslEnabled: async (enabled) => {
+ await window.api.setWslConfig({ enabled })
+ },
+
+ getDefaultServerUrl: async () => {
+ return window.api.getDefaultServerUrl().catch(() => null)
+ },
+
+ setDefaultServerUrl: async (url: string | null) => {
+ await window.api.setDefaultServerUrl(url)
+ },
+
+ getDisplayBackend: async () => {
+ return window.api.getDisplayBackend().catch(() => null)
+ },
+
+ setDisplayBackend: async (backend) => {
+ await window.api.setDisplayBackend(backend)
+ },
+
+ parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown),
+
+ webviewZoom,
+
+ checkAppExists: async (appName: string) => {
+ return window.api.checkAppExists(appName)
+ },
+
+ async readClipboardImage() {
+ const image = await window.api.readClipboardImage().catch(() => null)
+ if (!image) return null
+ const blob = new Blob([image.buffer], { type: "image/png" })
+ return new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })
+ },
+ }
+}
+
+let menuTrigger = null as null | ((id: string) => void)
+window.api.onMenuCommand((id) => {
+ menuTrigger?.(id)
+})
+listenForDeepLinks()
+
+render(() => {
+ const platform = createPlatform()
+
+ function handleClick(e: MouseEvent) {
+ const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
+ if (link?.href) {
+ e.preventDefault()
+ platform.openLink(link.href)
+ }
+ }
+
+ onMount(() => {
+ document.addEventListener("click", handleClick)
+ onCleanup(() => {
+ document.removeEventListener("click", handleClick)
+ })
+ })
+
+ return (
+ <PlatformProvider value={platform}>
+ <AppBaseProviders>
+ <ServerGate>
+ {(data) => {
+ const server: ServerConnection.Sidecar = {
+ displayName: "Local Server",
+ type: "sidecar",
+ variant: "base",
+ http: {
+ url: data().url,
+ username: "opencode",
+ password: data().password ?? undefined,
+ },
+ }
+
+ function Inner() {
+ const cmd = useCommand()
+
+ menuTrigger = (id) => cmd.trigger(id)
+
+ return null
+ }
+
+ return (
+ <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} router={MemoryRouter}>
+ <Inner />
+ </AppInterface>
+ )
+ }}
+ </ServerGate>
+ </AppBaseProviders>
+ </PlatformProvider>
+ )
+}, root!)
+
+// Gate component that waits for the server to be ready
+function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
+ const [serverData] = createResource(() => window.api.awaitInitialization(() => undefined))
+ console.log({ serverData })
+ if (serverData.state === "errored") throw serverData.error
+
+ return (
+ <Show
+ when={serverData.state !== "pending" && serverData()}
+ fallback={
+ <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
+ <Splash class="w-16 h-20 opacity-50 animate-pulse" />
+ </div>
+ }
+ >
+ {(data) => props.children(data)}
+ </Show>
+ )
+}
diff --git a/packages/desktop-electron/src/renderer/loading.html b/packages/desktop-electron/src/renderer/loading.html
new file mode 100644
index 000000000..8def243b4
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/loading.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en" style="background-color: var(--background-base)">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>OpenCode</title>
+ <link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
+ <link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
+ <link rel="shortcut icon" href="/favicon-v3.ico" />
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
+ <link rel="manifest" href="/site.webmanifest" />
+ <meta name="theme-color" content="#F8F7F7" />
+ <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
+ <meta property="og:image" content="/social-share.png" />
+ <meta property="twitter:image" content="/social-share.png" />
+ <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
+ </head>
+ <body class="antialiased overscroll-none text-12-regular overflow-hidden">
+ <noscript>You need to enable JavaScript to run this app.</noscript>
+ <div id="root" class="flex flex-col h-dvh"></div>
+ <script src="/loading.tsx" type="module"></script>
+ </body>
+</html>
diff --git a/packages/desktop-electron/src/renderer/loading.tsx b/packages/desktop-electron/src/renderer/loading.tsx
new file mode 100644
index 000000000..165950352
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/loading.tsx
@@ -0,0 +1,80 @@
+import { render } from "solid-js/web"
+import { MetaProvider } from "@solidjs/meta"
+import "@opencode-ai/app/index.css"
+import { Font } from "@opencode-ai/ui/font"
+import { Splash } from "@opencode-ai/ui/logo"
+import { Progress } from "@opencode-ai/ui/progress"
+import "./styles.css"
+import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
+import type { InitStep, SqliteMigrationProgress } from "../preload/types"
+
+const root = document.getElementById("root")!
+const lines = ["Just a moment...", "Migrating your database", "This may take a couple of minutes"]
+const delays = [3000, 9000]
+
+render(() => {
+ const [step, setStep] = createSignal<InitStep | null>(null)
+ const [line, setLine] = createSignal(0)
+ const [percent, setPercent] = createSignal(0)
+
+ const phase = createMemo(() => step()?.phase)
+
+ const value = createMemo(() => {
+ if (phase() === "done") return 100
+ return Math.max(25, Math.min(100, percent()))
+ })
+
+ window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined)
+
+ onMount(() => {
+ setLine(0)
+ setPercent(0)
+
+ const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms))
+
+ const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
+ if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
+ if (progress.type === "Done") setPercent(100)
+ })
+
+ onCleanup(() => {
+ listener()
+ timers.forEach(clearTimeout)
+ })
+ })
+
+ createEffect(() => {
+ if (phase() !== "done") return
+
+ const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
+ onCleanup(() => clearTimeout(timer))
+ })
+
+ const status = createMemo(() => {
+ if (phase() === "done") return "All done"
+ if (phase() === "sqlite_waiting") return lines[line()]
+ return "Just a moment..."
+ })
+
+ return (
+ <MetaProvider>
+ <div class="w-screen h-screen bg-background-base flex items-center justify-center">
+ <Font />
+ <div class="flex flex-col items-center gap-11">
+ <Splash class="w-20 h-25 opacity-15" />
+ <div class="w-60 flex flex-col items-center gap-4" aria-live="polite">
+ <span class="w-full overflow-hidden text-center text-ellipsis whitespace-nowrap text-text-strong text-14-normal">
+ {status()}
+ </span>
+ <Progress
+ value={value()}
+ class="w-20 [&_[data-slot='progress-track']]:h-1 [&_[data-slot='progress-track']]:border-0 [&_[data-slot='progress-track']]:rounded-none [&_[data-slot='progress-track']]:bg-surface-weak [&_[data-slot='progress-fill']]:rounded-none [&_[data-slot='progress-fill']]:bg-icon-warning-base"
+ aria-label="Database migration progress"
+ getValueLabel={({ value }) => `${Math.round(value)}%`}
+ />
+ </div>
+ </div>
+ </div>
+ </MetaProvider>
+ )
+}, root)
diff --git a/packages/desktop-electron/src/renderer/styles.css b/packages/desktop-electron/src/renderer/styles.css
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/styles.css
diff --git a/packages/desktop-electron/src/renderer/updater.ts b/packages/desktop-electron/src/renderer/updater.ts
new file mode 100644
index 000000000..fe9e601db
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/updater.ts
@@ -0,0 +1,14 @@
+import { initI18n, t } from "./i18n"
+
+export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
+
+export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
+ await initI18n()
+ try {
+ await window.api.runUpdater(alertOnFail)
+ } catch {
+ if (alertOnFail) {
+ window.alert(t("desktop.updater.checkFailed.message"))
+ }
+ }
+}
diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts
new file mode 100644
index 000000000..9c0a3a3a3
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/webview-zoom.ts
@@ -0,0 +1,38 @@
+// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+import { createSignal } from "solid-js"
+
+const OS_NAME = (() => {
+ if (navigator.userAgent.includes("Mac")) return "macos"
+ if (navigator.userAgent.includes("Windows")) return "windows"
+ if (navigator.userAgent.includes("Linux")) return "linux"
+ return "unknown"
+})()
+
+const [webviewZoom, setWebviewZoom] = createSignal(1)
+
+const MAX_ZOOM_LEVEL = 10
+const MIN_ZOOM_LEVEL = 0.2
+
+const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
+
+const applyZoom = (next: number) => {
+ setWebviewZoom(next)
+ void window.api.setZoomFactor(next)
+}
+
+window.addEventListener("keydown", (event) => {
+ if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
+
+ let newZoom = webviewZoom()
+
+ if (event.key === "-") newZoom -= 0.2
+ if (event.key === "=" || event.key === "+") newZoom += 0.2
+ if (event.key === "0") newZoom = 1
+
+ applyZoom(clamp(newZoom))
+})
+
+export { webviewZoom }
diff --git a/packages/desktop-electron/sst-env.d.ts b/packages/desktop-electron/sst-env.d.ts
new file mode 100644
index 000000000..64441936d
--- /dev/null
+++ b/packages/desktop-electron/sst-env.d.ts
@@ -0,0 +1,10 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+/* biome-ignore-all lint: auto-generated */
+
+/// <reference path="../../sst-env.d.ts" />
+
+import "sst"
+export {} \ No newline at end of file
diff --git a/packages/desktop-electron/tsconfig.json b/packages/desktop-electron/tsconfig.json
new file mode 100644
index 000000000..160f6c3fd
--- /dev/null
+++ b/packages/desktop-electron/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "allowJs": true,
+ "resolveJsonModule": true,
+ "strict": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "emitDeclarationOnly": false,
+ "outDir": "node_modules/.ts-dist",
+ "types": ["vite/client", "node", "electron"]
+ },
+ "references": [{ "path": "../app" }],
+ "include": ["src", "package.json"]
+}