diff options
| author | Brendan Allan <[email protected]> | 2026-03-04 15:12:34 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-04 15:12:34 +0800 |
| commit | 5cf235fa6cf7b4c890c68f8ff68a96fcae992abf (patch) | |
| tree | 25fdfd8ce95ad048fb097822995dcf060e8d6d8b /packages/desktop-electron | |
| parent | e4f0825c56300286ec0aa82b1006e4006a17e1e1 (diff) | |
| download | opencode-5cf235fa6cf7b4c890c68f8ff68a96fcae992abf.tar.gz opencode-5cf235fa6cf7b4c890c68f8ff68a96fcae992abf.zip | |
desktop: add electron version (#15663)
Diffstat (limited to 'packages/desktop-electron')
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 Binary files differnew file mode 100644 index 000000000..751e80f1f --- /dev/null +++ b/packages/desktop-electron/icons/beta/128x128.png diff --git a/packages/desktop-electron/icons/beta/[email protected] b/packages/desktop-electron/icons/beta/[email protected] Binary files differnew file mode 100644 index 000000000..fe330df41 --- /dev/null +++ b/packages/desktop-electron/icons/beta/[email protected] diff --git a/packages/desktop-electron/icons/beta/32x32.png b/packages/desktop-electron/icons/beta/32x32.png Binary files differnew file mode 100644 index 000000000..2703048ee --- /dev/null +++ b/packages/desktop-electron/icons/beta/32x32.png diff --git a/packages/desktop-electron/icons/beta/64x64.png b/packages/desktop-electron/icons/beta/64x64.png Binary files differnew file mode 100644 index 000000000..ecd7fe314 --- /dev/null +++ b/packages/desktop-electron/icons/beta/64x64.png diff --git a/packages/desktop-electron/icons/beta/Square107x107Logo.png b/packages/desktop-electron/icons/beta/Square107x107Logo.png Binary files differnew file mode 100644 index 000000000..e6ea73f4d --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square107x107Logo.png diff --git a/packages/desktop-electron/icons/beta/Square142x142Logo.png b/packages/desktop-electron/icons/beta/Square142x142Logo.png Binary files differnew file mode 100644 index 000000000..74ae729c4 --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square142x142Logo.png diff --git a/packages/desktop-electron/icons/beta/Square150x150Logo.png b/packages/desktop-electron/icons/beta/Square150x150Logo.png Binary files differnew file mode 100644 index 000000000..0b109b8f4 --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square150x150Logo.png diff --git a/packages/desktop-electron/icons/beta/Square284x284Logo.png b/packages/desktop-electron/icons/beta/Square284x284Logo.png Binary files differnew file mode 100644 index 000000000..0261ded42 --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square284x284Logo.png diff --git a/packages/desktop-electron/icons/beta/Square30x30Logo.png b/packages/desktop-electron/icons/beta/Square30x30Logo.png Binary files differnew file mode 100644 index 000000000..34158f10a --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square30x30Logo.png diff --git a/packages/desktop-electron/icons/beta/Square310x310Logo.png b/packages/desktop-electron/icons/beta/Square310x310Logo.png Binary files differnew file mode 100644 index 000000000..f18bfada4 --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square310x310Logo.png diff --git a/packages/desktop-electron/icons/beta/Square44x44Logo.png b/packages/desktop-electron/icons/beta/Square44x44Logo.png Binary files differnew file mode 100644 index 000000000..6d1cc06c0 --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square44x44Logo.png diff --git a/packages/desktop-electron/icons/beta/Square71x71Logo.png b/packages/desktop-electron/icons/beta/Square71x71Logo.png Binary files differnew file mode 100644 index 000000000..a26084dc2 --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square71x71Logo.png diff --git a/packages/desktop-electron/icons/beta/Square89x89Logo.png b/packages/desktop-electron/icons/beta/Square89x89Logo.png Binary files differnew file mode 100644 index 000000000..58b0eb605 --- /dev/null +++ b/packages/desktop-electron/icons/beta/Square89x89Logo.png diff --git a/packages/desktop-electron/icons/beta/StoreLogo.png b/packages/desktop-electron/icons/beta/StoreLogo.png Binary files differnew file mode 100644 index 000000000..648fd2114 --- /dev/null +++ b/packages/desktop-electron/icons/beta/StoreLogo.png 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 Binary files differnew file mode 100644 index 000000000..39d1dd0d5 --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..84908e71c --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..a6b8cb616 --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..6522e0fba --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..b3449bd4f --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..7aa97d827 --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..82bc9d22a --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..6b031ce85 --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..34859de5e --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..4cdb71d62 --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..a64be6ada --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..2de3c2734 --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..0ead28866 --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..bdd174825 --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..69f74758e --- /dev/null +++ b/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..f98de5da8 --- /dev/null +++ b/packages/desktop-electron/icons/beta/icon.icns diff --git a/packages/desktop-electron/icons/beta/icon.ico b/packages/desktop-electron/icons/beta/icon.ico Binary files differnew file mode 100644 index 000000000..df8588c8e --- /dev/null +++ b/packages/desktop-electron/icons/beta/icon.ico diff --git a/packages/desktop-electron/icons/beta/icon.png b/packages/desktop-electron/icons/beta/icon.png Binary files differnew file mode 100644 index 000000000..531304956 --- /dev/null +++ b/packages/desktop-electron/icons/beta/icon.png diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..e8ebb28ef --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..50c8015de --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..50c8015de --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..6e290dbc6 --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..4ef554b4d --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..b9ddfd47c --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..b9ddfd47c --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..052322d68 --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..50c8015de --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..9317b2500 --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..9317b2500 --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..6b921a17e --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..b83131d64 --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..6b921a17e --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..685004995 --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..1ffceb752 --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..81c4178c9 --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/beta/ios/[email protected] b/packages/desktop-electron/icons/beta/ios/[email protected] Binary files differnew file mode 100644 index 000000000..d5453adff --- /dev/null +++ b/packages/desktop-electron/icons/beta/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/128x128.png b/packages/desktop-electron/icons/dev/128x128.png Binary files differnew file mode 100644 index 000000000..d7fc4db14 --- /dev/null +++ b/packages/desktop-electron/icons/dev/128x128.png diff --git a/packages/desktop-electron/icons/dev/[email protected] b/packages/desktop-electron/icons/dev/[email protected] Binary files differnew file mode 100644 index 000000000..591882306 --- /dev/null +++ b/packages/desktop-electron/icons/dev/[email protected] diff --git a/packages/desktop-electron/icons/dev/32x32.png b/packages/desktop-electron/icons/dev/32x32.png Binary files differnew file mode 100644 index 000000000..53925cc4f --- /dev/null +++ b/packages/desktop-electron/icons/dev/32x32.png diff --git a/packages/desktop-electron/icons/dev/64x64.png b/packages/desktop-electron/icons/dev/64x64.png Binary files differnew file mode 100644 index 000000000..a88ef15c6 --- /dev/null +++ b/packages/desktop-electron/icons/dev/64x64.png diff --git a/packages/desktop-electron/icons/dev/Square107x107Logo.png b/packages/desktop-electron/icons/dev/Square107x107Logo.png Binary files differnew file mode 100644 index 000000000..0de29ec82 --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square107x107Logo.png diff --git a/packages/desktop-electron/icons/dev/Square142x142Logo.png b/packages/desktop-electron/icons/dev/Square142x142Logo.png Binary files differnew file mode 100644 index 000000000..af62e8e1e --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square142x142Logo.png diff --git a/packages/desktop-electron/icons/dev/Square150x150Logo.png b/packages/desktop-electron/icons/dev/Square150x150Logo.png Binary files differnew file mode 100644 index 000000000..2b19dc39c --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square150x150Logo.png diff --git a/packages/desktop-electron/icons/dev/Square284x284Logo.png b/packages/desktop-electron/icons/dev/Square284x284Logo.png Binary files differnew file mode 100644 index 000000000..eda6d9901 --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square284x284Logo.png diff --git a/packages/desktop-electron/icons/dev/Square30x30Logo.png b/packages/desktop-electron/icons/dev/Square30x30Logo.png Binary files differnew file mode 100644 index 000000000..dad821ba8 --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square30x30Logo.png diff --git a/packages/desktop-electron/icons/dev/Square310x310Logo.png b/packages/desktop-electron/icons/dev/Square310x310Logo.png Binary files differnew file mode 100644 index 000000000..555b3b197 --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square310x310Logo.png diff --git a/packages/desktop-electron/icons/dev/Square44x44Logo.png b/packages/desktop-electron/icons/dev/Square44x44Logo.png Binary files differnew file mode 100644 index 000000000..9f8ad001f --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square44x44Logo.png diff --git a/packages/desktop-electron/icons/dev/Square71x71Logo.png b/packages/desktop-electron/icons/dev/Square71x71Logo.png Binary files differnew file mode 100644 index 000000000..43feb7848 --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square71x71Logo.png diff --git a/packages/desktop-electron/icons/dev/Square89x89Logo.png b/packages/desktop-electron/icons/dev/Square89x89Logo.png Binary files differnew file mode 100644 index 000000000..628cc597f --- /dev/null +++ b/packages/desktop-electron/icons/dev/Square89x89Logo.png diff --git a/packages/desktop-electron/icons/dev/StoreLogo.png b/packages/desktop-electron/icons/dev/StoreLogo.png Binary files differnew file mode 100644 index 000000000..8d3aa53cf --- /dev/null +++ b/packages/desktop-electron/icons/dev/StoreLogo.png 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 Binary files differnew file mode 100644 index 000000000..b355e37fe --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..c33f8713b --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..04e37aa65 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..98e53cd22 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..40fe6e378 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..4814f1ddf --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..608493283 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..898066a3f --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..64035c0f3 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..f47691bf4 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..dba6f5635 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..764702604 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..2e8430a60 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..db953d128 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..d5c9ba6a8 --- /dev/null +++ b/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..d73a94904 --- /dev/null +++ b/packages/desktop-electron/icons/dev/icon.icns diff --git a/packages/desktop-electron/icons/dev/icon.ico b/packages/desktop-electron/icons/dev/icon.ico Binary files differnew file mode 100644 index 000000000..bec385d9a --- /dev/null +++ b/packages/desktop-electron/icons/dev/icon.ico diff --git a/packages/desktop-electron/icons/dev/icon.png b/packages/desktop-electron/icons/dev/icon.png Binary files differnew file mode 100644 index 000000000..6de37ea29 --- /dev/null +++ b/packages/desktop-electron/icons/dev/icon.png diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..0e823043e --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..54e4b2aac --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..54e4b2aac --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..645b01561 --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..054225c6e --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..0b1b2e0b7 --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..0b1b2e0b7 --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..d2c42592b --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..54e4b2aac --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..471ed2eec --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..471ed2eec --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..1a490cbf1 --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..f53b404e5 --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..1a490cbf1 --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..bdc759eef --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..d22096a2d --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..d675773d1 --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/dev/ios/[email protected] b/packages/desktop-electron/icons/dev/ios/[email protected] Binary files differnew file mode 100644 index 000000000..31698afce --- /dev/null +++ b/packages/desktop-electron/icons/dev/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/128x128.png b/packages/desktop-electron/icons/prod/128x128.png Binary files differnew file mode 100644 index 000000000..caf7b02eb --- /dev/null +++ b/packages/desktop-electron/icons/prod/128x128.png diff --git a/packages/desktop-electron/icons/prod/[email protected] b/packages/desktop-electron/icons/prod/[email protected] Binary files differnew file mode 100644 index 000000000..47fe4c61e --- /dev/null +++ b/packages/desktop-electron/icons/prod/[email protected] diff --git a/packages/desktop-electron/icons/prod/32x32.png b/packages/desktop-electron/icons/prod/32x32.png Binary files differnew file mode 100644 index 000000000..5868bcc93 --- /dev/null +++ b/packages/desktop-electron/icons/prod/32x32.png diff --git a/packages/desktop-electron/icons/prod/64x64.png b/packages/desktop-electron/icons/prod/64x64.png Binary files differnew file mode 100644 index 000000000..1ed7425d8 --- /dev/null +++ b/packages/desktop-electron/icons/prod/64x64.png diff --git a/packages/desktop-electron/icons/prod/Square107x107Logo.png b/packages/desktop-electron/icons/prod/Square107x107Logo.png Binary files differnew file mode 100644 index 000000000..1db249bf7 --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square107x107Logo.png diff --git a/packages/desktop-electron/icons/prod/Square142x142Logo.png b/packages/desktop-electron/icons/prod/Square142x142Logo.png Binary files differnew file mode 100644 index 000000000..1961c3408 --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square142x142Logo.png diff --git a/packages/desktop-electron/icons/prod/Square150x150Logo.png b/packages/desktop-electron/icons/prod/Square150x150Logo.png Binary files differnew file mode 100644 index 000000000..abc507347 --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square150x150Logo.png diff --git a/packages/desktop-electron/icons/prod/Square284x284Logo.png b/packages/desktop-electron/icons/prod/Square284x284Logo.png Binary files differnew file mode 100644 index 000000000..51e2a1b9f --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square284x284Logo.png diff --git a/packages/desktop-electron/icons/prod/Square30x30Logo.png b/packages/desktop-electron/icons/prod/Square30x30Logo.png Binary files differnew file mode 100644 index 000000000..066a1fd0c --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square30x30Logo.png diff --git a/packages/desktop-electron/icons/prod/Square310x310Logo.png b/packages/desktop-electron/icons/prod/Square310x310Logo.png Binary files differnew file mode 100644 index 000000000..2a85c8e95 --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square310x310Logo.png diff --git a/packages/desktop-electron/icons/prod/Square44x44Logo.png b/packages/desktop-electron/icons/prod/Square44x44Logo.png Binary files differnew file mode 100644 index 000000000..c855b8063 --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square44x44Logo.png diff --git a/packages/desktop-electron/icons/prod/Square71x71Logo.png b/packages/desktop-electron/icons/prod/Square71x71Logo.png Binary files differnew file mode 100644 index 000000000..c8168f711 --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square71x71Logo.png diff --git a/packages/desktop-electron/icons/prod/Square89x89Logo.png b/packages/desktop-electron/icons/prod/Square89x89Logo.png Binary files differnew file mode 100644 index 000000000..19ec1777d --- /dev/null +++ b/packages/desktop-electron/icons/prod/Square89x89Logo.png diff --git a/packages/desktop-electron/icons/prod/StoreLogo.png b/packages/desktop-electron/icons/prod/StoreLogo.png Binary files differnew file mode 100644 index 000000000..3fd053d34 --- /dev/null +++ b/packages/desktop-electron/icons/prod/StoreLogo.png 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 Binary files differnew file mode 100644 index 000000000..4f3ea0e36 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..7db80699b --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..a54ebe652 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..9337ccfa3 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..0bfc1082e --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..5b02ec732 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..322aeaeaa --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..ca1e336cc --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..f71110799 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..287a6b500 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..9d3d06a86 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..d4b6fde1b --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..bde8d7596 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png 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 Binary files differnew file mode 100644 index 000000000..03df7809d --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png 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 Binary files differnew file mode 100644 index 000000000..62363be04 --- /dev/null +++ b/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png 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 Binary files differnew file mode 100644 index 000000000..be910ad5f --- /dev/null +++ b/packages/desktop-electron/icons/prod/icon.icns diff --git a/packages/desktop-electron/icons/prod/icon.ico b/packages/desktop-electron/icons/prod/icon.ico Binary files differnew file mode 100644 index 000000000..ff88d21e4 --- /dev/null +++ b/packages/desktop-electron/icons/prod/icon.ico diff --git a/packages/desktop-electron/icons/prod/icon.png b/packages/desktop-electron/icons/prod/icon.png Binary files differnew file mode 100644 index 000000000..0ecbb6d5f --- /dev/null +++ b/packages/desktop-electron/icons/prod/icon.png diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..eb137e164 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..aa76ab10b --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..aa76ab10b --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..c58ea3d49 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..0eeb4d9bf --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..32601c70a --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..32601c70a --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..a372c4a11 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..aa76ab10b --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..e82ce2765 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..e82ce2765 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..15ad59362 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..2260671c0 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..15ad59362 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..5c66bd3b1 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..a5b05f3b5 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..9c0615d41 --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] diff --git a/packages/desktop-electron/icons/prod/ios/[email protected] b/packages/desktop-electron/icons/prod/ios/[email protected] Binary files differnew file mode 100644 index 000000000..6b792b36a --- /dev/null +++ b/packages/desktop-electron/icons/prod/ios/[email protected] 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"] +} |
