diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a9be759 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +release +dist +node_modules +.opencode/node_modules +.ruff_cache +*.log +.env +.env.* +.DS_Store diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0548e36..9af2576 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,12 @@ jobs: - name: Build frontend run: bun run build + - name: Run unit tests + run: bun test + + - name: Build Docker image + run: docker build -t opengui:web . + - name: Package .deb run: bunx electron-builder --linux deb --publish never diff --git a/.github/workflows/crocodile.yml b/.github/workflows/crocodile.yml index 490d342..75ce7df 100644 --- a/.github/workflows/crocodile.yml +++ b/.github/workflows/crocodile.yml @@ -1,7 +1,7 @@ name: crocodile on: - pull_request_target: + pull_request: types: [opened, synchronize, reopened, ready_for_review] jobs: @@ -14,8 +14,19 @@ jobs: pull-requests: write issues: write steps: - # Safe with pull_request_target because no PR code gets checked out or executed. - # Restrict to trusted authors only because action may fetch repo content internally. + # Runs only for same-repo or trusted contributors. Fork PRs from outsiders may not + # have enough token permissions for automatic review comments. + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Configure git identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + - uses: anomalyco/opencode/github@latest env: GITHUB_TOKEN: ${{ github.token }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa297e2..d8a9b6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,29 +18,31 @@ bun install ## Development -Run the web frontend + Electron in development mode (with HMR): +Run Electron app in development mode (renderer HMR + Electron shell): ```bash -bun run dev +bun dev ``` -Or run just the web frontend (no Electron): +Or run browser version with Bun backend API: ```bash -bun run dev:web +bun dev:web ``` ## Code Style -This project uses [Biome](https://biomejs.dev/) for linting and formatting. Always use the bun scripts: +This project currently uses `oxlint` via Bun scripts: ```bash -bun run lint # check and auto-fix lint + format issues -bun run lint:check # check only, no auto-fix -bun run format # auto-fix formatting only +bun run lint # lint check +bun run lint:check # lint check +bun run lint:fix # auto-fix where possible +bun run typecheck # TypeScript-aware checks +bun test # unit tests ``` -Run `bun run lint` before submitting a PR to make sure your code passes. +Run `bun run lint:check`, `bun run typecheck`, and `bun test` before submitting a PR. ## Commit Messages @@ -55,7 +57,7 @@ Write clear, concise commit messages. Focus on the "why" rather than the "what." 1. Fork the repository 2. Create a feature branch from `master`: `git checkout -b my-feature` 3. Make your changes -4. Run `bun run lint` to verify code style +4. Run `bun run lint:check`, `bun run typecheck`, and `bun test` 5. Commit your changes with a clear message 6. Push to your fork and open a pull request against `master` @@ -73,18 +75,19 @@ Check existing issues before opening a new one to avoid duplicates. If you're new to the codebase, here's where things live: ``` -main.cjs Electron main process (window management, IPC) -preload.cjs Preload script (contextBridge API for renderer) -opencode-bridge.mjs IPC bridge to the OpenCode SDK (SSE, sessions, prompts) +main.cjs Electron main process (window management, IPC) +preload.cjs Preload script (contextBridge API for renderer) +opencode-bridge.mjs IPC bridge to OpenCode SDK (SSE, sessions, prompts) +server/web-server.ts Bun backend for browser mode (RPC, events, server FS browser) src/ - index.ts Bun web server (development + production) - index.html HTML entry point - frontend.tsx React entry point - App.tsx Main app layout - hooks/ Custom React hooks (state management, STT) - components/ UI components (sidebar, messages, prompt box, etc.) - lib/ Utility modules - types/ TypeScript type definitions + index.ts Renderer-only Bun dev server entry + index.html HTML entry point + frontend.tsx React entry point + App.tsx Main app layout + hooks/ Custom React hooks (state management, backends, STT) + components/ UI components (sidebar, messages, prompt box, etc.) + lib/ Utility modules, including browser Electron shim + types/ TypeScript type definitions ``` ## Areas Where Help Is Needed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6695528 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM oven/bun:1.3-debian + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + git \ + iproute2 \ + openssh-client \ + procps \ + ripgrep \ + util-linux \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +COPY . . +RUN bun run build +COPY docker/host-exec /usr/local/bin/opengui-host-exec +COPY docker/entrypoint.sh /usr/local/bin/opengui-entrypoint +RUN chmod +x /usr/local/bin/opengui-host-exec /usr/local/bin/opengui-entrypoint \ + && mkdir -p /usr/local/host-bin \ + && for cmd in git opencode claude codex pi bun node npm python python3 bash sh rg fd make gcc g++; do ln -sf /usr/local/bin/opengui-host-exec /usr/local/host-bin/$cmd; done + +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV NODE_ENV=production +ENV OPENGUI_ALLOWED_ROOTS=/workspace + +EXPOSE 3000 + +ENTRYPOINT ["/usr/local/bin/opengui-entrypoint"] +CMD ["/usr/local/bin/bun", "server/web-server.ts"] diff --git a/README.md b/README.md index 6d7e26d..3d8d30a 100644 --- a/README.md +++ b/README.md @@ -87,18 +87,26 @@ No manual config file needed. Connection settings live in UI. ### Development -Run web frontend + Electron with HMR: +Run Electron app with HMR: ```bash -bun run dev +bun dev ``` -Run only web frontend: +Run web app with local backend API (projects, git, agents): ```bash -bun run dev:web +bun dev:web ``` +Open `http://127.0.0.1:3000`. Browser folder picker uses server paths. Set `OPENGUI_ALLOWED_ROOTS=/path/to/projects` to restrict browsable folders. + +### Docker + +Docker install supports contained mode and host-control mode. Host-control mode uses host CLIs through `nsenter` while Docker manages web server. + +See [docs/docker.md](docs/docker.md) for Docker modes and [docs/apache.md](docs/apache.md) for Apache reverse proxy + Basic Auth. + ### Production Build frontend bundle: @@ -110,9 +118,17 @@ bun run build Run Electron app in production mode: ```bash -bun run start:electron +bun start ``` +Build and run web app in production mode: + +```bash +bun start:web +``` + +For internet-facing deploys, keep OpenGUI bound to localhost and put Apache or another HTTPS reverse proxy in front. + ### Distribution Build Linux `.deb`: @@ -136,20 +152,21 @@ bun run dist:win ## Architecture ``` -main.cjs Electron main process (window management, IPC) -preload.cjs Preload script (contextBridge API for renderer) -opencode-bridge.mjs IPC bridge to the OpenCode SDK (SSE, sessions, prompts) +main.cjs Electron main process (window management, IPC) +preload.cjs Preload script (contextBridge API for renderer) +opencode-bridge.mjs IPC bridge to OpenCode SDK (SSE, sessions, prompts) +server/web-server.ts Bun backend for browser mode (RPC, events, server FS browser) src/ - index.ts Bun web server (development + production) - index.html HTML entry point - frontend.tsx React entry point - App.tsx Main app layout + index.ts Renderer-only Bun dev server entry + index.html HTML entry point + frontend.tsx React entry point + web Electron shim install + App.tsx Main app layout hooks/ - use-opencode.tsx Central state management (context + reducer) - useSTT.ts Speech-to-text hook - components/ UI components (sidebar, messages, prompt box, etc.) - lib/ Utility modules - types/ TypeScript type definitions + use-agent-impl-core.tsx Central agent/workspace state + components/ UI components (sidebar, messages, prompt box, etc.) + lib/ + web-electron-api.ts Browser shim for Electron preload API + types/ TypeScript type definitions ``` ## Configuration diff --git a/bun.lock b/bun.lock index 963d4e9..65a6c25 100644 --- a/bun.lock +++ b/bun.lock @@ -3,15 +3,17 @@ "configVersion": 1, "workspaces": { "": { - "name": "bun-react-template", + "name": "opengui", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.121", "@mariozechner/pi-coding-agent": "^0.69.0", - "@openai/codex": "^0.122.0", "@openai/codex-sdk": "^0.123.0", "@opencode-ai/sdk": "^1.14.28", + "@shikijs/core": "^3.23.0", + "@shikijs/engine-javascript": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", "@tanstack/react-virtual": "^3.13.24", - "bun-react-template": ".", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "electron-updater": "^6.8.3", @@ -24,7 +26,6 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "shiki": "^3.23.0", "tailwind-merge": "^3.5.0", }, "devDependencies": { @@ -41,23 +42,23 @@ }, }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.121", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.121", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.121", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.121", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.121", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.121", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.121", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.121", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.121" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-hwZNYTkGLKVixd/V/OCJwfH/SdfxZXGV0m6wvy5EBq6qfB+lvJTRz/MSOSa7dHqo4/F7zJY68crEEca68Wrxpw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.123", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.123", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.123", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.123", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.123", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.123", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.123", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.123", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.123" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-a4TysYoR9DBdkM9Uwh4J5ub7TwKmRPe5hFiWh4En+IKC+qkk5UFkxFM22c//cZjYZKynHX0ah2t6LUqb+najYA=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.121", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zVHcXvx6Hl/glDcOCH+EyNx4KPE9cMGLk42eEBSZe014tAN5W8bwM/By08iM6dxijnpH0NQRNNEAW+BryWzuDg=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.123", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tYAXCjlXZQklsUs0J//gip3fZQRzhlH5OCgvNXV70qe7A1iiwHqO2KPGvEHV1L+deEKQoMZmTaCOrQpN6zju3w=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.121", "", { "os": "darwin", "cpu": "x64" }, "sha512-lIXdqKj+bpfDxCk/eU1F1TXNqsIsLTRrkUG/wx19WIGZ8gLUmmVSveUKGlNegTs7S6evMvuezprJzDJT4TcvPA=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.123", "", { "os": "darwin", "cpu": "x64" }, "sha512-AcUC6sTon6z6HculP87KsAOeTMRLBwpovdhcXUTjXUpo/8nplJ7lBEzWjZCHt8FF1KuN/WBy1Z4bDg/59TQDmA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.121", "", { "os": "linux", "cpu": "arm64" }, "sha512-AQSnJzaiFvQpUPfO1tWLvsHgb6KNar4QYEQ/5/sk1itfgr3Fx9gxTreq43wX7AXSvkBX1QlDaP1aR1sfM/g/lQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.123", "", { "os": "linux", "cpu": "arm64" }, "sha512-7+GnbcF3/aZ8RJ1WmU/ogtPsOpknBAoUPer90MvZuFYBLPT9iI/U7f24gjrOHuYdcbDA5n7jFlhcfIO26F5DJQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.121", "", { "os": "linux", "cpu": "arm64" }, "sha512-4XaGK+dRBYy7krln7BrDG0WsdE6ejUSgHjWHlUGXoubFfZUvls4GSahLcYjJBArLi4dLnxKw8zEuiQguPAIbrw=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.123", "", { "os": "linux", "cpu": "arm64" }, "sha512-bYgRiaf2q+yVbGAoUluuhqrEW1zexL34+3HDmK9DneKXa2K2EJpw4M6Sq4XoBD/JezGaemoAP78Xv/M/QUS1OQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.121", "", { "os": "linux", "cpu": "x64" }, "sha512-DJUgpm7au086WaQV/S7BGOt2M8D90spGZRizT3twYsacf1BxzK1qsXqB/Pw1lUjPy6pI107pml/TaPzWuS/Vzg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.123", "", { "os": "linux", "cpu": "x64" }, "sha512-Xi+Rwk8uP5vWEnawJOlsk179fr0ATLl5J90MlbLj+puKaX5svEq8ljS+P3zq6zHTJeKh9GKLzPf7bc5YJKwcew=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.121", "", { "os": "linux", "cpu": "x64" }, "sha512-sQoGIgzLlBRrwizxsCV/lbaEuxXom/cfOwlDtQ2HnS1IzDDSjSf5d5pugpWItkOyXBWcHzMUu731WTTutvd/BQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.123", "", { "os": "linux", "cpu": "x64" }, "sha512-IX95lFKhmmndY/YPfWPsVV+C3rLYJmuuq5wCS53p6jYIkCMxH1iGfhBGF1EUWcXO4Uc8yqXFmQ3aaxMzOOPrwA=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.121", "", { "os": "win32", "cpu": "arm64" }, "sha512-6n/NHkHxs0/lCJX3XPADjo1EFzXBf0IwYz/nyzJGBCDJjGKmgTe0i8eYBr/hviwt1/OPeK7dmVzVSVl6EL9Azg=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.123", "", { "os": "win32", "cpu": "arm64" }, "sha512-WDZmAQG1rOiqNLZlSXaCjSWmqJvLk2io+vFQWWqSy2b5HCk9pa3PadLiaLztiihyk81wPhH9Q/44kOxdyfEGMw=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.121", "", { "os": "win32", "cpu": "x64" }, "sha512-v2/R918/t94cCwc6rmbxk+UYeQPtF2oBLtQAk+cT0M60hvqmCZO2noyZx5uTp8TQncOlG4MkINIeNY2yfmWSoQ=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.123", "", { "os": "win32", "cpu": "x64" }, "sha512-588xrd1i6d4kXQ6FqwL+cgBiN4evRQSi5DCtPa02CZ3VEbuVQBeFlyPlD8tfWtNNeGZ4NM8kjPNNzZz5omezPA=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], @@ -187,23 +188,23 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@openai/codex": ["@openai/codex@0.122.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.122.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.122.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.122.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.122.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.122.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.122.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-fMQoBo/D9S2/FNHgCMEGnEtG+LIm7ot2PbtpU26pyKDjKS47o9XByNv8gH3X0aDg6A4Algq21laUkFhonkgdsw=="], + "@openai/codex": ["@openai/codex@0.123.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.123.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.123.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.123.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.123.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.123.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.123.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-2614Hx8etVCYaLL38s01DHJ+MZSjcQ5bJZVzkjyQIWk49s2um+7pJLmOWWVHOtBDVzsxjligtfn0U5Vm1G4qOg=="], - "@openai/codex-darwin-arm64": ["@openai/codex@0.122.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J1rUDdVBgIwpFqwmkjgVWbbAk8oxuw/1JuIGUXJMz7wAsdvEgsVazwndIjQVN1nDtdD6zznlUtl69oPV1hhXcA=="], + "@openai/codex-darwin-arm64": ["@openai/codex@0.123.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pc4SNzbOTQknTY6/9WNIuJcwYFEbObE//2Q9LAf3XmbPOOHEs/NdvTz/kY/QLGP8mMAbWRfs7FV6vVN7tM+tJg=="], - "@openai/codex-darwin-x64": ["@openai/codex@0.122.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-VUXZxD/c/v2Mdler7epYJc8EH80fGbAC55QBVaxqSjX6OaoGO1sHqp7Nept1p52jMRtyc3VTv2/hkV1wEy+96w=="], + "@openai/codex-darwin-x64": ["@openai/codex@0.123.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-qVJOS7PLXSL5tatUQ/EkM5kmmJVqepUpJKRtcx3gM8HfzBcMM+VSjUogMdU2fvGXsv2oY6hBoQBvagOMRCB+vQ=="], - "@openai/codex-linux-arm64": ["@openai/codex@0.122.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-dtRm+R+BnwEZDFahIMl56tttzE3VtJLt2CuacKpn9ZDs8H9DW5lwGWdTEm94ANWWeyzigFObidQyitgdgwFHow=="], + "@openai/codex-linux-arm64": ["@openai/codex@0.123.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-vpYC4dWG+0hwfyC4SeJayzdFAML3VK0qezgxMjMNt/8eUZlFOrWEijOdTh5h+rf9ylgIBNgVVJXwcnQC/XwhrA=="], - "@openai/codex-linux-x64": ["@openai/codex@0.122.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-GYQBkwER9RPn7spJOwB3f/pTyk3KWKsRI8W+2Dl5H3XSxJJ5aVZeNKBGChSw6lyJWAmuZXssBu037KQx15lsGA=="], + "@openai/codex-linux-x64": ["@openai/codex@0.123.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-TtFmwDJggXVzSXNHVCu8PQorBBHAnrmAqa2sow+1v+Eb9eOZYrbls82rLgM1qqT31SBXh4nGIC7a/Tlv+tRzUw=="], "@openai/codex-sdk": ["@openai/codex-sdk@0.123.0", "", { "dependencies": { "@openai/codex": "0.123.0" } }, "sha512-AV1NGe61+OdHPwiCYXWFZrkvpWQ1ek9vHT/fQ32Nhwo3SU2jXJ8Hwe6b25hGCoctWO8mMdFkMwemhj57vW3Mkg=="], - "@openai/codex-win32-arm64": ["@openai/codex@0.122.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-7SjzC/Xzw7md2//2W03Eid2bgY5LHfKinD6VwgfwLJbYFGsd9LDIduZC/8GABWSDXOgqE2SoZDiD2KwlG63Tkw=="], + "@openai/codex-win32-arm64": ["@openai/codex@0.123.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-4aWBTOrudrx07F7App+geSfWiCpjj6HwTUEILr+fdSFN9t76dtcC/7IEH4IoYwZcszJgPqfm9A7yH38ZwpYvRw=="], - "@openai/codex-win32-x64": ["@openai/codex@0.122.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-0OR/QjqOZ7zX12s6Dh7qy/6H4WZRgFAp529EUg5C7tXj5xXdopi8A9InQfehu4KEJE3Qol2Lj/QBrOcahFXoPg=="], + "@openai/codex-win32-x64": ["@openai/codex@0.123.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-5swKmvHtzTug+Zk8trFBO81Kw0OBAfOsv/OGiQg+moGBWGn/9WhugRHSoj9gfZwBxRZPb+n2cmz1nICxh4I6XQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.28", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-qRFJfD+Zdz3jHHSupW4F6Io1ZFrQ6gCRFlG50O6kEU9xRxrBpK0wGvP+Y5VwwvD/gH9WKMHYinlQpDVI9/lgJQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.29", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-y6wNTlHhgfwLdp01EwdnMFVxUS1FLgz7MZh7H3+jROG2v02GqGDy/gPH3ME0kI+sqQ4qSlk/9AJ+YbKAruPaZw=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw=="], @@ -421,8 +422,6 @@ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], @@ -623,8 +622,6 @@ "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], - "bun-react-template": ["bun-react-template@root:", {}], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1221,8 +1218,6 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -1379,8 +1374,6 @@ "@mariozechner/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.90.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg=="], - "@openai/codex-sdk/@openai/codex": ["@openai/codex@0.123.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.123.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.123.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.123.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.123.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.123.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.123.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-2614Hx8etVCYaLL38s01DHJ+MZSjcQ5bJZVzkjyQIWk49s2um+7pJLmOWWVHOtBDVzsxjligtfn0U5Vm1G4qOg=="], - "@types/cacheable-request/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], "@types/keyv/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], @@ -1429,18 +1422,6 @@ "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "@openai/codex-sdk/@openai/codex/@openai/codex-darwin-arm64": ["@openai/codex@0.123.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pc4SNzbOTQknTY6/9WNIuJcwYFEbObE//2Q9LAf3XmbPOOHEs/NdvTz/kY/QLGP8mMAbWRfs7FV6vVN7tM+tJg=="], - - "@openai/codex-sdk/@openai/codex/@openai/codex-darwin-x64": ["@openai/codex@0.123.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-qVJOS7PLXSL5tatUQ/EkM5kmmJVqepUpJKRtcx3gM8HfzBcMM+VSjUogMdU2fvGXsv2oY6hBoQBvagOMRCB+vQ=="], - - "@openai/codex-sdk/@openai/codex/@openai/codex-linux-arm64": ["@openai/codex@0.123.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-vpYC4dWG+0hwfyC4SeJayzdFAML3VK0qezgxMjMNt/8eUZlFOrWEijOdTh5h+rf9ylgIBNgVVJXwcnQC/XwhrA=="], - - "@openai/codex-sdk/@openai/codex/@openai/codex-linux-x64": ["@openai/codex@0.123.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-TtFmwDJggXVzSXNHVCu8PQorBBHAnrmAqa2sow+1v+Eb9eOZYrbls82rLgM1qqT31SBXh4nGIC7a/Tlv+tRzUw=="], - - "@openai/codex-sdk/@openai/codex/@openai/codex-win32-arm64": ["@openai/codex@0.123.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-4aWBTOrudrx07F7App+geSfWiCpjj6HwTUEILr+fdSFN9t76dtcC/7IEH4IoYwZcszJgPqfm9A7yH38ZwpYvRw=="], - - "@openai/codex-sdk/@openai/codex/@openai/codex-win32-x64": ["@openai/codex@0.123.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-5swKmvHtzTug+Zk8trFBO81Kw0OBAfOsv/OGiQg+moGBWGn/9WhugRHSoj9gfZwBxRZPb+n2cmz1nICxh4I6XQ=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/claude-code-bridge.mjs b/claude-code-bridge.mjs index 27a8d65..ad65a20 100644 --- a/claude-code-bridge.mjs +++ b/claude-code-bridge.mjs @@ -52,7 +52,7 @@ function resolveBundledClaudeExecutable() { return undefined; } -const CLAUDE_EXECUTABLE_PATH = resolveBundledClaudeExecutable(); +const CLAUDE_EXECUTABLE_PATH = resolveBundledClaudeExecutable() || "claude"; const DEFAULT_STATUS = { state: "idle", diff --git a/codex-bridge.mjs b/codex-bridge.mjs index 0ae77aa..b7ff30c 100644 --- a/codex-bridge.mjs +++ b/codex-bridge.mjs @@ -1,10 +1,9 @@ import { execFile as execFileCallback } from "node:child_process" import { randomUUID } from "node:crypto" import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" +import { homedir, tmpdir } from "node:os" import { join, normalize } from "node:path" import { promisify } from "node:util" -import { app } from "electron" import { Codex } from "@openai/codex-sdk" const execFile = promisify(execFileCallback) @@ -318,8 +317,8 @@ function sanitizeFileName(id) { return encodeURIComponent(id).replace(/%/g, "_") } -function makeStoragePaths() { - const root = join(app.getPath("userData"), "codex") +function makeStoragePaths(userData = join(homedir(), ".config", "OpenGUI")) { + const root = join(userData, "codex") return { root, indexFile: join(root, "sessions.json"), @@ -630,16 +629,17 @@ function buildToolPartFromItem(sessionId, messageId, item, existingPart, phase) } class CodexBridgeManager { - constructor(getAllWindows) { + constructor(getAllWindows, options = {}) { this.getAllWindows = getAllWindows this.projects = new Map() this.sessionIndex = new Map() this.transcriptCache = new Map() this.liveSessions = new Map() this.aliases = new Map() - this.paths = makeStoragePaths() + this.paths = makeStoragePaths(options.userData) this.storageReady = this.loadStorage() this.codex = new Codex({ + codexPathOverride: process.env.CODEX_EXECUTABLE?.trim() || "codex", env: pickCodexEnv(process.env), }) } @@ -1506,8 +1506,8 @@ class CodexBridgeManager { } } -export function setupCodexBridge(ipcMain, getAllWindows) { - const manager = new CodexBridgeManager(getAllWindows) +export function setupCodexBridge(ipcMain, getAllWindows, options = {}) { + const manager = new CodexBridgeManager(getAllWindows, options) ipcMain.handle("codex:project:add", async (_event, config) => { try { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..022ec07 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + opengui: + build: . + container_name: opengui + restart: unless-stopped + network_mode: host + pid: host + privileged: true + environment: + # Safer default for reverse-proxy deploys. Override HOST=0.0.0.0 for direct LAN access. + HOST: "${HOST:-127.0.0.1}" + PORT: "${PORT:-4839}" + OPENGUI_OPENCODE_PORT: "${OPENGUI_OPENCODE_PORT:-48391}" + OPENGUI_HOST_EXEC: "1" + OPENGUI_HOST_UID: "${OPENGUI_HOST_UID:-1000}" + OPENGUI_HOST_GID: "${OPENGUI_HOST_GID:-1000}" + OPENGUI_HOST_HOME: "${HOME}" + OPENGUI_ALLOWED_ROOTS: "${OPENGUI_ALLOWED_ROOTS:-${HOME}/Code}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + GOOGLE_API_KEY: "${GOOGLE_API_KEY:-}" + OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}" + volumes: + - "${HOME}:${HOME}" + - opengui-data:/app/.opengui-data + +volumes: + opengui-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..efc6726 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -euo pipefail + +if [[ "${OPENGUI_HOST_EXEC:-0}" == "1" ]]; then + export PATH="/usr/local/host-bin:$PATH" + : "${OPENGUI_HOST_UID:=0}" + : "${OPENGUI_HOST_GID:=0}" + : "${OPENGUI_HOST_HOME:=/root}" + export HOME="$OPENGUI_HOST_HOME" + echo "OpenGUI Docker host-control mode enabled" + echo " host uid/gid: $OPENGUI_HOST_UID:$OPENGUI_HOST_GID" + echo " host home: $OPENGUI_HOST_HOME" + echo " allowed roots: ${OPENGUI_ALLOWED_ROOTS:-unset}" +else + echo "OpenGUI Docker contained mode enabled" +fi + +exec "$@" diff --git a/docker/host-exec b/docker/host-exec new file mode 100644 index 0000000..7549b74 --- /dev/null +++ b/docker/host-exec @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +cmd="$(basename "$0")" + +if [[ "${OPENGUI_HOST_EXEC:-0}" != "1" ]]; then + exec "/usr/bin/$cmd" "$@" +fi + +if ! command -v nsenter >/dev/null 2>&1; then + echo "opengui host-exec: nsenter not found" >&2 + exit 127 +fi + +host_uid="${OPENGUI_HOST_UID:-0}" +host_gid="${OPENGUI_HOST_GID:-0}" +host_home="${OPENGUI_HOST_HOME:-/root}" +workdir="$PWD" + +# Preserve only useful env. Secrets/API keys pass through Docker env normally. +export OPENGUI_HOST_CMD="$cmd" +export OPENGUI_HOST_WORKDIR="$workdir" +export HOME="$host_home" +# Do not leak /usr/local/host-bin into host namespace. Host shebangs using +# /usr/bin/env bash would find wrapper bash and recurse forever. +export PATH="${OPENGUI_HOST_PATH:-/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$host_home/.bun/bin:$host_home/.local/bin}" + +exec nsenter -t 1 -m -u -i -n -p \ + --setgid "$host_gid" \ + --setuid "$host_uid" \ + /bin/sh -lc 'cd "$OPENGUI_HOST_WORKDIR" 2>/dev/null || cd "$HOME"; exec "$OPENGUI_HOST_CMD" "$@"' \ + sh "$@" diff --git a/docs/apache.md b/docs/apache.md new file mode 100644 index 0000000..c0754cc --- /dev/null +++ b/docs/apache.md @@ -0,0 +1,108 @@ +# Apache reverse proxy for OpenGUI web + +Use Apache in front of OpenGUI web when you want HTTPS and simple single-user auth without changing app code. + +## Goal + +- public URL like `https://gui.example.com` +- Apache handles TLS and Basic Auth +- OpenGUI listens only on `127.0.0.1:4839` +- no direct public access to Bun port + +## 1. Run OpenGUI on loopback + +Example container env: + +```bash +-e HOST=127.0.0.1 \ +-e PORT=4839 \ +-e OPENGUI_OPENCODE_PORT=48391 +``` + +Keep firewall closed for `4839`. + +## 2. Enable Apache modules + +```bash +a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl auth_basic authn_file +systemctl reload apache2 +``` + +## 3. Create password file + +```bash +htpasswd -c /etc/apache2/.htpasswd-opengui opengui +``` + +Rotate later: + +```bash +htpasswd /etc/apache2/.htpasswd-opengui opengui +systemctl reload apache2 +``` + +## 4. HTTP vhost + +```apache + + ServerName gui.example.com + Redirect / https://gui.example.com/ + +``` + +## 5. HTTPS vhost + +```apache + + + ServerName gui.example.com + + ProxyPreserveHost On + ProxyRequests Off + RequestHeader set X-Forwarded-Proto "https" + + + AuthType Basic + AuthName "OpenGUI" + AuthUserFile /etc/apache2/.htpasswd-opengui + Require valid-user + + + ProxyPass /api/events ws://127.0.0.1:4839/api/events retry=0 + ProxyPassReverse /api/events ws://127.0.0.1:4839/api/events + + ProxyPass / http://127.0.0.1:4839/ + ProxyPassReverse / http://127.0.0.1:4839/ + + SSLCertificateFile /etc/letsencrypt/live/gui.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/gui.example.com/privkey.pem + Include /etc/letsencrypt/options-ssl-apache.conf + + +``` + +`/api/events` must allow WebSocket upgrades. + +## 6. TLS certificate + +Example with Certbot: + +```bash +certbot certonly --apache -d gui.example.com +systemctl reload apache2 +``` + +## 7. Verify + +- unauthenticated request returns `401` +- authenticated request loads app +- `/api/events` upgrades to WebSocket +- direct `http://SERVER-IP:4839` is unreachable from internet + +## Security notes + +- Basic Auth protects entry, but authenticated user still gets full OpenGUI power. +- In Docker host-control mode, this is near-host-level access. +- Keep strong password. +- Prefer dedicated subdomain. +- Keep `OPENGUI_ALLOWED_ROOTS` narrow. diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..8c803c0 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,109 @@ +# OpenGUI Docker + +OpenGUI web supports two Docker modes. + +## Host-control mode + +Host-control mode uses Docker for install and process management, but runs host CLIs (`git`, `opencode`, `claude`, `codex`, etc.) through `nsenter`. + +**Security warning:** this mode uses `--privileged`, `--pid host`, host mounts, and often `--network host`. Treat it like SSH access to host. Do not expose Bun server port directly to internet. + +### Build + +```bash +docker build -t opengui:web . +``` + +### Run behind reverse proxy (recommended for servers) + +Bind OpenGUI to loopback and put Apache or another HTTPS reverse proxy in front: + +```bash +docker run --rm -it \ + --name opengui-test \ + --network host \ + --pid host \ + --privileged \ + -e HOST=127.0.0.1 \ + -e PORT=4839 \ + -e OPENGUI_OPENCODE_PORT=48391 \ + -e OPENGUI_HOST_EXEC=1 \ + -e OPENGUI_HOST_UID="$(id -u)" \ + -e OPENGUI_HOST_GID="$(id -g)" \ + -e OPENGUI_HOST_HOME="$HOME" \ + -e OPENGUI_ALLOWED_ROOTS="$HOME/Code" \ + -v "$HOME:$HOME" \ + opengui:web +``` + +Proxy `https://your-hostname` to `http://127.0.0.1:4839` and forward WebSocket upgrades for `/api/events`. + +See [apache.md](apache.md) for Apache Basic Auth example. + +### Run for LAN access + +If you want direct LAN or phone access without reverse proxy, override host bind: + +```bash +docker run --rm -it \ + --name opengui-test \ + --network host \ + --pid host \ + --privileged \ + -e HOST=0.0.0.0 \ + -e PORT=3000 \ + -e OPENGUI_OPENCODE_PORT=48391 \ + -e OPENGUI_HOST_EXEC=1 \ + -e OPENGUI_HOST_UID="$(id -u)" \ + -e OPENGUI_HOST_GID="$(id -g)" \ + -e OPENGUI_HOST_HOME="$HOME" \ + -e OPENGUI_ALLOWED_ROOTS="$HOME/Code" \ + -v "$HOME:$HOME" \ + opengui:web +``` + +Open: + +```txt +http://127.0.0.1:3000 +``` + +From phone on LAN: + +```txt +http://SERVER-IP:3000 +``` + +Only paths under `OPENGUI_ALLOWED_ROOTS` appear in server folder browser. + +## Compose + +`docker-compose.yml` defaults to safer reverse-proxy shape: localhost bind on `127.0.0.1:4839` plus dedicated OpenCode port `48391`. + +```bash +OPENGUI_HOST_UID=$(id -u) \ +OPENGUI_HOST_GID=$(id -g) \ +OPENGUI_ALLOWED_ROOTS="$HOME/Code" \ +docker compose up --build +``` + +Override `HOST=0.0.0.0` only if you intentionally want direct LAN exposure. + +## Contained mode + +Contained mode runs CLIs installed inside container. Mount projects under `/workspace`: + +```bash +docker run --rm -it \ + -p 3000:3000 \ + -e HOST=0.0.0.0 \ + -e OPENGUI_ALLOWED_ROOTS=/workspace \ + -v "$HOME/Code:/workspace" \ + opengui:web +``` + +## Notes + +- Browser folder picker in web mode uses server paths, not client filesystem paths. +- Keep `OPENGUI_ALLOWED_ROOTS` narrow. +- If you publish this on internet, use HTTPS and auth at reverse proxy layer. diff --git a/opencode-bridge.mjs b/opencode-bridge.mjs index bae50b3..9902437 100644 --- a/opencode-bridge.mjs +++ b/opencode-bridge.mjs @@ -20,7 +20,10 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; // Local server management // --------------------------------------------------------------------------- -const LOCAL_SERVER_PORT = 4096; +const LOCAL_SERVER_PORT = Number.parseInt( + process.env.OPENGUI_OPENCODE_PORT ?? "4096", + 10, +); const LOCAL_SERVER_URL = `http://127.0.0.1:${LOCAL_SERVER_PORT}`; const STARTUP_POLL_INTERVAL = 500; // ms const STARTUP_TIMEOUT = process.platform === "win32" ? 60_000 : 15_000; // ms @@ -834,8 +837,17 @@ export function setupOpenCodeBridge(ipcMain, _getWindows) { } function normalizeServerConfig(config) { + let baseUrl = config.baseUrl.replace(/\/+$/, ""); + const webLocalPort = process.env.OPENGUI_OPENCODE_PORT?.trim(); + if ( + webLocalPort && + (baseUrl === "http://127.0.0.1:4096" || + baseUrl === "http://localhost:4096") + ) { + baseUrl = `http://127.0.0.1:${webLocalPort}`; + } return { - baseUrl: config.baseUrl.replace(/\/+$/, ""), + baseUrl, username: config.username?.trim() || undefined, password: config.password?.trim() || undefined, }; @@ -1234,7 +1246,7 @@ export function setupOpenCodeBridge(ipcMain, _getWindows) { directory, config.workspaceId, ); - await conn.connect(config); + await conn.connect(normalizedConfig); windowState.serverConfig = normalizedConfig; return { success: true, status: conn.getStatus() }; } catch (err) { diff --git a/package.json b/package.json index 3a93dd5..53aed69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opengui", - "version": "0.4.6", + "version": "0.5.0", "private": false, "description": "OpenGUI - A graphical interface for OpenCode", "homepage": "https://github.com/akemmanuel/OpenGUI", @@ -17,9 +17,9 @@ "main": "main.cjs", "scripts": { "dev": "bun dev.ts", - "dev:web": "bun --hot src/index.ts", - "start": "NODE_ENV=production bun src/index.ts", - "start:electron": "NODE_ENV=production bunx electron .", + "start": "NODE_ENV=production bunx electron .", + "dev:web": "bun --hot server/web-server.ts", + "start:web": "bun run build && NODE_ENV=production bun server/web-server.ts", "build": "bun run build.ts", "dist": "bunx electron-builder --linux deb", "dist:mac": "bunx electron-builder --mac dmg", @@ -32,13 +32,15 @@ "lint:fix": "oxlint --type-aware --fix" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.121", + "@anthropic-ai/claude-agent-sdk": "^0.2.123", "@mariozechner/pi-coding-agent": "^0.69.0", - "@openai/codex": "^0.122.0", "@openai/codex-sdk": "^0.123.0", - "@opencode-ai/sdk": "^1.14.28", + "@opencode-ai/sdk": "^1.14.29", + "@shikijs/core": "^3.23.0", + "@shikijs/engine-javascript": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", "@tanstack/react-virtual": "^3.13.24", - "bun-react-template": ".", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "electron-updater": "^6.8.3", @@ -51,7 +53,6 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "shiki": "^3.23.0", "tailwind-merge": "^3.5.0" }, "devDependencies": { @@ -114,23 +115,10 @@ "codex-bridge.mjs", "package.json", "node_modules/@anthropic-ai/claude-agent-sdk/**/*", - "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/**/*", - "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl/**/*", - "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64/**/*", - "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/**/*", - "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64/**/*", - "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/**/*", - "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/**/*", - "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64/**/*", "node_modules/@anthropic-ai/sdk/**/*", - "node_modules/@openai/codex/**/*", "node_modules/@openai/codex-sdk/**/*", - "node_modules/@openai/codex-linux-x64/**/*", - "node_modules/@openai/codex-linux-arm64/**/*", - "node_modules/@openai/codex-darwin-x64/**/*", - "node_modules/@openai/codex-darwin-arm64/**/*", - "node_modules/@openai/codex-win32-x64/**/*", - "node_modules/@openai/codex-win32-arm64/**/*", + "!node_modules/@openai/codex-sdk/node_modules/@openai/codex-*/**/*", + "!node_modules/@openai/codex-sdk/node_modules/@openai/codex/**/*", "node_modules/@mariozechner/pi-coding-agent/**/*", "node_modules/@mariozechner/pi-agent-core/**/*", "node_modules/@mariozechner/pi-ai/**/*", diff --git a/server/web-server.ts b/server/web-server.ts new file mode 100644 index 0000000..8e6b740 --- /dev/null +++ b/server/web-server.ts @@ -0,0 +1,392 @@ +import { EventEmitter } from "node:events"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, realpath, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, extname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; +import index from "../src/index.html"; +import type { ServerWebSocket } from "bun"; + +type Handler = (event: IpcEvent, ...args: unknown[]) => unknown; + +type WebSocketClient = ServerWebSocket; + +class FakeSender extends EventEmitter { + id = 1; + private destroyed = false; + + constructor(private readonly broadcast: (channel: string, data: unknown) => void) { + super(); + } + + send(channel: string, data: unknown) { + this.broadcast(channel, data); + } + + isDestroyed() { + return this.destroyed; + } + + destroy() { + this.destroyed = true; + this.emit("destroyed"); + } +} + +interface IpcEvent { + sender: FakeSender; +} + +class FakeIpcMain { + private handlers = new Map(); + private listeners = new Map(); + + handle(channel: string, handler: Handler) { + if (this.handlers.has(channel)) { + console.warn(`[web] Replacing RPC handler ${channel}`); + } + this.handlers.set(channel, handler); + } + + on(channel: string, handler: Handler) { + this.listeners.set(channel, handler); + } + + send(channel: string, event: IpcEvent, args: unknown[] = []) { + const listener = this.listeners.get(channel); + if (!listener) return; + listener(event, ...args); + } + + async invoke(channel: string, event: IpcEvent, args: unknown[]) { + const handler = this.handlers.get(channel); + if (!handler) throw new Error(`No RPC handler registered for ${channel}`); + return await handler(event, ...args); + } +} + +function parseCommand(command: string) { + const matches = command.match(/"[^"]*"|'[^']*'|\S+/g); + if (!matches) return []; + return matches.map((part) => part.replace(/^["']|["']$/g, "")); +} + +function isWebUrl(url: unknown) { + return typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://")); +} + +function spawnDetached(command: string, args: string[], cwd?: string) { + const child = Bun.spawn([command, ...args], { + cwd, + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }); + child.unref(); +} + +function openExternal(url: string) { + if (!isWebUrl(url)) return; + if (process.platform === "darwin") spawnDetached("open", [url]); + else if (process.platform === "win32") spawnDetached("cmd.exe", ["/c", "start", "", url]); + else spawnDetached("xdg-open", [url]); +} + +function openPath(path: string) { + if (process.platform === "darwin") spawnDetached("open", [path]); + else if (process.platform === "win32") spawnDetached("explorer.exe", [path]); + else spawnDetached("xdg-open", [path]); +} + +async function runPicker(command: string[]) { + let proc: ReturnType; + try { + proc = Bun.spawn(command, { + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }); + } catch { + return null; + } + + const timeout = setTimeout(() => proc.kill(), 120_000); + try { + const exitCode = await proc.exited; + if (exitCode !== 0) return null; + const output = (await new Response(proc.stdout as ReadableStream).text()).trim(); + return output || null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +async function chooseDirectory() { + if (process.platform === "darwin") { + return await runPicker([ + "osascript", + "-e", + 'POSIX path of (choose folder with prompt "Open project folder")', + ]); + } + + if (process.platform === "win32") { + const script = [ + "Add-Type -AssemblyName System.Windows.Forms", + "$dialog = New-Object System.Windows.Forms.FolderBrowserDialog", + "$dialog.Description = 'Open project folder'", + "if ($dialog.ShowDialog() -eq 'OK') { $dialog.SelectedPath }", + ].join("; "); + return await runPicker(["powershell.exe", "-NoProfile", "-Command", script]); + } + + const linuxPickers = [ + ["zenity", "--file-selection", "--directory", "--title=Open project folder"], + ["kdialog", "--getexistingdirectory", homedir(), "Open project folder"], + ["yad", "--file-selection", "--directory", "--title=Open project folder"], + ]; + + for (const picker of linuxPickers) { + const directory = await runPicker(picker); + if (directory) return directory; + } + + return null; +} + +function openTerminal(dirPath: string, command = "") { + if (!existsSync(dirPath)) return; + const parts = parseCommand(command); + if (parts.length > 0) { + const [cmd, ...args] = parts; + if (!cmd) return; + spawnDetached(cmd, args, dirPath); + return; + } + if (process.platform === "darwin") spawnDetached("open", ["-a", "Terminal", dirPath]); + else if (process.platform === "win32") spawnDetached("cmd.exe", ["/c", "start", "cmd.exe", "/k", `cd /d "${dirPath}"`]); + else { + const terminal = process.env.TERMINAL || "x-terminal-emulator"; + spawnDetached(terminal, [], dirPath); + } +} + +function createSettingsStore(userData: string) { + const require = createRequire(import.meta.url); + return require("../settings-store.cjs").createSettingsStore(userData); +} + +async function setupHandlers(ipcMain: FakeIpcMain, sender: FakeSender, broadcast: (channel: string, data: unknown) => void) { + const userData = join(homedir(), ".config", "OpenGUI-web"); + await mkdir(userData, { recursive: true }); + const settingsStore = createSettingsStore(userData); + + const emitSettingsChange = (key: string, value: unknown) => broadcast("settings:changed", { key, value }); + + ipcMain.handle("settings:get-all", () => settingsStore.getAll()); + ipcMain.handle("settings:get", (_event, key) => settingsStore.get(key as string)); + ipcMain.handle("settings:set", (_event, key, value) => { + const success = settingsStore.set(key as string, value as string); + if (success) emitSettingsChange(key as string, value); + return success; + }); + ipcMain.handle("settings:remove", (_event, key) => { + const success = settingsStore.remove(key as string); + if (success) emitSettingsChange(key as string, null); + return success; + }); + ipcMain.handle("settings:merge", (_event, entries) => { + if (!entries || typeof entries !== "object" || Array.isArray(entries)) return false; + const success = settingsStore.merge(entries); + if (success) { + for (const [key, value] of Object.entries(entries)) emitSettingsChange(key, value); + } + return success; + }); + + ipcMain.handle("window:minimize", () => undefined); + ipcMain.handle("window:maximize", () => undefined); + ipcMain.handle("window:close", () => undefined); + ipcMain.handle("window:isMaximized", () => false); + ipcMain.handle("window:detachProject", () => undefined); + ipcMain.handle("window:getDetachedProjects", () => []); + ipcMain.handle("platform:get", () => process.platform); + ipcMain.handle("platform:homeDir", () => homedir()); + ipcMain.handle("dialog:openDirectory", () => chooseDirectory()); + ipcMain.handle("shell:openExternal", (_event, url) => openExternal(typeof url === "string" ? url : "")); + ipcMain.handle("shell:openInFileBrowser", (_event, dirPath, command = "") => { + const dir = typeof dirPath === "string" ? dirPath : ""; + if (!dir) return; + if (typeof command === "string" && command) { + const parts = parseCommand(command); + if (parts.length > 0) { + const [cmd, ...args] = parts; + if (!cmd) return; + spawnDetached(cmd, args.length > 0 ? args : [dir], dir); + return; + } + } + openPath(dir); + }); + ipcMain.handle("shell:openInTerminal", (_event, dirPath, command = "") => + openTerminal(typeof dirPath === "string" ? dirPath : "", typeof command === "string" ? command : ""), + ); + + const getAllWindows = () => [ + { + isDestroyed: () => false, + webContents: { send: (channel: string, data: unknown) => broadcast(channel, data) }, + }, + ]; + + const [{ setupOpenCodeBridge }, { setupClaudeCodeBridge }, { setupPiBridge }, { setupCodexBridge }] = await Promise.all([ + import("../opencode-bridge.mjs"), + import("../claude-code-bridge.mjs"), + import("../pi-bridge.mjs"), + import("../codex-bridge.mjs"), + ]); + + setupOpenCodeBridge(ipcMain, getAllWindows); + setupClaudeCodeBridge(ipcMain, getAllWindows); + ipcMain.send("claude-code:renderer-ready", { sender }); + setupPiBridge(ipcMain, getAllWindows, { userData }); + setupCodexBridge(ipcMain, getAllWindows, { userData }); + + return { sender }; +} + +const clients = new Set(); +const broadcast = (channel: string, data: unknown) => { + const payload = JSON.stringify({ channel, data }); + for (const client of clients) client.send(payload); +}; + +const ipcMain = new FakeIpcMain(); +const sender = new FakeSender(broadcast); +const ready = setupHandlers(ipcMain, sender, broadcast); + +const port = Number(process.env.PORT || 3000); +const hostname = process.env.HOST || "127.0.0.1"; +const isProduction = process.env.NODE_ENV === "production"; + +function parseAllowedRoots() { + const raw = process.env.OPENGUI_ALLOWED_ROOTS || homedir(); + return raw + .split(",") + .map((entry) => resolve(entry.trim())) + .filter(Boolean); +} + +const allowedRoots = parseAllowedRoots(); + +async function resolveSafeDirectory(inputPath: string | null) { + const requested = resolve(inputPath?.trim() || allowedRoots[0] || homedir()); + const actual = await realpath(requested); + const info = await stat(actual); + if (!info.isDirectory()) throw new Error("Path is not a directory"); + const allowed = allowedRoots.some((root) => actual === root || actual.startsWith(`${root}/`)); + if (!allowed) throw new Error("Path outside OPENGUI_ALLOWED_ROOTS"); + return actual; +} + +async function listServerDirectories(inputPath: string | null) { + const path = await resolveSafeDirectory(inputPath); + const entries = await readdir(path, { withFileTypes: true }); + const dirs = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => ({ name: entry.name, path: join(path, entry.name), type: "dir" as const })) + .sort((a, b) => a.name.localeCompare(b.name)); + const parent = dirname(path); + const canGoUp = allowedRoots.some((root) => parent === root || parent.startsWith(`${root}/`)); + return { path, parent: canGoUp ? parent : null, roots: allowedRoots, entries: dirs }; +} + +const contentTypes: Record = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", +}; + +function serveBuiltFile(request: Request) { + const url = new URL(request.url); + const requestedPath = decodeURIComponent(url.pathname); + const safePath = requestedPath.includes("..") ? "/index.html" : requestedPath; + const distPath = resolve("dist", safePath === "/" ? "index.html" : safePath.slice(1)); + const distRoot = resolve("dist"); + const filePath = distPath.startsWith(distRoot) && existsSync(distPath) ? distPath : join(distRoot, "index.html"); + return new Response(Bun.file(filePath), { + headers: { "content-type": contentTypes[extname(filePath)] ?? "application/octet-stream" }, + }); +} + +function handleFetch(request: Request, server: Bun.Server) { + if (new URL(request.url).pathname === "/api/events") { + if (server.upgrade(request, { data: undefined })) return undefined; + return new Response("WebSocket upgrade failed", { status: 400 }); + } + if (isProduction) return serveBuiltFile(request); + return new Response("Not found", { status: 404 }); +} + +const routes = { + "/api/rpc": { + POST: async (request: Request) => { + await ready; + try { + const body = await request.json(); + const channel = String(body?.channel ?? ""); + const args = Array.isArray(body?.args) ? body.args : []; + const value = await ipcMain.invoke(channel, { sender }, args); + return Response.json({ ok: true, value }); + } catch (error) { + return Response.json( + { ok: false, error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } + }, + }, + "/api/fs/list": async (request: Request) => { + try { + const path = new URL(request.url).searchParams.get("path"); + return Response.json({ ok: true, value: await listServerDirectories(path) }); + } catch (error) { + return Response.json( + { ok: false, error: error instanceof Error ? error.message : String(error) }, + { status: 400 }, + ); + } + }, + "/api/health": Response.json({ ok: true, mode: "web", allowedRoots }), + ...(isProduction ? {} : { "/*": index }), +}; + +const server = Bun.serve({ + port, + hostname, + routes: routes as Parameters[0]["routes"], + fetch: handleFetch, + websocket: { + open(ws) { + clients.add(ws); + }, + close(ws) { + clients.delete(ws); + }, + message() {}, + }, + development: process.env.NODE_ENV !== "production" && { + hmr: true, + console: true, + }, +}); + +console.log(`OpenGUI web running at ${server.url}`); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index af30b0b..4a1edec 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -69,6 +69,7 @@ import { ProjectMenuContent, SessionItemMenu, } from "./SidebarItemMenus"; +import { ProjectPathDialog } from "./ProjectPathDialog"; import { WorktreeDialog } from "./WorktreeDialog"; import { WorktreeSetupDialog } from "./WorktreeSetupDialog"; @@ -119,6 +120,7 @@ export function AppSidebar({ projectMeta, isLocalWorkspace, activeWorkspace, + workspaceDirectory, } = useConnectionState(); // Inline rename state @@ -160,6 +162,19 @@ export function AppSidebar({ const homeDir = useHomeDir(); const normalizedRemoteProjectPath = normalizeProjectPath(remoteProjectPath); + const isWebRuntime = + typeof navigator !== "undefined" && !navigator.userAgent.includes("Electron"); + const requestProjectPath = useCallback( + (initialPath?: string) => + new Promise((resolve) => { + window.dispatchEvent( + new CustomEvent("opengui:open-project-path-dialog", { + detail: { resolve, initialPath }, + }), + ); + }), + [], + ); const normalizedSearchQuery = searchQuery.trim().toLowerCase(); const hasActiveSearch = normalizedSearchQuery.length > 0; @@ -493,12 +508,20 @@ export function AppSidebar({ const handleAddProject = useCallback(async () => { if (isLocalWorkspace) { - const dir = await openDirectory(); + const dir = isWebRuntime + ? await requestProjectPath(workspaceDirectory ?? undefined) + : await openDirectory(); if (dir) void connectToProject(dir); return; } setShowRemoteProjectInput(true); - }, [connectToProject, isLocalWorkspace, openDirectory]); + }, [ + connectToProject, + isLocalWorkspace, + isWebRuntime, + openDirectory, + requestProjectPath, + ]); const hasUnsentDraft = useCallback( (sessionId: string) => @@ -1338,6 +1361,8 @@ export function AppSidebar({ + + {/* Worktree creation dialog */} (null); + const [serverBrowserError, setServerBrowserError] = useState(null); + const [serverBrowserLoading, setServerBrowserLoading] = useState(false); const resolverRef = useRef<((value: string | null) => void) | null>(null); + const webRuntime = isWebRuntime(); useEffect(() => { const handleOpen = (event: Event) => { @@ -40,6 +62,7 @@ export function ProjectPathDialog() { resolverRef.current?.(null); resolverRef.current = customEvent.detail.resolve; setValue(customEvent.detail.initialPath ?? workspaceDirectory ?? ""); + setShowServerBrowser(false); setOpen(true); }; @@ -61,9 +84,38 @@ export function ProjectPathDialog() { const normalizedValue = nextValue ? normalizeProjectPath(nextValue) : null; resolverRef.current?.(normalizedValue); resolverRef.current = null; + setShowServerBrowser(false); setOpen(false); }; + const loadServerDirectory = async (path?: string) => { + setServerBrowserLoading(true); + setServerBrowserError(null); + try { + const params = new URLSearchParams(); + if (path) params.set("path", path); + const response = await fetch(`/api/fs/list?${params.toString()}`); + const body = await response.json(); + if (!response.ok || !body?.ok) throw new Error(body?.error || "Failed to list server folders"); + setServerListing(body.value); + setValue(body.value.path); + } catch (error) { + setServerBrowserError(error instanceof Error ? error.message : String(error)); + } finally { + setServerBrowserLoading(false); + } + }; + + const openServerBrowser = () => { + setShowServerBrowser(true); + void loadServerDirectory(value.trim() || undefined); + }; + + const selectServerDirectory = (path: string) => { + setValue(path); + void loadServerDirectory(path); + }; + return ( Open Project - {getPromptMessage(isLocalWorkspace)} + {webRuntime && isLocalWorkspace + ? "Choose a project path on the OpenGUI server. If you use this from a phone, paths are server paths, not phone files." + : getPromptMessage(isLocalWorkspace)} @@ -115,15 +169,66 @@ export function ProjectPathDialog() { type="button" variant="outline" onClick={async () => { + if (webRuntime) { + openServerBrowser(); + return; + } const nextPath = await window.electronAPI?.openDirectory(); if (nextPath) setValue(nextPath); }} > - Browse + {webRuntime ? "Browse server" : "Browse"} )} + {showServerBrowser && ( +
+
+ + {serverListing?.path ?? "Loading server folders..."} + + +
+ {serverBrowserError && ( +
+ {serverBrowserError} +
+ )} +
+ {serverBrowserLoading ? ( +
Loading...
+ ) : serverListing?.entries.length ? ( + serverListing.entries.map((entry) => ( + + )) + ) : ( +
No folders
+ )} +
+
+ Allowed roots: {serverListing?.roots.join(", ") || "server default"} +
+
+ )} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 5165d24..45fdb98 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -402,6 +402,7 @@ export function TitleBar({ } const isMac = platform === "darwin"; + const isWebRuntime = !navigator.userAgent.includes("Electron"); const dialogInitial = dialogMode === "edit" && editingWorkspace @@ -421,6 +422,7 @@ export function TitleBar({ }; const handleDoubleClick = () => { + if (isWebRuntime) return; void window.electronAPI?.maximize(); }; @@ -452,7 +454,7 @@ export function TitleBar({
-
- {isMac ? ( + {!isWebRuntime && ( +
+ {isMac ? (
} @@ -667,8 +670,9 @@ export function TitleBar({ isClose />
- )} -
+ )} +
+ )}
& { asChild?: boolean; isActive?: boolean; tooltip?: string | React.ComponentProps; } & VariantProps) { - const Comp = asChild ? Slot.Root : "button"; + const Comp: React.ElementType = asChild ? Slot.Root : "div"; const { isMobile, state } = useSidebar(); const button = ( @@ -516,8 +519,19 @@ function SidebarMenuButton({ data-sidebar="menu-button" data-size={size} data-active={isActive} + role={asChild ? undefined : "button"} + tabIndex={asChild || disabled ? undefined : 0} + aria-disabled={disabled || undefined} className={cn(sidebarMenuButtonVariants({ variant, size }), className)} - {...props} + onClick={disabled ? undefined : (onClick as React.MouseEventHandler | undefined)} + onKeyDown={(event) => { + (onKeyDown as React.KeyboardEventHandler | undefined)?.(event); + if (asChild || disabled || event.defaultPrevented) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.currentTarget.click(); + }} + {...(props as React.ComponentProps<"div">)} /> ); diff --git a/src/frontend.tsx b/src/frontend.tsx index d203bde..b965f32 100644 --- a/src/frontend.tsx +++ b/src/frontend.tsx @@ -6,8 +6,11 @@ */ import { createRoot } from "react-dom/client"; +import { installWebElectronAPI } from "./lib/web-electron-api"; import { App } from "./App"; +installWebElectronAPI(); + const elem = document.getElementById("root"); if (!elem) throw new Error("Root element not found"); // StrictMode removed: its double-mount behaviour causes IPC event diff --git a/src/hooks/use-agent-impl-core.tsx b/src/hooks/use-agent-impl-core.tsx index b33878a..aec9a1f 100644 --- a/src/hooks/use-agent-impl-core.tsx +++ b/src/hooks/use-agent-impl-core.tsx @@ -3747,9 +3747,20 @@ export function InternalAgentProvider({ (item) => item.id === stateRef.current.activeWorkspaceId, ) ?? createLocalWorkspace(); const url = serverUrl ?? workspace.serverUrl ?? DEFAULT_SERVER_URL; + const normalizedUrl = url.replace(/\/+$/, ""); const username = usernameOverride ?? workspace.username ?? undefined; const password = passwordOverride ?? workspace.password ?? undefined; const workspaceId = workspace.id; + const localServerApi = bridge?.platform?.server; + if ( + workspace.isLocal && + localServerApi && + (normalizedUrl === DEFAULT_SERVER_URL || + normalizedUrl === "http://127.0.0.1:4096" || + normalizedUrl === "http://localhost:4096") + ) { + await localServerApi.start(); + } const worktreeParentMap = getWorktreeParents(); const targetWorkspace = getWorkspaceRootDirectory( trimmedDirectory, @@ -3818,7 +3829,7 @@ export function InternalAgentProvider({ storageSet(STORAGE_KEYS.SERVER_URL, url); } }, - [addProject, connectedDirectorySet], + [addProject, bridge?.platform?.server, connectedDirectorySet], ); const refreshSessions = useCallback(async () => { diff --git a/src/lib/web-electron-api.ts b/src/lib/web-electron-api.ts new file mode 100644 index 0000000..55ee201 --- /dev/null +++ b/src/lib/web-electron-api.ts @@ -0,0 +1,227 @@ +import type { ElectronAPI } from "@/types/electron"; + +type Listener = (data: unknown) => void; + +const SETTINGS_PREFIX = "opengui:web:settings:"; +const listeners = new Map>(); + +function emit(channel: string, data: unknown) { + for (const listener of listeners.get(channel) ?? []) listener(data); +} + +function on(channel: string, callback: Listener) { + let set = listeners.get(channel); + if (!set) { + set = new Set(); + listeners.set(channel, set); + } + set.add(callback); + return () => set?.delete(callback); +} + +async function invoke(channel: string, ...args: unknown[]): Promise { + const response = await fetch("/api/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ channel, args }), + }); + const body = await response.json().catch(() => null); + if (!response.ok || !body?.ok) { + throw new Error(body?.error || `RPC failed: ${channel}`); + } + return body.value as T; +} + +function settingKey(key: string) { + return `${SETTINGS_PREFIX}${key}`; +} + +function settingsGetSync(key: string) { + return localStorage.getItem(settingKey(key)); +} + +function settingsSetSync(key: string, value: string) { + localStorage.setItem(settingKey(key), value); + void invoke("settings:set", key, value).catch(console.error); + emit("settings:changed", { key, value }); + return true; +} + +function settingsRemoveSync(key: string) { + localStorage.removeItem(settingKey(key)); + void invoke("settings:remove", key).catch(console.error); + emit("settings:changed", { key, value: null }); + return true; +} + +function getAllSettingsSync() { + const result: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(SETTINGS_PREFIX)) continue; + const value = localStorage.getItem(key); + if (value != null) result[key.slice(SETTINGS_PREFIX.length)] = value; + } + return result; +} + +function mergeSettingsSync(entries: Record) { + for (const [key, value] of Object.entries(entries)) settingsSetSync(key, value); + void invoke("settings:merge", entries).catch(console.error); + return true; +} + +function subscribeEvents() { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + let closed = false; + let retry: number | undefined; + + const connect = () => { + const ws = new WebSocket(`${protocol}//${location.host}/api/events`); + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + if (message?.channel) emit(message.channel, message.data); + } catch (error) { + console.error("Bad web event", error); + } + }; + ws.onclose = () => { + if (closed) return; + retry = window.setTimeout(connect, 1000); + }; + }; + + connect(); + return () => { + closed = true; + if (retry) window.clearTimeout(retry); + }; +} + +function baseAgent(prefix: "claude-code" | "pi" | "codex") { + return { + addProject: (config: unknown) => invoke(`${prefix}:project:add`, config), + removeProject: (directory: string, workspaceId?: string) => invoke(`${prefix}:project:remove`, directory, workspaceId), + disconnect: () => invoke(`${prefix}:disconnect`), + listSessions: (directory?: string, workspaceId?: string) => invoke(`${prefix}:session:list`, directory, workspaceId), + createSession: (title?: string, directory?: string, workspaceId?: string) => invoke(`${prefix}:session:create`, title, directory, workspaceId), + deleteSession: (sessionId: string, directory?: string, workspaceId?: string) => invoke(`${prefix}:session:delete`, sessionId, directory, workspaceId), + updateSession: (sessionId: string, title: string, directory?: string, workspaceId?: string) => invoke(`${prefix}:session:update`, sessionId, title, directory, workspaceId), + getSessionStatuses: (directory?: string, workspaceId?: string) => invoke(`${prefix}:session:statuses`, directory, workspaceId), + forkSession: (sessionId: string, messageID?: string, directory?: string, workspaceId?: string) => invoke(`${prefix}:session:fork`, sessionId, messageID, directory, workspaceId), + getProviders: (directory?: string, workspaceId?: string) => invoke(`${prefix}:providers`, directory, workspaceId), + getAgents: (directory?: string, workspaceId?: string) => invoke(`${prefix}:agents`, directory, workspaceId), + getCommands: (directory?: string, workspaceId?: string) => invoke(`${prefix}:commands`, directory, workspaceId), + getMessages: (sessionId: string, options?: unknown, directory?: string, workspaceId?: string) => invoke(`${prefix}:messages`, sessionId, options, directory, workspaceId), + startSession: (input: unknown) => invoke(`${prefix}:session:start`, input), + prompt: (sessionId: string, text: string, images: unknown, model: unknown, agent: unknown, variant: unknown, directory?: string, workspaceId?: string) => invoke(`${prefix}:prompt`, sessionId, text, images, model, agent, variant, directory, workspaceId), + abort: (sessionId: string) => invoke(`${prefix}:abort`, sessionId), + respondPermission: (sessionId: string, permissionId: string, response: unknown) => invoke(`${prefix}:permission`, sessionId, permissionId, response), + sendCommand: (sessionId: string, command: string, args: unknown, model: unknown, agent: unknown, variant: unknown, directory?: string, workspaceId?: string) => invoke(`${prefix}:command:send`, sessionId, command, args, model, agent, variant, directory, workspaceId), + summarizeSession: (sessionId: string, model: unknown, directory?: string, workspaceId?: string) => invoke(`${prefix}:session:summarize`, sessionId, model, directory, workspaceId), + findFiles: (directory?: string, workspaceId?: string, query?: string) => invoke(`${prefix}:find:files`, directory, workspaceId, query), + onEvent: (callback: Listener) => on(`${prefix}:bridge-event`, callback), + }; +} + +export function installWebElectronAPI() { + if (window.electronAPI) return; + subscribeEvents(); + + window.electronAPI = { + settings: { + getAllSync: getAllSettingsSync, + getSync: settingsGetSync, + setSync: settingsSetSync, + removeSync: settingsRemoveSync, + mergeSync: mergeSettingsSync, + set: async (key: string, value: string) => settingsSetSync(key, value), + remove: async (key: string) => settingsRemoveSync(key), + onDidChange: (callback: (change: unknown) => void) => on("settings:changed", callback), + }, + minimize: () => invoke("window:minimize"), + maximize: () => invoke("window:maximize"), + close: () => invoke("window:close"), + isMaximized: () => invoke("window:isMaximized"), + getPlatform: () => invoke("platform:get"), + onMaximizeChange: () => () => {}, + openDirectory: () => invoke("dialog:openDirectory"), + detachProject: (projectDir: string) => invoke("window:detachProject", projectDir), + getDetachedProject: () => new URLSearchParams(window.location.search).get("detach"), + getDetachedProjects: () => invoke("window:getDetachedProjects"), + onDetachedProjectsChange: () => () => {}, + openExternal: (url: string) => invoke("shell:openExternal", url), + updates: { + getState: async () => ({ status: "idle" }), + check: async () => undefined, + download: async () => undefined, + install: async () => undefined, + onStateChanged: () => () => {}, + }, + openInFileBrowser: (dirPath: string, command = "") => invoke("shell:openInFileBrowser", dirPath, command), + openInTerminal: (dirPath: string, command = "") => invoke("shell:openInTerminal", dirPath, command), + getHomeDir: () => invoke("platform:homeDir"), + worktree: { + detectSetup: (worktreePath: string) => invoke("worktree:detect-setup", worktreePath), + runSetup: (worktreePath: string, command: string) => invoke("worktree:run-setup", worktreePath, command), + }, + git: { + isRepo: (directory: string) => invoke("git:is-repo", directory), + listBranches: (directory: string) => invoke("git:branch:list", directory), + currentBranch: (directory: string) => invoke("git:current-branch", directory), + listWorktrees: (directory: string) => invoke("git:worktree:list", directory), + addWorktree: (directory: string, worktreePath: string, branch: string, isNewBranch: boolean) => invoke("git:worktree:add", directory, worktreePath, branch, isNewBranch), + removeWorktree: (directory: string, worktreePath: string) => invoke("git:worktree:remove", directory, worktreePath), + merge: (directory: string, branch: string) => invoke("git:merge", directory, branch), + mergeAbort: (directory: string) => invoke("git:merge:abort", directory), + getRemoteUrl: (directory: string) => invoke("git:remote:url", directory), + }, + claudeCode: baseAgent("claude-code"), + pi: baseAgent("pi"), + codex: baseAgent("codex"), + opencode: { + addProject: (config: unknown) => invoke("opencode:project:add", config), + removeProject: (directory: string, workspaceId?: string) => invoke("opencode:project:remove", directory, workspaceId), + disconnect: () => invoke("opencode:disconnect"), + listSessions: (directory?: string, workspaceId?: string) => invoke("opencode:session:list", directory, workspaceId), + createSession: (title?: string, directory?: string, workspaceId?: string) => invoke("opencode:session:create", title, directory, workspaceId), + deleteSession: (id: string) => invoke("opencode:session:delete", id), + updateSession: (id: string, title: string) => invoke("opencode:session:update", id, title), + getSessionStatuses: (directory?: string, workspaceId?: string) => invoke("opencode:session:statuses", directory, workspaceId), + revertSession: (id: string, messageID: string, partID?: string) => invoke("opencode:session:revert", id, messageID, partID), + unrevertSession: (id: string) => invoke("opencode:session:unrevert", id), + forkSession: (id: string, messageID?: string) => invoke("opencode:session:fork", id, messageID), + getProviders: (directory?: string, workspaceId?: string) => invoke("opencode:providers", directory, workspaceId), + listAllProviders: (directory?: string, workspaceId?: string) => invoke("opencode:provider:list", directory, workspaceId), + getProviderAuthMethods: (directory?: string, workspaceId?: string) => invoke("opencode:provider:auth-methods", directory, workspaceId), + connectProvider: (directory: string | undefined, workspaceId: string | undefined, providerID: string, auth: unknown) => invoke("opencode:provider:connect", directory, workspaceId, providerID, auth), + disconnectProvider: (directory: string | undefined, workspaceId: string | undefined, providerID: string) => invoke("opencode:provider:disconnect", directory, workspaceId, providerID), + oauthAuthorize: (directory: string | undefined, workspaceId: string | undefined, providerID: string, method: unknown) => invoke("opencode:provider:oauth:authorize", directory, workspaceId, providerID, method), + oauthCallback: (directory: string | undefined, workspaceId: string | undefined, providerID: string, method: unknown, code: string) => invoke("opencode:provider:oauth:callback", directory, workspaceId, providerID, method, code), + disposeInstance: (directory?: string, workspaceId?: string) => invoke("opencode:instance:dispose", directory, workspaceId), + getAgents: (directory?: string, workspaceId?: string) => invoke("opencode:agents", directory, workspaceId), + getMessages: (sessionId: string, options?: unknown, directory?: string, workspaceId?: string) => invoke("opencode:messages", sessionId, options, directory, workspaceId), + prompt: (sessionId: string, text: string, images: unknown, model: unknown, agent: unknown, variant: unknown) => invoke("opencode:prompt", sessionId, text, images, model, agent, variant), + abort: (sessionId: string) => invoke("opencode:abort", sessionId), + respondPermission: (sessionId: string, permissionId: string, response: unknown) => invoke("opencode:permission", sessionId, permissionId, response), + getCommands: (directory?: string, workspaceId?: string) => invoke("opencode:commands", directory, workspaceId), + sendCommand: (sessionId: string, command: string, args: unknown, model: unknown, agent: unknown, variant: unknown) => invoke("opencode:command:send", sessionId, command, args, model, agent, variant), + summarizeSession: (sessionId: string, model: unknown) => invoke("opencode:session:summarize", sessionId, model), + replyQuestion: (requestID: string, answers: unknown) => invoke("opencode:question:reply", requestID, answers), + rejectQuestion: (requestID: string) => invoke("opencode:question:reject", requestID), + getMcpStatus: (directory?: string, workspaceId?: string) => invoke("opencode:mcp:status", directory, workspaceId), + addMcp: (directory: string | undefined, workspaceId: string | undefined, name: string, config: unknown) => invoke("opencode:mcp:add", directory, workspaceId, name, config), + connectMcp: (directory: string | undefined, workspaceId: string | undefined, name: string) => invoke("opencode:mcp:connect", directory, workspaceId, name), + disconnectMcp: (directory: string | undefined, workspaceId: string | undefined, name: string) => invoke("opencode:mcp:disconnect", directory, workspaceId, name), + getConfig: (directory?: string, workspaceId?: string) => invoke("opencode:config:get", directory, workspaceId), + updateConfig: (directory: string | undefined, workspaceId: string | undefined, config: unknown) => invoke("opencode:config:update", directory, workspaceId, config), + findFiles: (directory?: string, workspaceId?: string, query?: string) => invoke("opencode:find:files", directory, workspaceId, query), + getSkills: (directory?: string, workspaceId?: string) => invoke("opencode:skills", directory, workspaceId), + startServer: () => invoke("opencode:server:start"), + stopServer: () => invoke("opencode:server:stop"), + getServerStatus: () => invoke("opencode:server:status"), + onEvent: (callback: Listener) => on("opencode:bridge-event", callback), + }, + } as unknown as ElectronAPI; +}