Compare commits

...

14 Commits

Author SHA1 Message Date
Shantur Rathore
68551f6731 fix(ui): unify apply_patch diagnostics matching 2026-04-20 21:08:33 +01:00
Shantur Rathore
662a6b94b0 fix(ui): remove delete shortcuts from recent lists 2026-04-20 20:51:36 +01:00
Pascal André
77df40169a Fix WSL UNC OpenCode binaries on Windows (#341)
## Summary
- support Windows validation and launch of OpenCode binaries stored
under WSL UNC paths like \\wsl.localhost\...
- harden the existing manual directory browser so absolute, UNC, and WSL
paths can be pasted and navigated reliably
- harden WSL env/path propagation, UNC workspace handling, runtime
shutdown, and add targeted tests

Partially addresses #5.

## Testing
- node --test --import tsx src/workspaces/__tests__/spawn.test.ts
- npm run typecheck --workspace @neuralnomads/codenomad
- npm run typecheck --workspace @codenomad/ui
2026-04-20 20:29:08 +01:00
Shantur Rathore
3b411e2e73 fix(ui): gate desktop privileges by host and window context (#347)
Don't let remote server windows use local features like local file browser etc
2026-04-20 20:28:11 +01:00
Shantur Rathore
016c7bda4a fix(tauri): use in-app certificate install confirmation 2026-04-20 08:49:50 +01:00
Pascal André
04fc28c492 feat(tauri): support self-signed remote HTTPS via server-backed proxy (#333)
## Summary

- add a server-backed HTTPS proxy flow for Tauri remote windows so
self-signed remote HTTPS works with the local CLI TLS assets and desktop
auth/cookie handling
- manage remote proxy sessions through `packages/server` with
per-session bootstrap, local-only cleanup, and explicit session
lifecycle handling
- support the Tauri desktop flow across environments, including packaged
Windows builds, `tauri dev`, and updated Linux/macOS handling for the
new local HTTPS proxy path

## Testing

- `npm run build --workspace @neuralnomads/codenomad`
- `cargo check`
- `npm run build --workspace @codenomad/tauri-app`
- Windows smoke test for concurrent remote proxy bootstrap sessions
- Windows manual validation of packaged Tauri remote connection flow

## Notes

- Windows was validated end-to-end.
- Linux and macOS code paths were updated for the new proxy flow, but
runtime validation on those platforms is still pending.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-19 23:26:55 +01:00
Shantur Rathore
623a09fd7e fix(ui): stabilize long reply hold during streaming 2026-04-19 19:56:48 +01:00
Shantur Rathore
b00aa7ef84 fix(build): add Windows ARM64 Rollup native package 2026-04-19 08:49:23 +01:00
Pascal André
acfa265595 fix(build): align Rollup native packages with supported platforms (#337)
Fixes #324

## Summary
- declare root Rollup optional dependencies for the repo's current
supported build matrix: macOS x64/arm64, Linux x64/arm64, and Windows
x64
- pin those root platform packages to the same Rollup version already
used by the repo
- keep the existing workflow/manual-install fallback steps in place for
now

## Validation
- regenerated `package-lock.json` with `npm install --package-lock-only
--ignore-scripts`
- verified the root package entry now records the supported platform
packages under `optionalDependencies`
- kept the change scoped to the platforms currently represented in
workflows and `packages/tauri-app/scripts/prebuild.js`
2026-04-19 08:40:49 +01:00
Pascal André
35b171764e fix(desktop): align Electron package and runtime app ids (#342)
Follow-up from #334

## Summary
- align the Electron package `build.appId` with the runtime identifier
already used in `app.setAppUserModelId(...)`
- remove the mismatch between packaged desktop identity and runtime
desktop identity
- keep the change narrowly scoped to identifier consistency only

## Validation
- verified the previous mismatch in `packages/electron-app/package.json`
vs `packages/electron-app/electron/main/main.ts`
- updated the packaging id to match the runtime id exactly
2026-04-18 23:56:58 +01:00
Pascal André
6b53ab2d73 fix(ui): prevent session status labels from being retranslated (#339)
Fixes #273

## Summary
- mark the session list header label as non-translatable
- mark compact session status badges as non-translatable
- prevent browser/page translation from duplicating already localized
labels like the repeated idle badge shown in #273

## Validation
- `npm run build --workspace @codenomad/ui`
2026-04-18 23:49:38 +01:00
Pascal André
1b829094ef fix(desktop): improve Linux desktop icon integration (#334)
Refs #330

## Summary
- add standard Linux hicolor icon sizes to the Tauri package outputs
- enable the GTK app id on Linux and ship a matching reverse-DNS desktop
entry alias for shell association
- mark the alias desktop entry `NoDisplay=true` so it does not surface
as a duplicate launcher in desktop menus
- include the same alias desktop entry for AppImage so the fix is not
limited to deb/rpm packages

## Validation
- confirmed in the Linux VM that the desktop-integrated launch no longer
shows the generic taskbar icon
- verified the alias desktop entry is now hidden from app menus via
`NoDisplay=true`
- attempted a fresh `tauri build --bundles deb`; the build still hits
the known optional `@tauri-apps/cli` native-binding issue in this
workspace after prebuild, not a code/config error from this PR
2026-04-18 23:46:03 +01:00
Pascal André
e28e9f5879 fix(desktop): show explicit missing Node errors (#336)
Fixes #294

## Summary
- detect missing desktop Node runtimes before spawning the bundled CLI
- return a clear error message that tells users to install Node.js or
set `NODE_BINARY`
- handle both direct spawns and desktop-shell launches consistently

## Validation
- `npm run bundle:server --workspace @codenomad/tauri-app && cargo build
--manifest-path packages/tauri-app/src-tauri/Cargo.toml`
- exercised the missing-runtime path in the Linux VM by launching with
an invalid `NODE_BINARY`
2026-04-18 23:39:39 +01:00
Pascal André
cb84547c88 fix(desktop): source shell rc before launching CLI (#332)
Fixes #326

## Summary
- source the user's bash or zsh rc before launching the bundled CLI from
Tauri
- use `-l -i -c` for zsh so shell-managed Node runtimes are available in
launcher-started sessions
- fixes the reproduced Linux launcher case where the app exits with `CLI
exited early: exit status: 127` while terminal launches work

## Validation
- reproduced the failure with the released Tauri `v0.14.0` Linux binary
- verified the patched binary succeeds under the same launcher-like
environment
- ran `cargo build` on the dev-based PR branch
2026-04-18 23:34:49 +01:00
62 changed files with 6341 additions and 595 deletions

367
package-lock.json generated
View File

@@ -15,6 +15,14 @@
"devDependencies": { "devDependencies": {
"baseline-browser-mapping": "^2.9.11" "baseline-browser-mapping": "^2.9.11"
}, },
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "4.52.5",
"@rollup/rollup-darwin-x64": "4.52.5",
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
"@rollup/rollup-linux-x64-gnu": "4.52.5",
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
"@rollup/rollup-win32-x64-msvc": "4.52.5"
},
"workspaces": { "workspaces": {
"packages": [ "packages": [
"packages/server", "packages/server",
@@ -64,6 +72,7 @@
"version": "7.28.5", "version": "7.28.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -2930,16 +2939,304 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
"integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz",
"integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz",
"integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.5", "version": "4.52.5",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz",
"integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz",
"integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==",
"cpu": [
"x64"
],
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz",
"integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz",
"integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz",
"integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz",
"integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz",
"integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz",
"integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz",
"integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz",
"integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz",
"integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz",
"integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz",
"integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz",
"integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz",
"integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz",
"integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz",
"integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz",
"integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
] ]
}, },
"node_modules/@shikijs/core": { "node_modules/@shikijs/core": {
@@ -3380,6 +3677,7 @@
"version": "7.20.5", "version": "7.20.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.20.7", "@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7", "@babel/types": "^7.20.7",
@@ -3481,6 +3779,7 @@
"version": "22.19.0", "version": "22.19.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3555,6 +3854,7 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
"colorette": "^2.0.20", "colorette": "^2.0.20",
@@ -3637,6 +3937,7 @@
"version": "6.12.6", "version": "6.12.6",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -3839,7 +4140,6 @@
"version": "5.3.2", "version": "5.3.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^2.1.0", "archiver-utils": "^2.1.0",
"async": "^3.2.4", "async": "^3.2.4",
@@ -3857,7 +4157,6 @@
"version": "2.1.0", "version": "2.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.4", "glob": "^7.1.4",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -3878,7 +4177,6 @@
"version": "2.3.8", "version": "2.3.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -3892,14 +4190,12 @@
"node_modules/archiver-utils/node_modules/safe-buffer": { "node_modules/archiver-utils/node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/archiver-utils/node_modules/string_decoder": { "node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -4213,7 +4509,6 @@
"version": "4.1.0", "version": "4.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@@ -4277,6 +4572,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4767,7 +5063,6 @@
"version": "4.1.2", "version": "4.1.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer-crc32": "^0.2.13", "buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2", "crc32-stream": "^4.0.2",
@@ -4897,7 +5192,6 @@
"version": "1.2.2", "version": "1.2.2",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"crc32": "bin/crc32.njs" "crc32": "bin/crc32.njs"
}, },
@@ -4909,7 +5203,6 @@
"version": "4.0.3", "version": "4.0.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
@@ -5275,6 +5568,7 @@
"version": "24.13.3", "version": "24.13.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"builder-util": "24.13.1", "builder-util": "24.13.1",
@@ -5441,7 +5735,6 @@
"version": "24.13.3", "version": "24.13.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@@ -5453,7 +5746,6 @@
"version": "10.1.0", "version": "10.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -5467,7 +5759,6 @@
"version": "6.2.0", "version": "6.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -5479,7 +5770,6 @@
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -6197,8 +6487,7 @@
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
@@ -7415,8 +7704,7 @@
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/isbinaryfile": { "node_modules/isbinaryfile": {
"version": "5.0.6", "version": "5.0.6",
@@ -7466,6 +7754,7 @@
"version": "1.21.7", "version": "1.21.7",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -7597,7 +7886,6 @@
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readable-stream": "^2.0.5" "readable-stream": "^2.0.5"
}, },
@@ -7609,7 +7897,6 @@
"version": "2.3.8", "version": "2.3.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -7623,14 +7910,12 @@
"node_modules/lazystream/node_modules/safe-buffer": { "node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lazystream/node_modules/string_decoder": { "node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -7695,26 +7980,22 @@
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.difference": { "node_modules/lodash.difference": {
"version": "4.5.0", "version": "4.5.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.sortby": { "node_modules/lodash.sortby": {
"version": "4.7.0", "version": "4.7.0",
@@ -7726,8 +8007,7 @@
"node_modules/lodash.union": { "node_modules/lodash.union": {
"version": "4.6.0", "version": "4.6.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lowercase-keys": { "node_modules/lowercase-keys": {
"version": "2.0.0", "version": "2.0.0",
@@ -8531,6 +8811,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -8678,8 +8959,7 @@
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/process-warning": { "node_modules/process-warning": {
"version": "3.0.0", "version": "3.0.0",
@@ -8928,7 +9208,6 @@
"version": "3.6.2", "version": "3.6.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@@ -8942,7 +9221,6 @@
"version": "1.1.3", "version": "1.1.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^5.1.0"
} }
@@ -9245,6 +9523,7 @@
"version": "4.52.5", "version": "4.52.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -9468,6 +9747,7 @@
"node_modules/seroval": { "node_modules/seroval": {
"version": "1.3.2", "version": "1.3.2",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
@@ -9791,6 +10071,7 @@
"node_modules/solid-js": { "node_modules/solid-js": {
"version": "1.9.10", "version": "1.9.10",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.1.0", "csstype": "^3.1.0",
"seroval": "~1.3.0", "seroval": "~1.3.0",
@@ -9931,7 +10212,6 @@
"version": "1.3.0", "version": "1.3.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@@ -10265,7 +10545,6 @@
"version": "2.2.0", "version": "2.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@@ -10458,6 +10737,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10707,6 +10987,7 @@
"version": "5.9.3", "version": "5.9.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11054,6 +11335,7 @@
"version": "5.4.21", "version": "5.4.21",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -11538,6 +11820,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -11732,6 +12015,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },
@@ -12020,7 +12304,6 @@
"version": "4.1.1", "version": "4.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^3.0.4", "archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2", "compress-commons": "^4.1.2",
@@ -12034,7 +12317,6 @@
"version": "3.0.4", "version": "3.0.4",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.2.3", "glob": "^7.2.3",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -12054,6 +12336,7 @@
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -30,5 +30,13 @@
}, },
"devDependencies": { "devDependencies": {
"baseline-browser-mapping": "^2.9.11" "baseline-browser-mapping": "^2.9.11"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "4.52.5",
"@rollup/rollup-darwin-x64": "4.52.5",
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
"@rollup/rollup-linux-x64-gnu": "4.52.5",
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
"@rollup/rollup-win32-x64-msvc": "4.52.5"
} }
} }

View File

@@ -277,6 +277,7 @@ function createWindow() {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
spellcheck: !isMac, spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=local"],
}, },
}) })
@@ -440,6 +441,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
spellcheck: !isMac, spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=remote"],
}, },
}) })

View File

@@ -1,6 +1,19 @@
const { contextBridge, ipcRenderer, webUtils } = require("electron") const { contextBridge, ipcRenderer, webUtils } = require("electron")
const electronAPI = { function resolveWindowContext() {
const prefix = "--codenomad-window-context="
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
const context = arg ? arg.slice(prefix.length) : "local"
return context === "remote" ? "remote" : "local"
}
function resolveRuntimeHost(windowContext) {
return "electron"
}
const windowContext = resolveWindowContext()
const localElectronAPI = {
onCliStatus: (callback) => { onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data)) ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status") return () => ipcRenderer.removeAllListeners("cli:status")
@@ -26,4 +39,15 @@ const electronAPI = {
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
} }
contextBridge.exposeInMainWorld("electronAPI", electronAPI) const remoteElectronAPI = {
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
setWakeLock: localElectronAPI.setWakeLock,
showNotification: localElectronAPI.showNotification,
}
contextBridge.exposeInMainWorld(
"electronAPI",
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
)
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))

View File

@@ -62,7 +62,7 @@
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"
}, },
"build": { "build": {
"appId": "ai.opencode.client", "appId": "ai.neuralnomads.codenomad.client",
"productName": "CodeNomad", "productName": "CodeNomad",
"directories": { "directories": {
"output": "release", "output": "release",

View File

@@ -337,6 +337,16 @@ export interface RemoteServerProbeResponse {
errorCode?: string errorCode?: string
} }
export interface RemoteProxySessionCreateRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteProxySessionCreateResponse {
sessionId: string
windowUrl: string
}
export type WorkspaceEventType = export type WorkspaceEventType =
| "workspace.created" | "workspace.created"
| "workspace.started" | "workspace.started"

View File

@@ -21,6 +21,7 @@ import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui" import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls" import { resolveHttpsOptions } from "./server/tls"
import { RemoteProxySessionManager } from "./server/remote-proxy"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service" import { SpeechService } from "./speech/service"
@@ -375,14 +376,15 @@ async function main() {
}) })
: null : null
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" })) const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" })) const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
const remoteProxySessionManager = new RemoteProxySessionManager({
authManager,
logger: logger.child({ component: "remote-proxy" }),
httpsOptions: tlsResolution?.httpsOptions,
})
const voiceModeManager = new VoiceModeManager({ const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager, connections: clientConnectionManager,
channel: pluginChannel, channel: pluginChannel,
@@ -422,6 +424,7 @@ async function main() {
clientConnectionManager, clientConnectionManager,
pluginChannel, pluginChannel,
voiceModeManager, voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl, uiDevServerUrl: uiResolution.uiDevServerUrl,
logger, logger,
@@ -447,6 +450,7 @@ async function main() {
clientConnectionManager, clientConnectionManager,
pluginChannel, pluginChannel,
voiceModeManager, voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined, uiDevServerUrl: undefined,
logger, logger,

View File

@@ -0,0 +1,248 @@
import assert from "node:assert/strict"
import { after, afterEach, describe, it } from "node:test"
import fs from "node:fs"
import http, { type IncomingMessage, type ServerResponse } from "node:http"
import os from "node:os"
import path from "node:path"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../../auth/manager"
import type { Logger } from "../../logger"
import { RemoteProxySessionManager } from "../remote-proxy"
import { resolveHttpsOptions } from "../tls"
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
const sharedTls = resolveHttpsOptions({
enabled: true,
configDir: sharedTempDir,
host: "127.0.0.1",
logger: createStubLogger(),
})
if (!sharedTls) {
throw new Error("Failed to generate HTTPS options for remote proxy tests")
}
const sharedHttpsOptions = sharedTls.httpsOptions
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
const managers = new Set<RemoteProxySessionManager>()
afterEach(async () => {
for (const manager of managers) {
await disposeManager(manager)
}
managers.clear()
})
after(() => {
fs.rmSync(sharedTempDir, { recursive: true, force: true })
httpsDispatcher.close().catch(() => {})
})
describe("RemoteProxySessionManager", () => {
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
assert.equal(blocked.status, 403)
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session2.token }),
})
assert.equal(wrongTokenResponse.status, 401)
assert.equal(await activateSession(session1), true)
assert.equal(await activateSession(session2), true)
}, (req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.url ?? "")
})
})
it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
assert.equal(apiResponse.status, 200)
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" })
assert.equal(redirectResponse.status, 302)
assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`)
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/redirect") {
res.writeHead(302, { location: "/base/after?ok=1" })
res.end()
return
}
res.writeHead(200, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
assert.equal(loginResponse.status, 200)
const setCookie = getSetCookie(loginResponse)[0]
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
assert.doesNotMatch(setCookie, /domain=/i)
const cookieHeader = setCookie.split(";", 1)[0]
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
headers: { cookie: cookieHeader },
})
assert.equal(await whoamiResponse.text(), "session=abc123")
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/login") {
res.writeHead(200, {
"content-type": "text/plain",
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
})
res.end("ok")
return
}
if (requestUrl === "/base/whoami") {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.headers.cookie ?? "")
return
}
res.writeHead(404, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("supports explicit deletion and idle cleanup of sessions", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
assert.equal(await manager.deleteSession(session.sessionId), true)
assert.equal(await manager.deleteSession(session.sessionId), false)
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
await internalCleanup.call(manager)
assert.equal(internalSessions.has(session3.sessionId), false)
assert.equal(await manager.deleteSession(session3.sessionId), false)
}, (_req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end("ok")
})
})
})
function createSessionManager() {
const manager = new RemoteProxySessionManager({
authManager: {
isLoopbackRequest: () => true,
} as unknown as AuthManager,
logger: createStubLogger(),
httpsOptions: sharedHttpsOptions,
})
managers.add(manager)
return manager
}
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
const created = await manager.createSession(baseUrl, false)
const windowUrl = new URL(created.windowUrl)
return {
sessionId: created.sessionId,
windowUrl,
proxyOrigin: windowUrl.origin,
token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")),
}
}
async function activateSession(session: { proxyOrigin: string; token: string }) {
const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session.token }),
})
if (!response.ok) {
return false
}
const body = (await response.json()) as { ok?: boolean }
return body.ok === true
}
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(values) && values.length > 0) {
return values
}
const fallback = response.headers.get("set-cookie")
return fallback ? [fallback] : []
}
async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
return fetch(url, { dispatcher: httpsDispatcher, ...init })
}
async function disposeManager(manager: RemoteProxySessionManager) {
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).keys())
for (const sessionId of sessions) {
await manager.deleteSession(sessionId)
}
clearInterval((manager as any).cleanupTimer as NodeJS.Timeout)
}
async function withUpstreamServer(
callback: (baseUrl: string) => Promise<void>,
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
) {
const server = http.createServer(handler)
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
try {
const address = server.address()
if (!address || typeof address === "string") {
throw new Error("Failed to resolve upstream server address")
}
await callback(`http://127.0.0.1:${address.port}`)
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
}
function createStubLogger(): Logger {
const logger = {
info() {},
warn() {},
error() {},
child() {
return logger
},
}
return logger as unknown as Logger
}

View File

@@ -26,6 +26,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees" import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech" import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers" import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars" import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
@@ -38,6 +39,7 @@ import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel" import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode" import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager" import type { SideCarManager } from "../sidecars/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
interface HttpServerDeps { interface HttpServerDeps {
bindHost: string bindHost: string
@@ -58,6 +60,7 @@ interface HttpServerDeps {
clientConnectionManager: ClientConnectionManager clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager
uiStaticDir: string uiStaticDir: string
uiDevServerUrl?: string uiDevServerUrl?: string
logger: Logger logger: Logger
@@ -199,7 +202,12 @@ export function createHttpServer(deps: HttpServerDeps) {
publicPagePaths.add("/auth/token") publicPagePaths.add("/auth/token")
} }
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) { const isLoopbackRemoteProxyDelete =
request.method === "DELETE" &&
pathname.startsWith("/api/remote-proxy/sessions/") &&
deps.authManager.isLoopbackRequest(request)
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
done() done()
return return
} }
@@ -274,6 +282,7 @@ export function createHttpServer(deps: HttpServerDeps) {
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
}) })
registerRemoteServerRoutes(app, { logger: apiLogger }) registerRemoteServerRoutes(app, { logger: apiLogger })
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
registerSpeechRoutes(app, { speechService: deps.speechService }) registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })

View File

@@ -0,0 +1,566 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import { randomBytes, randomUUID } from "crypto"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../auth/manager"
import type { Logger } from "../logger"
const LOOPBACK_HOST = "127.0.0.1"
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
const SESSION_IDLE_TTL_MS = 30 * 60_000
interface RemoteProxySession {
id: string
bootstrapToken: string
targetBaseUrl: URL
skipTlsVerify: boolean
localBaseUrl: URL
entryUrl: URL
bootstrapUrl: string
activated: boolean
cookiePrefix: string
app: FastifyInstance
dispatcher?: Agent
createdAt: number
lastAccessAt: number
}
export interface RemoteProxySessionManagerOptions {
authManager: AuthManager
logger: Logger
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
}
export interface RemoteProxySessionCreateResult {
sessionId: string
windowUrl: string
}
export class RemoteProxySessionManager {
private readonly sessions = new Map<string, RemoteProxySession>()
private readonly cleanupTimer: NodeJS.Timeout
constructor(private readonly options: RemoteProxySessionManagerOptions) {
this.cleanupTimer = setInterval(() => {
void this.cleanupExpiredSessions()
}, 60_000)
this.cleanupTimer.unref()
}
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
if (!this.options.httpsOptions) {
throw new Error("Local HTTPS is required for remote proxy sessions")
}
const targetBaseUrl = normalizeBaseUrl(baseUrl)
const sessionId = randomUUID()
const bootstrapToken = randomBytes(32).toString("base64url")
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
const app = Fastify({ logger: false, https: this.options.httpsOptions })
let session: RemoteProxySession | null = null
app.removeAllContentTypeParsers()
// Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body))
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(buildBootstrapPageHtml())
})
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
const body = parseTokenBody(request.body)
if (body.token !== session.bootstrapToken) {
reply.code(401).send({ error: "Invalid token" })
return
}
session.activated = true
session.lastAccessAt = Date.now()
reply.send({ ok: true })
})
app.all("/*", async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
app.setNotFoundHandler(async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
const address = new URL(addressInfo)
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
const returnTo = buildReturnToTarget(entryUrl)
session = {
id: sessionId,
bootstrapToken,
targetBaseUrl,
skipTlsVerify,
localBaseUrl,
entryUrl,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
activated: false,
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
app,
dispatcher,
createdAt: Date.now(),
lastAccessAt: Date.now(),
}
this.sessions.set(sessionId, session)
this.options.logger.info(
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
"Created remote proxy session",
)
return { sessionId, windowUrl: session.bootstrapUrl }
}
async deleteSession(sessionId: string): Promise<boolean> {
return this.disposeSession(sessionId)
}
private async cleanupExpiredSessions() {
const now = Date.now()
for (const session of Array.from(this.sessions.values())) {
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
continue
}
await this.disposeSession(session.id)
}
}
private async disposeSession(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (!session) {
return false
}
this.sessions.delete(sessionId)
session.dispatcher?.close().catch(() => {})
await session.app.close().catch(() => {})
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
return true
}
}
function normalizeBaseUrl(input: string): URL {
const parsed = new URL(input.trim())
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Server URL must use http:// or https://")
}
parsed.hash = ""
parsed.search = ""
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
return parsed
}
function buildReturnToTarget(entryUrl: URL): string {
const query = entryUrl.search ? entryUrl.search : ""
return `${entryUrl.pathname || "/"}${query}`
}
function buildBootstrapPageHtml(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
h1 { font-size: 18px; margin: 0 0 12px; }
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Connecting...</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error"></div>
</div>
<script>
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
const params = new URLSearchParams(location.search)
const returnTo = sanitizeReturnTo(params.get("returnTo"))
const errorEl = document.getElementById("error")
function sanitizeReturnTo(value) {
if (!value || typeof value !== "string") return "/"
if (!value.startsWith("/")) return "/"
if (value.startsWith("//")) return "/"
return value
}
function showError(message) {
errorEl.textContent = message
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || "Token exchange failed (" + res.status + ")")
return
}
window.location.replace(returnTo)
} catch (error) {
showError(error && error.message ? error.message : String(error))
}
}
run()
</script>
</body>
</html>`
}
function parseTokenBody(body: unknown): { token: string } {
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
const token = typeof value?.token === "string" ? value.token.trim() : ""
if (!token) {
throw new Error("Missing bootstrap token")
}
return { token }
}
function normalizeJsonBody(body: unknown): unknown {
if (Buffer.isBuffer(body)) {
return JSON.parse(body.toString("utf-8"))
}
if (typeof body === "string") {
return JSON.parse(body)
}
return body
}
function toRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
async function proxyRequest(args: {
request: FastifyRequest
reply: FastifyReply
session: RemoteProxySession
logger: Logger
}) {
const { request, reply, session, logger } = args
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
const headers = filterRequestHeaders(request.headers, session)
const init: any = {
method: request.method,
headers,
dispatcher: session.dispatcher,
redirect: "manual",
}
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
try {
const response = await fetch(upstreamUrl, init as any)
reply.code(response.status)
applyResponseHeaders(reply, response, session)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
reply.hijack()
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
} catch (error) {
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
if (!reply.sent) {
reply.code(502).send({ error: "Remote proxy request failed" })
}
}
}
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
const parsed = new URL(rawUrl, "https://localhost")
const url = new URL(baseUrl.toString())
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
url.search = stripInternalQuery(parsed.search)
url.hash = ""
return url.toString()
}
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
const basePath = normalizedBasePath(baseUrl)
if (basePath === "/") {
return requestPath
}
if (requestPath === "/") {
return basePath
}
if (pathHasBasePrefix(basePath, requestPath)) {
return requestPath
}
return `${basePath}${requestPath}`
}
function normalizedBasePath(baseUrl: URL): string {
return baseUrl.pathname || "/"
}
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
}
function stripInternalQuery(search: string): string {
if (!search || search === "?") {
return ""
}
return search
}
function filterRequestHeaders(
headers: FastifyRequest["headers"],
session: RemoteProxySession,
): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value) continue
const lower = key.toLowerCase()
if (
isHopByHopHeader(lower) ||
lower === "host" ||
lower === "content-length" ||
lower === "accept-encoding"
) {
continue
}
if (lower === "origin") {
next[key] = session.targetBaseUrl.origin
continue
}
if (lower === "referer") {
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
if (rewritten) {
next[key] = rewritten
}
continue
}
if (lower === "cookie") {
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
if (rewritten) {
next[key] = rewritten
}
continue
}
next[key] = Array.isArray(value) ? value.join(",") : value
}
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
if (!next.origin) {
next.origin = session.targetBaseUrl.origin
}
return next
}
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
if (!referer) {
return null
}
try {
const parsed = new URL(referer)
const rewritten = new URL(targetBaseUrl.toString())
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return null
}
}
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(setCookie)) {
for (const cookie of setCookie) {
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
}
}
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (
isHopByHopHeader(lower) ||
lower === "set-cookie" ||
lower === "content-length" ||
lower === "content-encoding"
) {
return
}
if (lower === "location") {
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
return
}
reply.header(key, value)
})
}
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
const next: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
next[key] = Array.isArray(value) ? value.map(String) : String(value)
}
return next
}
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
const parts = cookie.split(";").map((part) => part.trim())
const first = parts.shift() ?? ""
const separator = first.indexOf("=")
if (separator <= 0) {
return cookie
}
const name = first.slice(0, separator).trim()
const value = first.slice(separator + 1)
const rewritten = [`${cookiePrefix}${name}=${value}`]
for (const part of parts) {
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
continue
}
rewritten.push(part)
}
return rewritten.join("; ")
}
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
const next: string[] = []
for (const rawPart of cookieHeader.split(";")) {
const part = rawPart.trim()
if (!part) continue
const separator = part.indexOf("=")
if (separator <= 0) continue
const name = part.slice(0, separator).trim()
const value = part.slice(separator + 1)
if (!name.startsWith(cookiePrefix)) {
continue
}
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
}
return next.join("; ")
}
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
try {
const parsed = new URL(location, targetBaseUrl)
if (parsed.origin !== targetBaseUrl.origin) {
return location
}
const rewritten = new URL(localBaseUrl.toString())
rewritten.pathname = parsed.pathname
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return location
}
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}

View File

@@ -0,0 +1,54 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { RemoteProxySessionCreateResponse } from "../../api-types"
import { isLoopbackAddress } from "../../auth/http-auth"
import type { Logger } from "../../logger"
import type { RemoteProxySessionManager } from "../remote-proxy"
interface RouteDeps {
logger: Logger
sessionManager: RemoteProxySessionManager
}
const CreateSessionSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
const SessionParamsSchema = z.object({
id: z.string().uuid(),
})
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
try {
const body = CreateSessionSchema.parse(request.body ?? {})
return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
} catch (error) {
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
}
})
app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => {
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404)
return { error: "Not found" }
}
try {
const params = SessionParamsSchema.parse(request.params ?? {})
const deleted = await deps.sessionManager.deleteSession(params.id)
if (!deleted) {
reply.code(404)
return { error: "Remote proxy session not found" }
}
return { ok: true }
} catch (error) {
deps.logger.warn({ err: error }, "Failed to delete remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" }
}
})
}

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime" import { probeBinaryVersion } from "../../workspaces/spawn"
import type { SettingsService } from "../../settings/service" import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger" import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config" import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"

View File

@@ -0,0 +1,193 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
describe("parseWslUncPath", () => {
it("parses WSL UNC paths into distro and linux path", () => {
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
distro: "Ubuntu",
linuxPath: "/home/dev/.opencode/bin/opencode",
})
})
it("supports the legacy wsl$ UNC prefix", () => {
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
distro: "Ubuntu",
linuxPath: "/home/dev",
})
})
})
describe("resolveWslWorkingDirectory", () => {
it("keeps WSL workspace folders in the same distro", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
)
})
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
)
})
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
)
})
it("rejects WSL workspace folders from a different distro", () => {
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
})
})
describe("buildWindowsSpawnSpec", () => {
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve", "--port", "0"],
{
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
env: {
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
CODENOMAD_INSTANCE_ID: "workspace-123",
OPENCODE_SERVER_PASSWORD: "secret",
},
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--cd",
"/home/dev/workspace",
"--exec",
"/home/dev/.opencode/bin/opencode",
"serve",
"--port",
"0",
])
assert.equal(spec.cwd, undefined)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
})
it("upgrades existing WSLENV path entries to include /p", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
env: {
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
},
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
},
)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
})
it("propagates inherited known path variables even when they are not explicitly requested", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
env: {
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
},
},
)
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
})
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve", "--port", "0"],
{
cwd: String.raw`C:\Users\dev\workspace`,
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
"codenomad-wsl-launch",
String.raw`C:\Users\dev\workspace`,
"/home/dev/.opencode/bin/opencode",
"serve",
"--port",
"0",
])
})
it("uses wslpath for UNC network workspace folders", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
cwd: String.raw`\\server\share\workspace`,
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
"codenomad-wsl-launch",
String.raw`\\server\share\workspace`,
"/home/dev/.opencode/bin/opencode",
"serve",
])
})
it("can wrap WSL launches to emit the Linux PID marker", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
wslPidMarker: "__CODENOMAD_WSL_PID__:",
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
"codenomad-wsl-launch",
"/home/dev/workspace",
"/home/dev/.opencode/bin/opencode",
"serve",
])
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
})
it("builds the WSL kill command for tracked Linux PIDs", () => {
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
})
})

View File

@@ -4,100 +4,10 @@ import path from "path"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types" import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger" import { Logger } from "../logger"
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true } as const,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {} as const,
}
}
return { command: binaryPath, args, options: {} as const }
}
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean(
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> { function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {} const redacted: Record<string, string | undefined> = {}
@@ -130,6 +40,10 @@ export interface ProcessExitInfo {
interface ManagedProcess { interface ManagedProcess {
child: ChildProcess child: ChildProcess
requestedStop: boolean requestedStop: boolean
wsl?: {
distro: string
linuxPid: number | null
}
} }
export class WorkspaceRuntime { export class WorkspaceRuntime {
@@ -167,7 +81,13 @@ export class WorkspaceRuntime {
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const spec = buildSpawnSpec(options.binaryPath, args) const propagatedEnvKeys = Object.keys(options.environment ?? {})
const spec = buildSpawnSpec(options.binaryPath, args, {
cwd: options.folder,
env,
propagateEnvKeys: propagatedEnvKeys,
wslPidMarker: WSL_PID_MARKER,
})
const commandLine = [spec.command, ...spec.args].join(" ") const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info( this.logger.info(
{ {
@@ -197,14 +117,18 @@ export class WorkspaceRuntime {
) )
const detached = process.platform !== "win32" const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, { const child = spawn(spec.command, spec.args, {
cwd: options.folder, cwd: spec.cwd,
env, env: spec.env,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
detached, detached,
...spec.options, ...spec.options,
}) })
const managed: ManagedProcess = { child, requestedStop: false } const managed: ManagedProcess = {
child,
requestedStop: false,
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
}
this.processes.set(options.workspaceId, managed) this.processes.set(options.workspaceId, managed)
let stdoutBuffer = "" let stdoutBuffer = ""
@@ -284,6 +208,15 @@ export class WorkspaceRuntime {
const trimmed = line.trim() const trimmed = line.trim()
if (!trimmed) continue if (!trimmed) continue
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
if (Number.isFinite(linuxPid) && linuxPid > 0) {
managed.wsl.linuxPid = linuxPid
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
}
continue
}
recentStdout.push(trimmed) recentStdout.push(trimmed)
if (recentStdout.length > MAX_OUTPUT_LINES) { if (recentStdout.length > MAX_OUTPUT_LINES) {
recentStdout.shift() recentStdout.shift()
@@ -398,11 +331,44 @@ export class WorkspaceRuntime {
} }
} }
const trySignalWslProcess = (signal: NodeJS.Signals) => {
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
return false
}
try {
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("no such process") || combined.includes("not found")) {
return true
}
this.logger.debug(
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
"WSL kill failed",
)
return false
} catch (error) {
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => { const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") { if (process.platform === "win32") {
// Best-effort: terminate the whole process tree rooted at pid. // WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
// Use /F only for escalation. if (!trySignalWslProcess(signal)) {
tryTaskkill(signal === "SIGKILL") // Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
}
return return
} }

View File

@@ -0,0 +1,307 @@
import { spawnSync } from "child_process"
import path from "path"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
export interface SpawnSpec {
command: string
args: string[]
options: {
windowsVerbatimArguments?: boolean
}
cwd?: string
env?: NodeJS.ProcessEnv
wsl?: {
distro: string
pidMarker?: string
}
}
interface BuildSpawnSpecOptions {
cwd?: string
env?: NodeJS.ProcessEnv
propagateEnvKeys?: string[]
wslPidMarker?: string
}
interface WslPath {
distro: string
linuxPath: string
}
export type WslWorkingDirectory =
| { kind: "linux"; path: string }
| { kind: "windows"; path: string }
export function parseWslUncPath(input: string): WslPath | null {
const normalized = input.trim().replace(/\//g, "\\")
const match = normalized.match(WSL_UNC_PATH_REGEX)
if (!match) {
return null
}
const distro = match[1] ?? ""
const remainder = match[2] ?? ""
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
return {
distro,
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
}
}
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
const wslFolder = parseWslUncPath(folder)
if (wslFolder) {
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
}
const windowsFolder = normalizeWindowsPath(folder)
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
}
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
const wslPath = parseWslUncPath(binaryPath)
if (wslPath) {
return buildWslSpawnSpec(wslPath, args, options)
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true },
cwd: options.cwd,
env: options.env,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {},
cwd: options.cwd,
env: options.env,
}
}
return {
command: binaryPath,
args,
options: {},
cwd: options.cwd,
env: options.env,
}
}
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
if (process.platform !== "win32") {
return {
command: binaryPath,
args,
options: {},
cwd: options.cwd,
env: options.env,
}
}
return buildWindowsSpawnSpec(binaryPath, args, options)
}
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
return {
command: "wsl.exe",
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
options: {},
wsl: { distro },
}
}
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
try {
const spec = buildSpawnSpec(binaryPath, ["--version"])
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
cwd: spec.cwd,
env: spec.env,
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
if (options.cwd && !workingDirectory) {
throw new Error(
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
)
}
const wslArgs = ["--distribution", wslPath.distro]
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
wslArgs.push("--cd", workingDirectory.path)
}
if (shouldWrapWithShell) {
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
wslArgs.push(
"--exec",
"sh",
"-lc",
launchScript,
"codenomad-wsl-launch",
)
if (workingDirectory) {
wslArgs.push(workingDirectory.path)
}
wslArgs.push(
wslPath.linuxPath,
...args,
)
} else {
wslArgs.push("--exec", wslPath.linuxPath, ...args)
}
return {
command: "wsl.exe",
args: wslArgs,
options: {},
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
}
}
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
const steps: string[] = []
if (pidMarker) {
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
}
if (workingDirectory?.kind === "linux") {
steps.push('cd "$1"')
steps.push("shift")
} else if (workingDirectory?.kind === "windows") {
steps.push('cd "$(wslpath -au "$1")"')
steps.push("shift")
}
steps.push('exec "$@"')
return steps.join(" && ")
}
function normalizeWindowsPath(input: string): string | null {
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
if (!normalized) {
return null
}
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
return normalized
}
return null
}
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
if (!env) {
return env
}
const keysToPropagate = Array.from(
new Set([
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
]),
)
if (keysToPropagate.length === 0) {
return env
}
const next = { ...env }
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
for (const key of keysToPropagate) {
const existingEntry = byName.get(key)
if (existingEntry) {
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
continue
}
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
}
next.WSLENV = Array.from(byName.values()).join(":")
return next
}
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
if (!requiresPathTranslation) {
return entry
}
const [name, rawFlags = ""] = entry.split("/")
if (rawFlags.includes("p")) {
return entry
}
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
}

View File

@@ -213,6 +213,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@@ -408,6 +430,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -444,6 +468,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -456,17 +486,28 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.14.0" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1",
"dirs 5.0.1", "dirs 5.0.1",
"keepawake", "keepawake",
"libc", "libc",
"once_cell",
"parking_lot", "parking_lot",
"regex", "regex",
"reqwest 0.12.28",
"rustls",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
@@ -476,8 +517,8 @@ dependencies = [
"tauri-plugin-global-shortcut", "tauri-plugin-global-shortcut",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"thiserror 1.0.69",
"url", "url",
"webkit2gtk",
"which", "which",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@@ -969,6 +1010,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "endi" name = "endi"
version = "1.1.1" version = "1.1.1"
@@ -1139,6 +1189,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futf" name = "futf"
version = "0.1.5" version = "0.1.5"
@@ -1379,8 +1435,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1390,9 +1448,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1574,6 +1634,25 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1699,6 +1778,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1710,6 +1790,23 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -1999,6 +2096,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.91" version = "0.3.91"
@@ -2157,6 +2264,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@@ -2995,6 +3108,61 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -3212,6 +3380,50 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.2" version = "0.13.2"
@@ -3242,7 +3454,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams 0.5.0",
"web-sys", "web-sys",
] ]
@@ -3270,6 +3482,20 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@@ -3311,6 +3537,44 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -3531,6 +3795,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.18.0" version = "3.18.0"
@@ -3792,6 +4068,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3943,7 +4225,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.2",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -4367,6 +4649,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.50.0" version = "1.50.0"
@@ -4381,6 +4678,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -4691,6 +4998,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4902,6 +5215,19 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"
@@ -4937,6 +5263,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.3" version = "0.2.3"
@@ -4993,6 +5329,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -5286,6 +5631,15 @@ dependencies = [
"windows-targets 0.48.5", "windows-targets 0.48.5",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5927,6 +6281,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

View File

@@ -37,6 +37,12 @@ const braceExpansionPath = path.join(
"package.json", "package.json",
) )
const serverBuildDependencyPaths = [
path.join(serverRoot, "node_modules", "typescript", "package.json"),
path.join(serverRoot, "node_modules", "@types", "node-forge", "package.json"),
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
]
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite") const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
async function ensureMonacoAssets() { async function ensureMonacoAssets() {
@@ -98,7 +104,7 @@ function syncServerUiBundle() {
} }
function ensureServerDevDependencies() { function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) { if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
return return
} }
@@ -142,6 +148,7 @@ function ensureRollupPlatformBinary() {
"linux-arm64": "@rollup/rollup-linux-arm64-gnu", "linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64", "darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64", "darwin-x64": "@rollup/rollup-darwin-x64",
"win32-arm64": "@rollup/rollup-win32-arm64-msvc",
"win32-x64": "@rollup/rollup-win32-x64-msvc", "win32-x64": "@rollup/rollup-win32-x64-msvc",
} }

View File

@@ -12,10 +12,11 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
base64 = "0.22"
rustls = { version = "0.23", features = ["ring"] }
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
regex = "1" regex = "1"
once_cell = "1"
parking_lot = "0.12" parking_lot = "0.12"
thiserror = "1"
anyhow = "1" anyhow = "1"
which = "4" which = "4"
libc = "0.2" libc = "0.2"
@@ -28,4 +29,7 @@ url = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] } windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0.2"

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Categories=
Exec=codenomad-tauri
StartupWMClass=codenomad-tauri
Icon=codenomad-tauri
Name=CodeNomad
NoDisplay=true
Terminal=false
Type=Application

View File

@@ -0,0 +1,449 @@
use base64::Engine;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
const TLS_DIR_NAME: &str = "tls";
const CA_CERT_FILE: &str = "ca-cert.pem";
const SERVER_CERT_FILE: &str = "server-cert.pem";
const SERVER_KEY_FILE: &str = "server-key.pem";
const TRUSTED_MARKER: &str = "server-ca.trusted";
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy,
/// plus the CA certificate DER used for trust-store installation.
pub struct LocalCert {
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_der: Vec<u8>,
}
struct TlsAssetPaths {
cert_path: PathBuf,
key_path: PathBuf,
trust_path: PathBuf,
append_ca_to_cert: bool,
}
/// Loads the TLS assets already managed by `packages/server`.
pub fn ensure_local_cert() -> Result<LocalCert, String> {
let assets = resolve_tls_asset_paths()?;
let mut cert_pem = read_pem_file(&assets.cert_path)?;
let key_pem = read_pem_file(&assets.key_path)?;
let trust_pem = read_pem_file(&assets.trust_path)?;
if assets.append_ca_to_cert {
cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim());
}
let ca_cert_der = pem_to_der(&trust_pem)?;
Ok(LocalCert {
cert_pem,
key_pem,
ca_cert_der,
})
}
fn read_pem_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
}
fn server_tls_dir() -> Result<PathBuf, String> {
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
}
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
let tls_key_path = env::var("CLI_TLS_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_cert_path = env::var("CLI_TLS_CERT")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_ca_path = env::var("CLI_TLS_CA")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
match (tls_key_path, tls_cert_path) {
(Some(key_path), Some(cert_path)) => {
let append_ca_to_cert = tls_ca_path.is_some();
let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone());
Ok(TlsAssetPaths {
cert_path,
key_path,
trust_path,
append_ca_to_cert,
})
}
(Some(_), None) | (None, Some(_)) => Err(
"CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files"
.to_string(),
),
(None, None) => {
let tls_dir = server_tls_dir()?;
Ok(TlsAssetPaths {
cert_path: tls_dir.join(SERVER_CERT_FILE),
key_path: tls_dir.join(SERVER_KEY_FILE),
trust_path: tls_dir.join(CA_CERT_FILE),
append_ca_to_cert: true,
})
}
}
}
fn resolve_server_config_base_dir() -> Result<PathBuf, String> {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
let expanded = resolve_path_like_server(&raw)?;
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") {
return expanded
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display()));
}
Ok(expanded)
}
fn resolve_path_like_server(path: &str) -> Result<PathBuf, String> {
if path.starts_with("~/") {
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?;
return Ok(home.join(path.trim_start_matches("~/")));
}
let path = PathBuf::from(path);
if path.is_absolute() {
return Ok(path);
}
let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?;
Ok(cwd.join(path))
}
fn trusted_marker_path() -> Result<PathBuf, String> {
let base = dirs::data_local_dir()
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
#[cfg(windows)]
{
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER));
}
#[cfg(not(windows))]
{
Ok(base.join("codenomad").join(TRUSTED_MARKER))
}
}
fn trusted_marker_value(cert_der: &[u8]) -> String {
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn trusted_marker_file_suffix(cert_der: &[u8]) -> String {
trusted_marker_value(cert_der).chars().take(16).collect()
}
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
trusted_marker_path()
.ok()
.and_then(|path| fs::read_to_string(path).ok())
.map(|value| value.trim() == trusted_marker_value(cert_der))
.unwrap_or(false)
}
fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
let path = trusted_marker_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?;
}
fs::write(path, trusted_marker_value(cert_der))
.map_err(|e| format!("Failed to write trust marker: {e}"))
}
#[cfg(windows)]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!windows_cert_is_trusted(cert_der)?)
}
#[cfg(windows)]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use windows_sys::Win32::Security::Cryptography::{
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
};
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
let result = CertAddEncodedCertificateToStore(
store,
encoding,
cert_der.as_ptr(),
cert_der.len() as u32,
CERT_STORE_ADD_REPLACE_EXISTING,
std::ptr::null_mut(),
);
CertCloseStore(store, 0);
if result == 0 {
return Err(
"Failed to add certificate to trust store. The user may have declined the security dialog."
.into(),
);
}
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!(has_matching_trusted_marker(cert_der) && macos_cert_is_trusted(cert_der)?))
}
#[cfg(target_os = "macos")]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use std::process::Command;
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let mut command = Command::new("/usr/bin/security");
command.args(["add-trusted-cert", "-r", "trustRoot", "-k"]);
command.arg(&keychain_path);
let output = command.arg(&temp_path).output().map_err(|e| {
format!(
"Failed to launch macOS security tool to trust the local CA certificate: {e}"
)
})?;
let _ = fs::remove_file(&temp_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to add the local CodeNomad CA certificate to the macOS trust settings: {detail}"
));
}
if !macos_cert_is_trusted(cert_der)? {
return Err(format!(
"Added the local CodeNomad CA certificate to {} but could not verify that macOS trusts it",
keychain_path.display()
));
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(windows)]
fn windows_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use windows_sys::Win32::Security::Cryptography::{
CertCloseStore, CertEnumCertificatesInStore, CertOpenSystemStoreW,
};
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let mut context = CertEnumCertificatesInStore(store, std::ptr::null());
while !context.is_null() {
let encoded = std::slice::from_raw_parts(
(*context).pbCertEncoded,
(*context).cbCertEncoded as usize,
);
if encoded == cert_der {
CertCloseStore(store, 0);
return Ok(true);
}
context = CertEnumCertificatesInStore(store, context);
}
CertCloseStore(store, 0);
Ok(false)
}
}
#[cfg(target_os = "macos")]
fn resolve_macos_user_keychain() -> Result<PathBuf, String> {
let output = std::process::Command::new("/usr/bin/security")
.args(["default-keychain", "-d", "user"])
.output()
.map_err(|e| format!("Failed to resolve macOS default user keychain: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim().trim_matches('"');
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory for macOS keychain lookup".to_string())?;
Ok(home.join("Library/Keychains/login.keychain-db"))
}
#[cfg(target_os = "macos")]
fn macos_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use std::process::Command;
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-verify-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let fingerprint = macos_cert_sha256(&temp_path)?;
let find_output = Command::new("/usr/bin/security")
.args(["find-certificate", "-a", "-Z", "-c", "CodeNomad Local CA"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to query macOS keychain certificates: {e}"))?;
if !find_output.status.success() {
let _ = fs::remove_file(&temp_path);
let stderr = String::from_utf8_lossy(&find_output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", find_output.status)
} else {
stderr
};
return Err(format!(
"Failed to inspect the macOS keychain for the local CodeNomad CA certificate: {detail}"
));
}
let stdout = String::from_utf8_lossy(&find_output.stdout);
if !stdout.to_ascii_uppercase().contains(&fingerprint) {
let _ = fs::remove_file(&temp_path);
return Ok(false);
}
let verify_output = Command::new("/usr/bin/security")
.args(["verify-cert", "-q", "-L", "-l", "-p", "basic", "-c"])
.arg(&temp_path)
.args(["-k"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to verify macOS trust for the local CodeNomad CA certificate: {e}"))?;
let _ = fs::remove_file(&temp_path);
Ok(verify_output.status.success())
}
#[cfg(target_os = "macos")]
fn macos_cert_sha256(cert_path: &Path) -> Result<String, String> {
let output = std::process::Command::new("/usr/bin/shasum")
.args(["-a", "256"])
.arg(cert_path)
.output()
.map_err(|e| format!("Failed to compute SHA-256 for {}: {e}", cert_path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("shasum exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to compute SHA-256 for {}: {detail}",
cert_path.display()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let hash = stdout
.split_whitespace()
.next()
.ok_or_else(|| format!("Failed to parse SHA-256 output for {}", cert_path.display()))?;
Ok(hash.to_ascii_uppercase())
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn needs_trust_in_store(_cert_der: &[u8]) -> Result<bool, String> {
Ok(false)
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
Ok(())
}
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
let mut body = String::new();
let mut in_block = false;
for line in pem.lines() {
if line.starts_with("-----BEGIN CERTIFICATE-----") {
in_block = true;
continue;
}
if line.starts_with("-----END CERTIFICATE-----") {
break;
}
if in_block {
body.push_str(line.trim());
}
}
if body.is_empty() {
return Err("No certificate found in PEM file".to_string());
}
base64::engine::general_purpose::STANDARD
.decode(body)
.map_err(|e| format!("Failed to decode certificate PEM: {e}"))
}

View File

@@ -38,6 +38,7 @@ use windows_sys::Win32::System::JobObjects::{
#[cfg(windows)] #[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NO_WINDOW: u32 = 0x08000000;
const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:";
#[cfg(windows)] #[cfg(windows)]
#[derive(Debug)] #[derive(Debug)]
@@ -630,6 +631,13 @@ impl CliProcessManager {
let use_user_shell = supports_user_shell(); let use_user_shell = supports_user_shell();
if !use_user_shell && which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary '{}' not found. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
resolution.node_binary
));
}
let command_info = if use_user_shell { let command_info = if use_user_shell {
log_line("spawning via user shell"); log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?) ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
@@ -641,14 +649,6 @@ impl CliProcessManager {
}) })
}; };
if !use_user_shell {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed."
));
}
}
let child = match &command_info { let child = match &command_info {
ShellCommandType::UserShell(cmd) => { ShellCommandType::UserShell(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args)); log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
@@ -920,6 +920,17 @@ impl CliProcessManager {
continue; continue;
} }
if let Some(node_binary) = line.strip_prefix(MISSING_NODE_PREFIX) {
let mut locked = status.lock();
if locked.error.is_none() {
locked.error = Some(format!(
"Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
node_binary.trim()
));
}
continue;
}
if let Some(url) = local_url_regex if let Some(url) = local_url_regex
.as_ref() .as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
@@ -1083,7 +1094,8 @@ impl CliEntry {
]; ];
if dev { if dev {
// Dev: plain HTTP + Vite dev server proxy. // Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so
// remote proxy sessions can still spin up secure local windows.
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL") let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
.ok() .ok()
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
@@ -1100,7 +1112,7 @@ impl CliEntry {
.unwrap_or_else(|| "info".to_string()); .unwrap_or_else(|| "info".to_string());
args.push("--https".to_string()); args.push("--https".to_string());
args.push("false".to_string()); args.push("true".to_string());
args.push("--http".to_string()); args.push("--http".to_string());
args.push("true".to_string()); args.push("true".to_string());
args.push("--http-port".to_string()); args.push("--http-port".to_string());
@@ -1248,7 +1260,13 @@ fn build_shell_command_string(
for arg in entry.runner_args(cli_args) { for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg)); quoted.push(shell_escape(&arg));
} }
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" ")); let command = format!(
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
shell_escape(&entry.node_binary),
quoted.join(" "),
MISSING_NODE_PREFIX,
shell_escape(&entry.node_binary),
);
let args = build_shell_args(&shell, &command); let args = build_shell_args(&shell, &command);
log_line(&format!("user shell command: {} {:?}", shell, args)); log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args }) Ok(ShellCommand { shell, args })
@@ -1288,8 +1306,11 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.unwrap_or("") .unwrap_or("")
.to_lowercase(); .to_lowercase();
let _ = shell_name; if shell_name.contains("zsh") || shell_name.contains("bash") {
vec!["-l".into(), "-c".into(), command.into()] vec!["-i".into(), "-l".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
} }
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> { fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {

View File

@@ -0,0 +1,88 @@
use crate::AppState;
use tauri::{AppHandle, Manager, WebviewWindow};
use url::Url;
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
pub fn should_bootstrap_tls_navigation(target_url: &Url, allow_tls_certificate: bool) -> bool {
allow_tls_certificate && target_url.scheme() == "https"
}
pub fn ensure_remote_window_tls_handler(
window: &WebviewWindow,
app_handle: &AppHandle,
window_label: &str,
) -> Result<(), String> {
{
let state = app_handle.state::<AppState>();
let mut handlers = state
.remote_tls_handlers
.lock()
.map_err(|err| err.to_string())?;
if !handlers.insert(window_label.to_string()) {
return Ok(());
}
}
let app_handle = app_handle.clone();
let window_label = window_label.to_string();
window
.with_webview(move |platform_webview| {
let webview = platform_webview.inner();
let app_handle = app_handle.clone();
let window_label = window_label.clone();
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
allow_remote_tls_certificate(
&app_handle,
&window_label,
view,
failing_uri,
certificate,
)
});
})
.map_err(|err| err.to_string())
}
fn allow_remote_tls_certificate(
app_handle: &AppHandle,
window_label: &str,
view: &WebView,
failing_uri: &str,
certificate: &webkit2gtk::gio::TlsCertificate,
) -> bool {
let Ok(parsed_uri) = Url::parse(failing_uri) else {
return false;
};
let Some(host) = parsed_uri.host_str() else {
return false;
};
let state = app_handle.state::<AppState>();
let skip_tls_verify = state
.remote_skip_tls_verify
.lock()
.ok()
.and_then(|values| values.get(window_label).copied())
.unwrap_or(false);
if !skip_tls_verify {
return false;
}
let expected_origin = state
.remote_origins
.lock()
.ok()
.and_then(|origins| origins.get(window_label).cloned());
let parsed_origin = parsed_uri.origin().ascii_serialization();
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
return false;
}
let Some(context) = view.context() else {
return false;
};
context.allow_tls_certificate_for_host(certificate, host);
view.load_uri(failing_uri);
true
}

View File

@@ -1,12 +1,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[allow(dead_code)]
mod cert_manager;
mod cli_manager; mod cli_manager;
#[cfg(target_os = "linux")]
mod linux_tls;
use cli_manager::{CliProcessManager, CliStatus}; use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake; use keepawake::KeepAwake;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -36,6 +40,8 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.1; const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2; const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0; const MAX_ZOOM_LEVEL: f64 = 5.0;
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
#[cfg(windows)] #[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -45,6 +51,9 @@ pub struct AppState {
pub wake_lock: Mutex<Option<KeepAwake>>, pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>, pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>, pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -53,9 +62,59 @@ struct RemoteWindowPayload {
id: String, id: String,
name: String, name: String,
base_url: String, base_url: String,
entry_url: Option<String>,
proxy_session_id: Option<String>,
#[allow(dead_code)]
skip_tls_verify: bool, skip_tls_verify: bool,
} }
fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
tauri::async_runtime::spawn(async move {
if let Err(err) = cleanup_remote_proxy_session(&app, &session_id).await {
eprintln!(
"[tauri] failed to clean up remote proxy session {}: {}",
session_id, err
);
}
});
}
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
let status = app.state::<AppState>().manager.status();
let Some(base_url) = status.url else {
return Ok(());
};
let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?;
cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}"));
cleanup_url.set_query(None);
cleanup_url.set_fragment(None);
let client = if cleanup_url.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert()?;
let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der)
.map_err(|err| err.to_string())?;
reqwest::Client::builder()
.add_root_certificate(ca_cert)
.build()
.map_err(|err| err.to_string())?
} else {
reqwest::Client::new()
};
let response = client
.delete(cleanup_url.as_str())
.send()
.await
.map_err(|err| err.to_string())?;
if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(());
}
Err(format!("unexpected status {}", response.status()))
}
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
struct WakeLockConfig { struct WakeLockConfig {
@@ -119,7 +178,7 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool { fn should_allow_internal(url: &Url) -> bool {
match url.scheme() { match url.scheme() {
"tauri" | "asset" | "file" => true, "tauri" | "asset" | "file" | "about" => true,
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`. // On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will // This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank. // redirect it to the system browser and the app will appear blank.
@@ -167,25 +226,61 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false false
} }
#[tauri::command] async fn open_remote_window_impl(
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> { app: AppHandle,
if payload.skip_tls_verify && payload.base_url.starts_with("https://") { payload: RemoteWindowPayload,
return Err( ) -> Result<(), String> {
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app." let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
.to_string(), let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
);
}
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id); let label = format!("remote-{}", payload.id);
let title = format!( let title = format!(
"{} - {}", "{} - {}",
payload.name, payload.name,
parsed.host_str().unwrap_or(payload.base_url.as_str()) Url::parse(&payload.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| payload.base_url.clone())
); );
let window_url = parsed.clone();
let allow_linux_tls_certificate =
parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify);
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), window_url.origin().ascii_serialization());
app.state::<AppState>()
.remote_skip_tls_verify
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), allow_linux_tls_certificate);
let replaced_session = {
let state = app.state::<AppState>();
let mut sessions = state
.remote_proxy_sessions
.lock()
.map_err(|err| err.to_string())?;
match payload.proxy_session_id.clone() {
Some(session_id) => sessions.insert(label.clone(), session_id),
None => sessions.remove(&label),
}
};
if let Some(previous) = replaced_session {
if payload.proxy_session_id.as_deref() != Some(previous.as_str()) {
schedule_remote_proxy_session_cleanup(app.clone(), previous);
}
}
if let Some(existing) = app.get_webview_window(&label) { if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(parsed.clone()); #[cfg(target_os = "linux")]
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
let _ = existing.navigate(window_url.clone());
let _ = existing.set_title(&title); let _ = existing.set_title(&title);
let _ = existing.show(); let _ = existing.show();
let _ = existing.unminimize(); let _ = existing.unminimize();
@@ -193,25 +288,52 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
return Ok(()); return Ok(());
} }
app.state::<AppState>() #[cfg(target_os = "linux")]
.remote_origins let initial_url = if linux_tls::should_bootstrap_tls_navigation(
.lock() &window_url,
.map_err(|err| err.to_string())? allow_linux_tls_certificate,
.insert(label.clone(), parsed.origin().ascii_serialization()); ) {
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
};
let window = #[cfg(not(target_os = "linux"))]
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone())) let initial_url = window_url.clone();
.title(title)
.inner_size(1400.0, 900.0) let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.min_inner_size(800.0, 600.0) .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
.build() .title(title)
.map_err(|err| err.to_string())?; .inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[cfg(target_os = "linux")]
{
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
if initial_url != window_url {
let _ = window.navigate(window_url.clone());
}
}
let app_handle = app.clone(); let app_handle = app.clone();
let label_for_cleanup = label.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event { if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() { if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label); origins.remove(&label_for_cleanup);
}
if let Ok(mut sessions) = app_handle.state::<AppState>().remote_proxy_sessions.lock() {
if let Some(session_id) = sessions.remove(&label_for_cleanup) {
schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id);
}
}
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
values.remove(&label_for_cleanup);
}
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
handlers.remove(&label_for_cleanup);
} }
} }
}); });
@@ -219,6 +341,47 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
Ok(()) Ok(())
} }
#[tauri::command]
fn needs_local_certificate_install() -> Result<bool, String> {
#[cfg(not(target_os = "linux"))]
{
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}")
})?;
return cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
});
}
#[cfg(target_os = "linux")]
{
Ok(false)
}
}
#[tauri::command]
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
if payload.proxy_session_id.is_some() && parsed.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!(
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
)
})?;
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
return Err(format!(
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
));
}
}
}
open_remote_window_impl(app, payload).await
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> { fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths paths
.iter() .iter()
@@ -346,6 +509,8 @@ fn set_windows_app_user_model_id() {
fn set_windows_app_user_model_id() {} fn set_windows_app_user_model_id() {}
fn main() { fn main() {
let _ = rustls::crypto::ring::default_provider().install_default();
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard") let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url)) .on_navigation(|webview, url| intercept_navigation(webview, url))
.build(); .build();
@@ -373,10 +538,16 @@ fn main() {
wake_lock: Mutex::new(None), wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL), zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()), remote_origins: Mutex::new(HashMap::new()),
remote_proxy_sessions: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id(); set_windows_app_user_model_id();
build_menu(&app.handle())?; build_menu(&app.handle())?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
}
if let Some(shortcut) = fullscreen_shortcut() { if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut(); let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone()); let _ = shortcut_manager.register(shortcut.clone());
@@ -411,6 +582,7 @@ fn main() {
cli_restart, cli_restart,
wake_lock_start, wake_lock_start,
wake_lock_stop, wake_lock_stop,
needs_local_certificate_install,
open_remote_window open_remote_window
]) ])
.on_menu_event(|app_handle, event| { .on_menu_event(|app_handle, event| {

View File

@@ -9,6 +9,7 @@
"frontendDist": "resources/ui-loading" "frontendDist": "resources/ui-loading"
}, },
"app": { "app": {
"enableGTKAppId": true,
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [
{ {
@@ -41,6 +42,35 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"linux": {
"appimage": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop"
}
},
"deb": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
}
},
"rpm": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
}
}
},
"resources": [ "resources": [
"resources/server", "resources/server",
"resources/ui-loading" "resources/ui-loading"

View File

@@ -22,7 +22,7 @@ import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors" import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors" import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases" import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env" import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n" import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock" import { setWakeLockDesired } from "./lib/native/wake-lock"
import { import {
@@ -137,7 +137,7 @@ const App: Component = () => {
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const shouldShow = const shouldShow =
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true) !isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide" document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
}) })
@@ -444,7 +444,7 @@ const App: Component = () => {
// Listen for Tauri menu events // Listen for Tauri menu events
onMount(() => { onMount(() => {
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__ const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) { if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null let unlistenMenu: (() => void) | null = null

View File

@@ -58,6 +58,16 @@ function resolveAbsolutePath(root: string, relativePath: string) {
return `${trimmedRoot}${normalized}` return `${trimmedRoot}${normalized}`
} }
function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) {
if (!metadata || metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
}
type FolderRow = type FolderRow =
| { type: "up"; path: string } | { type: "up"; path: string }
| { type: "folder"; entry: FileSystemEntry } | { type: "folder"; entry: FileSystemEntry }
@@ -67,6 +77,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const [rootPath, setRootPath] = createSignal("") const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
const [pathInput, setPathInput] = createSignal("")
const [pathInputDirty, setPathInputDirty] = createSignal(false)
const [creatingFolder, setCreatingFolder] = createSignal(false) const [creatingFolder, setCreatingFolder] = createSignal(false)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map()) const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set()) const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
@@ -75,12 +87,16 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadataCache = new Map<string, FileSystemListingMetadata>() const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>() const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
let latestNavigationId = 0
function resetState() { function resetState() {
setRootPath("")
setDirectoryChildren(new Map<string, FileSystemEntry[]>()) setDirectoryChildren(new Map<string, FileSystemEntry[]>())
setLoadingPaths(new Set<string>()) setLoadingPaths(new Set<string>())
setCurrentPathKey(null) setCurrentPathKey(null)
setCurrentMetadata(null) setCurrentMetadata(null)
setPathInput("")
setPathInputDirty(false)
metadataCache.clear() metadataCache.clear()
inFlightRequests.clear() inFlightRequests.clear()
setError(null) setError(null)
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function initialize() { async function initialize() {
setLoading(true) setLoading(true)
try { try {
const metadata = await loadDirectory() await navigateTo()
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -197,13 +209,22 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
} }
async function navigateTo(path?: string) { async function navigateTo(path?: string) {
const navigationId = ++latestNavigationId
setError(null) setError(null)
try { try {
const metadata = await loadDirectory(path) const metadata = await loadDirectory(path)
if (navigationId !== latestNavigationId) {
return null
}
applyMetadata(metadata) applyMetadata(metadata)
return metadata
} catch (err) { } catch (err) {
if (navigationId !== latestNavigationId) {
return null
}
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback") const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message) setError(message)
return null
} }
} }
@@ -225,31 +246,58 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
}) })
function handleNavigateTo(path: string) { function handleNavigateTo(path: string) {
setPathInputDirty(false)
void navigateTo(path) void navigateTo(path)
} }
function handleNavigateUp() { function handleNavigateUp() {
const parent = currentMetadata()?.parentPath const parent = currentMetadata()?.parentPath
if (parent) { if (parent) {
setPathInputDirty(false)
void navigateTo(parent) void navigateTo(parent)
} }
} }
const currentAbsolutePath = createMemo(() => { const currentAbsolutePath = createMemo(() => {
const metadata = currentMetadata() return getAbsolutePathFromMetadata(currentMetadata())
if (!metadata) { })
return ""
createEffect(() => {
const absolutePath = currentAbsolutePath()
if (!pathInputDirty()) {
setPathInput(absolutePath)
} }
if (metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
}) })
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath())) const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
const canSubmitPath = createMemo(() => pathInput().trim().length > 0)
async function handlePathSubmit() {
const target = pathInput().trim()
if (!target) {
return
}
const metadata = await navigateTo(target)
if (!metadata) {
return
}
setPathInputDirty(false)
setPathInput(getAbsolutePathFromMetadata(metadata))
}
async function handleSelectCurrent() {
const target = pathInput().trim()
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
if (!metadata) {
return
}
setPathInputDirty(false)
const absolute = getAbsolutePathFromMetadata(metadata)
if (absolute) {
setPathInput(absolute)
props.onSelect(absolute)
}
}
function handleEntrySelect(entry: FileSystemEntry) { function handleEntrySelect(entry: FileSystemEntry) {
const absolutePath = entry.absolutePath const absolutePath = entry.absolutePath
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function handleCreateFolder() { async function handleCreateFolder() {
if (creatingFolder()) return if (creatingFolder()) return
const metadata = currentMetadata() const target = pathInput().trim()
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
if (!metadata || metadata.pathKind === "drives") { if (!metadata || metadata.pathKind === "drives") {
return return
} }
setPathInputDirty(false)
setPathInput(getAbsolutePathFromMetadata(metadata))
const name = const name =
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), { (await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<div class="directory-browser-current"> <div class="directory-browser-current">
<div class="directory-browser-current-meta"> <div class="directory-browser-current-meta">
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span> <span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span> <input
type="text"
value={pathInput()}
onInput={(event) => {
setPathInput(event.currentTarget.value)
setPathInputDirty(true)
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
void handlePathSubmit()
}
}}
spellcheck={false}
class="selector-input directory-browser-current-path"
/>
</div> </div>
<div class="directory-browser-current-actions"> <div class="directory-browser-current-actions">
<button <button
type="button" type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select" class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent() || creatingFolder()} disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
onClick={() => { onClick={() => void handleSelectCurrent()}
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
> >
{t("directoryBrowser.selectCurrent")} {t("directoryBrowser.selectCurrent")}
</button> </button>

View File

@@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions" import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
import { useFolderDrop } from "../lib/hooks/use-folder-drop" import { useFolderDrop } from "../lib/hooks/use-folder-drop"
import VersionPill from "./version-pill" import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons" import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
@@ -16,6 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen" import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url" import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
import { openRemoteServerWindow } from "../lib/native/remote-window" import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -57,7 +58,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null) const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false) const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null) const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
type LanguageOption = { value: Locale; label: string } type LanguageOption = { value: Locale; label: string }
@@ -77,6 +77,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const folders = () => recentFolders() const folders = () => recentFolders()
const serverList = () => remoteServers() const serverList = () => remoteServers()
const isLoading = () => Boolean(props.isLoading) const isLoading = () => Boolean(props.isLoading)
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
function getActiveListLength() { function getActiveListLength() {
return activeTab() === "local" ? folders().length : serverList().length return activeTab() === "local" ? folders().length : serverList().length
@@ -123,17 +124,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const normalizedKey = e.key.toLowerCase() const normalizedKey = e.key.toLowerCase()
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n" const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
const blockedKeys = [ const blockedKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", "Enter"]
"ArrowDown",
"ArrowUp",
"PageDown",
"PageUp",
"Home",
"End",
"Enter",
"Backspace",
"Delete",
]
if (isLoading()) { if (isLoading()) {
if (isBrowseShortcut || blockedKeys.includes(e.key)) { if (isBrowseShortcut || blockedKeys.includes(e.key)) {
@@ -191,21 +182,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault() e.preventDefault()
handleEnterKey() handleEnterKey()
} else if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault()
if (listLength > 0 && focusMode() === "recent") {
if (activeTab() === "local") {
const folder = folders()[selectedIndex()]
if (folder) {
handleRemove(folder.path)
}
} else {
const server = serverList()[selectedIndex()]
if (server) {
removeRemoteServerProfile(server.id)
}
}
}
} }
} }
@@ -230,6 +206,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
createEffect(() => { createEffect(() => {
activeTab() activeTab()
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
setActiveTab("local")
return
}
setSelectedIndex(0) setSelectedIndex(0)
setFocusMode("recent") setFocusMode("recent")
}) })
@@ -304,11 +284,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} }
function openServerDialog() { function openServerDialog() {
if (!canUseRemoteServerWindows()) return
resetServerDialog() resetServerDialog()
setIsServerDialogOpen(true) setIsServerDialogOpen(true)
} }
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) { async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
if (openWindow && !canUseRemoteServerWindows()) {
throw new Error("Remote server windows can only be opened from a local desktop window")
}
const trimmedName = input.name.trim() const trimmedName = input.name.trim()
const trimmedUrl = input.baseUrl.trim() const trimmedUrl = input.baseUrl.trim()
if (!trimmedName || !trimmedUrl) { if (!trimmedName || !trimmedUrl) {
@@ -332,7 +317,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}) })
if (openWindow) { if (openWindow) {
await openRemoteServerWindow(profile) const remoteProxySession =
isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
? await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify,
})
: undefined
try {
await openRemoteServerWindow(profile, remoteProxySession?.windowUrl, remoteProxySession?.sessionId)
} catch (error) {
if (remoteProxySession) {
void serverApi.deleteRemoteProxySession(remoteProxySession.sessionId).catch(() => {})
}
throw error
}
await markRemoteServerConnected(profile.id) await markRemoteServerConnected(profile.id)
} }
@@ -362,6 +363,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} }
async function handleConnectSavedServer(id: string) { async function handleConnectSavedServer(id: string) {
if (!canUseRemoteServerWindows()) return
const target = remoteServers().find((entry) => entry.id === id) const target = remoteServers().find((entry) => entry.id === id)
if (!target || connectingServerId()) return if (!target || connectingServerId()) return
setConnectingServerId(id) setConnectingServerId(id)
@@ -380,7 +382,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
async function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") setFocusMode("new")
if (nativeDialogsAvailable) { if (supportsNativeDialogsInCurrentWindow()) {
const fallbackPath = folders()[0]?.path const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({ const selected = await openNativeFolderDialog({
title: t("folderSelection.dialog.title"), title: t("folderSelection.dialog.title"),
@@ -537,15 +539,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
> >
<Settings class="w-4 h-4" /> <Settings class="w-4 h-4" />
</button> </button>
<button <Show when={canUseRemoteServerWindows()}>
type="button" <button
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" type="button"
onClick={() => openSettings("remote")} class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label={t("instanceTabs.remote.ariaLabel")} onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")} aria-label={t("instanceTabs.remote.ariaLabel")}
> title={t("instanceTabs.remote.title")}
<MonitorUp class="w-4 h-4" /> >
</button> <MonitorUp class="w-4 h-4" />
</button>
</Show>
<Show when={props.onClose}> <Show when={props.onClose}>
<button <button
type="button" type="button"
@@ -619,7 +623,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden"> <div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<div class="panel flex flex-col flex-1 min-h-0"> <div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header !gap-0 !p-0"> <div class="panel-header !gap-0 !p-0">
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none"> <div class={`grid ${canUseRemoteServerWindows() ? "grid-cols-2" : "grid-cols-1"} gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none`}>
<button <button
type="button" type="button"
class="border-r border-base px-4 py-3 text-left transition-colors" class="border-r border-base px-4 py-3 text-left transition-colors"
@@ -654,35 +658,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
)} )}
</p> </p>
</button> </button>
<button <Show when={canUseRemoteServerWindows()}>
type="button" <button
class="px-4 py-3 text-left transition-colors" type="button"
classList={{ class="px-4 py-3 text-left transition-colors"
"text-primary": activeTab() === "servers", classList={{
"text-muted hover:text-secondary": activeTab() !== "servers", "text-primary": activeTab() === "servers",
}} "text-muted hover:text-secondary": activeTab() !== "servers",
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}} }}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{ style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)", "background-color": "var(--surface-secondary)",
}} }}
onClick={() => setActiveTab("servers")}
> >
{t("folderSelection.servers.count", { count: remoteServers().length })} <div
</p> class="panel-title text-base"
</button> style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
</Show>
</div> </div>
</div> </div>
@@ -690,23 +696,25 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
when={activeTab() === "local"} when={activeTab() === "local"}
fallback={ fallback={
<Show <Show
when={remoteServers().length > 0} when={canUseRemoteServerWindows() && remoteServers().length > 0}
fallback={ fallback={
<div class="panel-empty-state flex-1"> <Show when={canUseRemoteServerWindows()}>
<div class="panel-empty-state-icon"> <div class="panel-empty-state flex-1">
<Globe class="w-12 h-12 mx-auto" /> <div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div> </div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p> </Show>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div>
} }
> >
<div <div
@@ -874,15 +882,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
</button> </button>
<button <Show when={canUseRemoteServerWindows()}>
onClick={openServerDialog} <button
class="button-primary w-full flex items-center justify-center text-sm" onClick={openServerDialog}
> class="button-primary w-full flex items-center justify-center text-sm"
<div class="flex items-center gap-2"> >
<Globe class="w-4 h-4" /> <div class="flex items-center gap-2">
<span>{t("folderSelection.actions.connectButton")}</span> <Globe class="w-4 h-4" />
</div> <span>{t("folderSelection.actions.connectButton")}</span>
</button> </div>
</button>
</Show>
</div> </div>
{/* OpenCode settings section */} {/* OpenCode settings section */}
@@ -918,10 +928,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<kbd class="kbd">Enter</kbd> <kbd class="kbd">Enter</kbd>
<span>{t("folderSelection.hints.select")}</span> <span>{t("folderSelection.hints.select")}</span>
</div> </div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>{t("folderSelection.hints.remove")}</span>
</div>
</Show> </Show>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" class="kbd-hint" /> <Kbd shortcut="cmd+n" class="kbd-hint" />

View File

@@ -6,6 +6,7 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { isOsNotificationSupportedSync } from "../lib/os-notifications" import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { canOpenRemoteWindows } from "../lib/runtime-env"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen" import { openSettings } from "../stores/settings-screen"
import type { AppTabRecord } from "../stores/app-tabs" import type { AppTabRecord } from "../stores/app-tabs"
@@ -99,14 +100,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<Dynamic component={notificationIcon()} class="w-4 h-4" /> <Dynamic component={notificationIcon()} class="w-4 h-4" />
</button> </button>
<button <Show when={canOpenRemoteWindows()}>
class="new-tab-button tab-remote-button" <button
onClick={() => openSettings("remote")} class="new-tab-button tab-remote-button"
title={t("instanceTabs.remote.title")} onClick={() => openSettings("remote")}
aria-label={t("instanceTabs.remote.ariaLabel")} title={t("instanceTabs.remote.title")}
> aria-label={t("instanceTabs.remote.ariaLabel")}
<MonitorUp class="w-4 h-4" /> >
</button> <MonitorUp class="w-4 h-4" />
</button>
</Show>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -171,9 +171,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault() e.preventDefault()
void handleEnterKey() void handleEnterKey()
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault()
void handleDeleteKey()
} }
} }
@@ -187,29 +184,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
} }
} }
async function handleDeleteKey() {
const sessions = parentSessions()
const index = selectedIndex()
if (index >= sessions.length) {
return
}
await handleSessionDelete(sessions[index].id)
const updatedSessions = parentSessions()
if (updatedSessions.length === 0) {
setFocusMode("new-session")
setSelectedIndex(0)
return
}
const nextIndex = Math.min(index, updatedSessions.length - 1)
setSelectedIndex(nextIndex)
setFocusMode("sessions")
scrollToIndex(nextIndex)
}
onMount(() => { onMount(() => {
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown)
@@ -562,10 +536,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<kbd class="kbd">Enter</kbd> <kbd class="kbd">Enter</kbd>
<span>{t("instanceWelcome.hints.resume")}</span> <span>{t("instanceWelcome.hints.resume")}</span>
</div> </div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>{t("instanceWelcome.hints.delete")}</span>
</div>
</div> </div>
</div> </div>

View File

@@ -357,7 +357,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pill = activeSessionStatusPill() const pill = activeSessionStatusPill()
if (!pill) return null if (!pill) return null
return ( return (
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}> <span
class={`status-indicator session-status session-status-list ${pill.className} notranslate`}
title={pill.title}
translate="no"
>
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />} {pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{pill.text} {pill.text}
</span> </span>

View File

@@ -638,18 +638,25 @@ export default function MessageSection(props: MessageSectionProps) {
const autoPinHoldTargetKey = createMemo(() => { const autoPinHoldTargetKey = createMemo(() => {
if (!holdLongAssistantRepliesEnabled()) return null if (!holdLongAssistantRepliesEnabled()) return null
const messageId = lastVisibleMessageId() const messageId = lastVisibleMessageId()
return isAssistantTextMessage(messageId) ? messageId : null return isStreamingAssistantTextMessage(messageId) ? messageId : null
}) })
function toggleHoldLongAssistantReplies() { function toggleHoldLongAssistantReplies() {
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() }) updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
} }
function isAssistantTextMessage(messageId: string | null | undefined) { function isStreamingAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false if (!messageId) return false
const resolvedStore = store() const resolvedStore = store()
const record = resolvedStore.getMessage(messageId) const record = resolvedStore.getMessage(messageId)
if (!record || record.role !== "assistant") return false if (!record || record.role !== "assistant") return false
if (record.status !== "streaming") return false
const info = resolvedStore.getMessageInfo(messageId)
if (!info) return false
const timeInfo = info?.time as { end?: number } | undefined
const isStreaming = timeInfo?.end === undefined || timeInfo.end === 0
if (!isStreaming) return false
const { orderedParts } = buildRecordDisplayData(props.instanceId, record) const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
return orderedParts.some((part) => { return orderedParts.some((part) => {

View File

@@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog" import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions" import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>()) const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>()) const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false) const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
const binaries = () => opencodeBinaries() const binaries = () => opencodeBinaries()
@@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
async function handleBrowseBinary() { async function handleBrowseBinary() {
if (props.disabled) return if (props.disabled) return
setValidationError(null) setValidationError(null)
if (nativeDialogsAvailable) { if (supportsNativeDialogsInCurrentWindow()) {
const selected = await openNativeFileDialog({ const selected = await openNativeFileDialog({
title: t("opencodeBinarySelector.dialog.title"), title: t("opencodeBinarySelector.dialog.title"),
}) })

View File

@@ -520,7 +520,11 @@ const SessionList: Component<SessionListProps> = (props) => {
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} /> <ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span> </span>
</Show> </Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}> <span
class={`status-indicator session-status session-status-list ${statusClassName()} notranslate`}
title={statusTooltip()}
translate="no"
>
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />} {needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()} {statusText()}
</span> </span>
@@ -736,7 +740,9 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list-header p-3 border-b border-base"> <div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? ( {props.headerContent ?? (
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3> <h3 class="text-sm font-semibold text-primary notranslate" translate="no">
{t("sessionList.header.title")}
</h3>
<KeyboardHint <KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)} shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/> />

View File

@@ -15,25 +15,33 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section"
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
import { canOpenRemoteWindows } from "../lib/runtime-env"
export const SettingsScreen: Component = () => { export const SettingsScreen: Component = () => {
const { t } = useI18n() const { t } = useI18n()
const sections = createMemo(() => [ const sections = createMemo(() => {
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") }, const items = [
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, { id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }, { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") }, { id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") }, { id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
]) ]
if (canOpenRemoteWindows()) {
items.splice(2, 0, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") })
}
return items
})
const renderSection = () => { const renderSection = () => {
switch (activeSettingsSection()) { switch (activeSettingsSection()) {
case "notifications": case "notifications":
return <NotificationsSettingsSection /> return <NotificationsSettingsSection />
case "remote": case "remote":
return <RemoteAccessSettingsSection /> return canOpenRemoteWindows() ? <RemoteAccessSettingsSection /> : <AppearanceSettingsSection />
case "speech": case "speech":
return <SpeechSettingsSection /> return <SpeechSettingsSection />
case "sidecars": case "sidecars":

View File

@@ -17,6 +17,8 @@ interface LspDiagnostic {
range?: LspRange range?: LspRange
} }
export type DiagnosticsMap = Record<string, LspDiagnostic[] | undefined>
export interface DiagnosticEntry { export interface DiagnosticEntry {
id: string id: string
severity: number severity: number
@@ -30,7 +32,7 @@ export interface DiagnosticEntry {
column: number column: number
} }
function normalizeDiagnosticPath(path: string) { export function normalizeDiagnosticPath(path: string) {
return path.replace(/\\/g, "/") return path.replace(/\\/g, "/")
} }
@@ -53,49 +55,71 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr
const metadata = (state.metadata || {}) as Record<string, unknown> const metadata = (state.metadata || {}) as Record<string, unknown>
const input = (state.input || {}) as Record<string, unknown> const input = (state.input || {}) as Record<string, unknown>
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined
if (!diagnosticsMap) return [] if (!diagnosticsMap) return []
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find( return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path])
(value) => typeof value === "string" && value.length > 0, }
) as string | undefined
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): string | undefined {
if (!normalizedPreferred) return [] if (Object.keys(diagnostics).length === 0) return undefined
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
if (candidateEntries.length === 0) return []
const prioritizedEntries = candidateEntries.filter(([path]) => { const normalizedPreferred = preferredPaths
const normalized = normalizeDiagnosticPath(path) .filter((value): value is string => typeof value === "string" && value.length > 0)
return normalized === normalizedPreferred .map((value) => normalizeDiagnosticPath(value))
})
if (prioritizedEntries.length === 0) return [] if (normalizedPreferred.length === 0) return undefined
for (const preferred of normalizedPreferred) {
if (diagnostics[preferred]) return preferred
}
const keys = Object.keys(diagnostics)
for (const preferred of normalizedPreferred) {
const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred)
if (direct) return direct
}
for (const preferred of normalizedPreferred) {
const suffixMatch = keys.find((key) => {
const normalized = normalizeDiagnosticPath(key)
return normalized === preferred || normalized.endsWith("/" + preferred)
})
if (suffixMatch) return suffixMatch
}
return undefined
}
export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): DiagnosticEntry[] {
const key = resolveDiagnosticsKey(diagnostics, preferredPaths)
if (!key) return []
const list = diagnostics[key]
if (!Array.isArray(list) || list.length === 0) return []
const entries: DiagnosticEntry[] = [] const entries: DiagnosticEntry[] = []
for (const [pathKey, list] of prioritizedEntries) { const normalizedPath = normalizeDiagnosticPath(key)
if (!Array.isArray(list)) continue for (let index = 0; index < list.length; index++) {
const normalizedPath = normalizeDiagnosticPath(pathKey) const diagnostic = list[index]
for (let index = 0; index < list.length; index++) { if (!diagnostic || typeof diagnostic.message !== "string") continue
const diagnostic = list[index] const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
if (!diagnostic || typeof diagnostic.message !== "string") continue const severityMeta = getSeverityMeta(tone)
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined) const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const severityMeta = getSeverityMeta(tone) const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 entries.push({
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 id: `${normalizedPath}-${index}-${diagnostic.message}`,
entries.push({ severity: severityMeta.rank,
id: `${normalizedPath}-${index}-${diagnostic.message}`, tone,
severity: severityMeta.rank, label: severityMeta.label,
tone, icon: severityMeta.icon,
label: severityMeta.label, message: diagnostic.message,
icon: severityMeta.icon, filePath: normalizedPath,
message: diagnostic.message, displayPath: getRelativePath(normalizedPath),
filePath: normalizedPath, line,
displayPath: getRelativePath(normalizedPath), column,
line, })
column,
})
}
} }
return entries.sort((a, b) => a.severity - b.severity) return entries.sort((a, b) => a.severity - b.severity)

View File

@@ -1,107 +1,14 @@
import { For, Show, createMemo } from "solid-js" import { For, Show, createMemo } from "solid-js"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
import type { DiagnosticEntry } from "../diagnostics" import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics"
type LspRangePosition = {
line?: number
character?: number
}
type LspRange = {
start?: LspRangePosition
}
type LspDiagnostic = {
message?: string
severity?: number
range?: LspRange
}
type ApplyPatchFile = { type ApplyPatchFile = {
filePath?: string filePath?: string
relativePath?: string relativePath?: string
type?: string type?: string
diff?: string diff?: string
} patch?: string
function normalizePath(value: string): string {
return value.replace(/\\/g, "/")
}
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
if (severity === 1) return "error"
if (severity === 2) return "warning"
return "info"
}
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
}
function resolveDiagnosticsKey(
diagnostics: Record<string, LspDiagnostic[] | undefined>,
file: ApplyPatchFile,
): string | undefined {
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
if (absolute && diagnostics[absolute]) return absolute
if (relative && diagnostics[relative]) return relative
if (absolute) {
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
if (direct) return direct
}
if (relative) {
const suffixMatch = Object.keys(diagnostics).find((key) => {
const normalized = normalizePath(key)
return normalized === relative || normalized.endsWith("/" + relative)
})
if (suffixMatch) return suffixMatch
}
return undefined
}
function buildDiagnostics(
diagnostics: Record<string, LspDiagnostic[] | undefined>,
file: ApplyPatchFile,
t: (key: string, params?: Record<string, unknown>) => string,
): DiagnosticEntry[] {
const key = resolveDiagnosticsKey(diagnostics, file)
if (!key) return []
const list = diagnostics[key]
if (!Array.isArray(list) || list.length === 0) return []
const normalizedKey = normalizePath(key)
const entries: DiagnosticEntry[] = []
for (let index = 0; index < list.length; index++) {
const diagnostic = list[index]
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone, t)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
entries.push({
id: `${normalizedKey}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedKey,
displayPath: getRelativePath(normalizedKey),
line,
column,
})
}
return entries.sort((a, b) => a.severity - b.severity)
} }
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) { function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
@@ -164,7 +71,7 @@ export const applyPatchRenderer: ToolRenderer = {
}) })
const diagnosticsMap = createMemo(() => { const diagnosticsMap = createMemo(() => {
const value = (payload.metadata as any).diagnostics const value = (payload.metadata as any).diagnostics
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {} return value && typeof value === "object" ? (value as DiagnosticsMap) : {}
}) })
if (files().length === 0) { if (files().length === 0) {
@@ -178,9 +85,9 @@ export const applyPatchRenderer: ToolRenderer = {
<For each={files()}> <For each={files()}>
{(file, index) => { {(file, index) => {
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 }) const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
const diffText = typeof file.diff === "string" ? file.diff : "" const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t)) const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath]))
return ( return (
<div class="tool-call-apply-patch-file"> <div class="tool-call-apply-patch-file">

View File

@@ -161,8 +161,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null) const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null) const [activeHoldTargetKey, setActiveHoldTargetKey] = createSignal<string | null>(null)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null const [didTriggerHoldForCurrentTarget, setDidTriggerHoldForCurrentTarget] = createSignal(false)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>() const itemElements = new Map<string, HTMLDivElement>()
@@ -196,6 +197,17 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
return performance.now() <= userScrollIntentUntil return performance.now() <= userScrollIntentUntil
} }
function clearAutoPinHold(options?: { resumeBottom?: boolean }) {
if (activeHoldTargetKey() === null) return
setActiveHoldTargetKey(null)
if (options?.resumeBottom && autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll() || activeHoldTargetKey() !== null) return
scrollToBottom(false)
})
}
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) { function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) { if (detachScrollIntentListeners) {
detachScrollIntentListeners() detachScrollIntentListeners()
@@ -257,7 +269,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// scrollbar). If follow mode stays enabled, the next render notification // scrollbar). If follow mode stays enabled, the next render notification
// snaps the list straight back to bottom. A real upward viewport move away // snaps the list straight back to bottom. A real upward viewport move away
// from bottom should always break follow unless a hold target is active. // from bottom should always break follow unless a hold target is active.
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && heldItemCount() === null) { if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && activeHoldTargetKey() === null) {
setAutoScroll(false) setAutoScroll(false)
lastObservedPinnedAtBottom = false lastObservedPinnedAtBottom = false
return return
@@ -265,9 +277,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Sync autoScroll state based on scroll position if it was a user scroll // Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) { if (hasUserScrollIntent()) {
if (atBottom && heldItemCount() !== null) { clearAutoPinHold()
setHeldItemCount(null)
}
if (atBottom && !autoScroll()) { if (atBottom && !autoScroll()) {
setAutoScroll(true) setAutoScroll(true)
} else if (!atBottom && autoScroll()) { } else if (!atBottom && autoScroll()) {
@@ -303,7 +313,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
updateScrollButtons() updateScrollButtons()
updateAutoPinHold()
props.onScroll?.() props.onScroll?.()
// Find active key (roughly the first visible item) // Find active key (roughly the first visible item)
@@ -335,25 +344,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
function updateAutoPinHold() { function updateAutoPinHold() {
const element = scrollElement() const element = scrollElement()
const itemCount = props.items().length
const heldCount = heldItemCount()
if (!element) return if (!element) return
if (heldCount !== null) { const targetKey = holdTargetKey()
if (itemCount > heldCount) { const heldKey = activeHoldTargetKey()
setHeldItemCount(null)
if (autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll()) return
scrollToBottom(false)
})
}
return
}
if (itemCount < heldCount) { if (heldKey !== null) {
setHeldItemCount(null) if (targetKey !== heldKey) {
return clearAutoPinHold({ resumeBottom: true })
} }
return return
@@ -361,9 +359,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (!autoScroll()) return if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return if (externalSuspendAutoPinToBottom()) return
const targetKey = holdTargetKey()
if (!targetKey) return if (!targetKey) return
if (didTriggerHoldForCurrentTarget()) return
const itemWrapper = itemElements.get(targetKey) const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return if (!itemWrapper) return
@@ -379,7 +376,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (Math.abs(alignDelta) > 1) { if (Math.abs(alignDelta) > 1) {
element.scrollTop = Math.max(0, element.scrollTop + alignDelta) element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
} }
setHeldItemCount(itemCount) setActiveHoldTargetKey(targetKey)
setDidTriggerHoldForCurrentTarget(true)
} }
} }
@@ -395,7 +393,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}, },
notifyContentRendered: () => { notifyContentRendered: () => {
updateAutoPinHold() updateAutoPinHold()
if (heldItemCount() !== null) return if (activeHoldTargetKey() !== null) return
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
@@ -411,14 +409,23 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
createEffect(on(() => props.resetKey?.(), () => { createEffect(on(() => props.resetKey?.(), () => {
itemElements.clear() itemElements.clear()
setHeldItemCount(null) setActiveHoldTargetKey(null)
setDidTriggerHoldForCurrentTarget(false)
lastObservedScrollOffset = 0 lastObservedScrollOffset = 0
lastObservedPinnedAtBottom = false lastObservedPinnedAtBottom = false
})) }))
createEffect(on(holdTargetKey, (nextTargetKey, prevTargetKey) => {
if (nextTargetKey !== prevTargetKey && didTriggerHoldForCurrentTarget()) {
setDidTriggerHoldForCurrentTarget(false)
}
if (activeHoldTargetKey() === null) return
if (nextTargetKey === activeHoldTargetKey()) return
clearAutoPinHold({ resumeBottom: true })
}, { defer: true }))
// Handle autoScroll (Follow) on items change // Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => { createEffect(on(() => props.items().length, (len, prevLen) => {
updateAutoPinHold()
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) { if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true)) requestAnimationFrame(() => scrollToBottom(true))
} }
@@ -427,16 +434,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Handle followToken change // Handle followToken change
createEffect(on(() => props.followToken?.(), () => { createEffect(on(() => props.followToken?.(), () => {
updateAutoPinHold()
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
}, { defer: true })) }, { defer: true }))
createEffect(on(() => holdTargetKey(), () => {
updateAutoPinHold()
}, { defer: true }))
// Reset state on resetKey change // Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => { createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return if (nextKey === lastResetKey) return
@@ -459,13 +461,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
}) })
createEffect(() => {
if (typeof window === "undefined") return
const handleResize = () => updateAutoPinHold()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
return ( return (
<div class="virtual-follow-list-shell" ref={shellElement => { <div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement) setShellElement(shellElement)

View File

@@ -12,6 +12,8 @@ import type {
SpeechTranscriptionResponse, SpeechTranscriptionResponse,
SideCar, SideCar,
ServerMeta, ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
RemoteServerProbeRequest, RemoteServerProbeRequest,
RemoteServerProbeResponse, RemoteServerProbeResponse,
VoiceModeStateResponse, VoiceModeStateResponse,
@@ -256,6 +258,15 @@ export const serverApi = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
}, },
createRemoteProxySession(payload: RemoteProxySessionCreateRequest): Promise<RemoteProxySessionCreateResponse> {
return request<RemoteProxySessionCreateResponse>("/api/remote-proxy/sessions", {
method: "POST",
body: JSON.stringify(payload),
})
},
deleteRemoteProxySession(id: string): Promise<void> {
return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" })
},
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> { fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status") return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
}, },

View File

@@ -7,7 +7,7 @@ import {
normalizeDroppedDirectoryPaths, normalizeDroppedDirectoryPaths,
supportsDesktopFolderDrop, supportsDesktopFolderDrop,
} from "../native/desktop-file-drop" } from "../native/desktop-file-drop"
import { runtimeEnv } from "../runtime-env" import { isTauriHost } from "../runtime-env"
interface UseFolderDropOptions { interface UseFolderDropOptions {
enabled: Accessor<boolean> enabled: Accessor<boolean>
@@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
const bind: FolderDropBindings = { const bind: FolderDropBindings = {
onDragEnter(event) { onDragEnter(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) { if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
return return
} }
event.preventDefault() event.preventDefault()
@@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
setIsActive(true) setIsActive(true)
}, },
onDragOver(event) { onDragOver(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) { if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
return return
} }
event.preventDefault() event.preventDefault()
@@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
setIsActive(true) setIsActive(true)
}, },
onDragLeave(event) { onDragLeave(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) { if (!isSupported || isTauriHost() || !containsFileDrop(event)) {
return return
} }
event.preventDefault() event.preventDefault()
@@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
return return
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
reset() reset()
return return
} }

View File

@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Connecting...", "folderSelection.servers.dialog.connecting": "Connecting...",
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.", "folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.", "folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
"folderSelection.servers.certificateInstall.title": "Install Local Certificate",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continue",
"folderSelection.servers.certificateInstall.cancelLabel": "Cancel",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.",
"folderSelection.sidecars.button": "Open SideCar", "folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Conectando...", "folderSelection.servers.dialog.connecting": "Conectando...",
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.", "folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.", "folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
"folderSelection.servers.certificateInstall.cancelLabel": "Cancelar",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.",
"folderSelection.sidecars.button": "Open SideCar", "folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Connexion...", "folderSelection.servers.dialog.connecting": "Connexion...",
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.", "folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.", "folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
"folderSelection.servers.certificateInstall.title": "Installer le certificat local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
"folderSelection.servers.certificateInstall.cancelLabel": "Annuler",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.",
"folderSelection.sidecars.button": "Open SideCar", "folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "מתחבר...", "folderSelection.servers.dialog.connecting": "מתחבר...",
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.", "folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.", "folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
"folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.",
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
"folderSelection.servers.certificateInstall.cancelLabel": "ביטול",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.",
"folderSelection.sidecars.button": "Open SideCar", "folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "接続中...", "folderSelection.servers.dialog.connecting": "接続中...",
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。", "folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。", "folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
"folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。",
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
"folderSelection.servers.certificateInstall.cancelLabel": "キャンセル",
"folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。",
"folderSelection.sidecars.button": "Open SideCar", "folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Подключение...", "folderSelection.servers.dialog.connecting": "Подключение...",
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.", "folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.", "folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
"folderSelection.servers.certificateInstall.title": "Установить локальный сертификат",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.",
"folderSelection.servers.certificateInstall.confirmLabel": "Продолжить",
"folderSelection.servers.certificateInstall.cancelLabel": "Отмена",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.",
"folderSelection.sidecars.button": "Open SideCar", "folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "连接中...", "folderSelection.servers.dialog.connecting": "连接中...",
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。", "folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。", "folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
"folderSelection.servers.certificateInstall.title": "安装本地证书",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。",
"folderSelection.servers.certificateInstall.confirmLabel": "继续",
"folderSelection.servers.certificateInstall.cancelLabel": "取消",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。",
"folderSelection.sidecars.button": "Open SideCar", "folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -1,12 +1,16 @@
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env" import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env"
import { getLogger } from "../logger" import { getLogger } from "../logger"
const log = getLogger("actions") const log = getLogger("actions")
export async function restartCli(): Promise<boolean> { export async function restartCli(): Promise<boolean> {
if (!canRestartCli()) {
return false
}
try { try {
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
if (api?.restartCli) { if (api?.restartCli) {
await api.restartCli() await api.restartCli()
@@ -15,7 +19,7 @@ export async function restartCli(): Promise<boolean> {
return false return false
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
if (typeof window.__TAURI__?.core?.invoke === "function") { if (typeof window.__TAURI__?.core?.invoke === "function") {
await invoke("cli_restart") await invoke("cli_restart")
return true return true

View File

@@ -1,6 +1,6 @@
import { listen } from "@tauri-apps/api/event" import { listen } from "@tauri-apps/api/event"
import { getLogger } from "../logger" import { getLogger } from "../logger"
import { runtimeEnv } from "../runtime-env" import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env"
const log = getLogger("actions") const log = getLogger("actions")
@@ -21,7 +21,7 @@ function getFilePath(file: File): string | null {
if (typeof file.path === "string" && file.path.trim().length > 0) { if (typeof file.path === "string" && file.path.trim().length > 0) {
return file.path return file.path
} }
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file) const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
if (typeof electronPath === "string" && electronPath.trim().length > 0) { if (typeof electronPath === "string" && electronPath.trim().length > 0) {
return electronPath return electronPath
@@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]>
} }
export function supportsDesktopFolderDrop(): boolean { export function supportsDesktopFolderDrop(): boolean {
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web" return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop()
} }
export function containsFileDrop(event: DragEvent): boolean { export function containsFileDrop(event: DragEvent): boolean {
@@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<s
if (uniquePaths.length === 0) { if (uniquePaths.length === 0) {
return [] return []
} }
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
return resolveElectronDirectoryPaths(uniquePaths) return resolveElectronDirectoryPaths(uniquePaths)
} }
return uniquePaths return uniquePaths
} }
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> { export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
if (runtimeEnv.host !== "tauri") { if (!isTauriHost()) {
return () => {} return () => {}
} }
@@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
} }
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> { export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
if (runtimeEnv.host !== "tauri") { if (!isTauriHost()) {
return () => {} return () => {}
} }

View File

@@ -1,4 +1,4 @@
import { runtimeEnv } from "../runtime-env" import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env"
import type { NativeDialogOptions } from "./types" import type { NativeDialogOptions } from "./types"
import { openElectronNativeDialog } from "./electron/functions" import { openElectronNativeDialog } from "./electron/functions"
import { openTauriNativeDialog } from "./tauri/functions" import { openTauriNativeDialog } from "./tauri/functions"
@@ -6,20 +6,23 @@ import { openTauriNativeDialog } from "./tauri/functions"
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types" export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null { function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
switch (runtimeEnv.host) { if (isElectronHost()) {
case "electron": return openElectronNativeDialog
return openElectronNativeDialog
case "tauri":
return openTauriNativeDialog
default:
return null
} }
if (isTauriHost()) {
return openTauriNativeDialog
}
return null
} }
export function supportsNativeDialogs(): boolean { export function supportsNativeDialogs(): boolean {
return resolveNativeHandler() !== null return resolveNativeHandler() !== null
} }
export function supportsNativeDialogsInCurrentWindow(): boolean {
return canUseNativeDialogs()
}
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> { async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
const handler = resolveNativeHandler() const handler = resolveNativeHandler()
if (!handler) { if (!handler) {

View File

@@ -1,23 +1,37 @@
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import type { RemoteServerProfile } from "../../../../server/src/api-types" import type { RemoteServerProfile } from "../../../../server/src/api-types"
import { runtimeEnv } from "../runtime-env" import { showConfirmDialog } from "../../stores/alerts"
import { tGlobal } from "../i18n"
import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
export interface RemoteWindowOpenPayload { export interface RemoteWindowOpenPayload {
id: string id: string
name: string name: string
baseUrl: string baseUrl: string
entryUrl?: string
proxySessionId?: string
skipTlsVerify: boolean skipTlsVerify: boolean
} }
export async function openRemoteServerWindow(profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">): Promise<void> { export async function openRemoteServerWindow(
profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">,
entryUrl?: string,
proxySessionId?: string,
): Promise<void> {
if (!canOpenRemoteWindows()) {
throw new Error("Remote server windows can only be opened from a local desktop window")
}
const payload: RemoteWindowOpenPayload = { const payload: RemoteWindowOpenPayload = {
id: profile.id, id: profile.id,
name: profile.name, name: profile.name,
baseUrl: profile.baseUrl, baseUrl: profile.baseUrl,
entryUrl,
proxySessionId,
skipTlsVerify: profile.skipTlsVerify, skipTlsVerify: profile.skipTlsVerify,
} }
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
if (typeof api?.openRemoteWindow === "function") { if (typeof api?.openRemoteWindow === "function") {
await api.openRemoteWindow(payload) await api.openRemoteWindow(payload)
@@ -25,7 +39,29 @@ export async function openRemoteServerWindow(profile: Pick<RemoteServerProfile,
} }
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
const requiresLocalCertificate =
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")
if (requiresLocalCertificate) {
const needsInstall = await invoke<boolean>("needs_local_certificate_install")
if (needsInstall) {
const accepted = await showConfirmDialog(
tGlobal("folderSelection.servers.certificateInstall.confirmMessage"),
{
title: tGlobal("folderSelection.servers.certificateInstall.title"),
variant: "warning",
confirmLabel: tGlobal("folderSelection.servers.certificateInstall.confirmLabel"),
cancelLabel: tGlobal("folderSelection.servers.certificateInstall.cancelLabel"),
},
)
if (!accepted) {
throw new Error(tGlobal("folderSelection.servers.certificateInstall.cancelled"))
}
}
}
await invoke("open_remote_window", { payload }) await invoke("open_remote_window", { payload })
return return
} }

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env" import { isElectronHost, isTauriHost } from "../runtime-env"
import { getLogger } from "../logger" import { getLogger } from "../logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -56,11 +56,11 @@ async function setWebWakeLock(enabled: boolean): Promise<boolean> {
function hasAnyWakeLockSupport(): boolean { function hasAnyWakeLockSupport(): boolean {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const api = (window as any).electronAPI const api = (window as any).electronAPI
if (api?.setWakeLock) return true if (api?.setWakeLock) return true
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
return typeof window.__TAURI__?.core?.invoke === "function" return typeof window.__TAURI__?.core?.invoke === "function"
} }
return Boolean((navigator as any)?.wakeLock?.request) return Boolean((navigator as any)?.wakeLock?.request)
@@ -106,13 +106,13 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
async function applyWakeLock(enabled: boolean): Promise<boolean> { async function applyWakeLock(enabled: boolean): Promise<boolean> {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") { if (isElectronHost()) {
const ok = await setElectronWakeLock(enabled) const ok = await setElectronWakeLock(enabled)
if (ok || !enabled) return ok if (ok || !enabled) return ok
// fallback to web API if electron preload didn't expose it // fallback to web API if electron preload didn't expose it
} }
if (runtimeEnv.host === "tauri") { if (isTauriHost()) {
const ok = await setTauriWakeLock(enabled) const ok = await setTauriWakeLock(enabled)
if (ok || !enabled) return ok if (ok || !enabled) return ok
// fallback to web API if tauri command isn't available // fallback to web API if tauri command isn't available

View File

@@ -2,10 +2,12 @@ import { getLogger } from "./logger"
export type HostRuntime = "electron" | "tauri" | "web" export type HostRuntime = "electron" | "tauri" | "web"
export type PlatformKind = "desktop" | "mobile" export type PlatformKind = "desktop" | "mobile"
export type WindowContextKind = "local" | "remote"
export interface RuntimeEnvironment { export interface RuntimeEnvironment {
host: HostRuntime host: HostRuntime
platform: PlatformKind platform: PlatformKind
windowContext: WindowContextKind
} }
declare global { declare global {
@@ -14,6 +16,7 @@ declare global {
} }
interface Window { interface Window {
__CODENOMAD_WINDOW_CONTEXT__?: WindowContextKind
electronAPI?: unknown electronAPI?: unknown
__TAURI__?: { __TAURI__?: {
core?: TauriCoreModule core?: TauriCoreModule
@@ -21,11 +24,41 @@ declare global {
} }
} }
function detectWindowContext(): WindowContextKind {
if (typeof window === "undefined") {
return "remote"
}
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "remote") {
return "remote"
}
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "local") {
return "local"
}
const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined" || typeof win.__TAURI__ !== "undefined") {
return "local"
}
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
return "local"
}
return "remote"
}
function detectHost(): HostRuntime { function detectHost(): HostRuntime {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return "web" return "web"
} }
const explicitHost = window.__CODENOMAD_RUNTIME_HOST__
if (explicitHost) {
return explicitHost
}
const win = window as Window & { electronAPI?: unknown } const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined") { if (typeof win.electronAPI !== "undefined") {
return "electron" return "electron"
@@ -71,16 +104,24 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
cachedEnv = { cachedEnv = {
host: detectHost(), host: detectHost(),
platform: detectPlatform(), platform: detectPlatform(),
windowContext: detectWindowContext(),
} }
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`) log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform} context=${cachedEnv.windowContext}`)
} }
return cachedEnv return cachedEnv
} }
export const runtimeEnv = detectRuntimeEnvironment() export const runtimeEnv = detectRuntimeEnvironment()
export const isElectronHost = () => runtimeEnv.host === "electron" export const isElectronHost = () => detectHost() === "electron"
export const isTauriHost = () => runtimeEnv.host === "tauri" export const isTauriHost = () => detectHost() === "tauri"
export const isWebHost = () => runtimeEnv.host === "web" export const isWebHost = () => detectHost() === "web"
export const isMobilePlatform = () => runtimeEnv.platform === "mobile" export const isDesktopHost = () => isElectronHost() || isTauriHost()
export const isMobilePlatform = () => detectPlatform() === "mobile"
export const isLocalWindow = () => detectWindowContext() === "local"
export const isRemoteWindow = () => detectWindowContext() === "remote"
export const canUseNativeDialogs = () => isDesktopHost() && isLocalWindow()
export const canOpenRemoteWindows = () => isDesktopHost() && isLocalWindow()
export const canRestartCli = () => isDesktopHost() && isLocalWindow()
export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow()

View File

@@ -6,7 +6,7 @@ import type {
} from "../../stores/preferences" } from "../../stores/preferences"
import type { Command } from "../commands" import type { Command } from "../commands"
import { tGlobal } from "../i18n" import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env" import { isWebHost } from "../runtime-env"
export type BehaviorSettingKind = "toggle" | "enum" export type BehaviorSettingKind = "toggle" | "enum"
@@ -84,7 +84,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
next, next,
) )
}, },
disabled: () => runtimeEnv.host === "web", disabled: () => isWebHost(),
}, },
{ {
kind: "toggle", kind: "toggle",
@@ -337,13 +337,13 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[]
), ),
description: () => description: () =>
tGlobal( tGlobal(
runtimeEnv.host === "web" isWebHost()
? "commands.keyboardShortcutHints.description.disabledWeb" ? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description", : "commands.keyboardShortcutHints.description",
), ),
category: "System", category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"), keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web", disabled: () => isWebHost(),
action: actions.toggleKeyboardShortcutHints, action: actions.toggleKeyboardShortcutHints,
}, },
{ {

View File

@@ -397,7 +397,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const role: MessageRole = info.role === "user" ? "user" : "assistant" const role: MessageRole = info.role === "user" ? "user" : "assistant"
const hasError = Boolean((info as any).error) const hasError = Boolean((info as any).error)
const status: MessageStatus = hasError ? "error" : "complete" const hasEnded = typeof timeInfo.end === "number" && timeInfo.end > 0
const status: MessageStatus = hasError ? "error" : hasEnded ? "complete" : "streaming"
let record = store.getMessage(messageId) let record = store.getMessage(messageId)
if (!record) { if (!record) {

View File

@@ -37,6 +37,8 @@ declare global {
id: string id: string
name: string name: string
baseUrl: string baseUrl: string
entryUrl?: string
proxySessionId?: string
skipTlsVerify: boolean skipTlsVerify: boolean
}) => Promise<{ ok: boolean }> }) => Promise<{ ok: boolean }>
} }
@@ -61,10 +63,12 @@ declare global {
} }
interface Window { interface Window {
__CODENOMAD_API_BASE__?: string __CODENOMAD_API_BASE__?: string
__CODENOMAD_EVENTS_URL__?: string __CODENOMAD_EVENTS_URL__?: string
electronAPI?: ElectronAPI __CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web"
__TAURI__?: TauriBridge __CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote"
codenomadLogger?: LoggerControls electronAPI?: ElectronAPI
__TAURI__?: TauriBridge
codenomadLogger?: LoggerControls
} }
} }