feat(desktop): Linux AppImage polish — window controls, updater UX, docs (#2144)

* feat(electron): add Linux AppImage releases

* ci: cache Linux OpenCode CLI artifacts

* fix(ci): await Linux release inventory check

* fix(electron): add frameless window controls on Linux desktop

Linux AppImages were created without native WM decorations and without
in-app controls, leaving users unable to close the window with a mouse.

Treat Linux like Windows: frameless BrowserWindow plus the existing
WindowsWindowControls header buttons and app-menu entry. macOS keeps
hidden title bar with traffic lights unchanged.

Shared usesFramelessElectronChrome() helper drives main window, mini
chat, header insets, and titlebar controls.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* feat(desktop): add configurable window controls position by OS

Add desktopWindowControlsPosition setting (auto/left/right) with OS-aware
defaults: Linux left, Windows right. Wire frameless chrome controls in
Header, TitlebarLeftControls, and MiniChatLayout, plus a Sessions settings
control for Windows and Linux desktop shells.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(desktop): address Linux AppImage release review findings

Propagate updater capability errors to the UI, treat missing
latest-linux.yml feeds as no-update, stop installed-apps IPC spam on
Linux, document FUSE/AppImage limits, add CHANGELOG entry, migrate
remaining btriapitsyn URLs, and run Electron Linux unit tests on PRs.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: jibanez-staticduo <staticduo@gmail.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-07-13 08:59:31 +03:00
committed by GitHub
co-authored by Serhii Dziupin jibanez-staticduo
parent 7e248d4e9b
commit 502c96630e
55 changed files with 1679 additions and 112 deletions
+7
View File
@@ -31,3 +31,10 @@ jobs:
- name: Lint - name: Lint
run: bun run 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
+178 -1
View File
@@ -359,6 +359,148 @@ jobs:
path: packages/electron/dist/latest.yml path: packages/electron/dist/latest.yml
retention-days: 1 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: combine-electron-manifests:
needs: [create-release, build-desktop-electron-macos] needs: [create-release, build-desktop-electron-macos]
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -404,12 +546,47 @@ jobs:
secrets: inherit secrets: inherit
finalize-release: 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 runs-on: ubuntu-latest
env: env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }} DISCORD_UPDATE_ROLE_ID: ${{ secrets.DISCORD_UPDATE_ROLE_ID }}
steps: 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 - name: Publish release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with: with:
+2
View File
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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 ## [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. - **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.
+16 -4
View File
@@ -3,7 +3,7 @@
## Getting Started ## Getting Started
```bash ```bash
git clone https://github.com/btriapitsyn/openchamber.git git clone https://github.com/openchamber/openchamber.git
cd openchamber cd openchamber
bun install 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 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. 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. 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). For desktop-specific details, see [`packages/electron/README.md`](./packages/electron/README.md).
### VS Code Extension ### VS Code Extension
@@ -94,7 +96,17 @@ Windows:
bun run electron:build 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 ## Before Submitting
@@ -149,4 +161,4 @@ You can still help:
## Questions? ## 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).
+23 -8
View File
@@ -1,7 +1,7 @@
# <picture><source media="(prefers-color-scheme: dark)" srcset="docs/references/badges/openchamber-logo-dark.svg"><img src="docs/references/badges/openchamber-logo-light.svg" width="32" height="32" align="absmiddle" /></picture> OpenChamber # <picture><source media="(prefers-color-scheme: dark)" srcset="docs/references/badges/openchamber-logo-dark.svg"><img src="docs/references/badges/openchamber-logo-light.svg" width="32" height="32" align="absmiddle" /></picture> 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 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/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 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) [![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) [![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) [![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 - Background notifications plus reliable cross-tab session activity tracking
- Built-in self-update + restart flow that keeps your server settings intact - 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 - Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal
- Multiple native windows for separate projects or sessions - 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). > **Prerequisite:** Desktop bundles the matching OpenCode CLI. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai).
### **Desktop (macOS + Windows)** ### **Desktop (macOS + Windows + Linux)**
Download from [Releases](https://github.com/btriapitsyn/openchamber/releases).
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 <downloaded-appimage>`. 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** ### **VS Code**
Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions. 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+_ _requires Node.js 22+_
```bash ```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 openchamber --ui-password be-creative-here
``` ```
@@ -352,7 +368,7 @@ chown -R 1000:1000 data/
</details> </details>
<details> <details>
<summary><strong>Desktop (macOS + Windows)</strong></summary> <summary><strong>Desktop (macOS + Windows + Linux)</strong></summary>
- Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal - Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal
- Multiple native windows for separate projects or sessions - 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: Active development. Here's what's being worked on or planned:
- Linux desktop app
- Mobile app with remote instance and laptop connectivity - Mobile app with remote instance and laptop connectivity
- More built-in tunneling options - More built-in tunneling options
- Kanban board for multi-agent management - keeping the human in the loop and in control - Kanban board for multi-agent management - keeping the human in the loop and in control
+16 -3
View File
@@ -1,6 +1,6 @@
# OpenChamber Desktop # 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`. 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`. 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 ## 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. 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 ## 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_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` |
| `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server | | `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_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_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server |
| `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead | | `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 | | `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally |
+34 -31
View File
@@ -14,6 +14,9 @@ import { ElectronSshManager } from './ssh-manager.mjs';
import { createTrayController } from './tray.mjs'; import { createTrayController } from './tray.mjs';
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.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'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
const execFileAsync = promisify(execFile); 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 // Set the product name early so electron-log derives its log directory as
// ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/). // ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/).
app.setName('OpenChamber'); app.setName('OpenChamber');
if (process.platform === 'linux') {
app.setDesktopName('openchamber.desktop');
}
if (isDev) { if (isDev) {
app.setPath('userData', path.join(app.getPath('appData'), 'OpenChamber Dev')); app.setPath('userData', path.join(app.getPath('appData'), 'OpenChamber Dev'));
} }
@@ -2182,12 +2188,11 @@ const readThemeSource = () => {
}; };
const getWindowIconPath = () => { const getWindowIconPath = () => {
if (process.platform !== 'win32' && process.platform !== 'linux') { if (process.platform !== 'win32' && process.platform !== 'linux') return undefined;
return undefined; const iconFileName = process.platform === 'linux' ? 'icon.png' : 'icon.ico';
}
const iconPath = isDev const iconPath = isDev
? path.join(__dirname, 'resources', 'icons', 'icon.ico') ? path.join(__dirname, 'resources', 'icons', iconFileName)
: path.join(process.resourcesPath, 'icons', 'icon.ico'); : path.join(process.resourcesPath, 'icons', iconFileName);
return fs.existsSync(iconPath) ? iconPath : undefined; return fs.existsSync(iconPath) ? iconPath : undefined;
}; };
@@ -2209,7 +2214,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {}; const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {};
const desktopHome = os.homedir() || ''; const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion()); 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). // macOS vibrancy, on by default; users can disable it (Appearance settings).
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false; const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
const titleBarOverlayEnabled = 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 // here: setting it in the constructor leaves the material uncomposited on a
// cold launch until a window event. No `transparent: true` either — vibrancy // cold launch until a window event. No `transparent: true` either — vibrancy
// alone is enough and composites reliably once applied to a live window. // alone is enough and composites reliably once applied to a live window.
frame: process.platform === 'win32' ? false : undefined, frame: usesFramelessChrome ? false : undefined,
autoHideMenuBar: autoHidesNativeMenuBar, autoHideMenuBar: autoHidesNativeMenuBar,
// Electron's hiddenInset adds its own extra inset, which leaves the controls // 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. // 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 desktopRequestHeaders = effectiveRuntimeConfig.requestHeaders || {};
const desktopHome = os.homedir() || ''; const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion()); const desktopMacosMajor = String(macosMajorVersion());
const usesFramelessChrome = process.platform === 'win32' || process.platform === 'linux';
// macOS vibrancy, on by default; users can disable it (Appearance settings). // macOS vibrancy, on by default; users can disable it (Appearance settings).
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false; const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
const browserWindow = new BrowserWindow({ 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 // here: setting it in the constructor leaves the material uncomposited on a
// cold launch until a window event. No `transparent: true` either — vibrancy // cold launch until a window event. No `transparent: true` either — vibrancy
// alone is enough and composites reliably once applied to a live window. // 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', 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, trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined,
webPreferences: { webPreferences: {
additionalArguments: [ additionalArguments: [
@@ -2819,10 +2826,6 @@ const compareSemver = (left, right) => {
return 0; return 0;
}; };
const parseGithubRepo = () => {
return { owner: 'openchamber', repo: 'openchamber' };
};
const setupAutoUpdater = () => { const setupAutoUpdater = () => {
if (!app.isPackaged) { if (!app.isPackaged) {
return; return;
@@ -2834,11 +2837,13 @@ const setupAutoUpdater = () => {
autoUpdater.disableWebInstaller = false; autoUpdater.disableWebInstaller = false;
autoUpdater.logger = log; autoUpdater.logger = log;
const { owner, repo } = parseGithubRepo(); const testBuild = typeof __OPENCHAMBER_UPDATER_E2E_BUILD__ !== 'undefined'
autoUpdater.setFeedURL({ && __OPENCHAMBER_UPDATER_E2E_BUILD__ === true;
provider: 'github', const feed = resolveUpdaterFeed({ testBuild });
owner, autoUpdater.setFeedURL(feed);
repo, log.info('[electron] updater feed configured', {
provider: feed.provider,
target: feed.provider === 'github' ? `${feed.owner}/${feed.repo}` : feed.url,
}); });
autoUpdater.on('download-progress', (progress) => { autoUpdater.on('download-progress', (progress) => {
@@ -3798,7 +3803,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
emitToAllWindows('openchamber:installed-apps-updated', apps); emitToAllWindows('openchamber:installed-apps-updated', apps);
}; };
if (process.platform !== 'darwin' && process.platform !== 'win32') { 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) { if (!hasCache || isCacheStale || args.force === true) {
void refresh(); void refresh();
@@ -3901,22 +3906,18 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
case 'desktop_check_for_updates': { case 'desktop_check_for_updates': {
assertUpdaterCapability({ packaged: app.isPackaged });
const currentVersion = APP_VERSION; const currentVersion = APP_VERSION;
let updateResult = null; const { available, updateInfo, updateResult, nextVersion, pendingUpdate } = await checkForDesktopUpdate({
try { autoUpdater,
updateResult = await autoUpdater.checkForUpdates(); currentVersion,
} catch { pendingUpdate: state.pendingUpdate,
} compareVersions: compareSemver,
});
const updateInfo = updateResult?.updateInfo;
const nextVersion =
(typeof updateInfo?.version === 'string' && updateInfo.version) ||
currentVersion;
const available = compareSemver(nextVersion, currentVersion) > 0;
const body = const body =
(typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) || (typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) ||
await parseRelevantChangelogNotes(currentVersion, nextVersion); await parseRelevantChangelogNotes(currentVersion, nextVersion);
state.pendingUpdate = available ? { version: nextVersion, electronUpdate: updateResult } : null; state.pendingUpdate = pendingUpdate;
return { return {
available, available,
currentVersion, currentVersion,
@@ -3929,6 +3930,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
case 'desktop_download_and_install_update': case 'desktop_download_and_install_update':
assertUpdaterCapability({ packaged: app.isPackaged });
if (!state.pendingUpdate) { if (!state.pendingUpdate) {
throw new Error('No pending update'); throw new Error('No pending update');
} }
@@ -3974,6 +3976,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
case 'desktop_restart': { case 'desktop_restart': {
const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged); const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged);
if (applyUpdate) assertUpdaterCapability({ packaged: app.isPackaged });
log.info(`[electron] desktop_restart applyUpdate=${applyUpdate} packaged=${app.isPackaged}`); log.info(`[electron] desktop_restart applyUpdate=${applyUpdate} packaged=${app.isPackaged}`);
if (applyUpdate && process.platform === 'darwin' && typeof app.isInApplicationsFolder === 'function') { if (applyUpdate && process.platform === 'darwin' && typeof app.isInApplicationsFolder === 'function') {
try { try {
+28 -1
View File
@@ -21,7 +21,8 @@
"Electron runtime dependencies installed via bun install", "Electron runtime dependencies installed via bun install",
"Bun available for sidecar compilation", "Bun available for sidecar compilation",
"macOS: Xcode + build tools for notarized packaging", "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": { "scripts": {
"dev": "node ./scripts/electron-dev.mjs", "dev": "node ./scripts/electron-dev.mjs",
@@ -30,9 +31,14 @@
"prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs", "prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs",
"verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged", "verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged",
"verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged", "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", "bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs", "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", "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", "finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs",
"type-check": "node --check ./main.mjs && node --check ./preload.mjs", "type-check": "node --check ./main.mjs && node --check ./preload.mjs",
@@ -54,6 +60,10 @@
"from": "resources/icons/icon.ico", "from": "resources/icons/icon.ico",
"to": "icons/icon.ico" "to": "icons/icon.ico"
}, },
{
"from": "resources/icons/icon.png",
"to": "icons/icon.png"
},
{ {
"from": "resources/icons/tray", "from": "resources/icons/tray",
"to": "icons/tray" "to": "icons/tray"
@@ -95,6 +105,23 @@
"verifyUpdateCodeSignature": false, "verifyUpdateCodeSignature": false,
"artifactName": "${productName}-${version}-win-${arch}.${ext}" "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": { "nsis": {
"oneClick": true, "oneClick": true,
"perMachine": false, "perMachine": false,
+5 -1
View File
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const updaterE2eBuild = process.env.OPENCHAMBER_UPDATER_E2E_BUILD === '1';
const result = await Bun.build({ const result = await Bun.build({
entrypoints: [path.join(root, 'main.mjs')], entrypoints: [path.join(root, 'main.mjs')],
@@ -34,6 +35,9 @@ const result = await Bun.build({
minify: false, minify: false,
sourcemap: 'none', sourcemap: 'none',
naming: '[name].mjs', naming: '[name].mjs',
define: {
__OPENCHAMBER_UPDATER_E2E_BUILD__: updaterE2eBuild ? 'true' : 'false',
},
}); });
if (!result.success) { if (!result.success) {
@@ -41,4 +45,4 @@ if (!result.success) {
process.exit(1); 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})`);
@@ -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 macX64 = await read('latest-yml-x86_64-apple-darwin', 'latest-mac.yml');
const macArm64 = await read('latest-yml-aarch64-apple-darwin', 'latest-mac.yml'); const macArm64 = await read('latest-yml-aarch64-apple-darwin', 'latest-mac.yml');
if (macX64 || macArm64) { if (macX64 || macArm64) {
+10 -1
View File
@@ -1,8 +1,11 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { resolveTargetArchitecture } from './target-architecture.mjs';
const env = { ...process.env }; 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) { if (process.platform === 'win32' && !env.CSC_LINK && !env.WINDOWS_CSC_LINK) {
env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'; env.CSC_IDENTITY_AUTO_DISCOVERY = 'false';
@@ -22,7 +25,13 @@ const bunBinary = bunBinaryCandidates.find((candidate) => {
return false; return false;
}) || (process.platform === 'win32' ? 'bun.exe' : 'bun'); }) || (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, env,
stdio: 'inherit', stdio: 'inherit',
}); });
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveTargetArchitecture } from './target-architecture.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const electronRoot = path.resolve(__dirname, '..'); const electronRoot = path.resolve(__dirname, '..');
@@ -39,8 +40,8 @@ const readPinnedSdkVersion = () => {
return trimmed; return trimmed;
}; };
const artifactForCurrentPlatform = () => { const artifactForPlatform = (platform, targetArchitecture) => {
const { platform, arch } = process; const arch = targetArchitecture.opencode;
if (platform === 'darwin') { if (platform === 'darwin') {
if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' }; if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' };
if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.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}`); 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 outputBinary = outputBinaryPath(artifact.binary);
const existingVersion = readBinaryVersion(outputBinary); const existingVersion = readBinaryVersion(outputBinary);
if (existingVersion === version) { if (existingVersion === version) {
@@ -142,7 +144,7 @@ const main = async () => {
return; 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 archivePath = path.join(cacheDir, artifact.name);
const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`; const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`;
if (!fs.existsSync(archivePath)) { if (!fs.existsSync(archivePath)) {
+3 -1
View File
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { rebuild } from '@electron/rebuild'; import { rebuild } from '@electron/rebuild';
import { resolveTargetArchitecture } from './target-architecture.mjs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -16,6 +17,7 @@ const require = createRequire(import.meta.url);
const electronPkg = require('electron/package.json'); const electronPkg = require('electron/package.json');
const electronVersion = electronPkg.version; const electronVersion = electronPkg.version;
const targetArchitecture = resolveTargetArchitecture();
const copyDirectory = async (src, dst) => { const copyDirectory = async (src, dst) => {
await fsp.mkdir(dst, { recursive: true }); await fsp.mkdir(dst, { recursive: true });
@@ -142,7 +144,7 @@ try {
buildPath: rebuildPath.buildPath, buildPath: rebuildPath.buildPath,
electronVersion, electronVersion,
force: true, force: true,
arch: process.env.ELECTRON_BUILDER_ARCH || process.arch, arch: targetArchitecture.electronBuilder,
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'], onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
}); });
} finally { } finally {
@@ -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;
};
@@ -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');
});
@@ -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.
@@ -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 <x64|arm64> --next <N+1.AppImage> --version <N+1> --dir <feed-dir>
updater-e2e-fixture.mjs serve --dir <feed-dir> [--port <port>]
updater-e2e-fixture.mjs run --arch <x64|arm64> --current <N.AppImage> --next <N+1.AppImage> --version <N+1> --dir <feed-dir> [--port <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;
});
}
@@ -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 });
}
});
@@ -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);
}
}
@@ -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 });
}
});
@@ -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 <manifest> <artifact> <version>');
}
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);
}
}
@@ -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 });
}
});
+35
View File
@@ -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.`,
);
}
};
@@ -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: () => {},
}));
});
+42
View File
@@ -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,
};
};
+47
View File
@@ -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);
});
+47
View File
@@ -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 };
};
+74
View File
@@ -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);
}
});
@@ -4,12 +4,17 @@ import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { invokeDesktop } from '@/lib/desktop'; import { invokeDesktop } from '@/lib/desktop';
import type { DesktopWindowControlsSide } from '@/lib/desktop';
type WindowsWindowControlsProps = { type WindowsWindowControlsProps = {
visible: boolean; 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 { t } = useI18n();
const [isMaximized, setIsMaximized] = React.useState(false); 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 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 ( return (
<div className="app-region-no-drag -mr-3 ml-2 flex h-12 shrink-0 items-center" aria-label={t('header.windowControls.groupAria')}> <div className={containerClassName} aria-label={t('header.windowControls.groupAria')}>
<button <button
type="button" type="button"
className={buttonClassName} className={buttonClassName}
+7 -11
View File
@@ -31,6 +31,7 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { UpdateDialog } from '@/components/ui/UpdateDialog';
@@ -789,12 +790,7 @@ export const Header: React.FC<HeaderProps> = ({
return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
}, []); }, []);
const isWindowsElectronDesktop = React.useMemo(() => { const { usesFramelessChrome, side: windowControlsSide } = useDesktopWindowControlsLayout();
if (typeof window === 'undefined') {
return false;
}
return Boolean(window.__OPENCHAMBER_ELECTRON__) && window.__OPENCHAMBER_PLATFORM__ === 'win32';
}, []);
const macosMajorVersion = React.useMemo(() => { const macosMajorVersion = React.useMemo(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -1598,11 +1594,11 @@ export const Header: React.FC<HeaderProps> = ({
if (isTabletStandalonePwa) { if (isTabletStandalonePwa) {
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)'; return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)';
} }
if ((!isDesktopApp || isWindowsElectronDesktop) && !isVSCode) { if ((!isDesktopApp || usesFramelessChrome) && !isVSCode) {
return 'calc(0.75rem + var(--oc-wco-left-inset, 0px))'; return 'calc(0.75rem + var(--oc-wco-left-inset, 0px))';
} }
return '0.75rem'; return '0.75rem';
}, [isDesktopApp, isDesktopWindowFullscreen, isMacPlatform, isTabletStandalonePwa, isVSCode, isWindowsElectronDesktop]); }, [isDesktopApp, isDesktopWindowFullscreen, isMacPlatform, isTabletStandalonePwa, isVSCode, usesFramelessChrome]);
useEffect(() => { useEffect(() => {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
@@ -1671,7 +1667,7 @@ export const Header: React.FC<HeaderProps> = ({
}, [isDesktopApp, isMacPlatform, macosMajorVersion]); }, [isDesktopApp, isMacPlatform, macosMajorVersion]);
const webWindowControlsOverlayStyle = React.useMemo<React.CSSProperties | undefined>(() => { const webWindowControlsOverlayStyle = React.useMemo<React.CSSProperties | undefined>(() => {
if ((isDesktopApp && !isWindowsElectronDesktop) || isVSCode) { if ((isDesktopApp && !usesFramelessChrome) || isVSCode) {
return undefined; return undefined;
} }
@@ -1682,7 +1678,7 @@ export const Header: React.FC<HeaderProps> = ({
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
}; };
}, [isDesktopApp, isVSCode, isWindowsElectronDesktop]); }, [isDesktopApp, isVSCode, usesFramelessChrome]);
const updateHeaderHeight = React.useCallback(() => { const updateHeaderHeight = React.useCallback(() => {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
@@ -2217,7 +2213,7 @@ export const Header: React.FC<HeaderProps> = ({
Icon={'picture-in-picture-2'} Icon={'picture-in-picture-2'}
/> />
{desktopSidebarActions} {desktopSidebarActions}
<WindowsWindowControls visible={isWindowsElectronDesktop} /> <WindowsWindowControls visible={usesFramelessChrome && windowControlsSide === 'right'} position="right" />
</div> </div>
</div> </div>
</div> </div>
@@ -6,8 +6,10 @@ import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useProjectActionsContext } from '@/hooks/useProjectActionsContext'; import { useProjectActionsContext } from '@/hooks/useProjectActionsContext';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { invokeDesktop } from '@/lib/desktop'; import { invokeDesktop } from '@/lib/desktop';
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
const ICON_BUTTON_CLASS = 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'; '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<HTMLDivElement | null>(null); const clusterRef = React.useRef<HTMLDivElement | null>(null);
const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides)); const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides));
const isWindowsElectronDesktop = React.useMemo(() => { const { usesFramelessChrome, side: windowControlsSide } = useDesktopWindowControlsLayout();
if (typeof window === 'undefined') {
return false;
}
return Boolean(window.__OPENCHAMBER_ELECTRON__) && window.__OPENCHAMBER_PLATFORM__ === 'win32';
}, []);
const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => { const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
@@ -88,7 +85,11 @@ export const TitlebarLeftControls: React.FC = () => {
}} }}
> >
<div ref={clusterRef} className="flex items-center gap-2"> <div ref={clusterRef} className="flex items-center gap-2">
{isWindowsElectronDesktop ? ( {usesFramelessChrome && windowControlsSide === 'left' ? (
<WindowsWindowControls visible position="left" />
) : null}
{usesFramelessChrome ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@@ -8,6 +8,7 @@ import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDro
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { invokeDesktop, isElectronShell } from '@/lib/desktop'; import { invokeDesktop, isElectronShell } from '@/lib/desktop';
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { useSessionMessages, useSessions } from '@/sync/sync-context'; import { useSessionMessages, useSessions } from '@/sync/sync-context';
@@ -69,9 +70,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const [pinned, setPinned] = React.useState(false); const [pinned, setPinned] = React.useState(false);
const macosMajor = typeof window !== 'undefined' ? window.__OPENCHAMBER_MACOS_MAJOR__ ?? 0 : 0; const macosMajor = typeof window !== 'undefined' ? window.__OPENCHAMBER_MACOS_MAJOR__ ?? 0 : 0;
const hasMacTrafficLights = Number.isFinite(macosMajor) && macosMajor > 0; const hasMacTrafficLights = Number.isFinite(macosMajor) && macosMajor > 0;
const isWindowsElectronDesktop = typeof window !== 'undefined' const { usesFramelessChrome, side: windowControlsSide } = useDesktopWindowControlsLayout();
&& Boolean(window.__OPENCHAMBER_ELECTRON__)
&& window.__OPENCHAMBER_PLATFORM__ === 'win32';
const macosHeaderSizeClass = hasMacTrafficLights const macosHeaderSizeClass = hasMacTrafficLights
? macosMajor >= 26 ? macosMajor >= 26
? 'h-12' ? 'h-12'
@@ -256,10 +255,13 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
className={cn( className={cn(
'flex items-center gap-3 bg-background pr-3', 'flex items-center gap-3 bg-background pr-3',
hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3', hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3',
isWindowsElectronDesktop ? 'h-12' : macosHeaderSizeClass || 'min-h-14', usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
)} )}
style={dragRegionStyle} style={dragRegionStyle}
> >
{usesFramelessChrome && windowControlsSide === 'left' ? (
<WindowsWindowControls visible position="left" />
) : null}
<SessionSwitcherDropdown> <SessionSwitcherDropdown>
<button <button
type="button" type="button"
@@ -318,7 +320,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
> >
<Icon name="external-link" className="h-4 w-4" /> <Icon name="external-link" className="h-4 w-4" />
</Button> </Button>
<WindowsWindowControls visible={isWindowsElectronDesktop} /> <WindowsWindowControls visible={usesFramelessChrome && windowControlsSide === 'right'} position="right" />
</header> </header>
); );
}; };
@@ -14,14 +14,29 @@ import {
setDesktopKeepAwake, setDesktopKeepAwake,
setDesktopLaunchAtLogin, setDesktopLaunchAtLogin,
setDesktopMinimizeToTray, setDesktopMinimizeToTray,
usesFramelessElectronChrome,
type DesktopWindowControlsPosition,
} from '@/lib/desktop'; } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { updateDesktopSettings } from '@/lib/persistence';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; 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 = () => { export const DesktopNetworkSettings: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
const isLocalDesktop = isDesktopShell() && isDesktopLocalOriginActive(); 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 [savedValue, setSavedValue] = React.useState(false);
const [draftValue, setDraftValue] = React.useState(false); const [draftValue, setDraftValue] = React.useState(false);
const [savedPassword, setSavedPassword] = React.useState(''); 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 () => { const handleLaunchAtLoginToggle = React.useCallback(async () => {
if (!launchAtLoginSupported || isSavingLaunchAtLogin) { if (!launchAtLoginSupported || isSavingLaunchAtLogin) {
return; return;
@@ -324,12 +344,54 @@ export const DesktopNetworkSettings: React.FC = () => {
} }
}, [draftPassword, draftValue, isDirty, t]); }, [draftPassword, draftValue, isDirty, t]);
if (!isLocalDesktop) { if (!isLocalDesktop && !showWindowControlsPosition) {
return null; return null;
} }
return ( return (
<div className="mb-8"> <div className="mb-8">
{showWindowControlsPosition ? (
<>
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}</h3>
</div>
<section className="space-y-2 px-2 pb-2 pt-0">
<div data-settings-item="sessions.desktop-window-controls-position" className="space-y-1 py-1.5">
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
</div>
<div
className="mt-1 flex flex-wrap items-center gap-1"
role="group"
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
>
{WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => {
const selected = desktopWindowControlsPosition === option.id;
return (
<Button
key={option.id}
type="button"
variant="outline"
size="xs"
className={cn(
'!font-normal',
selected ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10' : 'text-foreground',
)}
aria-pressed={selected}
onClick={() => handleWindowControlsPositionChange(option.id)}
>
{tUnsafe(option.labelKey)}
</Button>
);
})}
</div>
</div>
</section>
</>
) : null}
{!isLocalDesktop ? null : (
<>
<div className="mb-1 px-1"> <div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.title')}</h3> <h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.title')}</h3>
</div> </div>
@@ -502,6 +564,8 @@ export const DesktopNetworkSettings: React.FC = () => {
</Button> </Button>
</div> </div>
</section> </section>
</>
)}
</div> </div>
); );
}; };
@@ -14,7 +14,7 @@ import { DesktopNetworkSettings } from './DesktopNetworkSettings';
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings'; import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device'; 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 { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import type { OpenChamberSection } from './types'; import type { OpenChamberSection } from './types';
@@ -39,7 +39,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
const showAbout = isMobile && isWebRuntime(); const showAbout = isMobile && isWebRuntime();
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
void runtimeEndpointEpoch; void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive(); const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome());
// If no section specified, show all (mobile/legacy behavior) // If no section specified, show all (mobile/legacy behavior)
if (!section) { if (!section) {
@@ -153,7 +153,7 @@ const SessionsSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch(); const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
void runtimeEndpointEpoch; void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive(); const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome());
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<DefaultsSettings /> <DefaultsSettings />
@@ -184,7 +184,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
<div className="flex flex-col items-center gap-2 pt-2"> <div className="flex flex-col items-center gap-2 pt-2">
<div className="flex items-center justify-center gap-4"> <div className="flex items-center justify-center gap-4">
<a <a
href="https://github.com/btriapitsyn/openchamber" href="https://github.com/openchamber/openchamber"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors" className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
@@ -0,0 +1,21 @@
import { useMemo } from 'react';
import {
resolveDesktopWindowControlsSide,
usesFramelessElectronChrome,
type DesktopWindowControlsSide,
} from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
export function useDesktopWindowControlsLayout(): {
usesFramelessChrome: boolean;
side: DesktopWindowControlsSide;
} {
const preference = useUIStore((state) => state.desktopWindowControlsPosition);
return useMemo(() => {
const usesFramelessChrome = usesFramelessElectronChrome();
const side = resolveDesktopWindowControlsSide(preference);
return { usesFramelessChrome, side };
}, [preference]);
}
+50 -10
View File
@@ -38,6 +38,9 @@ export type SkillCatalogConfig = {
gitIdentityId?: string; gitIdentityId?: string;
}; };
export type DesktopWindowControlsPosition = 'auto' | 'left' | 'right';
export type DesktopWindowControlsSide = 'left' | 'right';
export type DesktopSettings = { export type DesktopSettings = {
themeId?: string; themeId?: string;
useSystemTheme?: boolean; useSystemTheme?: boolean;
@@ -136,6 +139,7 @@ export type DesktopSettings = {
pwaAppName?: string; pwaAppName?: string;
pwaOrientation?: 'system' | 'portrait' | 'landscape'; pwaOrientation?: 'system' | 'portrait' | 'landscape';
mobileKeyboardMode?: MobileKeyboardMode; mobileKeyboardMode?: MobileKeyboardMode;
desktopWindowControlsPosition?: DesktopWindowControlsPosition;
inputSpellcheckEnabled?: boolean; inputSpellcheckEnabled?: boolean;
showOpenCodeUpdateNotifications?: boolean; showOpenCodeUpdateNotifications?: boolean;
openCodeUpdateToastDismissedVersion?: string; openCodeUpdateToastDismissedVersion?: string;
@@ -231,6 +235,39 @@ const getDesktopBridge = (): DesktopBridgeGlobal | null => {
export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron'; 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 => { export const hasDesktopInvoke = (): boolean => {
return typeof getDesktopBridge()?.invoke === 'function'; return typeof getDesktopBridge()?.invoke === 'function';
}; };
@@ -619,13 +656,8 @@ export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
return null; return null;
} }
try { const info = await invokeDesktop<UpdateInfo>('desktop_check_for_updates');
const info = await invokeDesktop<UpdateInfo>('desktop_check_for_updates'); return info as UpdateInfo;
return info as UpdateInfo;
} catch (error) {
console.warn('Failed to check for updates', error);
return null;
}
}; };
export const downloadDesktopUpdate = async ( export const downloadDesktopUpdate = async (
@@ -674,8 +706,8 @@ export const downloadDesktopUpdate = async (
await invokeDesktop('desktop_download_and_install_update'); await invokeDesktop('desktop_download_and_install_update');
return true; return true;
} catch (error) { } catch (error) {
console.warn('Failed to download update', error); // Propagate actionable updater capability / install errors to the UI store.
return false; throw error instanceof Error ? error : new Error(String(error));
} finally { } finally {
if (unlisten) { if (unlisten) {
try { try {
@@ -873,6 +905,11 @@ export const fetchDesktopInstalledApps = async (
return { apps: [], success: false, hasCache: false, isCacheStale: false }; 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') : []; const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : [];
if (candidate.length === 0) { if (candidate.length === 0) {
return { apps: [], success: true, hasCache: false, isCacheStale: false }; return { apps: [], success: true, hasCache: false, isCacheStale: false };
@@ -886,7 +923,10 @@ export const fetchDesktopInstalledApps = async (
if (!result || typeof result !== 'object') { if (!result || typeof result !== 'object') {
return { apps: [], success: false, hasCache: false, isCacheStale: false }; 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)) { if (!Array.isArray(payload.apps)) {
return { apps: [], success: false, hasCache: false, isCacheStale: false }; return { apps: [], success: false, hasCache: false, isCacheStale: false };
} }
@@ -893,6 +893,12 @@ export const settingsDict = {
'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Failed to archive {count} session(s)', 'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Failed to archive {count} session(s)',
'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Failed to delete {count} session(s)', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Failed to delete {count} session(s)',
'settings.openchamber.desktopNetwork.title': 'Desktop Network Access', '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.launchAtLoginAria': 'Start OpenChamber at login',
'settings.openchamber.desktopNetwork.field.launchAtLogin': 'Start OpenChamber when you log in', '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.', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'Starts the app in the background without opening a window. Use the desktop status icon to open it.',
@@ -860,6 +860,12 @@ export const settingsDict = {
"settings.openchamber.sessionRetention.toast.failedArchiveCount": "No se pudo archivar {count} sesión(es)", "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.sessionRetention.toast.failedDeleteCount": "No se pudo eliminar {count} sesión(es)",
"settings.openchamber.desktopNetwork.title": "Acceso de red de escritorio", "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.launchAtLoginAria": "Iniciar OpenChamber al iniciar sesión",
"settings.openchamber.desktopNetwork.field.launchAtLogin": "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.", "settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Inicia la app en segundo plano sin abrir una ventana. Usa el icono de estado del escritorio para abrirla.",
@@ -785,6 +785,12 @@ export const settingsDict = {
'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Échec de l\'archivage des sessions {count}', '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.sessionRetention.toast.failedDeleteCount': 'Échec de la suppression des sessions {count}',
'settings.openchamber.desktopNetwork.title': 'Accès au réseau de bureau', '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.launchAtLoginAria': 'Démarrez OpenChamber lors de la connexion',
'settings.openchamber.desktopNetwork.field.launchAtLogin': 'Démarrez OpenChamber lorsque vous vous connectez', '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.', '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.',
@@ -893,6 +893,12 @@ export const settingsDict = {
'settings.openchamber.sessionRetention.toast.failedArchiveCount': '{count} 個の Session のアーカイブに失敗しました', 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '{count} 個の Session のアーカイブに失敗しました',
'settings.openchamber.sessionRetention.toast.failedDeleteCount': '{count} 個の Session の削除に失敗しました', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '{count} 個の Session の削除に失敗しました',
'settings.openchamber.desktopNetwork.title': 'Desktop ネットワークアクセス', '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.launchAtLoginAria': 'ログイン時に OpenChamber を起動',
'settings.openchamber.desktopNetwork.field.launchAtLogin': 'ログイン時に OpenChamber を起動', 'settings.openchamber.desktopNetwork.field.launchAtLogin': 'ログイン時に OpenChamber を起動',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'ウィンドウを開かずにバックグラウンドでアプリを起動します。デスクトップのステータスアイコンから開けます。', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'ウィンドウを開かずにバックグラウンドでアプリを起動します。デスクトップのステータスアイコンから開けます。',
@@ -860,6 +860,12 @@ export const settingsDict = {
'settings.openchamber.sessionRetention.toast.failedArchiveCount': '세션 {count}개를 보관하지 못했습니다', 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '세션 {count}개를 보관하지 못했습니다',
'settings.openchamber.sessionRetention.toast.failedDeleteCount': '세션 {count}개를 삭제하지 못했습니다', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '세션 {count}개를 삭제하지 못했습니다',
'settings.openchamber.desktopNetwork.title': 'Desktop 네트워크 접속', '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.launchAtLoginAria': '로그인 시 OpenChamber 시작',
'settings.openchamber.desktopNetwork.field.launchAtLogin': '로그인 시 OpenChamber 시작', 'settings.openchamber.desktopNetwork.field.launchAtLogin': '로그인 시 OpenChamber 시작',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': '창을 열지 않고 백그라운드에서 앱을 시작합니다. 데스크톱 상태 아이콘으로 열 수 있습니다.', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': '창을 열지 않고 백그라운드에서 앱을 시작합니다. 데스크톱 상태 아이콘으로 열 수 있습니다.',
@@ -737,6 +737,12 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Po restarcie otwórz z innego urządzenia: ', '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.hint.openNow': 'Otwórz z innego urządzenia: ',
'settings.openchamber.desktopNetwork.title': 'Dostęp sieciowy pulpitu', '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.changesViewAria': 'Tryb widoku zmian Git',
'settings.openchamber.git.changesViewTitle': 'Widok zmian', 'settings.openchamber.git.changesViewTitle': 'Widok zmian',
'settings.openchamber.git.enableGitmoji': 'Włącz wybieranie Gitmoji', 'settings.openchamber.git.enableGitmoji': 'Włącz wybieranie Gitmoji',
@@ -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.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.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.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.launchAtLoginAria": "Iniciar o OpenChamber ao fazer login",
"settings.openchamber.desktopNetwork.field.launchAtLogin": "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.", "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.",
@@ -860,6 +860,12 @@ export const settingsDict = {
"settings.openchamber.sessionRetention.toast.failedArchiveCount": "Не вдалося заархівувати сесій: {count}", "settings.openchamber.sessionRetention.toast.failedArchiveCount": "Не вдалося заархівувати сесій: {count}",
"settings.openchamber.sessionRetention.toast.failedDeleteCount": "Не вдалося видалити сесій: {count}", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "Не вдалося видалити сесій: {count}",
"settings.openchamber.desktopNetwork.title": "Мережевий доступ десктопного застосунку", "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.launchAtLoginAria": "Запускати OpenChamber під час входу в систему",
"settings.openchamber.desktopNetwork.field.launchAtLogin": "Запускати OpenChamber під час входу в систему", "settings.openchamber.desktopNetwork.field.launchAtLogin": "Запускати OpenChamber під час входу в систему",
"settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Запускає застосунок у фоні без відкриття вікна. Відкрийте його через піктограму стану на робочому столі.", "settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Запускає застосунок у фоні без відкриття вікна. Відкрийте його через піктограму стану на робочому столі.",
@@ -860,6 +860,12 @@ export const settingsDict = {
'settings.openchamber.sessionRetention.toast.failedArchiveCount': '归档 {count} 个会话失败', 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '归档 {count} 个会话失败',
'settings.openchamber.sessionRetention.toast.failedDeleteCount': '删除 {count} 个会话失败', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '删除 {count} 个会话失败',
'settings.openchamber.desktopNetwork.title': '桌面端网络访问', '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.launchAtLoginAria': '登录时启动 OpenChamber',
'settings.openchamber.desktopNetwork.field.launchAtLogin': '登录时启动 OpenChamber', 'settings.openchamber.desktopNetwork.field.launchAtLogin': '登录时启动 OpenChamber',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': '在后台启动应用且不打开窗口。可通过桌面状态图标打开。', 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': '在后台启动应用且不打开窗口。可通过桌面状态图标打开。',
@@ -857,6 +857,12 @@
'settings.openchamber.sessionRetention.toast.failedArchiveCount': '封存 {count} 個工作階段失敗', 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '封存 {count} 個工作階段失敗',
'settings.openchamber.sessionRetention.toast.failedDeleteCount': '刪除 {count} 個工作階段失敗', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '刪除 {count} 個工作階段失敗',
'settings.openchamber.desktopNetwork.title': '桌面端網路存取', '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.allowLanAccessAria': '允許桌面 sidecar 區域網路存取',
'settings.openchamber.desktopNetwork.field.allowLanAccess': '允許你本機網路中的其他裝置開啟此應用程式', 'settings.openchamber.desktopNetwork.field.allowLanAccess': '允許你本機網路中的其他裝置開啟此應用程式',
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '會重新啟動應用程式,以便手機、平板和同一 Wi‑Fi 下的其他電腦存取。', 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '會重新啟動應用程式,以便手機、平板和同一 Wi‑Fi 下的其他電腦存取。',
+10
View File
@@ -540,6 +540,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
store.setWeekStartPreference(settings.weekStartPreference); 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' if (typeof settings.chatRenderMode === 'string'
&& (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) { && (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) {
if (settings.chatRenderMode !== store.chatRenderMode) { if (settings.chatRenderMode !== store.chatRenderMode) {
@@ -1114,6 +1120,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
&& (candidate.weekStartPreference === 'auto' || candidate.weekStartPreference === 'sunday' || candidate.weekStartPreference === 'monday')) { && (candidate.weekStartPreference === 'auto' || candidate.weekStartPreference === 'sunday' || candidate.weekStartPreference === 'monday')) {
result.weekStartPreference = candidate.weekStartPreference; 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' if (typeof candidate.chatRenderMode === 'string'
&& (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) { && (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) {
result.chatRenderMode = candidate.chatRenderMode; result.chatRenderMode = candidate.chatRenderMode;
+8
View File
@@ -370,6 +370,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['desktop', 'startup', 'login'], keywords: ['desktop', 'startup', 'login'],
isAvailable: (ctx) => ctx.isDesktopLocalOrigin, 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', id: 'sessions.desktop-minimize-to-tray',
page: 'sessions', page: 'sessions',
+8
View File
@@ -20,6 +20,7 @@ export type ActivityRenderMode = 'collapsed' | 'summary';
export type SessionRetentionAction = 'archive' | 'delete'; export type SessionRetentionAction = 'archive' | 'delete';
export type TimeFormatPreference = 'auto' | '12h' | '24h'; export type TimeFormatPreference = 'auto' | '12h' | '24h';
export type WeekStartPreference = 'auto' | 'sunday' | 'monday'; export type WeekStartPreference = 'auto' | 'sunday' | 'monday';
export type DesktopWindowControlsPosition = 'auto' | 'left' | 'right';
export type FileEditorKeymap = 'default' | 'vim'; export type FileEditorKeymap = 'default' | 'vim';
function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap { function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap {
@@ -649,6 +650,7 @@ interface UIStore {
showExpandedEditTools: boolean; showExpandedEditTools: boolean;
timeFormatPreference: TimeFormatPreference; timeFormatPreference: TimeFormatPreference;
weekStartPreference: WeekStartPreference; weekStartPreference: WeekStartPreference;
desktopWindowControlsPosition: DesktopWindowControlsPosition;
mermaidRenderingMode: MermaidRenderingMode; mermaidRenderingMode: MermaidRenderingMode;
userMessageRenderingMode: UserMessageRenderingMode; userMessageRenderingMode: UserMessageRenderingMode;
collapsibleUserMessages: boolean; collapsibleUserMessages: boolean;
@@ -803,6 +805,7 @@ interface UIStore {
setShowExpandedEditTools: (value: boolean) => void; setShowExpandedEditTools: (value: boolean) => void;
setTimeFormatPreference: (value: TimeFormatPreference) => void; setTimeFormatPreference: (value: TimeFormatPreference) => void;
setWeekStartPreference: (value: WeekStartPreference) => void; setWeekStartPreference: (value: WeekStartPreference) => void;
setDesktopWindowControlsPosition: (value: DesktopWindowControlsPosition) => void;
setMermaidRenderingMode: (value: MermaidRenderingMode) => void; setMermaidRenderingMode: (value: MermaidRenderingMode) => void;
setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void; setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void;
setCollapsibleUserMessages: (value: boolean) => void; setCollapsibleUserMessages: (value: boolean) => void;
@@ -950,6 +953,7 @@ export const useUIStore = create<UIStore>()(
showExpandedEditTools: false, showExpandedEditTools: false,
timeFormatPreference: 'auto', timeFormatPreference: 'auto',
weekStartPreference: 'auto', weekStartPreference: 'auto',
desktopWindowControlsPosition: 'auto',
mermaidRenderingMode: 'svg', mermaidRenderingMode: 'svg',
userMessageRenderingMode: 'markdown', userMessageRenderingMode: 'markdown',
collapsibleUserMessages: true, collapsibleUserMessages: true,
@@ -2100,6 +2104,9 @@ export const useUIStore = create<UIStore>()(
setWeekStartPreference: (value) => { setWeekStartPreference: (value) => {
set({ weekStartPreference: value }); set({ weekStartPreference: value });
}, },
setDesktopWindowControlsPosition: (value) => {
set({ desktopWindowControlsPosition: value });
},
setMermaidRenderingMode: (value) => { setMermaidRenderingMode: (value) => {
set({ mermaidRenderingMode: value }); set({ mermaidRenderingMode: value });
}, },
@@ -2357,6 +2364,7 @@ export const useUIStore = create<UIStore>()(
showExpandedEditTools: state.showExpandedEditTools, showExpandedEditTools: state.showExpandedEditTools,
timeFormatPreference: state.timeFormatPreference, timeFormatPreference: state.timeFormatPreference,
weekStartPreference: state.weekStartPreference, weekStartPreference: state.weekStartPreference,
desktopWindowControlsPosition: state.desktopWindowControlsPosition,
mermaidRenderingMode: state.mermaidRenderingMode, mermaidRenderingMode: state.mermaidRenderingMode,
userMessageRenderingMode: state.userMessageRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode,
collapsibleUserMessages: state.collapsibleUserMessages, collapsibleUserMessages: state.collapsibleUserMessages,
+4 -4
View File
@@ -1,15 +1,15 @@
# OpenChamber VS Code Extension # 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 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/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 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) [![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) [![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. [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 ## What you get
+1 -1
View File
@@ -7,7 +7,7 @@
"private": true, "private": true,
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/btriapitsyn/openchamber.git" "url": "https://github.com/openchamber/openchamber.git"
}, },
"qna": "https://discord.gg/ZYRSdnwwKA", "qna": "https://discord.gg/ZYRSdnwwKA",
"license": "MIT", "license": "MIT",
+5 -5
View File
@@ -1,17 +1,17 @@
# <picture><source media="(prefers-color-scheme: dark)" srcset="https://github.com/btriapitsyn/openchamber/raw/HEAD/docs/references/badges/openchamber-logo-dark.svg"><img src="https://github.com/btriapitsyn/openchamber/raw/HEAD/docs/references/badges/openchamber-logo-light.svg" width="32" height="32" align="absmiddle" /></picture> @openchamber/web # <picture><source media="(prefers-color-scheme: dark)" srcset="https://github.com/openchamber/openchamber/raw/HEAD/docs/references/badges/openchamber-logo-dark.svg"><img src="https://github.com/openchamber/openchamber/raw/HEAD/docs/references/badges/openchamber-logo-light.svg" width="32" height="32" align="absmiddle" /></picture> @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 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/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 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) [![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. 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 ## Install
```bash ```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). Or install manually: `bun add -g @openchamber/web` (or npm, pnpm, yarn).
@@ -184,6 +184,12 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.desktopMinimizeToTrayEnabled === 'boolean') { if (typeof candidate.desktopMinimizeToTrayEnabled === 'boolean') {
result.desktopMinimizeToTrayEnabled = candidate.desktopMinimizeToTrayEnabled; 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)) { if (candidate.permissionAutoAccept && typeof candidate.permissionAutoAccept === 'object' && !Array.isArray(candidate.permissionAutoAccept)) {
const sessions = {}; const sessions = {};
const sourceSessions = candidate.permissionAutoAccept.sessions; const sourceSessions = candidate.permissionAutoAccept.sessions;
+1 -1
View File
@@ -11,7 +11,7 @@ const __dirname = path.dirname(__filename);
const PACKAGE_NAME = '@openchamber/web'; const PACKAGE_NAME = '@openchamber/web';
const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/'); const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/');
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; 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'; const GITHUB_RELEASES_URL = 'https://github.com/openchamber/openchamber/releases';
let cachedDetectedPm = null; let cachedDetectedPm = null;