diff --git a/.github/workflows/oc-review.yml b/.github/workflows/oc-review.yml index 655b6665..be7aba8b 100644 --- a/.github/workflows/oc-review.yml +++ b/.github/workflows/oc-review.yml @@ -31,3 +31,10 @@ jobs: - name: Lint run: bun run lint + + - name: Electron Linux packaging unit tests + working-directory: packages/electron + run: | + bun run test:architecture + bun run test:updater + bun run type-check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6940691..4bc1b31f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,6 +359,148 @@ jobs: path: packages/electron/dist/latest.yml retention-days: 1 + build-desktop-electron-linux: + needs: create-release + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: x64 + host_arch: x86_64 + artifact_arch: x86_64 + manifest: latest-linux.yml + - runner: ubuntu-24.04-arm + arch: arm64 + host_arch: aarch64 + artifact_arch: arm64 + manifest: latest-linux-arm64.yml + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - name: Verify native Linux architecture + env: + EXPECTED_HOST_ARCH: ${{ matrix.host_arch }} + OPENCHAMBER_TARGET_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + test "$(uname -m)" = "$EXPECTED_HOST_ARCH" + test "$(node -p 'process.arch')" = "$OPENCHAMBER_TARGET_ARCH" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + shell: bash + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + + - name: Run focused Electron release tests + working-directory: packages/electron + run: | + bun run test:architecture + bun run test:updater + + - name: Build and package Linux AppImage + working-directory: packages/electron + env: + OPENCHAMBER_TARGET_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli + bun run bundle:main + bun run rebuild:native + node ./scripts/package.mjs --linux --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged + bun run verify:linux-appimage + + - name: Validate Linux update manifest + working-directory: packages/electron + env: + VERSION: ${{ needs.create-release.outputs.version }} + ARTIFACT_ARCH: ${{ matrix.artifact_arch }} + MANIFEST: ${{ matrix.manifest }} + run: | + set -euo pipefail + APPIMAGE="dist/OpenChamber-${VERSION}-linux-${ARTIFACT_ARCH}.AppImage" + node ./scripts/verify-update-manifest.mjs "dist/${MANIFEST}" "$APPIMAGE" "$VERSION" + + - name: Upload validated Linux release files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-release-${{ matrix.arch }} + path: | + packages/electron/dist/OpenChamber-${{ needs.create-release.outputs.version }}-linux-${{ matrix.artifact_arch }}.AppImage + packages/electron/dist/${{ matrix.manifest }} + if-no-files-found: error + retention-days: 1 + + publish-electron-linux: + needs: [create-release, build-desktop-electron-linux] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download x64 Linux release files + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: linux-release-x64 + path: artifacts/x64 + + - name: Download arm64 Linux release files + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: linux-release-arm64 + path: artifacts/arm64 + + - name: Revalidate separate Linux manifests + env: + VERSION: ${{ needs.create-release.outputs.version }} + run: | + set -euo pipefail + node packages/electron/scripts/verify-update-manifest.mjs \ + artifacts/x64/latest-linux.yml \ + "artifacts/x64/OpenChamber-${VERSION}-linux-x86_64.AppImage" \ + "$VERSION" + node packages/electron/scripts/verify-update-manifest.mjs \ + artifacts/arm64/latest-linux-arm64.yml \ + "artifacts/arm64/OpenChamber-${VERSION}-linux-arm64.AppImage" \ + "$VERSION" + + - name: Upload Linux AppImages and manifests to release + if: ${{ github.event.inputs.dry_run != 'true' }} + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + tag_name: v${{ needs.create-release.outputs.version }} + files: | + artifacts/x64/OpenChamber-${{ needs.create-release.outputs.version }}-linux-x86_64.AppImage + artifacts/x64/latest-linux.yml + artifacts/arm64/OpenChamber-${{ needs.create-release.outputs.version }}-linux-arm64.AppImage + artifacts/arm64/latest-linux-arm64.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + combine-electron-manifests: needs: [create-release, build-desktop-electron-macos] runs-on: ubuntu-latest @@ -404,12 +546,47 @@ jobs: secrets: inherit finalize-release: - needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows, publish-npm, combine-electron-manifests, mobile-release] + needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows, build-desktop-electron-linux, publish-electron-linux, publish-npm, combine-electron-manifests, mobile-release] runs-on: ubuntu-latest env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }} steps: + - name: Verify final Linux release asset inventory + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ needs.create-release.outputs.version }} + run: | + node - <<'NODE' + (async () => { + const { REPOSITORY: repo, VERSION: version, GITHUB_TOKEN: token } = process.env; + const expected = [ + `OpenChamber-${version}-linux-x86_64.AppImage`, + 'latest-linux.yml', + `OpenChamber-${version}-linux-arm64.AppImage`, + 'latest-linux-arm64.yml', + ]; + const response = await fetch(`https://api.github.com/repos/${repo}/releases/tags/v${version}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' }, + }); + if (!response.ok) throw new Error(`Failed to inspect release assets: ${response.status} ${await response.text()}`); + const release = await response.json(); + for (const name of expected) { + const matches = release.assets.filter((asset) => asset.name === name); + if (matches.length !== 1) throw new Error(`Expected exactly one ${name} release asset, found ${matches.length}`); + if (!Number.isSafeInteger(matches[0].size) || matches[0].size <= 0) { + throw new Error(`Release asset ${name} has invalid size ${matches[0].size}`); + } + } + console.log(`Verified ${expected.length} Linux release assets and both architecture manifests.`); + })().catch((error) => { + console.error(error); + process.exit(1); + }); + NODE + - name: Publish release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index e1714caa..67a74e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Desktop/Linux:** official AppImage releases for x86_64 and arm64, with in-app window controls (position follows OS defaults or Settings → Sessions), writable-AppImage auto-update, and clearer updater errors when the AppImage is missing or read-only. Linux does not yet include system tray or launch-at-login. + ## [1.16.0] - 2026-07-13 - **Session goals:** arm the new target button in the composer and your next prompt becomes a [goal](https://docs.openchamber.dev/session-goals/) — the session keeps working toward it on its own, with an independent small-model audit checking each finished turn, until the objective is verifiably complete, blocked, or over its optional token budget. The loop runs on the server, so it continues with the app closed and survives restarts. A goal strip above the composer shows progress with pause/resume; goals can also start from the plan-implement dialog, from scheduled tasks ("Run as goal"), or with the new "Craft a Goal" starter and `/craft-goal` command. While a goal runs, per-turn "ready" notifications are replaced by a single notification when it settles. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a3d6c72..23972248 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ ## Getting Started ```bash -git clone https://github.com/btriapitsyn/openchamber.git +git clone https://github.com/openchamber/openchamber.git cd openchamber bun install ``` @@ -31,12 +31,14 @@ bun run electron:dev:bundled # Electron shell using built web assets bun run electron:build # Package desktop app for the current platform ``` -Desktop supports macOS and Windows. The build output is written to `packages/electron/dist`. +Desktop supports macOS, Windows, and Linux. The build output is written to `packages/electron/dist`. macOS builds create `dmg` and `zip` files. You need Xcode/build tools for notarized packaging and icon asset work. Windows builds create an NSIS installer. If signing env vars are not set, the build script makes an unsigned installer. +Linux builds produce an AppImage for the native x64 or arm64 host. + For desktop-specific details, see [`packages/electron/README.md`](./packages/electron/README.md). ### VS Code Extension @@ -94,7 +96,17 @@ Windows: bun run electron:build ``` -Linux is supported for web/CLI development. A Linux desktop app is still planned, so Electron packaging is mainly macOS and Windows right now. +Linux x64 and arm64 AppImages are packaged natively on the matching host architecture. Use Bun for dependency installation and packaging orchestration: + +```bash +OPENCHAMBER_TARGET_ARCH=x64 bun run electron:build +# On an arm64 host: +OPENCHAMBER_TARGET_ARCH=arm64 bun run electron:build + +bun run --cwd packages/electron verify:linux-appimage +``` + +The final AppImage verifier checks desktop identity and the architecture of Electron, the bundled OpenCode CLI, and packaged native modules. ## Before Submitting @@ -149,4 +161,4 @@ You can still help: ## Questions? -Open an [issue](https://github.com/btriapitsyn/openchamber/issues) or ask in [Discord](https://discord.gg/ZYRSdnwwKA). +Open an [issue](https://github.com/openchamber/openchamber/issues) or ask in [Discord](https://discord.gg/ZYRSdnwwKA). diff --git a/README.md b/README.md index f01b25be..9c21c487 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OpenChamber -[![GitHub stars](https://img.shields.io/github/stars/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/btriapitsyn/openchamber/stargazers) -[![GitHub release](https://img.shields.io/github/v/release/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/btriapitsyn/openchamber/releases/latest) +[![GitHub stars](https://img.shields.io/github/stars/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/openchamber/openchamber/stargazers) +[![GitHub release](https://img.shields.io/github/v/release/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/openchamber/openchamber/releases/latest) [![Created with OpenCode](docs/references/badges/created-with-opencode.svg)](https://opencode.ai) [![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) [![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=ko-fi&logoColor=FFFCF0)](https://ko-fi.com/G2G41SAWNS) @@ -57,7 +57,7 @@ - Background notifications plus reliable cross-tab session activity tracking - Built-in self-update + restart flow that keeps your server settings intact -### Desktop (macOS + Windows) +### Desktop (macOS + Windows + Linux) - Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal - Multiple native windows for separate projects or sessions @@ -88,8 +88,24 @@ > **Prerequisite:** Desktop bundles the matching OpenCode CLI. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai). -### **Desktop (macOS + Windows)** -Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). +### **Desktop (macOS + Windows + Linux)** + +Download the latest Desktop release from [GitHub Releases](https://github.com/openchamber/openchamber/releases). + +On Linux, choose the AppImage for your system: + +- `linux-x86_64.AppImage` for 64-bit Intel or AMD systems +- `linux-arm64.AppImage` for ARM64/aarch64 systems + +Make the AppImage executable before launching it, for example with `chmod +x `. Keep the AppImage in a location your user can write to so OpenChamber can download and apply in-app updates. + +Linux AppImages need FUSE (`libfuse.so.2`). On Ubuntu/Debian install `libfuse2` (or `fuse` / `libfuse2t64` on newer releases). If FUSE is unavailable, run with extraction instead: + +```bash +APPIMAGE_EXTRACT_AND_RUN=1 ./OpenChamber-*-linux-*.AppImage +``` + +Linux Desktop ships as AppImage with in-app window controls and auto-update when running from a writable AppImage. System tray and launch-at-login are not available on Linux yet (macOS/Windows only). ### **VS Code** Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions. @@ -98,7 +114,7 @@ Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=f _requires Node.js 22+_ ```bash -curl -fsSL https://raw.githubusercontent.com/btriapitsyn/openchamber/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/openchamber/openchamber/main/scripts/install.sh | bash openchamber --ui-password be-creative-here ``` @@ -352,7 +368,7 @@ chown -R 1000:1000 data/
-Desktop (macOS + Windows) +Desktop (macOS + Windows + Linux) - Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal - Multiple native windows for separate projects or sessions @@ -406,7 +422,6 @@ chown -R 1000:1000 data/ Active development. Here's what's being worked on or planned: -- Linux desktop app - Mobile app with remote instance and laptop connectivity - More built-in tunneling options - Kanban board for multi-agent management - keeping the human in the loop and in control diff --git a/packages/electron/README.md b/packages/electron/README.md index 5939a916..101d19b4 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -1,6 +1,6 @@ # OpenChamber Desktop -Electron desktop runtime for OpenChamber on macOS and Windows. +Electron desktop runtime for OpenChamber on macOS, Windows, and Linux. This package owns the native shell: windows, menus, deep links, native notifications, auto-updates, host switching, SSH connections, tunnel helpers, and packaged desktop builds. The web UI and OpenChamber server logic still live in `packages/web` and shared React UI lives in `packages/ui`. @@ -66,7 +66,7 @@ That runs, in order: Build output goes to `packages/electron/dist`. -macOS builds produce `dmg` and `zip` artifacts. Windows builds produce an NSIS installer. +macOS builds produce `dmg` and `zip` artifacts. Windows builds produce an NSIS installer. Linux builds produce an AppImage for the native x64 or arm64 host. ## Platform Notes @@ -74,7 +74,19 @@ macOS packaging needs Xcode/build tools for notarized builds and icon asset comp Windows packaging needs NSIS support through `electron-builder`. If no Windows signing env is set, `package.mjs` disables code signing and builds an unsigned installer. -The package supports macOS and Windows desktop features. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps works on macOS and Windows. +Linux AppImages must be built natively. Set `OPENCHAMBER_TARGET_ARCH=x64` or `OPENCHAMBER_TARGET_ARCH=arm64` when packaging; the build rejects a target that does not match the Linux host. The same target selects the bundled OpenCode CLI, native Electron rebuild, and Electron Builder architecture. Linux identity is stable across architectures: executable `openchamber`, desktop file `openchamber.desktop`, icon `openchamber`, and `StartupWMClass=openchamber`. + +After packaging, run `bun run --cwd packages/electron verify:linux-appimage`. The verifier extracts the final AppImage and checks its ELF architecture, desktop identity, Electron executable, pinned OpenCode CLI version and architecture, and all packaged native `.node` modules. + +Running a packaged Linux AppImage requires FUSE (`libfuse.so.2`, typically `libfuse2` / `libfuse2t64` on Debian/Ubuntu). Without FUSE, start with `APPIMAGE_EXTRACT_AND_RUN=1`. Keep the AppImage on a writable path so in-app updates can replace it. + +Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet). + +### Updater End-to-End Fixture + +A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture. + +The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls and auto-update; system tray and launch-at-login remain macOS/Windows only. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps and installed-app discovery work on macOS and Windows (Linux returns an empty list without errors). ## Bundled OpenCode CLI @@ -98,6 +110,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u | `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` | | `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server | | `OPENCHAMBER_OPENCODE_CLI_VERSION` | Optional packaging override for the bundled OpenCode CLI version; defaults to the pinned root `@opencode-ai/sdk` version | +| `OPENCHAMBER_TARGET_ARCH` | Explicit desktop package architecture (`x64` or `arm64`); Linux requires it to match the native host | | `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server | | `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead | | `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally | diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index c25b354a..aec816cc 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -14,6 +14,9 @@ import { ElectronSshManager } from './ssh-manager.mjs'; import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; +import { assertUpdaterCapability } from './updater-capability.mjs'; +import { checkForDesktopUpdate } from './updater-check.mjs'; +import { resolveUpdaterFeed } from './updater-feed.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -60,6 +63,9 @@ const shouldStartInBackground = (loginItemSettings = readLoginItemSettings()) => // Set the product name early so electron-log derives its log directory as // ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/). app.setName('OpenChamber'); +if (process.platform === 'linux') { + app.setDesktopName('openchamber.desktop'); +} if (isDev) { app.setPath('userData', path.join(app.getPath('appData'), 'OpenChamber Dev')); } @@ -2182,12 +2188,11 @@ const readThemeSource = () => { }; const getWindowIconPath = () => { - if (process.platform !== 'win32' && process.platform !== 'linux') { - return undefined; - } + if (process.platform !== 'win32' && process.platform !== 'linux') return undefined; + const iconFileName = process.platform === 'linux' ? 'icon.png' : 'icon.ico'; const iconPath = isDev - ? path.join(__dirname, 'resources', 'icons', 'icon.ico') - : path.join(process.resourcesPath, 'icons', 'icon.ico'); + ? path.join(__dirname, 'resources', 'icons', iconFileName) + : path.join(process.resourcesPath, 'icons', iconFileName); return fs.existsSync(iconPath) ? iconPath : undefined; }; @@ -2209,7 +2214,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); - const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32'; + const usesFramelessChrome = process.platform === 'win32' || process.platform === 'linux'; + const usesCustomTitleBar = process.platform === 'darwin' || usesFramelessChrome; // macOS vibrancy, on by default; users can disable it (Appearance settings). const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false; const titleBarOverlayEnabled = false; @@ -2231,7 +2237,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } // here: setting it in the constructor leaves the material uncomposited on a // cold launch until a window event. No `transparent: true` either — vibrancy // alone is enough and composites reliably once applied to a live window. - frame: process.platform === 'win32' ? false : undefined, + frame: usesFramelessChrome ? false : undefined, autoHideMenuBar: autoHidesNativeMenuBar, // Electron's hiddenInset adds its own extra inset, which leaves the controls // visibly lower than the app header. Use a plain hidden title bar instead. @@ -2604,6 +2610,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const desktopRequestHeaders = effectiveRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); + const usesFramelessChrome = process.platform === 'win32' || process.platform === 'linux'; // macOS vibrancy, on by default; users can disable it (Appearance settings). const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false; const browserWindow = new BrowserWindow({ @@ -2619,9 +2626,9 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj // here: setting it in the constructor leaves the material uncomposited on a // cold launch until a window event. No `transparent: true` either — vibrancy // alone is enough and composites reliably once applied to a live window. - frame: process.platform === 'win32' ? false : undefined, + frame: usesFramelessChrome ? false : undefined, autoHideMenuBar: process.platform !== 'darwin', - titleBarStyle: process.platform === 'darwin' || process.platform === 'win32' ? 'hidden' : 'default', + titleBarStyle: process.platform === 'darwin' || usesFramelessChrome ? 'hidden' : 'default', trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined, webPreferences: { additionalArguments: [ @@ -2819,10 +2826,6 @@ const compareSemver = (left, right) => { return 0; }; -const parseGithubRepo = () => { - return { owner: 'openchamber', repo: 'openchamber' }; -}; - const setupAutoUpdater = () => { if (!app.isPackaged) { return; @@ -2834,11 +2837,13 @@ const setupAutoUpdater = () => { autoUpdater.disableWebInstaller = false; autoUpdater.logger = log; - const { owner, repo } = parseGithubRepo(); - autoUpdater.setFeedURL({ - provider: 'github', - owner, - repo, + const testBuild = typeof __OPENCHAMBER_UPDATER_E2E_BUILD__ !== 'undefined' + && __OPENCHAMBER_UPDATER_E2E_BUILD__ === true; + const feed = resolveUpdaterFeed({ testBuild }); + autoUpdater.setFeedURL(feed); + log.info('[electron] updater feed configured', { + provider: feed.provider, + target: feed.provider === 'github' ? `${feed.owner}/${feed.repo}` : feed.url, }); autoUpdater.on('download-progress', (progress) => { @@ -3798,7 +3803,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { emitToAllWindows('openchamber:installed-apps-updated', apps); }; if (process.platform !== 'darwin' && process.platform !== 'win32') { - throw new Error('desktop_get_installed_apps is only supported on macOS and Windows'); + return { apps: [], hasCache: false, isCacheStale: false, supported: false }; } if (!hasCache || isCacheStale || args.force === true) { void refresh(); @@ -3901,22 +3906,18 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } case 'desktop_check_for_updates': { + assertUpdaterCapability({ packaged: app.isPackaged }); const currentVersion = APP_VERSION; - let updateResult = null; - try { - updateResult = await autoUpdater.checkForUpdates(); - } catch { - } - - const updateInfo = updateResult?.updateInfo; - const nextVersion = - (typeof updateInfo?.version === 'string' && updateInfo.version) || - currentVersion; - const available = compareSemver(nextVersion, currentVersion) > 0; + const { available, updateInfo, updateResult, nextVersion, pendingUpdate } = await checkForDesktopUpdate({ + autoUpdater, + currentVersion, + pendingUpdate: state.pendingUpdate, + compareVersions: compareSemver, + }); const body = (typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) || await parseRelevantChangelogNotes(currentVersion, nextVersion); - state.pendingUpdate = available ? { version: nextVersion, electronUpdate: updateResult } : null; + state.pendingUpdate = pendingUpdate; return { available, currentVersion, @@ -3929,6 +3930,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } case 'desktop_download_and_install_update': + assertUpdaterCapability({ packaged: app.isPackaged }); if (!state.pendingUpdate) { throw new Error('No pending update'); } @@ -3974,6 +3976,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_restart': { const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged); + if (applyUpdate) assertUpdaterCapability({ packaged: app.isPackaged }); log.info(`[electron] desktop_restart applyUpdate=${applyUpdate} packaged=${app.isPackaged}`); if (applyUpdate && process.platform === 'darwin' && typeof app.isInApplicationsFolder === 'function') { try { diff --git a/packages/electron/package.json b/packages/electron/package.json index e7e2024c..74911df9 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -21,7 +21,8 @@ "Electron runtime dependencies installed via bun install", "Bun available for sidecar compilation", "macOS: Xcode + build tools for notarized packaging", - "Windows: NSIS installed for installer creation" + "Windows: NSIS installed for installer creation", + "Linux: native x64 or arm64 host (no cross-arch packaging); FUSE/libfuse2 to run AppImages, or APPIMAGE_EXTRACT_AND_RUN=1" ], "scripts": { "dev": "node ./scripts/electron-dev.mjs", @@ -30,9 +31,14 @@ "prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs", "verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged", "verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged", + "verify:linux-appimage": "node ./scripts/verify-linux-appimage.mjs", "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "rebuild:native": "node ./scripts/rebuild-native.mjs", + "test:architecture": "node --test ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs", + "test:updater": "node --test ./updater-capability.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/updater-e2e-fixture.test.mjs", + "updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs", + "verify:update-manifest": "node ./scripts/verify-update-manifest.mjs", "package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", "finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs", "type-check": "node --check ./main.mjs && node --check ./preload.mjs", @@ -54,6 +60,10 @@ "from": "resources/icons/icon.ico", "to": "icons/icon.ico" }, + { + "from": "resources/icons/icon.png", + "to": "icons/icon.png" + }, { "from": "resources/icons/tray", "to": "icons/tray" @@ -95,6 +105,23 @@ "verifyUpdateCodeSignature": false, "artifactName": "${productName}-${version}-win-${arch}.${ext}" }, + "linux": { + "target": [ + "AppImage" + ], + "category": "Development", + "icon": "resources/icons/icon.png", + "executableName": "openchamber", + "artifactName": "${productName}-${version}-linux-${arch}.${ext}", + "desktop": { + "entry": { + "Name": "OpenChamber", + "Comment": "Desktop runtime for OpenChamber", + "Icon": "openchamber", + "StartupWMClass": "openchamber" + } + } + }, "nsis": { "oneClick": true, "perMachine": false, diff --git a/packages/electron/scripts/bundle-main.mjs b/packages/electron/scripts/bundle-main.mjs index e8d06d36..1c867134 100644 --- a/packages/electron/scripts/bundle-main.mjs +++ b/packages/electron/scripts/bundle-main.mjs @@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); +const updaterE2eBuild = process.env.OPENCHAMBER_UPDATER_E2E_BUILD === '1'; const result = await Bun.build({ entrypoints: [path.join(root, 'main.mjs')], @@ -34,6 +35,9 @@ const result = await Bun.build({ minify: false, sourcemap: 'none', naming: '[name].mjs', + define: { + __OPENCHAMBER_UPDATER_E2E_BUILD__: updaterE2eBuild ? 'true' : 'false', + }, }); if (!result.success) { @@ -41,4 +45,4 @@ if (!result.success) { process.exit(1); } -console.log('[electron] main.mjs bundled -> dist-bundle/main.mjs'); +console.log(`[electron] main.mjs bundled -> dist-bundle/main.mjs (updater E2E=${updaterE2eBuild})`); diff --git a/packages/electron/scripts/finalize-latest-yml.mjs b/packages/electron/scripts/finalize-latest-yml.mjs index 68b2de18..2b657a92 100644 --- a/packages/electron/scripts/finalize-latest-yml.mjs +++ b/packages/electron/scripts/finalize-latest-yml.mjs @@ -85,12 +85,6 @@ if (winX64 || winArm64) { }); } -const linuxX64 = await read('latest-yml-x86_64-unknown-linux-gnu', 'latest-linux.yml'); -if (linuxX64) output['latest-linux.yml'] = serialize(linuxX64); - -const linuxArm64 = await read('latest-yml-aarch64-unknown-linux-gnu', 'latest-linux-arm64.yml'); -if (linuxArm64) output['latest-linux-arm64.yml'] = serialize(linuxArm64); - 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) { diff --git a/packages/electron/scripts/package.mjs b/packages/electron/scripts/package.mjs index bfceffa9..c59993e7 100644 --- a/packages/electron/scripts/package.mjs +++ b/packages/electron/scripts/package.mjs @@ -1,8 +1,11 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { resolveTargetArchitecture } from './target-architecture.mjs'; const env = { ...process.env }; +const builderArgs = process.argv.slice(2); +const targetArchitecture = resolveTargetArchitecture({ environment: env, builderArgs }); if (process.platform === 'win32' && !env.CSC_LINK && !env.WINDOWS_CSC_LINK) { env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'; @@ -22,7 +25,13 @@ const bunBinary = bunBinaryCandidates.find((candidate) => { return false; }) || (process.platform === 'win32' ? 'bun.exe' : 'bun'); -const child = spawn(bunBinary, ['x', 'electron-builder', ...process.argv.slice(2)], { +if (process.platform === 'linux' && !builderArgs.some((argument) => ( + argument === '--x64' || argument === '--arm64' || argument === '--arch' || argument.startsWith('--arch=') +))) { + builderArgs.push(`--${targetArchitecture.electronBuilder}`); +} + +const child = spawn(bunBinary, ['x', 'electron-builder', ...builderArgs], { env, stdio: 'inherit', }); diff --git a/packages/electron/scripts/prepare-opencode-cli.mjs b/packages/electron/scripts/prepare-opencode-cli.mjs index d7f5ede5..06834f0d 100644 --- a/packages/electron/scripts/prepare-opencode-cli.mjs +++ b/packages/electron/scripts/prepare-opencode-cli.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveTargetArchitecture } from './target-architecture.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const electronRoot = path.resolve(__dirname, '..'); @@ -39,8 +40,8 @@ const readPinnedSdkVersion = () => { return trimmed; }; -const artifactForCurrentPlatform = () => { - const { platform, arch } = process; +const artifactForPlatform = (platform, targetArchitecture) => { + const arch = targetArchitecture.opencode; if (platform === 'darwin') { if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' }; if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.zip', binary: 'opencode' }; @@ -134,7 +135,8 @@ const main = async () => { throw new Error(`Invalid OpenCode CLI version: ${version}`); } - const artifact = artifactForCurrentPlatform(); + const targetArchitecture = resolveTargetArchitecture(); + const artifact = artifactForPlatform(process.platform, targetArchitecture); const outputBinary = outputBinaryPath(artifact.binary); const existingVersion = readBinaryVersion(outputBinary); if (existingVersion === version) { @@ -142,7 +144,7 @@ const main = async () => { return; } - const cacheDir = path.join(cacheRoot, version, `${process.platform}-${process.arch}`); + const cacheDir = path.join(cacheRoot, version, `${process.platform}-${targetArchitecture.opencode}`); const archivePath = path.join(cacheDir, artifact.name); const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`; if (!fs.existsSync(archivePath)) { diff --git a/packages/electron/scripts/rebuild-native.mjs b/packages/electron/scripts/rebuild-native.mjs index cd2a08bd..ed4d52b7 100644 --- a/packages/electron/scripts/rebuild-native.mjs +++ b/packages/electron/scripts/rebuild-native.mjs @@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; import { rebuild } from '@electron/rebuild'; +import { resolveTargetArchitecture } from './target-architecture.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -16,6 +17,7 @@ const require = createRequire(import.meta.url); const electronPkg = require('electron/package.json'); const electronVersion = electronPkg.version; +const targetArchitecture = resolveTargetArchitecture(); const copyDirectory = async (src, dst) => { await fsp.mkdir(dst, { recursive: true }); @@ -142,7 +144,7 @@ try { buildPath: rebuildPath.buildPath, electronVersion, force: true, - arch: process.env.ELECTRON_BUILDER_ARCH || process.arch, + arch: targetArchitecture.electronBuilder, onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'], }); } finally { diff --git a/packages/electron/scripts/target-architecture.mjs b/packages/electron/scripts/target-architecture.mjs new file mode 100644 index 00000000..7bda2d99 --- /dev/null +++ b/packages/electron/scripts/target-architecture.mjs @@ -0,0 +1,77 @@ +const ARCHITECTURES = { + x64: { + node: 'x64', + electronBuilder: 'x64', + opencode: 'x64', + }, + arm64: { + node: 'arm64', + electronBuilder: 'arm64', + opencode: 'arm64', + }, +}; + +const ARCHITECTURE_ALIASES = new Map([ + ['x64', 'x64'], + ['amd64', 'x64'], + ['x86_64', 'x64'], + ['arm64', 'arm64'], + ['aarch64', 'arm64'], +]); + +export const normalizeTargetArchitecture = (value, source = 'target architecture') => { + const normalized = ARCHITECTURE_ALIASES.get(String(value || '').trim().toLowerCase()); + if (!normalized) { + throw new Error( + `Unsupported ${source} ${JSON.stringify(value)}. Supported architectures: x64, arm64.`, + ); + } + return ARCHITECTURES[normalized]; +}; + +export const readElectronBuilderArchitecture = (args = []) => { + const requested = []; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--x64' || argument === '--arm64') requested.push(argument.slice(2)); + if (argument === '--arch' && args[index + 1]) requested.push(args[index + 1]); + if (argument.startsWith('--arch=')) requested.push(argument.slice('--arch='.length)); + } + if (requested.length === 0) return null; + + const architectures = new Set(requested.map((value) => normalizeTargetArchitecture(value, 'electron-builder architecture').node)); + if (architectures.size !== 1) { + throw new Error(`Exactly one Electron target architecture is required, got: ${requested.join(', ')}.`); + } + return [...architectures][0]; +}; + +export const resolveTargetArchitecture = ({ + platform = process.platform, + hostArchitecture = process.arch, + environment = process.env, + builderArgs = [], +} = {}) => { + const host = normalizeTargetArchitecture(hostArchitecture, 'host architecture'); + const builderArchitecture = readElectronBuilderArchitecture(builderArgs); + const requestedValues = [ + environment.OPENCHAMBER_TARGET_ARCH, + environment.ELECTRON_BUILDER_ARCH, + builderArchitecture, + ].filter(Boolean); + const requestedArchitectures = new Set( + requestedValues.map((value) => normalizeTargetArchitecture(value, 'target architecture').node), + ); + if (requestedArchitectures.size > 1) { + throw new Error(`Conflicting target architectures: ${requestedValues.join(', ')}.`); + } + + const target = normalizeTargetArchitecture(requestedValues[0] || host.node); + if (platform === 'linux' && target.node !== host.node) { + throw new Error( + `Linux AppImages must be built natively: host is ${host.node}, target is ${target.node}. ` + + `Run this build on a ${target.node} Linux host.`, + ); + } + return target; +}; diff --git a/packages/electron/scripts/target-architecture.test.mjs b/packages/electron/scripts/target-architecture.test.mjs new file mode 100644 index 00000000..8fd753cf --- /dev/null +++ b/packages/electron/scripts/target-architecture.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + normalizeTargetArchitecture, + readElectronBuilderArchitecture, + resolveTargetArchitecture, +} from './target-architecture.mjs'; + +test('normalizes host and release architecture aliases', () => { + assert.equal(normalizeTargetArchitecture('amd64').node, 'x64'); + assert.equal(normalizeTargetArchitecture('x86_64').electronBuilder, 'x64'); + assert.equal(normalizeTargetArchitecture('aarch64').opencode, 'arm64'); +}); + +test('reads a single electron-builder target architecture', () => { + assert.equal(readElectronBuilderArchitecture(['--linux', '--arch=aarch64']), 'arm64'); + assert.equal(readElectronBuilderArchitecture(['--linux', '--x64']), 'x64'); +}); + +test('rejects unsupported architectures', () => { + assert.throws(() => normalizeTargetArchitecture('ia32'), /Supported architectures: x64, arm64/); +}); + +test('rejects conflicting architecture inputs', () => { + assert.throws( + () => resolveTargetArchitecture({ + platform: 'linux', + hostArchitecture: 'x64', + environment: { OPENCHAMBER_TARGET_ARCH: 'x64', ELECTRON_BUILDER_ARCH: 'arm64' }, + }), + /Conflicting target architectures/, + ); +}); + +test('rejects cross-architecture Linux packaging', () => { + assert.throws( + () => resolveTargetArchitecture({ + platform: 'linux', + hostArchitecture: 'x86_64', + environment: { OPENCHAMBER_TARGET_ARCH: 'aarch64' }, + }), + /must be built natively.*host is x64, target is arm64/, + ); +}); + +test('accepts matching native Linux architecture aliases', () => { + assert.equal(resolveTargetArchitecture({ + platform: 'linux', + hostArchitecture: 'x64', + environment: { OPENCHAMBER_TARGET_ARCH: 'amd64' }, + }).node, 'x64'); +}); diff --git a/packages/electron/scripts/updater-e2e-fixture.md b/packages/electron/scripts/updater-e2e-fixture.md new file mode 100644 index 00000000..3c924471 --- /dev/null +++ b/packages/electron/scripts/updater-e2e-fixture.md @@ -0,0 +1,36 @@ +# Linux Updater E2E Fixture + +This local-only harness verifies AppImage N-to-N+1 replacement without changing the +production GitHub updater provider. It supports native x64 and arm64 hosts. + +1. Build both versions on the native target architecture. For N and N+1, set the + test-build marker only while bundling main, then complete normal packaging: + + ```bash + OPENCHAMBER_TARGET_ARCH=x64 OPENCHAMBER_UPDATER_E2E_BUILD=1 bun run bundle:main + OPENCHAMBER_TARGET_ARCH=x64 node ./scripts/package.mjs --linux --x64 --publish=never + ``` + + Use `OPENCHAMBER_TARGET_ARCH=arm64` and `--arm64` on an arm64 host. Keep the N and + N+1 AppImages in separate output directories before rebuilding. + +2. Launch N against a loopback fixture containing N+1: + + ```bash + bun run updater:e2e:fixture -- run \ + --arch x64 \ + --current /absolute/path/OpenChamber-N-linux-x86_64.AppImage \ + --next /absolute/path/OpenChamber-N+1-linux-x86_64.AppImage \ + --version N+1 \ + --dir /tmp/openchamber-updater-e2e + ``` + +3. In N, check for updates, download/install, and restart. Verify the restarted app + reports N+1 and that the file at `APPIMAGE` was replaced. Repeat with `--arch arm64` + and the arm64 AppImages on the arm64 host. + +The harness binds only `127.0.0.1`. Runtime override activation additionally requires +`OPENCHAMBER_E2E=1`, the loopback URL set by the harness, and the build-time marker. +Normal packages omit the build-time marker and always use `openchamber/openchamber`. +The renderer, IPC bridge, command-line arguments, and persistent configuration do not +have access to the feed URL. diff --git a/packages/electron/scripts/updater-e2e-fixture.mjs b/packages/electron/scripts/updater-e2e-fixture.mjs new file mode 100644 index 00000000..f5580ff8 --- /dev/null +++ b/packages/electron/scripts/updater-e2e-fixture.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ARCHITECTURES = new Map([ + ['x64', 'latest-linux.yml'], + ['arm64', 'latest-linux-arm64.yml'], +]); + +const usage = `Usage: + updater-e2e-fixture.mjs stage --arch --next --version --dir + updater-e2e-fixture.mjs serve --dir [--port ] + updater-e2e-fixture.mjs run --arch --current --next --version --dir [--port ] + +Both AppImages must be packaged with OPENCHAMBER_UPDATER_E2E_BUILD=1 during bundle:main. +The run command stages N+1, serves it on 127.0.0.1, and launches N with only the two +runtime E2E gates. Use the Desktop update UI to check, download, apply, and restart. +Keep this process running until the restarted N+1 is verified, then press Ctrl-C.`; + +const parseArguments = (argv) => { + const [command, ...rest] = argv; + const options = {}; + for (let index = 0; index < rest.length; index += 2) { + const key = rest[index]; + const value = rest[index + 1]; + if (!key?.startsWith('--') || value === undefined) throw new Error(usage); + options[key.slice(2)] = value; + } + return { command, options }; +}; + +const requireOption = (options, name) => { + const value = options[name]; + if (!value) throw new Error(`Missing --${name}\n\n${usage}`); + return value; +}; + +const resolveArchitecture = (value) => { + if (!ARCHITECTURES.has(value)) throw new Error(`Unsupported architecture: ${value || '(missing)'}`); + return value; +}; + +const resolveExistingFile = (value, name) => { + const filePath = path.resolve(value); + if (!fs.statSync(filePath).isFile()) throw new Error(`--${name} must be a file: ${filePath}`); + return filePath; +}; + +const sha512 = (filePath) => crypto.createHash('sha512').update(fs.readFileSync(filePath)).digest('base64'); + +export const stageUpdaterFixture = ({ architecture, nextAppImage, version, directory }) => { + const manifestName = ARCHITECTURES.get(resolveArchitecture(architecture)); + const sourcePath = resolveExistingFile(nextAppImage, 'next'); + const feedDirectory = path.resolve(directory); + fs.mkdirSync(feedDirectory, { recursive: true }); + const artifactName = path.basename(sourcePath); + const artifactPath = path.join(feedDirectory, artifactName); + if (sourcePath !== artifactPath) fs.copyFileSync(sourcePath, artifactPath); + const size = fs.statSync(artifactPath).size; + const checksum = sha512(artifactPath); + const manifest = [ + `version: ${version}`, + 'files:', + ` - url: ${encodeURIComponent(artifactName)}`, + ` sha512: ${checksum}`, + ` size: ${size}`, + `path: ${encodeURIComponent(artifactName)}`, + `sha512: ${checksum}`, + `releaseDate: '${new Date().toISOString()}'`, + '', + ].join('\n'); + fs.writeFileSync(path.join(feedDirectory, manifestName), manifest, { mode: 0o644 }); + return { artifactPath, manifestName, size }; +}; + +export const createFixtureServer = ({ directory, port = 0 }) => { + const feedDirectory = path.resolve(directory); + const files = new Map(fs.readdirSync(feedDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => [`/${encodeURIComponent(entry.name)}`, path.join(feedDirectory, entry.name)])); + const server = http.createServer((request, response) => { + const requestUrl = new URL(request.url || '/', 'http://127.0.0.1'); + const filePath = files.get(requestUrl.pathname); + if ((request.method !== 'GET' && request.method !== 'HEAD') || !filePath) { + response.writeHead(404).end(); + return; + } + const stat = fs.statSync(filePath); + response.writeHead(200, { + 'Content-Length': stat.size, + 'Content-Type': filePath.endsWith('.yml') ? 'text/yaml' : 'application/octet-stream', + }); + if (request.method === 'HEAD') response.end(); + else fs.createReadStream(filePath).pipe(response); + }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(Number(port), '127.0.0.1', () => { + const address = server.address(); + resolve({ server, url: `http://127.0.0.1:${address.port}/` }); + }); + }); +}; + +const waitForSignal = () => new Promise((resolve) => { + process.once('SIGINT', resolve); + process.once('SIGTERM', resolve); +}); + +const main = async () => { + const { command, options } = parseArguments(process.argv.slice(2)); + if (command === '--help' || command === 'help' || !command) { + console.log(usage); + return; + } + const directory = requireOption(options, 'dir'); + if (command === 'stage' || command === 'run') { + const result = stageUpdaterFixture({ + architecture: requireOption(options, 'arch'), + nextAppImage: requireOption(options, 'next'), + version: requireOption(options, 'version'), + directory, + }); + console.log(`[electron] staged ${result.manifestName} and ${path.basename(result.artifactPath)}`); + if (command === 'stage') return; + } + if (command !== 'serve' && command !== 'run') throw new Error(usage); + const { server, url } = await createFixtureServer({ directory, port: options.port || 0 }); + console.log(`[electron] updater E2E fixture listening at ${url}`); + if (command === 'run') { + const currentAppImage = resolveExistingFile(requireOption(options, 'current'), 'current'); + const child = spawn(currentAppImage, [], { + env: { + ...process.env, + APPIMAGE: currentAppImage, + OPENCHAMBER_E2E: '1', + OPENCHAMBER_UPDATER_E2E_URL: url, + }, + stdio: 'inherit', + }); + child.once('error', (error) => console.error(`[electron] failed to launch N AppImage: ${error.message}`)); + } + await waitForSignal(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +}; + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/packages/electron/scripts/updater-e2e-fixture.test.mjs b/packages/electron/scripts/updater-e2e-fixture.test.mjs new file mode 100644 index 00000000..adaf8517 --- /dev/null +++ b/packages/electron/scripts/updater-e2e-fixture.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createFixtureServer, stageUpdaterFixture } from './updater-e2e-fixture.mjs'; +import { parseUpdateManifest, verifyUpdateManifest } from './verify-update-manifest.mjs'; + +test('stages architecture-specific generic updater fixtures with valid metadata', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-updater-fixture-')); + try { + const source = path.join(root, 'OpenChamber-1.15.1-linux-arm64.AppImage'); + const directory = path.join(root, 'feed'); + fs.writeFileSync(source, 'fixture-appimage'); + const result = stageUpdaterFixture({ + architecture: 'arm64', + nextAppImage: source, + version: '1.15.1', + directory, + }); + assert.equal(result.manifestName, 'latest-linux-arm64.yml'); + const manifestPath = path.join(directory, result.manifestName); + assert.deepEqual(parseUpdateManifest(fs.readFileSync(manifestPath, 'utf8')).files.length, 1); + assert.deepEqual(verifyUpdateManifest({ + manifestPath, + artifactPath: result.artifactPath, + expectedVersion: '1.15.1', + }), { + name: 'OpenChamber-1.15.1-linux-arm64.AppImage', + size: 16, + version: '1.15.1', + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('serves only staged fixture files over loopback', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-updater-server-')); + const artifact = path.join(root, 'OpenChamber.AppImage'); + fs.writeFileSync(artifact, 'fixture'); + const { server, url } = await createFixtureServer({ directory: root }); + try { + assert.equal(new URL(url).hostname, '127.0.0.1'); + const response = await fetch(`${url}OpenChamber.AppImage`); + assert.equal(response.status, 200); + assert.equal(await response.text(), 'fixture'); + assert.equal((await fetch(`${url}../package.json`)).status, 404); + } finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/electron/scripts/verify-linux-appimage.mjs b/packages/electron/scripts/verify-linux-appimage.mjs new file mode 100644 index 00000000..97da9a05 --- /dev/null +++ b/packages/electron/scripts/verify-linux-appimage.mjs @@ -0,0 +1,164 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { normalizeTargetArchitecture } from './target-architecture.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(electronRoot, '../..'); +const ELF_MACHINE = { x64: 62, arm64: 183 }; +// sherpa-onnx-node loads this Node-API addon from its platform-specific prebuilt +// package in the separate server worker, so verify its architecture here rather +// than Electron-rebuilding it with the source-built modules. +const REQUIRED_NATIVE_MODULES = ['better_sqlite3.node', 'pty.node', 'sherpa-onnx.node']; + +/** electron-builder AppImage arch token: x64 → x86_64, arm64 → arm64 */ +export const linuxAppImageArchSuffix = (architecture) => ( + architecture === 'x64' ? 'x86_64' : 'arm64' +); + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); + +export const readElfArchitecture = (filePath) => { + const header = Buffer.alloc(20); + const descriptor = fs.openSync(filePath, 'r'); + try { + if (fs.readSync(descriptor, header, 0, header.length, 0) !== header.length) { + throw new Error(`ELF header is truncated: ${filePath}`); + } + } finally { + fs.closeSync(descriptor); + } + if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + throw new Error(`Expected an ELF binary: ${filePath}`); + } + const byteOrder = header[5]; + if (byteOrder !== 1 && byteOrder !== 2) throw new Error(`Unsupported ELF byte order: ${filePath}`); + const machine = byteOrder === 1 ? header.readUInt16LE(18) : header.readUInt16BE(18); + const architecture = Object.entries(ELF_MACHINE).find(([, value]) => value === machine)?.[0]; + if (!architecture) throw new Error(`Unsupported ELF machine ${machine}: ${filePath}`); + return architecture; +}; + +export const assertElfArchitecture = (filePath, expectedArchitecture, label) => { + if (!fs.existsSync(filePath)) throw new Error(`Missing ${label}: ${filePath}`); + const actual = readElfArchitecture(filePath); + if (actual !== expectedArchitecture) { + throw new Error(`${label} architecture mismatch: expected ${expectedArchitecture}, got ${actual} (${filePath})`); + } +}; + +const collectFiles = (root, predicate) => { + const matches = []; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) visit(fullPath); + else if (entry.isFile() && predicate(entry.name, fullPath)) matches.push(fullPath); + } + }; + visit(root); + return matches; +}; + +const defaultCliVersion = (binaryPath) => { + const result = spawnSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15000, + }); + if (result.status !== 0) throw new Error(`Failed to run packaged OpenCode CLI: ${binaryPath}`); + return (result.stdout || '').trim().split(/\s+/)[0] || ''; +}; + +export const verifyExtractedPayload = ({ + root, + targetArchitecture, + expectedOpenCodeVersion, + runCliVersion = defaultCliVersion, +}) => { + const desktopPath = path.join(root, 'openchamber.desktop'); + if (!fs.existsSync(desktopPath)) throw new Error(`Missing desktop entry: ${desktopPath}`); + const desktop = fs.readFileSync(desktopPath, 'utf8'); + for (const entry of ['Name=OpenChamber', 'Icon=openchamber', 'StartupWMClass=openchamber']) { + if (!desktop.split(/\r?\n/).includes(entry)) throw new Error(`Desktop identity mismatch: missing ${entry}`); + } + if (!/^Exec=AppRun(?:\s|$)/m.test(desktop)) throw new Error('Desktop identity mismatch: expected AppImage AppRun entrypoint'); + + assertElfArchitecture(path.join(root, 'openchamber'), targetArchitecture, 'Electron executable'); + const cliPath = path.join(root, 'resources', 'opencode-cli', 'opencode'); + assertElfArchitecture(cliPath, targetArchitecture, 'OpenCode CLI'); + const actualVersion = runCliVersion(cliPath); + if (actualVersion !== expectedOpenCodeVersion) { + throw new Error(`OpenCode CLI version mismatch: expected ${expectedOpenCodeVersion}, got ${actualVersion || '(empty)'}`); + } + + const unpackedModules = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules'); + if (!fs.existsSync(unpackedModules)) throw new Error(`Missing unpacked native modules: ${unpackedModules}`); + const nativeModules = collectFiles(unpackedModules, (name, fullPath) => { + if (!name.endsWith('.node')) return false; + const normalizedPath = fullPath.split(path.sep).join('/'); + if (!normalizedPath.includes('/prebuilds/')) return true; + return normalizedPath.includes(`/prebuilds/linux-${targetArchitecture}/`); + }); + for (const requiredName of REQUIRED_NATIVE_MODULES) { + if (!nativeModules.some((modulePath) => path.basename(modulePath) === requiredName)) { + throw new Error(`Missing packaged native module: ${requiredName}`); + } + } + for (const modulePath of nativeModules) assertElfArchitecture(modulePath, targetArchitecture, 'Native module'); + return { nativeModuleCount: nativeModules.length, openCodeVersion: actualVersion }; +}; + +const findAppImage = (version, architecture) => { + const suffix = linuxAppImageArchSuffix(architecture); + const expected = path.join(electronRoot, 'dist', `OpenChamber-${version}-linux-${suffix}.AppImage`); + if (!fs.existsSync(expected)) throw new Error(`Linux AppImage not found: ${expected}`); + return expected; +}; + +const extractAppImage = (appImagePath, destination) => { + fs.chmodSync(appImagePath, fs.statSync(appImagePath).mode | 0o100); + const result = spawnSync(appImagePath, ['--appimage-extract'], { + cwd: destination, + encoding: 'utf8', + stdio: ['ignore', 'ignore', 'pipe'], + timeout: 120000, + }); + if (result.status !== 0) { + throw new Error(`Failed to extract AppImage: ${appImagePath}\n${(result.stderr || '').trim()}`); + } + return path.join(destination, 'squashfs-root'); +}; + +const main = () => { + const rootPackage = readJson(path.join(workspaceRoot, 'package.json')); + const target = normalizeTargetArchitecture(process.env.OPENCHAMBER_TARGET_ARCH || process.arch).node; + const appImagePath = process.argv[2] ? path.resolve(process.argv[2]) : findAppImage(rootPackage.version, target); + assertElfArchitecture(appImagePath, target, 'AppImage'); + + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-appimage-')); + try { + const result = verifyExtractedPayload({ + root: extractAppImage(appImagePath, temporaryDirectory), + targetArchitecture: target, + expectedOpenCodeVersion: rootPackage.dependencies?.['@opencode-ai/sdk'], + }); + console.log(`[electron] verified Linux ${target} AppImage: ${appImagePath}`); + console.log(`[electron] verified OpenCode CLI ${result.openCodeVersion} and ${result.nativeModuleCount} native modules`); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}; + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/packages/electron/scripts/verify-linux-appimage.test.mjs b/packages/electron/scripts/verify-linux-appimage.test.mjs new file mode 100644 index 00000000..3ba2084d --- /dev/null +++ b/packages/electron/scripts/verify-linux-appimage.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { linuxAppImageArchSuffix, readElfArchitecture, verifyExtractedPayload } from './verify-linux-appimage.mjs'; + +const writeElf = (filePath, architecture) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const header = Buffer.alloc(20); + header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + header.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18); + fs.writeFileSync(filePath, header, { mode: 0o755 }); +}; + +const createPayload = () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-payload-test-')); + fs.writeFileSync(path.join(root, 'openchamber.desktop'), [ + '[Desktop Entry]', 'Name=OpenChamber', 'Exec=AppRun --no-sandbox %U', 'Icon=openchamber', 'StartupWMClass=openchamber', '', + ].join('\n')); + writeElf(path.join(root, 'openchamber'), 'x64'); + writeElf(path.join(root, 'resources/opencode-cli/opencode'), 'x64'); + for (const name of ['better_sqlite3.node', 'pty.node', 'sherpa-onnx.node']) { + writeElf(path.join(root, 'resources/app.asar.unpacked/node_modules', name), 'x64'); + } + return root; +}; + +test('reads supported ELF architectures', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-elf-test-')); + try { + writeElf(path.join(root, 'x64'), 'x64'); + writeElf(path.join(root, 'arm64'), 'arm64'); + assert.equal(readElfArchitecture(path.join(root, 'x64')), 'x64'); + assert.equal(readElfArchitecture(path.join(root, 'arm64')), 'arm64'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('AppImage artifact names use electron-builder arch suffixes', () => { + assert.equal(linuxAppImageArchSuffix('x64'), 'x86_64'); + assert.equal(linuxAppImageArchSuffix('arm64'), 'arm64'); +}); + +test('verifies identity, version, and native payload architecture', () => { + const root = createPayload(); + try { + const result = verifyExtractedPayload({ + root, + targetArchitecture: 'x64', + expectedOpenCodeVersion: '1.17.18', + runCliVersion: () => '1.17.18', + }); + assert.equal(result.nativeModuleCount, 3); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('fails on a missing native module', () => { + const root = createPayload(); + try { + fs.rmSync(path.join(root, 'resources/app.asar.unpacked/node_modules/pty.node')); + assert.throws(() => verifyExtractedPayload({ + root, + targetArchitecture: 'x64', + expectedOpenCodeVersion: '1.17.18', + runCliVersion: () => '1.17.18', + }), /Missing packaged native module: pty\.node/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('fails on wrong CLI version or native architecture', () => { + const root = createPayload(); + try { + assert.throws(() => verifyExtractedPayload({ + root, + targetArchitecture: 'x64', + expectedOpenCodeVersion: '1.17.18', + runCliVersion: () => '1.17.17', + }), /OpenCode CLI version mismatch/); + writeElf(path.join(root, 'resources/app.asar.unpacked/node_modules/pty.node'), 'arm64'); + assert.throws(() => verifyExtractedPayload({ + root, + targetArchitecture: 'x64', + expectedOpenCodeVersion: '1.17.18', + runCliVersion: () => '1.17.18', + }), /Native module architecture mismatch/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/electron/scripts/verify-update-manifest.mjs b/packages/electron/scripts/verify-update-manifest.mjs new file mode 100644 index 00000000..aca446dd --- /dev/null +++ b/packages/electron/scripts/verify-update-manifest.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const parseUpdateManifest = (content) => { + const version = content.match(/^version:\s*(\S+)\s*$/m)?.[1] || ''; + const lines = content.split(/\r?\n/); + const files = []; + let entry = null; + for (const line of lines) { + const start = line.match(/^\s{2}-\s+(url|sha512|size|blockMapSize):\s*(\S+)\s*$/); + const field = start || line.match(/^\s{4}(url|sha512|size|blockMapSize):\s*(\S+)\s*$/); + if (start) { + if (entry) files.push(entry); + entry = {}; + } + if (!field || !entry) continue; + const [, key, value] = field; + entry[key] = key === 'size' || key === 'blockMapSize' ? Number(value) : value; + } + if (entry) files.push(entry); + return { + version, + files: files.filter((file) => file.url && file.sha512 && Number.isSafeInteger(file.size)), + }; +}; + +export const verifyUpdateManifest = ({ manifestPath, artifactPath, expectedVersion }) => { + const manifest = parseUpdateManifest(fs.readFileSync(manifestPath, 'utf8')); + const expectedName = path.basename(artifactPath); + if (manifest.version !== expectedVersion) { + throw new Error(`Update manifest version mismatch: expected ${expectedVersion}, got ${manifest.version || '(missing)'}`); + } + if (manifest.files.length !== 1) { + throw new Error(`Linux update manifest must contain exactly one artifact, got ${manifest.files.length}`); + } + const [entry] = manifest.files; + if (decodeURIComponent(path.basename(entry.url)) !== expectedName) { + throw new Error(`Update manifest artifact mismatch: expected ${expectedName}, got ${entry.url}`); + } + const bytes = fs.readFileSync(artifactPath); + if (entry.size !== bytes.length) { + throw new Error(`Update manifest size mismatch: expected ${bytes.length}, got ${entry.size}`); + } + const checksum = crypto.createHash('sha512').update(bytes).digest('base64'); + if (entry.sha512 !== checksum) throw new Error('Update manifest sha512 mismatch'); + return { name: expectedName, size: bytes.length, version: manifest.version }; +}; + +const main = () => { + const [manifestPath, artifactPath, expectedVersion] = process.argv.slice(2); + if (!manifestPath || !artifactPath || !expectedVersion) { + throw new Error('Usage: verify-update-manifest.mjs '); + } + const result = verifyUpdateManifest({ + manifestPath: path.resolve(manifestPath), + artifactPath: path.resolve(artifactPath), + expectedVersion, + }); + console.log(`[electron] verified ${path.basename(manifestPath)} for ${result.name} (${result.size} bytes)`); +}; + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/packages/electron/scripts/verify-update-manifest.test.mjs b/packages/electron/scripts/verify-update-manifest.test.mjs new file mode 100644 index 00000000..d880b79c --- /dev/null +++ b/packages/electron/scripts/verify-update-manifest.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { verifyUpdateManifest } from './verify-update-manifest.mjs'; + +const fixture = (manifestName, artifactName, fields) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-manifest-test-')); + const artifactPath = path.join(root, artifactName); + const manifestPath = path.join(root, manifestName); + const bytes = Buffer.from(`artifact:${artifactName}`); + fs.writeFileSync(artifactPath, bytes); + fs.writeFileSync(manifestPath, [ + 'version: 1.15.0', + 'files:', + ...(fields || [ + ` - url: ${artifactName}`, + ` sha512: ${crypto.createHash('sha512').update(bytes).digest('base64')}`, + ` size: ${bytes.length}`, + ]), + `path: ${artifactName}`, + 'releaseDate: 2026-07-10T00:00:00.000Z', + '', + ].join('\n')); + return { root, artifactPath, manifestPath }; +}; + +for (const [manifestName, artifactName] of [ + ['latest-linux.yml', 'OpenChamber-1.15.0-linux-x86_64.AppImage'], + ['latest-linux-arm64.yml', 'OpenChamber-1.15.0-linux-arm64.AppImage'], +]) { + test(`validates architecture-specific ${manifestName}`, () => { + const value = fixture(manifestName, artifactName); + try { + assert.equal(verifyUpdateManifest({ ...value, expectedVersion: '1.15.0' }).name, artifactName); + } finally { + fs.rmSync(value.root, { recursive: true, force: true }); + } + }); +} + +test('accepts electron-builder field ordering and optional blockMapSize', () => { + const artifactName = 'OpenChamber-1.15.0-linux-x86_64.AppImage'; + const bytes = Buffer.from(`artifact:${artifactName}`); + const value = fixture('latest-linux.yml', artifactName, [ + ` - sha512: ${crypto.createHash('sha512').update(bytes).digest('base64')}`, + ` size: ${bytes.length}`, + ' blockMapSize: 1234', + ` url: ${artifactName}`, + ]); + try { + assert.equal(verifyUpdateManifest({ ...value, expectedVersion: '1.15.0' }).name, artifactName); + } finally { + fs.rmSync(value.root, { recursive: true, force: true }); + } +}); + +test('rejects a manifest that points at the other architecture artifact', () => { + const value = fixture('latest-linux-arm64.yml', 'OpenChamber-1.15.0-linux-arm64.AppImage'); + try { + const x64Artifact = path.join(value.root, 'OpenChamber-1.15.0-linux-x86_64.AppImage'); + fs.copyFileSync(value.artifactPath, x64Artifact); + assert.throws(() => verifyUpdateManifest({ + manifestPath: value.manifestPath, + artifactPath: x64Artifact, + expectedVersion: '1.15.0', + }), /artifact mismatch/); + } finally { + fs.rmSync(value.root, { recursive: true, force: true }); + } +}); diff --git a/packages/electron/updater-capability.mjs b/packages/electron/updater-capability.mjs new file mode 100644 index 00000000..049b2471 --- /dev/null +++ b/packages/electron/updater-capability.mjs @@ -0,0 +1,35 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const assertUpdaterCapability = ({ + platform = process.platform, + packaged, + appImagePath = process.env.APPIMAGE, + access = fs.accessSync, + stat = fs.statSync, +} = {}) => { + if (platform !== 'linux' || !packaged) return; + + if (!appImagePath) { + throw new Error( + 'Updates require the packaged Linux AppImage. Start OpenChamber from its .AppImage file, not an extracted or repackaged copy.', + ); + } + if (!path.isAbsolute(appImagePath)) { + throw new Error(`Updates require APPIMAGE to be an absolute path, got: ${appImagePath}`); + } + + try { + if (!stat(appImagePath).isFile()) throw new Error('not a file'); + } catch { + throw new Error(`The running AppImage cannot be found at ${appImagePath}. Start OpenChamber from a valid .AppImage file.`); + } + + try { + access(appImagePath, fs.constants.W_OK); + } catch { + throw new Error( + `The AppImage is not writable at ${appImagePath}. Move it to a writable location or grant write permission before updating.`, + ); + } +}; diff --git a/packages/electron/updater-capability.test.mjs b/packages/electron/updater-capability.test.mjs new file mode 100644 index 00000000..b612210b --- /dev/null +++ b/packages/electron/updater-capability.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { assertUpdaterCapability } from './updater-capability.mjs'; + +test('preserves updater behavior outside packaged Linux', () => { + assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'darwin', packaged: true })); + assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'win32', packaged: true })); + assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'linux', packaged: false })); +}); + +test('rejects packaged Linux execution outside an AppImage', () => { + assert.throws( + () => assertUpdaterCapability({ platform: 'linux', packaged: true, appImagePath: '' }), + /Start OpenChamber from its \.AppImage file/, + ); +}); + +test('rejects missing and non-writable AppImages with actionable errors', () => { + assert.throws( + () => assertUpdaterCapability({ + platform: 'linux', + packaged: true, + appImagePath: '/opt/OpenChamber.AppImage', + stat: () => { throw new Error('missing'); }, + }), + /cannot be found.*valid \.AppImage file/, + ); + assert.throws( + () => assertUpdaterCapability({ + platform: 'linux', + packaged: true, + appImagePath: '/opt/OpenChamber.AppImage', + stat: () => ({ isFile: () => true }), + access: () => { throw new Error('read-only'); }, + }), + /not writable.*grant write permission/, + ); +}); + +test('accepts a writable packaged AppImage', () => { + assert.doesNotThrow(() => assertUpdaterCapability({ + platform: 'linux', + packaged: true, + appImagePath: '/home/user/OpenChamber.AppImage', + stat: () => ({ isFile: () => true }), + access: () => {}, + })); +}); diff --git a/packages/electron/updater-check.mjs b/packages/electron/updater-check.mjs new file mode 100644 index 00000000..77b80c63 --- /dev/null +++ b/packages/electron/updater-check.mjs @@ -0,0 +1,42 @@ +const MISSING_UPDATE_FEED_RE = + /404|ENOTFOUND|Cannot find (?:channel|latest)|latest-linux(?:-arm64)?\.yml|HttpError:\s*404|status code 404/i; + +export const isMissingUpdateFeedError = (error) => { + const message = error instanceof Error ? error.message : String(error ?? ''); + return MISSING_UPDATE_FEED_RE.test(message); +}; + +export const checkForDesktopUpdate = async ({ autoUpdater, currentVersion, pendingUpdate, compareVersions }) => { + let updateResult; + try { + updateResult = await autoUpdater.checkForUpdates(); + } catch (error) { + // Before the first Linux (or platform) release publishes its feed, electron-updater + // returns 404 for latest-*.yml. Treat that as authoritative "no update" instead of + // surfacing a hard failure that looks like a broken updater. + if (isMissingUpdateFeedError(error)) { + return { + available: false, + updateInfo: null, + updateResult: null, + nextVersion: currentVersion, + pendingUpdate: null, + }; + } + const detail = error instanceof Error && error.message ? `: ${error.message}` : ''; + throw new Error(`Unable to check for updates${detail}. Check your network connection and try again.`, { cause: error }); + } + + const updateInfo = updateResult?.updateInfo; + const nextVersion = + (typeof updateInfo?.version === 'string' && updateInfo.version) || + currentVersion; + const available = compareVersions(nextVersion, currentVersion) > 0; + return { + available, + updateInfo, + updateResult, + nextVersion, + pendingUpdate: available ? { version: nextVersion, electronUpdate: updateResult } : null, + }; +}; diff --git a/packages/electron/updater-check.test.mjs b/packages/electron/updater-check.test.mjs new file mode 100644 index 00000000..430c7264 --- /dev/null +++ b/packages/electron/updater-check.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { checkForDesktopUpdate } from './updater-check.mjs'; + +const compareVersions = (left, right) => left.localeCompare(right, undefined, { numeric: true }); + +test('signals failed checks without replacing an existing pending update', async () => { + const pendingUpdate = { version: '2.0.0', electronUpdate: { id: 'existing' } }; + await assert.rejects( + checkForDesktopUpdate({ + autoUpdater: { checkForUpdates: async () => { throw new Error('feed unavailable'); } }, + currentVersion: '1.0.0', + pendingUpdate, + compareVersions, + }), + /Unable to check for updates: feed unavailable.*network connection/, + ); + assert.deepEqual(pendingUpdate, { version: '2.0.0', electronUpdate: { id: 'existing' } }); +}); + +test('treats missing update feed (404) as no update available', async () => { + const result = await checkForDesktopUpdate({ + autoUpdater: { + checkForUpdates: async () => { + throw new Error('HttpError: 404 Not Found "https://github.com/.../latest-linux.yml"'); + }, + }, + currentVersion: '1.15.0', + pendingUpdate: { version: '1.16.0' }, + compareVersions, + }); + assert.equal(result.available, false); + assert.equal(result.pendingUpdate, null); + assert.equal(result.nextVersion, '1.15.0'); +}); + +test('authoritative no-update result clears pending update', async () => { + const result = await checkForDesktopUpdate({ + autoUpdater: { checkForUpdates: async () => ({ updateInfo: { version: '1.0.0' } }) }, + currentVersion: '1.0.0', + pendingUpdate: { version: '2.0.0' }, + compareVersions, + }); + assert.equal(result.available, false); + assert.equal(result.pendingUpdate, null); +}); diff --git a/packages/electron/updater-feed.mjs b/packages/electron/updater-feed.mjs new file mode 100644 index 00000000..8fb9aed8 --- /dev/null +++ b/packages/electron/updater-feed.mjs @@ -0,0 +1,47 @@ +import fs from 'node:fs'; + +export const PRODUCTION_UPDATER_FEED = Object.freeze({ + provider: 'github', + owner: 'openchamber', + repo: 'openchamber', +}); + +const isLoopbackHostname = (hostname) => { + if (hostname === '::1' || hostname === '[::1]') return true; + const octets = hostname.split('.'); + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) return false; + const values = octets.map(Number); + return values[0] === 127 && values.every((value) => value <= 255); +}; + +export const parseLoopbackUpdaterUrl = (value) => { + if (!value) return null; + try { + const url = new URL(value); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') + || !isLoopbackHostname(url.hostname) + || url.username + || url.password + || url.search + || url.hash) { + return null; + } + return url.toString(); + } catch { + return null; + } +}; + +export const resolveUpdaterFeed = ({ + environment = process.env, + testBuild = false, +} = {}) => { + if (environment.OPENCHAMBER_E2E !== '1' + || testBuild !== true) { + return PRODUCTION_UPDATER_FEED; + } + + const url = parseLoopbackUpdaterUrl(environment.OPENCHAMBER_UPDATER_E2E_URL); + if (!url) return PRODUCTION_UPDATER_FEED; + return { provider: 'generic', url }; +}; diff --git a/packages/electron/updater-feed.test.mjs b/packages/electron/updater-feed.test.mjs new file mode 100644 index 00000000..bee12683 --- /dev/null +++ b/packages/electron/updater-feed.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PRODUCTION_UPDATER_FEED, + parseLoopbackUpdaterUrl, + resolveUpdaterFeed, +} from './updater-feed.mjs'; + +const overrideEnvironment = { + OPENCHAMBER_E2E: '1', + OPENCHAMBER_UPDATER_E2E_URL: 'http://127.0.0.1:49152/updates/', +}; + +test('production updater feed is immutable GitHub configuration', () => { + assert.equal(Object.isFrozen(PRODUCTION_UPDATER_FEED), true); + assert.deepEqual(PRODUCTION_UPDATER_FEED, { + provider: 'github', + owner: 'openchamber', + repo: 'openchamber', + }); +}); + +test('requires the complete E2E environment and embedded build-marker conjunction', () => { + const cases = [ + {}, + { environment: overrideEnvironment }, + { environment: { OPENCHAMBER_E2E: '1' }, testBuild: true }, + { + environment: { OPENCHAMBER_UPDATER_E2E_URL: overrideEnvironment.OPENCHAMBER_UPDATER_E2E_URL }, + testBuild: true, + }, + { environment: overrideEnvironment, testBuild: false }, + ]; + for (const input of cases) assert.equal(resolveUpdaterFeed(input), PRODUCTION_UPDATER_FEED); +}); + +test('accepts only credential-free loopback HTTP(S) URLs', () => { + assert.equal(parseLoopbackUpdaterUrl('http://127.0.0.1:8080/feed'), 'http://127.0.0.1:8080/feed'); + assert.equal(parseLoopbackUpdaterUrl('https://127.255.0.1/feed/'), 'https://127.255.0.1/feed/'); + assert.equal(parseLoopbackUpdaterUrl('http://[::1]:8080/feed'), 'http://[::1]:8080/feed'); + + for (const value of [ + 'http://localhost:8080/feed', + 'http://0.0.0.0:8080/feed', + 'http://192.168.1.5:8080/feed', + 'https://example.com/feed', + 'file:///tmp/feed', + 'ftp://127.0.0.1/feed', + 'http://user:secret@127.0.0.1/feed', + 'http://127.0.0.1/feed?token=secret', + 'http://127.0.0.1/feed#fragment', + 'not-a-url', + ]) assert.equal(parseLoopbackUpdaterUrl(value), null, value); +}); + +test('uses a generic feed only when every test-only gate is valid', () => { + assert.deepEqual(resolveUpdaterFeed({ + environment: overrideEnvironment, + testBuild: true, + }), { + provider: 'generic', + url: 'http://127.0.0.1:49152/updates/', + }); +}); + +test('invalid URLs fall back to the production feed even with both test gates', () => { + for (const url of ['https://example.com/feed', 'http://localhost/feed', '']) { + assert.equal(resolveUpdaterFeed({ + environment: { ...overrideEnvironment, OPENCHAMBER_UPDATER_E2E_URL: url }, + testBuild: true, + }), PRODUCTION_UPDATER_FEED); + } +}); diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx index 153850cb..fba39e3a 100644 --- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx +++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx @@ -4,12 +4,17 @@ import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { invokeDesktop } from '@/lib/desktop'; +import type { DesktopWindowControlsSide } from '@/lib/desktop'; type WindowsWindowControlsProps = { visible: boolean; + position?: DesktopWindowControlsSide; }; -export const WindowsWindowControls = React.memo(function WindowsWindowControls({ visible }: WindowsWindowControlsProps) { +export const WindowsWindowControls = React.memo(function WindowsWindowControls({ + visible, + position = 'right', +}: WindowsWindowControlsProps) { const { t } = useI18n(); const [isMaximized, setIsMaximized] = React.useState(false); @@ -44,9 +49,12 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({ } const buttonClassName = 'app-region-no-drag inline-flex h-12 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'; + const containerClassName = position === 'left' + ? 'app-region-no-drag -ml-3 mr-2 flex h-12 shrink-0 items-center' + : 'app-region-no-drag -mr-3 ml-2 flex h-12 shrink-0 items-center'; return ( -
+
diff --git a/packages/ui/src/components/layout/TitlebarLeftControls.tsx b/packages/ui/src/components/layout/TitlebarLeftControls.tsx index 9f29795d..3b12d2ae 100644 --- a/packages/ui/src/components/layout/TitlebarLeftControls.tsx +++ b/packages/ui/src/components/layout/TitlebarLeftControls.tsx @@ -6,8 +6,10 @@ import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; import { useProjectActionsContext } from '@/hooks/useProjectActionsContext'; import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; +import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { invokeDesktop } from '@/lib/desktop'; +import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout'; const ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary hover:bg-interactive-hover transition-colors'; @@ -32,12 +34,7 @@ export const TitlebarLeftControls: React.FC = () => { const clusterRef = React.useRef(null); const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides)); - const isWindowsElectronDesktop = React.useMemo(() => { - if (typeof window === 'undefined') { - return false; - } - return Boolean(window.__OPENCHAMBER_ELECTRON__) && window.__OPENCHAMBER_PLATFORM__ === 'win32'; - }, []); + const { usesFramelessChrome, side: windowControlsSide } = useDesktopWindowControlsLayout(); const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); @@ -88,7 +85,11 @@ export const TitlebarLeftControls: React.FC = () => { }} >
- {isWindowsElectronDesktop ? ( + {usesFramelessChrome && windowControlsSide === 'left' ? ( + + ) : null} + + {usesFramelessChrome ? ( - + ); }; diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx index df480fcc..6634b7b7 100644 --- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx @@ -14,14 +14,29 @@ import { setDesktopKeepAwake, setDesktopLaunchAtLogin, setDesktopMinimizeToTray, + usesFramelessElectronChrome, + type DesktopWindowControlsPosition, } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; +import { updateDesktopSettings } from '@/lib/persistence'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; +import { useUIStore } from '@/stores/useUIStore'; +import { cn } from '@/lib/utils'; + +const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ + { id: 'auto', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsAuto' }, + { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, + { id: 'right', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsRight' }, +]; export const DesktopNetworkSettings: React.FC = () => { const { t } = useI18n(); + const tUnsafe = React.useCallback((key: string) => t(key as Parameters[0]), [t]); const isLocalDesktop = isDesktopShell() && isDesktopLocalOriginActive(); + const showWindowControlsPosition = usesFramelessElectronChrome(); + const desktopWindowControlsPosition = useUIStore((state) => state.desktopWindowControlsPosition); + const setDesktopWindowControlsPosition = useUIStore((state) => state.setDesktopWindowControlsPosition); const [savedValue, setSavedValue] = React.useState(false); const [draftValue, setDraftValue] = React.useState(false); const [savedPassword, setSavedPassword] = React.useState(''); @@ -211,6 +226,11 @@ export const DesktopNetworkSettings: React.FC = () => { } }, []); + const handleWindowControlsPositionChange = React.useCallback((value: DesktopWindowControlsPosition) => { + setDesktopWindowControlsPosition(value); + void updateDesktopSettings({ desktopWindowControlsPosition: value }); + }, [setDesktopWindowControlsPosition]); + const handleLaunchAtLoginToggle = React.useCallback(async () => { if (!launchAtLoginSupported || isSavingLaunchAtLogin) { return; @@ -324,12 +344,54 @@ export const DesktopNetworkSettings: React.FC = () => { } }, [draftPassword, draftValue, isDirty, t]); - if (!isLocalDesktop) { + if (!isLocalDesktop && !showWindowControlsPosition) { return null; } return (
+ {showWindowControlsPosition ? ( + <> +
+

{t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}

+
+
+
+
+ {t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')} +
+
+ {WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => { + const selected = desktopWindowControlsPosition === option.id; + return ( + + ); + })} +
+
+
+ + ) : null} + + {!isLocalDesktop ? null : ( + <>

{t('settings.openchamber.desktopNetwork.title')}

@@ -502,6 +564,8 @@ export const DesktopNetworkSettings: React.FC = () => {
+ + )}
); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 984da259..f0bfcf28 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -14,7 +14,7 @@ import { DesktopNetworkSettings } from './DesktopNetworkSettings'; import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; -import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime, usesFramelessElectronChrome } from '@/lib/desktop'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import type { OpenChamberSection } from './types'; @@ -39,7 +39,7 @@ export const OpenChamberPage: React.FC = ({ section }) => const showAbout = isMobile && isWebRuntime(); const isVSCode = isVSCodeRuntime(); void runtimeEndpointEpoch; - const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive(); + const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome()); // If no section specified, show all (mobile/legacy behavior) if (!section) { @@ -153,7 +153,7 @@ const SessionsSectionContent: React.FC = () => { const isVSCode = isVSCodeRuntime(); const runtimeEndpointEpoch = useRuntimeEndpointEpoch(); void runtimeEndpointEpoch; - const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive(); + const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome()); return (
diff --git a/packages/ui/src/components/ui/AboutDialog.tsx b/packages/ui/src/components/ui/AboutDialog.tsx index 852a406b..24cbd26d 100644 --- a/packages/ui/src/components/ui/AboutDialog.tsx +++ b/packages/ui/src/components/ui/AboutDialog.tsx @@ -184,7 +184,7 @@ export const AboutDialog: React.FC = ({
state.desktopWindowControlsPosition); + + return useMemo(() => { + const usesFramelessChrome = usesFramelessElectronChrome(); + const side = resolveDesktopWindowControlsSide(preference); + return { usesFramelessChrome, side }; + }, [preference]); +} diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 07e8c4eb..4374b86b 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -38,6 +38,9 @@ export type SkillCatalogConfig = { gitIdentityId?: string; }; +export type DesktopWindowControlsPosition = 'auto' | 'left' | 'right'; +export type DesktopWindowControlsSide = 'left' | 'right'; + export type DesktopSettings = { themeId?: string; useSystemTheme?: boolean; @@ -136,6 +139,7 @@ export type DesktopSettings = { pwaAppName?: string; pwaOrientation?: 'system' | 'portrait' | 'landscape'; mobileKeyboardMode?: MobileKeyboardMode; + desktopWindowControlsPosition?: DesktopWindowControlsPosition; inputSpellcheckEnabled?: boolean; showOpenCodeUpdateNotifications?: boolean; openCodeUpdateToastDismissedVersion?: string; @@ -231,6 +235,39 @@ const getDesktopBridge = (): DesktopBridgeGlobal | null => { export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron'; +export const getElectronPlatform = (): string | null => { + if (typeof window === 'undefined') return null; + const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__; + return typeof platform === 'string' ? platform : null; +}; + +/** Width of the three in-app window control buttons (3 × w-11). */ +export const DESKTOP_WINDOW_CONTROLS_WIDTH_PX = 132; + +/** Windows and Linux use frameless windows with in-app minimize/maximize/close controls. */ +export const usesFramelessElectronChrome = (): boolean => { + if (!isElectronShell()) return false; + const platform = getElectronPlatform(); + return platform === 'win32' || platform === 'linux'; +}; + +export const getDefaultDesktopWindowControlsSide = (platform: string | null = getElectronPlatform()): DesktopWindowControlsSide => { + if (platform === 'linux') { + return 'left'; + } + return 'right'; +}; + +export const resolveDesktopWindowControlsSide = ( + preference: DesktopWindowControlsPosition | undefined, + platform: string | null = getElectronPlatform(), +): DesktopWindowControlsSide => { + if (preference === 'left' || preference === 'right') { + return preference; + } + return getDefaultDesktopWindowControlsSide(platform); +}; + export const hasDesktopInvoke = (): boolean => { return typeof getDesktopBridge()?.invoke === 'function'; }; @@ -619,13 +656,8 @@ export const checkForDesktopUpdates = async (): Promise => { return null; } - try { - const info = await invokeDesktop('desktop_check_for_updates'); - return info as UpdateInfo; - } catch (error) { - console.warn('Failed to check for updates', error); - return null; - } + const info = await invokeDesktop('desktop_check_for_updates'); + return info as UpdateInfo; }; export const downloadDesktopUpdate = async ( @@ -674,8 +706,8 @@ export const downloadDesktopUpdate = async ( await invokeDesktop('desktop_download_and_install_update'); return true; } catch (error) { - console.warn('Failed to download update', error); - return false; + // Propagate actionable updater capability / install errors to the UI store. + throw error instanceof Error ? error : new Error(String(error)); } finally { if (unlisten) { try { @@ -873,6 +905,11 @@ export const fetchDesktopInstalledApps = async ( return { apps: [], success: false, hasCache: false, isCacheStale: false }; } + // Linux desktop does not resolve installed GUI apps; skip the IPC round-trip. + if (getElectronPlatform() === 'linux') { + return { apps: [], success: true, hasCache: false, isCacheStale: false }; + } + const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : []; if (candidate.length === 0) { return { apps: [], success: true, hasCache: false, isCacheStale: false }; @@ -886,7 +923,10 @@ export const fetchDesktopInstalledApps = async ( if (!result || typeof result !== 'object') { return { apps: [], success: false, hasCache: false, isCacheStale: false }; } - const payload = result as { apps?: unknown; hasCache?: unknown; isCacheStale?: unknown }; + const payload = result as { apps?: unknown; hasCache?: unknown; isCacheStale?: unknown; supported?: unknown }; + if (payload.supported === false) { + return { apps: [], success: true, hasCache: false, isCacheStale: false }; + } if (!Array.isArray(payload.apps)) { return { apps: [], success: false, hasCache: false, isCacheStale: false }; } diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 133302e6..b9ab1e01 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -893,6 +893,12 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Failed to archive {count} session(s)', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Failed to delete {count} session(s)', 'settings.openchamber.desktopNetwork.title': 'Desktop Network Access', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Window controls position', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Choose where minimize, maximize, and close buttons appear. Auto follows your operating system.', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Window controls position', + 'settings.openchamber.desktopNetwork.option.windowControlsAuto': 'Auto', + 'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Left', + 'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Right', 'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'Start OpenChamber at login', 'settings.openchamber.desktopNetwork.field.launchAtLogin': 'Start OpenChamber when you log in', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'Starts the app in the background without opening a window. Use the desktop status icon to open it.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 1be3982f..65d80c78 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -860,6 +860,12 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "No se pudo archivar {count} sesión(es)", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "No se pudo eliminar {count} sesión(es)", "settings.openchamber.desktopNetwork.title": "Acceso de red de escritorio", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posición de los controles de ventana", + "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Elige dónde aparecen los botones de minimizar, maximizar y cerrar. Automático sigue el sistema operativo.", + "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Posición de los controles de ventana", + "settings.openchamber.desktopNetwork.option.windowControlsAuto": "Automático", + "settings.openchamber.desktopNetwork.option.windowControlsLeft": "Izquierda", + "settings.openchamber.desktopNetwork.option.windowControlsRight": "Derecha", "settings.openchamber.desktopNetwork.field.launchAtLoginAria": "Iniciar OpenChamber al iniciar sesión", "settings.openchamber.desktopNetwork.field.launchAtLogin": "Iniciar OpenChamber al iniciar sesión", "settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Inicia la app en segundo plano sin abrir una ventana. Usa el icono de estado del escritorio para abrirla.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 0355955e..5739d9e7 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -785,6 +785,12 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Échec de l\'archivage des sessions {count}', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Échec de la suppression des sessions {count}', 'settings.openchamber.desktopNetwork.title': 'Accès au réseau de bureau', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Position des contrôles de fenêtre', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Choisissez où apparaissent les boutons Réduire, Agrandir et Fermer. Auto suit le système d’exploitation.', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Position des contrôles de fenêtre', + 'settings.openchamber.desktopNetwork.option.windowControlsAuto': 'Auto', + 'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Gauche', + 'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Droite', 'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'Démarrez OpenChamber lors de la connexion', 'settings.openchamber.desktopNetwork.field.launchAtLogin': 'Démarrez OpenChamber lorsque vous vous connectez', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'Démarre l\'application en arrière-plan sans ouvrir de fenêtre. Utilisez l\'icône d\'état du bureau pour l\'ouvrir.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 8301fc9d..654f6e72 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -893,6 +893,12 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '{count} 個の Session のアーカイブに失敗しました', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '{count} 個の Session の削除に失敗しました', 'settings.openchamber.desktopNetwork.title': 'Desktop ネットワークアクセス', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'ウィンドウコントロールの位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '最小化・最大化・閉じるボタンの表示位置を選びます。自動は OS に合わせます。', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'ウィンドウコントロールの位置', + 'settings.openchamber.desktopNetwork.option.windowControlsAuto': '自動', + 'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左', + 'settings.openchamber.desktopNetwork.option.windowControlsRight': '右', 'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'ログイン時に OpenChamber を起動', 'settings.openchamber.desktopNetwork.field.launchAtLogin': 'ログイン時に OpenChamber を起動', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'ウィンドウを開かずにバックグラウンドでアプリを起動します。デスクトップのステータスアイコンから開けます。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 7d27beae..38a91de0 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -860,6 +860,12 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '세션 {count}개를 보관하지 못했습니다', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '세션 {count}개를 삭제하지 못했습니다', 'settings.openchamber.desktopNetwork.title': 'Desktop 네트워크 접속', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '창 컨트롤 위치', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '최소화, 최대화, 닫기 버튼이 표시될 위치를 선택합니다. 자동은 운영체제를 따릅니다.', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '창 컨트롤 위치', + 'settings.openchamber.desktopNetwork.option.windowControlsAuto': '자동', + 'settings.openchamber.desktopNetwork.option.windowControlsLeft': '왼쪽', + 'settings.openchamber.desktopNetwork.option.windowControlsRight': '오른쪽', 'settings.openchamber.desktopNetwork.field.launchAtLoginAria': '로그인 시 OpenChamber 시작', 'settings.openchamber.desktopNetwork.field.launchAtLogin': '로그인 시 OpenChamber 시작', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': '창을 열지 않고 백그라운드에서 앱을 시작합니다. 데스크톱 상태 아이콘으로 열 수 있습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 22afea49..7020feb6 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -737,6 +737,12 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Po restarcie otwórz z innego urządzenia: ', 'settings.openchamber.desktopNetwork.hint.openNow': 'Otwórz z innego urządzenia: ', 'settings.openchamber.desktopNetwork.title': 'Dostęp sieciowy pulpitu', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Pozycja elementów sterujących oknem', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Wybierz, gdzie mają się pojawiać przyciski minimalizacji, maksymalizacji i zamykania. Automatycznie dopasowuje się do systemu operacyjnego.', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Pozycja elementów sterujących oknem', + 'settings.openchamber.desktopNetwork.option.windowControlsAuto': 'Automatycznie', + 'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Lewo', + 'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Prawo', 'settings.openchamber.git.changesViewAria': 'Tryb widoku zmian Git', 'settings.openchamber.git.changesViewTitle': 'Widok zmian', 'settings.openchamber.git.enableGitmoji': 'Włącz wybieranie Gitmoji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index db5cd37d..6561dcdc 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -860,6 +860,12 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "Não foi possível arquivar {count} sessão(es)", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "Não foi possível excluir {count} sessão(es)", "settings.openchamber.desktopNetwork.title": "Acesso de rede do desktop", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posição dos controles da janela", + "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Escolha onde os botões de minimizar, maximizar e fechar aparecem. Automático segue o sistema operacional.", + "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Posição dos controles da janela", + "settings.openchamber.desktopNetwork.option.windowControlsAuto": "Automático", + "settings.openchamber.desktopNetwork.option.windowControlsLeft": "Esquerda", + "settings.openchamber.desktopNetwork.option.windowControlsRight": "Direita", "settings.openchamber.desktopNetwork.field.launchAtLoginAria": "Iniciar o OpenChamber ao fazer login", "settings.openchamber.desktopNetwork.field.launchAtLogin": "Iniciar o OpenChamber ao fazer login", "settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Inicia o app em segundo plano sem abrir uma janela. Use o ícone de status da área de trabalho para abrir.", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 0a2f7a91..76042f93 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -860,6 +860,12 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "Не вдалося заархівувати сесій: {count}", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "Не вдалося видалити сесій: {count}", "settings.openchamber.desktopNetwork.title": "Мережевий доступ десктопного застосунку", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Позиція елементів керування вікном", + "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Виберіть, де з’являються кнопки згортання, розгортання та закриття. Авто відповідає вашій операційній системі.", + "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Позиція елементів керування вікном", + "settings.openchamber.desktopNetwork.option.windowControlsAuto": "Авто", + "settings.openchamber.desktopNetwork.option.windowControlsLeft": "Зліва", + "settings.openchamber.desktopNetwork.option.windowControlsRight": "Справа", "settings.openchamber.desktopNetwork.field.launchAtLoginAria": "Запускати OpenChamber під час входу в систему", "settings.openchamber.desktopNetwork.field.launchAtLogin": "Запускати OpenChamber під час входу в систему", "settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Запускає застосунок у фоні без відкриття вікна. Відкрийте його через піктограму стану на робочому столі.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 02b1c4c6..d6f25171 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -860,6 +860,12 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '归档 {count} 个会话失败', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '删除 {count} 个会话失败', 'settings.openchamber.desktopNetwork.title': '桌面端网络访问', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '窗口控件位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '选择最小化、最大化和关闭按钮的显示位置。自动会按操作系统决定。', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '窗口控件位置', + 'settings.openchamber.desktopNetwork.option.windowControlsAuto': '自动', + 'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左侧', + 'settings.openchamber.desktopNetwork.option.windowControlsRight': '右侧', 'settings.openchamber.desktopNetwork.field.launchAtLoginAria': '登录时启动 OpenChamber', 'settings.openchamber.desktopNetwork.field.launchAtLogin': '登录时启动 OpenChamber', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': '在后台启动应用且不打开窗口。可通过桌面状态图标打开。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 93660216..3ead90b9 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -857,6 +857,12 @@ 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '封存 {count} 個工作階段失敗', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '刪除 {count} 個工作階段失敗', 'settings.openchamber.desktopNetwork.title': '桌面端網路存取', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '視窗控制項位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '選擇最小化、最大化和關閉按鈕的顯示位置。自動會依作業系統決定。', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '視窗控制項位置', + 'settings.openchamber.desktopNetwork.option.windowControlsAuto': '自動', + 'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左側', + 'settings.openchamber.desktopNetwork.option.windowControlsRight': '右側', 'settings.openchamber.desktopNetwork.field.allowLanAccessAria': '允許桌面 sidecar 區域網路存取', 'settings.openchamber.desktopNetwork.field.allowLanAccess': '允許你本機網路中的其他裝置開啟此應用程式', 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '會重新啟動應用程式,以便手機、平板和同一 Wi‑Fi 下的其他電腦存取。', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index a413f3a0..c23db33d 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -540,6 +540,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setWeekStartPreference(settings.weekStartPreference); } } + if (typeof settings.desktopWindowControlsPosition === 'string' + && (settings.desktopWindowControlsPosition === 'auto' || settings.desktopWindowControlsPosition === 'left' || settings.desktopWindowControlsPosition === 'right')) { + if (settings.desktopWindowControlsPosition !== store.desktopWindowControlsPosition) { + store.setDesktopWindowControlsPosition(settings.desktopWindowControlsPosition); + } + } if (typeof settings.chatRenderMode === 'string' && (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) { if (settings.chatRenderMode !== store.chatRenderMode) { @@ -1114,6 +1120,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { && (candidate.weekStartPreference === 'auto' || candidate.weekStartPreference === 'sunday' || candidate.weekStartPreference === 'monday')) { result.weekStartPreference = candidate.weekStartPreference; } + if (typeof candidate.desktopWindowControlsPosition === 'string' + && (candidate.desktopWindowControlsPosition === 'auto' || candidate.desktopWindowControlsPosition === 'left' || candidate.desktopWindowControlsPosition === 'right')) { + result.desktopWindowControlsPosition = candidate.desktopWindowControlsPosition; + } if (typeof candidate.chatRenderMode === 'string' && (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) { result.chatRenderMode = candidate.chatRenderMode; diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 3375f081..2b10834d 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -370,6 +370,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ keywords: ['desktop', 'startup', 'login'], isAvailable: (ctx) => ctx.isDesktopLocalOrigin, }, + { + id: 'sessions.desktop-window-controls-position', + page: 'sessions', + titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition', + descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription', + keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'], + isAvailable: (ctx) => ctx.isDesktop && (ctx.isWindows || !ctx.isMac), + }, { id: 'sessions.desktop-minimize-to-tray', page: 'sessions', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 3514ea39..460400a9 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -20,6 +20,7 @@ export type ActivityRenderMode = 'collapsed' | 'summary'; export type SessionRetentionAction = 'archive' | 'delete'; export type TimeFormatPreference = 'auto' | '12h' | '24h'; export type WeekStartPreference = 'auto' | 'sunday' | 'monday'; +export type DesktopWindowControlsPosition = 'auto' | 'left' | 'right'; export type FileEditorKeymap = 'default' | 'vim'; function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap { @@ -649,6 +650,7 @@ interface UIStore { showExpandedEditTools: boolean; timeFormatPreference: TimeFormatPreference; weekStartPreference: WeekStartPreference; + desktopWindowControlsPosition: DesktopWindowControlsPosition; mermaidRenderingMode: MermaidRenderingMode; userMessageRenderingMode: UserMessageRenderingMode; collapsibleUserMessages: boolean; @@ -803,6 +805,7 @@ interface UIStore { setShowExpandedEditTools: (value: boolean) => void; setTimeFormatPreference: (value: TimeFormatPreference) => void; setWeekStartPreference: (value: WeekStartPreference) => void; + setDesktopWindowControlsPosition: (value: DesktopWindowControlsPosition) => void; setMermaidRenderingMode: (value: MermaidRenderingMode) => void; setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void; setCollapsibleUserMessages: (value: boolean) => void; @@ -950,6 +953,7 @@ export const useUIStore = create()( showExpandedEditTools: false, timeFormatPreference: 'auto', weekStartPreference: 'auto', + desktopWindowControlsPosition: 'auto', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', collapsibleUserMessages: true, @@ -2100,6 +2104,9 @@ export const useUIStore = create()( setWeekStartPreference: (value) => { set({ weekStartPreference: value }); }, + setDesktopWindowControlsPosition: (value) => { + set({ desktopWindowControlsPosition: value }); + }, setMermaidRenderingMode: (value) => { set({ mermaidRenderingMode: value }); }, @@ -2357,6 +2364,7 @@ export const useUIStore = create()( showExpandedEditTools: state.showExpandedEditTools, timeFormatPreference: state.timeFormatPreference, weekStartPreference: state.weekStartPreference, + desktopWindowControlsPosition: state.desktopWindowControlsPosition, mermaidRenderingMode: state.mermaidRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode, collapsibleUserMessages: state.collapsibleUserMessages, diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 23a72d12..39c87f3e 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -1,15 +1,15 @@ # OpenChamber VS Code Extension -[![GitHub stars](https://img.shields.io/github/stars/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/btriapitsyn/openchamber/stargazers) -[![GitHub release](https://img.shields.io/github/v/release/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/btriapitsyn/openchamber/releases/latest) +[![GitHub stars](https://img.shields.io/github/stars/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/openchamber/openchamber/stargazers) +[![GitHub release](https://img.shields.io/github/v/release/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/openchamber/openchamber/releases/latest) [![Discord](https://img.shields.io/badge/Discord-join.png?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) [![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=ko-fi&logoColor=FFFCF0)](https://ko-fi.com/G2G41SAWNS) [OpenCode](https://opencode.ai) AI coding agent, right inside your editor. No tab-switching, no context loss. -![VS Code Extension](https://github.com/btriapitsyn/openchamber/raw/HEAD/packages/vscode/extension.jpg) +![VS Code Extension](https://github.com/openchamber/openchamber/raw/HEAD/packages/vscode/extension.jpg) -**Like the extension? There's also a [desktop app and web version](https://github.com/btriapitsyn/openchamber) with even more features.** +**Like the extension? There's also a [desktop app and web version](https://github.com/openchamber/openchamber) with even more features.** ## What you get diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 5c30568f..7783b151 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -7,7 +7,7 @@ "private": true, "repository": { "type": "git", - "url": "https://github.com/btriapitsyn/openchamber.git" + "url": "https://github.com/openchamber/openchamber.git" }, "qna": "https://discord.gg/ZYRSdnwwKA", "license": "MIT", diff --git a/packages/web/README.md b/packages/web/README.md index 3365f2d0..2faa2df7 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -1,17 +1,17 @@ -# @openchamber/web +# @openchamber/web -[![GitHub stars](https://img.shields.io/github/stars/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/btriapitsyn/openchamber/stargazers) -[![GitHub release](https://img.shields.io/github/v/release/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/btriapitsyn/openchamber/releases/latest) +[![GitHub stars](https://img.shields.io/github/stars/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/openchamber/openchamber/stargazers) +[![GitHub release](https://img.shields.io/github/v/release/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/openchamber/openchamber/releases/latest) [![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) Run [OpenCode](https://opencode.ai) in your browser. Install the CLI, open `localhost:3000`, done. Works on desktop browsers, tablets, and phones as a PWA. -Full project overview, screenshots, and all features: [github.com/btriapitsyn/openchamber](https://github.com/btriapitsyn/openchamber) +Full project overview, screenshots, and all features: [github.com/openchamber/openchamber](https://github.com/openchamber/openchamber) ## Install ```bash -curl -fsSL https://raw.githubusercontent.com/btriapitsyn/openchamber/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/openchamber/openchamber/main/scripts/install.sh | bash ``` Or install manually: `bun add -g @openchamber/web` (or npm, pnpm, yarn). diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index cf34c0bc..997d5b53 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -184,6 +184,12 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.desktopMinimizeToTrayEnabled === 'boolean') { result.desktopMinimizeToTrayEnabled = candidate.desktopMinimizeToTrayEnabled; } + if (typeof candidate.desktopWindowControlsPosition === 'string') { + const mode = candidate.desktopWindowControlsPosition.trim(); + if (mode === 'auto' || mode === 'left' || mode === 'right') { + result.desktopWindowControlsPosition = mode; + } + } if (candidate.permissionAutoAccept && typeof candidate.permissionAutoAccept === 'object' && !Array.isArray(candidate.permissionAutoAccept)) { const sessions = {}; const sourceSessions = candidate.permissionAutoAccept.sessions; diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 0921dcf4..9c2cd38b 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -11,7 +11,7 @@ const __dirname = path.dirname(__filename); const PACKAGE_NAME = '@openchamber/web'; const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/'); const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; -const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'; +const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md'; const GITHUB_RELEASES_URL = 'https://github.com/openchamber/openchamber/releases'; let cachedDetectedPm = null;