Compare commits
262 Commits
v0.10.3-de
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 | ||
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
9d6a5bcdc0 | ||
|
|
514b187b00 | ||
|
|
240acb7729 | ||
|
|
278b563c1a | ||
|
|
0af79002ed | ||
|
|
f3981a1cce | ||
|
|
031e8d5717 | ||
|
|
995fb3b6a3 | ||
|
|
aeb0ff11b3 | ||
|
|
b61cfbd9f9 | ||
|
|
481dd1a88a | ||
|
|
3f6cdd36f3 | ||
|
|
fe932c8307 | ||
|
|
64ac885157 | ||
|
|
1d953dfe64 | ||
|
|
42589464e5 | ||
|
|
197dee2aea | ||
|
|
045d8da8b2 | ||
|
|
c9bd4b7395 | ||
|
|
41a5026331 | ||
|
|
d1a27ac31b | ||
|
|
37b3f85e61 | ||
|
|
55a6479c0e | ||
|
|
f88064af06 | ||
|
|
27bccb8d6b | ||
|
|
1b4eff9419 | ||
|
|
6c1febf50e | ||
|
|
75622ef366 | ||
|
|
864f913e3e | ||
|
|
b7d4f8f869 | ||
|
|
0dc5867fb3 | ||
|
|
d13ecba322 | ||
|
|
740f37db86 | ||
|
|
d447b05821 | ||
|
|
1233121a13 | ||
|
|
a950d47df0 | ||
|
|
1c68f5d288 | ||
|
|
3bad0afd7d | ||
|
|
8567d49178 | ||
|
|
09284ee2ce | ||
|
|
a2e30f1b54 | ||
|
|
a4af811de3 | ||
|
|
c5aa59ca75 | ||
|
|
b8e0714b68 | ||
|
|
3f890e5de1 | ||
|
|
935926d875 | ||
|
|
74f753abf4 | ||
|
|
d15340a4b8 | ||
|
|
108cad82d0 | ||
|
|
823dd2d687 | ||
|
|
313e82880b | ||
|
|
68407a01a4 | ||
|
|
0283493f2a | ||
|
|
e989795de3 | ||
|
|
103d2bf1a8 | ||
|
|
0ce7a47e03 | ||
|
|
5df8809c82 | ||
|
|
6e22614648 | ||
|
|
5d87e1e563 | ||
|
|
d735b189f5 | ||
|
|
3d575f4f68 | ||
|
|
b58728dc0e | ||
|
|
672177f570 | ||
|
|
6961efde0b | ||
|
|
b3e0233f4b | ||
|
|
fcebcb0174 | ||
|
|
eaab5e2e9f | ||
|
|
b12825f923 | ||
|
|
8245f474b8 | ||
|
|
3a15b311a8 | ||
|
|
6cb6c0af32 | ||
|
|
7f631611fd | ||
|
|
9d91ecc649 | ||
|
|
87afb06d34 | ||
|
|
4402d9afb0 | ||
|
|
153065d025 | ||
|
|
2abda0e6b4 | ||
|
|
800133361d | ||
|
|
034cb5dea9 | ||
|
|
d7ab84f245 | ||
|
|
7c3f808d69 | ||
|
|
a59e929b12 | ||
|
|
8ff4019839 | ||
|
|
d9068ac8c6 | ||
|
|
51f8eff3f7 | ||
|
|
627ff2d42b | ||
|
|
0d9da40102 | ||
|
|
ff94c9714e | ||
|
|
429825f434 | ||
|
|
d836d2e62d | ||
|
|
f77fb1562e | ||
|
|
b33421a375 | ||
|
|
c64a9a03f9 | ||
|
|
0d215342e3 | ||
|
|
beb14ea0a2 | ||
|
|
6a4e548d2c | ||
|
|
201988b97c | ||
|
|
ad943b2bd4 | ||
|
|
6dac8a6209 | ||
|
|
bec1af6523 | ||
|
|
1719802c0f | ||
|
|
3719dcecf8 | ||
|
|
3dae143830 | ||
|
|
f050273a8e | ||
|
|
8f955cf21c | ||
|
|
a893fca66e | ||
|
|
4f8aba5658 | ||
|
|
219e012c1b | ||
|
|
17716a730b | ||
|
|
c57170d122 | ||
|
|
24c1b7e8ad | ||
|
|
3c76f9776c | ||
|
|
80a02b68b9 | ||
|
|
c766b5ab62 | ||
|
|
133e937772 | ||
|
|
95df743339 | ||
|
|
cd6266757d | ||
|
|
ec0bffe0c2 | ||
|
|
ed322a16bf | ||
|
|
044e46cd6b | ||
|
|
38f75ab06d | ||
|
|
b6bf58ea8f | ||
|
|
2c27fc53ad | ||
|
|
4c5acefa07 | ||
|
|
224cab6a42 | ||
|
|
48b2d7c5ee | ||
|
|
594809538d | ||
|
|
13802537b4 | ||
|
|
ca2b3c232f | ||
|
|
c51e71c7a2 | ||
|
|
482313f662 | ||
|
|
9a4d378238 | ||
|
|
5d5fbfb5f2 | ||
|
|
d147ad49ff | ||
|
|
9b435e3621 | ||
|
|
ab9e188b02 | ||
|
|
2991de528a | ||
|
|
f1bd681618 | ||
|
|
b91dbb1a60 | ||
|
|
688b127c6d | ||
|
|
0f9c99e3bd | ||
|
|
1122070b9c | ||
|
|
57b81f00f8 | ||
|
|
362105fe78 | ||
|
|
5834d2df1b | ||
|
|
ef4c8ef425 | ||
|
|
5f755a7e1c | ||
|
|
8607fab5b5 | ||
|
|
0368fe8248 | ||
|
|
b970281fa7 | ||
|
|
8e5a7fc213 | ||
|
|
15f362e8b5 | ||
|
|
7bbd0a1787 | ||
|
|
f8aae56728 | ||
|
|
027d7fc97d | ||
|
|
e90aef4b3c | ||
|
|
e4e89008b2 | ||
|
|
90baefbb7e | ||
|
|
1c138f4489 | ||
|
|
d36e568ed0 | ||
|
|
d6462ef524 | ||
|
|
6a6fcff2c8 | ||
|
|
a06884ebce | ||
|
|
62bd88f6a4 | ||
|
|
6479561779 | ||
|
|
635237c258 | ||
|
|
33f0aa5714 | ||
|
|
7ca6285d58 | ||
|
|
14c60fef6c | ||
|
|
336de6a19e | ||
|
|
377c8e2249 | ||
|
|
697dea21f8 | ||
|
|
34d3f803d5 | ||
|
|
f824a063a5 | ||
|
|
96fe1b86dd | ||
|
|
5fabf286e8 | ||
|
|
e8947d61b1 | ||
|
|
1ccd14eae8 | ||
|
|
b162764ccb | ||
|
|
2124e540aa | ||
|
|
b5790998b7 | ||
|
|
9800afb785 | ||
|
|
3b73d9d5b9 | ||
|
|
f7ac30afe3 | ||
|
|
ce370d5100 | ||
|
|
c639e535b5 | ||
|
|
e84adebe61 | ||
|
|
d45a1ff078 | ||
|
|
b4121696bb | ||
|
|
f75c942162 | ||
|
|
127a1f628d | ||
|
|
859312ba3b | ||
|
|
4eaa711f01 | ||
|
|
c8ff858565 | ||
|
|
6de6ef5a4a | ||
|
|
4dee154490 | ||
|
|
ef388adc4f | ||
|
|
e8cfad1266 | ||
|
|
3f82dd21fe | ||
|
|
dc13d9a7d0 | ||
|
|
29557fba6d | ||
|
|
dea5079713 | ||
|
|
ddc58a2c3c | ||
|
|
eafd4d83af | ||
|
|
1a0734c6b1 | ||
|
|
f29f197b9a | ||
|
|
e16c5752ed | ||
|
|
375f92410e | ||
|
|
53f1dd4150 | ||
|
|
b7f638f07d | ||
|
|
32113ea100 | ||
|
|
b31135f622 | ||
|
|
eb6701185b | ||
|
|
d948ad8e35 | ||
|
|
f58267dd30 | ||
|
|
95c747923c | ||
|
|
f3b9ee4e04 | ||
|
|
309a123c1f | ||
|
|
761e3d4268 | ||
|
|
265d497ef4 | ||
|
|
56a052086f | ||
|
|
9a4d205d97 | ||
|
|
ff71302969 | ||
|
|
5067db3dd0 | ||
|
|
1ef01da019 | ||
|
|
edd3ded1d8 | ||
|
|
e30ff6358d | ||
|
|
dbde403b3e | ||
|
|
230c981cc2 | ||
|
|
34978c87fb | ||
|
|
3e6d0a402c | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
240
.github/workflows/build-and-upload.yml
vendored
240
.github/workflows/build-and-upload.yml
vendored
@@ -3,6 +3,11 @@ name: Build and Upload Binaries
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version:
|
||||
description: "Version to apply to workspace packages (release builds)"
|
||||
required: false
|
||||
@@ -23,6 +28,21 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
upload_actions_artifacts:
|
||||
description: "Upload built artifacts to GitHub Actions run artifacts"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
actions_artifacts_retention_days:
|
||||
description: "Retention (days) for GitHub Actions artifacts"
|
||||
required: false
|
||||
default: 7
|
||||
type: number
|
||||
actions_artifacts_name_prefix:
|
||||
description: "Optional prefix for Actions artifact names"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
set_versions:
|
||||
description: "Run npm version to set workspace versions"
|
||||
required: false
|
||||
@@ -45,6 +65,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -54,7 +76,21 @@ jobs:
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_FETCH_RETRIES: 5
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
|
||||
exit 0
|
||||
fi
|
||||
echo "npm version failed (attempt $attempt/3); retrying..." >&2
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
@@ -65,6 +101,112 @@ jobs:
|
||||
- name: Build macOS binaries (Electron)
|
||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
release_root="packages/electron-app/release"
|
||||
apps=()
|
||||
while IFS= read -r -d '' app; do
|
||||
apps+=("$app")
|
||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||
|
||||
if [ "${#apps[@]}" -eq 0 ]; then
|
||||
echo "No CodeNomad.app found under $release_root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# GitHub macOS runners typically have no signing identity. Without any signature,
|
||||
# the shipped .app can fail Gatekeeper with:
|
||||
# code has no resources but signature indicates they must be present
|
||||
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
|
||||
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
|
||||
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
|
||||
for app in "${apps[@]}"; do
|
||||
echo "codesign (adhoc): $app"
|
||||
codesign --force --deep --sign - "$app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
done
|
||||
else
|
||||
echo "macOS codesigning identity present; skipping ad-hoc signing"
|
||||
fi
|
||||
|
||||
- name: Repackage Electron macOS zips (ditto)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Prefer the workflow-provided version; fall back to package.json.
|
||||
VERSION_TO_USE="${VERSION:-}"
|
||||
if [ -z "$VERSION_TO_USE" ]; then
|
||||
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
|
||||
fi
|
||||
|
||||
release_root="packages/electron-app/release"
|
||||
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
|
||||
# Use find to locate built app bundles instead of ** globs.
|
||||
apps=()
|
||||
while IFS= read -r -d '' app; do
|
||||
apps+=("$app")
|
||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||
if [ "${#apps[@]}" -eq 0 ]; then
|
||||
echo "No CodeNomad.app found under $release_root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for app in "${apps[@]}"; do
|
||||
bundle_dir=$(basename "$(dirname "$app")")
|
||||
arch="x64"
|
||||
if [[ "$bundle_dir" == *"arm64"* ]]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
|
||||
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
|
||||
rm -f "$out_zip"
|
||||
echo "ditto -ck: $app -> $out_zip"
|
||||
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
|
||||
done
|
||||
|
||||
- name: Validate Electron macOS codesign (unzipped)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
|
||||
if [ "${#zips[@]}" -eq 0 ]; then
|
||||
echo "No Electron macOS zip artifacts found to validate" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for zip in "${zips[@]}"; do
|
||||
echo "Validating codesign for: $zip"
|
||||
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
|
||||
mkdir -p "$extract_dir"
|
||||
|
||||
# Use ditto for extraction as well to preserve bundle metadata.
|
||||
ditto -x -k "$zip" "$extract_dir"
|
||||
|
||||
app_path=""
|
||||
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
|
||||
if [ -d "$candidate" ]; then
|
||||
app_path="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$app_path" ]; then
|
||||
echo "No .app found after extracting $zip" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||
done
|
||||
|
||||
- name: Upload release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -76,6 +218,15 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron macOS)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
@@ -85,6 +236,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -115,6 +268,15 @@ jobs:
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
- name: Upload Actions artifacts (Electron Windows)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
@@ -124,6 +286,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -155,6 +319,15 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
@@ -164,6 +337,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -206,7 +381,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
@@ -217,6 +392,15 @@ jobs:
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Actions artifacts (Tauri macOS)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (macOS)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -237,6 +421,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -279,7 +465,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
@@ -290,6 +476,15 @@ jobs:
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Actions artifacts (Tauri macOS arm64)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (macOS arm64)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -310,6 +505,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -355,7 +552,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
@@ -368,6 +565,15 @@ jobs:
|
||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||
}
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Windows)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (Windows)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
@@ -388,6 +594,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -443,7 +651,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
@@ -469,6 +677,15 @@ jobs:
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
||||
path: packages/tauri-app/release-tauri/*
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -490,6 +707,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -587,6 +806,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -623,3 +844,12 @@ jobs:
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux RPM)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
||||
path: packages/electron-app/release/*.rpm
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
122
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
122
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
name: Comment PR Artifacts
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RETENTION_DAYS: 7
|
||||
steps:
|
||||
- name: Check PR authorization
|
||||
id: auth
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BASE_REF" = "dev" ]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Wait for PR build and comment
|
||||
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = Number(process.env.PR_NUMBER);
|
||||
const headSha = process.env.HEAD_SHA;
|
||||
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
|
||||
const marker = '<!-- codenomad-pr-artifacts -->';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let matchedRun = null;
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'pr-build.yml',
|
||||
event: 'pull_request',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const matchingRuns = runs
|
||||
.filter((run) => run.head_sha === headSha)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
matchedRun = matchingRuns[0] || null;
|
||||
if (matchedRun && matchedRun.status === 'completed') {
|
||||
break;
|
||||
}
|
||||
|
||||
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
|
||||
await sleep(10000);
|
||||
}
|
||||
|
||||
if (!matchedRun) {
|
||||
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedRun.status !== 'completed') {
|
||||
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifacts = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunArtifacts,
|
||||
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
||||
);
|
||||
const active = artifacts.filter((artifact) => !artifact.expired);
|
||||
|
||||
const runUrl = matchedRun.html_url;
|
||||
const artifactsBlock = active.length
|
||||
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
|
||||
: 'Artifacts: (none found on this run)';
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'PR builds are available as GitHub Actions artifacts:',
|
||||
'',
|
||||
runUrl,
|
||||
'',
|
||||
`Artifacts expire in ${retentionDays} days.`,
|
||||
artifactsBlock,
|
||||
].join('\n');
|
||||
|
||||
const created = await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
core.info(`Created artifacts comment: ${created.data.html_url}`);
|
||||
61
.github/workflows/dev-release.yml
vendored
61
.github/workflows/dev-release.yml
vendored
@@ -1,12 +1,13 @@
|
||||
name: Develop Pre-Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
schedule:
|
||||
# Nightly build of dev (only if dev has new commits)
|
||||
- cron: "0 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
@@ -15,25 +16,63 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version_suffix: ${{ steps.vars.outputs.version_suffix }}
|
||||
run: ${{ steps.gate.outputs.run }}
|
||||
dev_sha: ${{ steps.gate.outputs.dev_sha }}
|
||||
version_suffix: ${{ steps.gate.outputs.version_suffix }}
|
||||
steps:
|
||||
- name: Compute version suffix
|
||||
id: vars
|
||||
- name: Decide whether to run
|
||||
id: gate
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA8="${GITHUB_SHA::8}"
|
||||
|
||||
api() {
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"$1"
|
||||
}
|
||||
|
||||
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
|
||||
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
|
||||
echo "Failed to resolve dev head SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
|
||||
SHA8="${DEV_SHA::8}"
|
||||
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
|
||||
|
||||
SHOULD_RUN="false"
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||
SHOULD_RUN="true"
|
||||
else
|
||||
# Nightly: only run if dev has advanced since last successful dev-release build.
|
||||
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
|
||||
if [ -z "${LAST_SHA}" ]; then
|
||||
SHOULD_RUN="true"
|
||||
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
|
||||
SHOULD_RUN="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
|
||||
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
prerelease:
|
||||
needs: prepare
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||
ref: ${{ needs.gate.outputs.dev_sha }}
|
||||
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||
dist_tag: latest
|
||||
prerelease: true
|
||||
|
||||
6
.github/workflows/manual-npm-publish.yml
vendored
6
.github/workflows/manual-npm-publish.yml
vendored
@@ -19,6 +19,10 @@ on:
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
@@ -46,6 +50,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
58
.github/workflows/pr-build.yml
vendored
Normal file
58
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: PR Build Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: pr-build-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
allowed: ${{ steps.auth.outputs.allowed }}
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
steps:
|
||||
- name: Check PR authorization
|
||||
id: auth
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BASE_REF" = "dev" ]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: authorize
|
||||
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
upload: false
|
||||
upload_actions_artifacts: true
|
||||
actions_artifacts_retention_days: 7
|
||||
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
||||
set_versions: false
|
||||
10
.github/workflows/release-ui.yml
vendored
10
.github/workflows/release-ui.yml
vendored
@@ -1,7 +1,13 @@
|
||||
name: Release UI
|
||||
|
||||
on:
|
||||
workflow_call: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
@@ -18,6 +24,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
55
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
55
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Restrict Non-Dev PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
restrict-non-dev-prs:
|
||||
if: ${{ github.event.pull_request.base.ref != 'dev' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
steps:
|
||||
- name: Check allowed actor
|
||||
id: auth
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Comment on unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
|
||||
|
||||
- name: Close unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr close "$PR_NUMBER"
|
||||
|
||||
- name: Fail unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
run: |
|
||||
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||
exit 1
|
||||
11
.github/workflows/reusable-release.yml
vendored
11
.github/workflows/reusable-release.yml
vendored
@@ -3,6 +3,11 @@ name: Reusable Release
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version_suffix:
|
||||
description: "Suffix appended to package.json version"
|
||||
required: false
|
||||
@@ -46,6 +51,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -84,6 +91,7 @@ jobs:
|
||||
needs: prepare-release
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||
@@ -95,6 +103,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-ui.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
@@ -103,6 +113,7 @@ jobs:
|
||||
- build-and-upload
|
||||
uses: ./.github/workflows/manual-npm-publish.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
package_name: ${{ inputs.npm_package_name }}
|
||||
|
||||
172
README.md
172
README.md
@@ -1,125 +1,127 @@
|
||||
# CodeNomad
|
||||
|
||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
||||
## The AI Coding Cockpit for OpenCode
|
||||
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
||||
CodeNomad transforms OpenCode from a terminal tool into a **premium desktop workspace** — built for developers who live inside AI coding sessions for hours and need control, speed, and clarity.
|
||||
|
||||
> OpenCode gives you the engine. CodeNomad gives you the cockpit.
|
||||
|
||||

|
||||
_Manage multiple OpenCode sessions side-by-side._
|
||||
|
||||
<details>
|
||||
<summary>📸 More Screenshots</summary>
|
||||
---
|
||||
|
||||

|
||||
_Global command palette for keyboard-first control._
|
||||
## Features
|
||||
|
||||

|
||||
_Rich media previews for images and assets._
|
||||
- **🚀 Multi-Instance Workspace**
|
||||
- **🌐 Remote Access**
|
||||
- **🧠 Session Management**
|
||||
- **🎙️ Voice Input & Speech**
|
||||
- **🌳 Git Worktrees**
|
||||
- **💬 Rich Message Experience**
|
||||
- **⌨️ Command Palette**
|
||||
- **📁 File System Browser**
|
||||
- **🔐 Authentication & Security**
|
||||
- **🔔 Notifications**
|
||||
- **🎨 Theming**
|
||||
- **🌍 Internationalization**
|
||||
|
||||

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
</details>
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
Choose the way that fits your workflow:
|
||||
### 🖥️ Desktop App
|
||||
|
||||
### 🖥️ Desktop App (Recommended)
|
||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||
Available as both Electron and Tauri builds — choose based on your preference.
|
||||
|
||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Run**: Install and launch like any other app.
|
||||
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
|
||||
|
||||
### 🦀 Tauri App (Experimental)
|
||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
||||
|
||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
||||
| Platform | Formats |
|
||||
|----------|---------|
|
||||
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
||||
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
|
||||
|
||||
### 💻 CodeNomad Server
|
||||
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
||||
|
||||
Run as a local server and access via browser. Perfect for remote development.
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||
- [packages/server/README.md](packages/server/README.md)
|
||||
|
||||
To see all available options:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
|
||||
|
||||
### 🧪 Dev Releases
|
||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||
|
||||
Bleeding-edge builds from the `dev` branch:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||
- **Node.js 18+** — for server mode or building from source
|
||||
|
||||
## Troubleshooting
|
||||
---
|
||||
|
||||
### macOS says the app is damaged
|
||||
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
xattr -l /Applications/CodeNomad.app
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
||||
|
||||
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
|
||||
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
|
||||
|
||||
Try running with one of these environment variables:
|
||||
|
||||
```bash
|
||||
# Most reliable workaround (can reduce rendering performance)
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
|
||||
# Alternative for some Wayland setups
|
||||
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
|
||||
```
|
||||
|
||||
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
|
||||
```
|
||||
|
||||
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
|
||||
|
||||
## Architecture & Development
|
||||
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
CodeNomad is a monorepo built with:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||
| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
|
||||
| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, beautiful |
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs |
|
||||
| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) |
|
||||
|
||||
### Quick Build
|
||||
To build the Desktop App from source:
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
```bash
|
||||
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
|
||||
cd CodeNomad
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary>
|
||||
|
||||
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
On Intel Macs, also check **System Settings → Privacy & Security** on first launch.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary>
|
||||
|
||||
WebKitGTK DMA-BUF/GBM issue. Run with:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
```
|
||||
|
||||
See full workaround in the original README.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
|
||||
---
|
||||
|
||||
**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 845 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 835 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 1.1 MiB |
186
package-lock.json
generated
186
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -64,7 +64,6 @@
|
||||
"version": "7.28.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -2809,9 +2808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
||||
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
|
||||
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
@@ -3253,9 +3252,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3305,6 +3304,32 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
|
||||
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
@@ -3355,7 +3380,6 @@
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
@@ -3457,7 +3481,6 @@
|
||||
"version": "22.19.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3532,7 +3555,6 @@
|
||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -3615,7 +3637,6 @@
|
||||
"version": "6.12.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -3818,6 +3839,7 @@
|
||||
"version": "5.3.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^3.2.4",
|
||||
@@ -3835,6 +3857,7 @@
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -3855,6 +3878,7 @@
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -3868,12 +3892,14 @@
|
||||
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -4187,6 +4213,7 @@
|
||||
"version": "4.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
@@ -4250,7 +4277,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4741,6 +4767,7 @@
|
||||
"version": "4.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^4.0.2",
|
||||
@@ -4870,6 +4897,7 @@
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
@@ -4881,6 +4909,7 @@
|
||||
"version": "4.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
@@ -5246,7 +5275,6 @@
|
||||
"version": "24.13.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"builder-util": "24.13.1",
|
||||
@@ -5413,6 +5441,7 @@
|
||||
"version": "24.13.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"archiver": "^5.3.1",
|
||||
@@ -5424,6 +5453,7 @@
|
||||
"version": "10.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -5437,6 +5467,7 @@
|
||||
"version": "6.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -5448,6 +5479,7 @@
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -6165,7 +6197,8 @@
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
@@ -7382,7 +7415,8 @@
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.6",
|
||||
@@ -7432,7 +7466,6 @@
|
||||
"version": "1.21.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7564,6 +7597,7 @@
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
@@ -7575,6 +7609,7 @@
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -7588,12 +7623,14 @@
|
||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -7658,22 +7695,26 @@
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.sortby": {
|
||||
"version": "4.7.0",
|
||||
@@ -7685,7 +7726,8 @@
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
@@ -8214,6 +8256,27 @@
|
||||
"regex-recursion": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
|
||||
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
@@ -8468,7 +8531,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8616,7 +8678,8 @@
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "3.0.0",
|
||||
@@ -8865,6 +8928,7 @@
|
||||
"version": "3.6.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -8878,6 +8942,7 @@
|
||||
"version": "1.1.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
@@ -9180,7 +9245,6 @@
|
||||
"version": "4.52.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -9404,7 +9468,6 @@
|
||||
"node_modules/seroval": {
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -9728,7 +9791,6 @@
|
||||
"node_modules/solid-js": {
|
||||
"version": "1.9.10",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
@@ -9869,6 +9931,7 @@
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -10202,6 +10265,7 @@
|
||||
"version": "2.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
@@ -10218,14 +10282,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tauri-plugin-keepawake-api": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
@@ -10402,7 +10458,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10652,7 +10707,6 @@
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -10966,11 +11020,40 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/virtua": {
|
||||
"version": "0.48.8",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
||||
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.14.0",
|
||||
"react-dom": ">=16.14.0",
|
||||
"solid-js": ">=1.0",
|
||||
"svelte": ">=5.0",
|
||||
"vue": ">=3.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -11455,7 +11538,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -11650,7 +11732,6 @@
|
||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
@@ -11939,6 +12020,7 @@
|
||||
"version": "4.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^3.0.4",
|
||||
"compress-commons": "^4.1.2",
|
||||
@@ -11952,6 +12034,7 @@
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.2.3",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -11985,7 +12068,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -11995,6 +12078,7 @@
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
@@ -12021,7 +12105,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12031,6 +12115,7 @@
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
@@ -12062,7 +12147,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12070,16 +12155,18 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.1.11",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
@@ -12092,7 +12179,8 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -22,7 +22,7 @@
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||
"bumpVersion": "node ./scripts/bump-version.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.10.3",
|
||||
"minServerVersion": "0.13.3",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,23 @@ export interface Env {
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname === "/version.json") {
|
||||
const response = await env.ASSETS.fetch(request)
|
||||
|
||||
const newHeaders = new Headers(response.headers)
|
||||
newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
|
||||
newHeaders.set("Pragma", "no-cache")
|
||||
newHeaders.set("Expires", "0")
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
return env.ASSETS.fetch(request)
|
||||
},
|
||||
}
|
||||
|
||||
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
electron/resources/server/
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import { requestMicrophoneAccess } from "./permissions"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
@@ -65,6 +67,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
return { canceled: result.canceled, paths: result.filePaths }
|
||||
})
|
||||
|
||||
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
||||
if (!Array.isArray(paths)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const directories = paths.filter((value): value is string => {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return fs.statSync(value).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return directories
|
||||
})
|
||||
|
||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
@@ -92,6 +112,33 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
return { enabled: false }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"media:requestMicrophoneAccess",
|
||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"remote:openWindow",
|
||||
async (
|
||||
_event,
|
||||
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
|
||||
): Promise<{ ok: boolean }> => {
|
||||
const opener = (mainWindow as BrowserWindow & {
|
||||
__codenomadOpenRemoteWindow?: (payload: {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
skipTlsVerify: boolean
|
||||
}) => Promise<void>
|
||||
}).__codenomadOpenRemoteWindow
|
||||
if (!opener) {
|
||||
throw new Error("Remote window opening is not available")
|
||||
}
|
||||
await opener(payload)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"notifications:show",
|
||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { configureMediaPermissionHandlers } from "./permissions"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
@@ -20,6 +21,8 @@ let pendingCliUrl: string | null = null
|
||||
let pendingBootstrapToken: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
@@ -92,8 +95,13 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||
const origins = new Set<string>()
|
||||
if (window) {
|
||||
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
|
||||
origins.add(origin)
|
||||
}
|
||||
}
|
||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
@@ -108,13 +116,13 @@ function getAllowedRendererOrigins(): string[] {
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
@@ -127,7 +135,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
@@ -135,13 +143,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
event.preventDefault()
|
||||
handleExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
remoteWindowOrigins.set(window.id, new Set([origin]))
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to store allowed origin", url, error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindowAllowedOrigin(window: BrowserWindow) {
|
||||
remoteWindowOrigins.delete(window.id)
|
||||
}
|
||||
|
||||
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
insecureWindowOrigins.set(window.id, new Set([origin]))
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to store insecure origin", url, error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindowInsecureOrigin(window: BrowserWindow) {
|
||||
insecureWindowOrigins.delete(window.id)
|
||||
}
|
||||
|
||||
function isInsecureOriginAllowed(url: string) {
|
||||
try {
|
||||
const targetOrigin = new URL(url).origin
|
||||
for (const origins of insecureWindowOrigins.values()) {
|
||||
if (origins.has(targetOrigin)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
@@ -206,25 +255,30 @@ function createWindow() {
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
const window = mainWindow
|
||||
|
||||
setupNavigationGuards(window)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
clearWindowAllowedOrigin(window)
|
||||
loadLoadingScreen(window)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
window.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
createApplicationMenu(window)
|
||||
setupCliIPC(window, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
window.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
@@ -321,13 +375,68 @@ function finalizeCliSwap(url: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const window = mainWindow
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
setWindowAllowedOrigin(window, url)
|
||||
pendingCliUrl = null
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
function buildRemoteWindowTitle(name: string, baseUrl: string) {
|
||||
try {
|
||||
const parsed = new URL(baseUrl)
|
||||
return `${name} - ${parsed.host}`
|
||||
} catch {
|
||||
return `${name} - ${baseUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
|
||||
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
|
||||
}
|
||||
|
||||
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
|
||||
const targetUrl = new URL(payload.baseUrl)
|
||||
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
|
||||
const window = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor: "#1a1a1a",
|
||||
icon: getIconPath(),
|
||||
title,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
setWindowAllowedOrigin(window, targetUrl.toString())
|
||||
if (payload.skipTlsVerify) {
|
||||
addWindowInsecureOrigin(window, targetUrl.toString())
|
||||
}
|
||||
|
||||
setupNavigationGuards(window)
|
||||
window.on("closed", () => {
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
})
|
||||
|
||||
try {
|
||||
await window.loadURL(targetUrl.toString())
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||
let bootstrapExchangeInFlight = false
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
@@ -350,6 +459,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const sessionCookieName = cliManager.getAuthCookieName()
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
@@ -380,14 +490,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
name: sessionCookieName,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
@@ -489,6 +599,7 @@ app.whenReady().then(() => {
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
@@ -502,6 +613,17 @@ app.whenReady().then(() => {
|
||||
}
|
||||
|
||||
createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
|
||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||
if (isInsecureOriginAllowed(url)) {
|
||||
event.preventDefault()
|
||||
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||
callback(true)
|
||||
return
|
||||
}
|
||||
callback(false)
|
||||
})
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
||||
58
packages/electron-app/electron/main/permissions.ts
Normal file
58
packages/electron-app/electron/main/permissions.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { session, systemPreferences } from "electron"
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
|
||||
if (!origin) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = new URL(origin).origin
|
||||
return allowedOrigins.includes(normalized)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
||||
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
||||
if (permission !== "media") {
|
||||
return false
|
||||
}
|
||||
|
||||
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
|
||||
return mediaTypes.length === 0 || mediaTypes.includes("audio")
|
||||
}
|
||||
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
||||
if (!isAudioMediaRequest(permission, details)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
|
||||
})
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
if (!isAudioMediaRequest(permission, details)) {
|
||||
callback(false)
|
||||
return
|
||||
}
|
||||
|
||||
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
|
||||
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestMicrophoneAccess(): Promise<boolean> {
|
||||
if (!isMac) {
|
||||
return true
|
||||
}
|
||||
|
||||
const status = systemPreferences.getMediaAccessStatus("microphone")
|
||||
if (status === "granted") {
|
||||
return true
|
||||
}
|
||||
|
||||
return systemPreferences.askForMediaAccess("microphone")
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { app, utilityProcess, type UtilityProcess } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { parse as parseYaml } from "yaml"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = path.dirname(mainFilename)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
@@ -38,6 +42,9 @@ interface CliEntryResolution {
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
type ManagedChild = ChildProcess | UtilityProcess
|
||||
type ChildLaunchMode = "spawn" | "utility"
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
@@ -97,7 +104,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
||||
return "local"
|
||||
}
|
||||
|
||||
const mode = parsed?.preferences?.listeningMode
|
||||
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
||||
if (mode === "local" || mode === "all") {
|
||||
return mode
|
||||
}
|
||||
@@ -117,11 +124,13 @@ export declare interface CliProcessManager {
|
||||
}
|
||||
|
||||
export class CliProcessManager extends EventEmitter {
|
||||
private child?: ChildProcess
|
||||
private child?: ManagedChild
|
||||
private childLaunchMode: ChildLaunchMode = "spawn"
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
@@ -132,36 +141,67 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
let child: ManagedChild
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||
const runtimePath = this.resolveShellNodeCommand()
|
||||
const entryPath = this.resolveBundledProdEntry()
|
||||
const supervisorPath = this.resolveCliSupervisorPath()
|
||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||
const supervisorPayload = JSON.stringify({
|
||||
command: shellCommand.command,
|
||||
args: shellCommand.args,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||
)
|
||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||
env: shellEnv,
|
||||
stdio: "pipe",
|
||||
serviceName: "CodeNomad CLI Supervisor",
|
||||
})
|
||||
this.childLaunchMode = "utility"
|
||||
} else {
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
if (!child.pid) {
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
this.childLaunchMode = "spawn"
|
||||
}
|
||||
|
||||
if (this.childLaunchMode === "spawn" && !child.pid) {
|
||||
console.error("[cli] spawn failed: no pid")
|
||||
}
|
||||
|
||||
@@ -176,23 +216,48 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.handleStream(data.toString(), "stderr")
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
if (this.childLaunchMode === "utility") {
|
||||
const utilityChild = child as UtilityProcess
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
utilityChild.on("error", (error) => {
|
||||
const message = this.describeUtilityProcessError(error)
|
||||
console.error("[cli] utility supervisor failed:", error)
|
||||
this.updateStatus({ state: "error", error: message })
|
||||
this.emit("error", new Error(message))
|
||||
})
|
||||
|
||||
utilityChild.on("exit", (code) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
||||
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
} else {
|
||||
const spawnedChild = child as ChildProcess
|
||||
|
||||
spawnedChild.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
|
||||
spawnedChild.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<CliStatus>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -219,16 +284,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.childLaunchMode === "utility") {
|
||||
return this.stopUtilityChild(child as UtilityProcess)
|
||||
}
|
||||
|
||||
const spawnedChild = child as ChildProcess
|
||||
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
const pid = spawnedChild.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
@@ -304,7 +375,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
spawnedChild.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
@@ -324,10 +395,54 @@ export class CliProcessManager extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
|
||||
try {
|
||||
process.kill(pid, "SIGKILL")
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
child.once("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
})
|
||||
|
||||
if (child.pid === undefined) {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
child.kill()
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(): CliStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getAuthCookieName(): string {
|
||||
return this.authCookieName
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
@@ -335,14 +450,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
const pid = this.child.pid
|
||||
if (pid && process.platform !== "win32") {
|
||||
if (this.childLaunchMode === "utility") {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL")
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
} else if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} catch {
|
||||
this.child.kill("SIGKILL")
|
||||
;(this.child as ChildProcess).kill("SIGKILL")
|
||||
}
|
||||
} else {
|
||||
this.child.kill("SIGKILL")
|
||||
;(this.child as ChildProcess).kill("SIGKILL")
|
||||
}
|
||||
this.child = undefined
|
||||
}
|
||||
@@ -416,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--generate-token"]
|
||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
||||
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
@@ -431,7 +554,9 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
if (options.dev) {
|
||||
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||
args.push("--ui-dev-server", devServer, "--log-level", "debug")
|
||||
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
|
||||
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
|
||||
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
|
||||
}
|
||||
|
||||
return args
|
||||
@@ -447,6 +572,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
private buildExecutableCommand(command: string, args: string[]): string {
|
||||
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
|
||||
}
|
||||
|
||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||
if (cliEntry.runner === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
@@ -517,4 +646,58 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||
}
|
||||
|
||||
private resolveCliSupervisorPath(): string {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
||||
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
||||
}
|
||||
|
||||
private resolveShellNodeCommand(): string {
|
||||
const configured = process.env.NODE_BINARY?.trim()
|
||||
return configured && configured.length > 0 ? configured : "node"
|
||||
}
|
||||
|
||||
private resolveBundledProdEntry(): string {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
||||
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
||||
}
|
||||
|
||||
private describeUtilityProcessError(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error && typeof error === "object") {
|
||||
const typed = error as { type?: unknown; location?: unknown }
|
||||
if (typeof typed.type === "string") {
|
||||
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
||||
}
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
@@ -12,8 +12,18 @@ const electronAPI = {
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawn } = require("child_process")
|
||||
|
||||
const SHUTDOWN_GRACE_MS = 30_000
|
||||
|
||||
let child = null
|
||||
let shutdownTimer = null
|
||||
|
||||
function log(message, error) {
|
||||
if (error) {
|
||||
console.error(`[cli-supervisor] ${message}`, error)
|
||||
return
|
||||
}
|
||||
console.log(`[cli-supervisor] ${message}`)
|
||||
}
|
||||
|
||||
function clearShutdownTimer() {
|
||||
if (shutdownTimer) {
|
||||
clearTimeout(shutdownTimer)
|
||||
shutdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function forwardStream(stream, target) {
|
||||
if (!stream) return
|
||||
stream.on("data", (chunk) => {
|
||||
target.write(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
function terminateChild(force) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(force ? "SIGKILL" : "SIGTERM")
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
function requestShutdown(force = false) {
|
||||
if (!child) {
|
||||
process.exit(force ? 1 : 0)
|
||||
return
|
||||
}
|
||||
|
||||
terminateChild(force)
|
||||
if (force) {
|
||||
process.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
clearShutdownTimer()
|
||||
shutdownTimer = setTimeout(() => {
|
||||
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
|
||||
terminateChild(true)
|
||||
}, SHUTDOWN_GRACE_MS)
|
||||
shutdownTimer.unref()
|
||||
}
|
||||
|
||||
function installShutdownHandlers() {
|
||||
process.on("SIGTERM", () => requestShutdown(false))
|
||||
process.on("SIGINT", () => requestShutdown(false))
|
||||
process.on("disconnect", () => requestShutdown(false))
|
||||
process.on("uncaughtException", (error) => {
|
||||
log("uncaught exception", error)
|
||||
requestShutdown(true)
|
||||
})
|
||||
process.on("unhandledRejection", (error) => {
|
||||
log("unhandled rejection", error)
|
||||
requestShutdown(true)
|
||||
})
|
||||
}
|
||||
|
||||
function parsePayload() {
|
||||
const raw = process.argv[2]
|
||||
if (!raw) {
|
||||
throw new Error("Supervisor payload is required")
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Supervisor payload must be an object")
|
||||
}
|
||||
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
|
||||
throw new Error("Supervisor payload command is required")
|
||||
}
|
||||
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
|
||||
throw new Error("Supervisor payload args must be a string array")
|
||||
}
|
||||
|
||||
return {
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
installShutdownHandlers()
|
||||
|
||||
const payload = parsePayload()
|
||||
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
|
||||
|
||||
child = spawn(payload.command, payload.args, {
|
||||
cwd: payload.cwd,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
forwardStream(child.stdout, process.stdout)
|
||||
forwardStream(child.stderr, process.stderr)
|
||||
|
||||
child.on("error", (error) => {
|
||||
log("failed to spawn shell command", error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearShutdownTimer()
|
||||
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
|
||||
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
|
||||
process.exit()
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -15,8 +15,13 @@
|
||||
},
|
||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev": "npm run dev:info",
|
||||
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
|
||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||
"prepare:resources": "node scripts/prepare-resources.js",
|
||||
"prebuild": "npm run prepare:resources",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
@@ -30,8 +35,11 @@
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"prepackage:mac": "npm run prepare:resources",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"prepackage:win": "npm run prepare:resources",
|
||||
"package:win": "electron-builder --win",
|
||||
"prepackage:linux": "npm run prepare:resources",
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -42,6 +50,7 @@
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
@@ -78,6 +87,12 @@
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"entitlements": "electron/resources/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
|
||||
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
|
||||
},
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
|
||||
@@ -111,6 +111,12 @@ async function build(platform) {
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
||||
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
|
||||
132
packages/electron-app/scripts/prepare-resources.js
Normal file
132
packages/electron-app/scripts/prepare-resources.js
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs"
|
||||
import path, { join } from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const appDir = join(__dirname, "..")
|
||||
const workspaceRoot = join(appDir, "..", "..")
|
||||
const serverRoot = join(appDir, "..", "server")
|
||||
const resourcesRoot = join(appDir, "electron", "resources")
|
||||
const serverDest = join(resourcesRoot, "server")
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||
|
||||
function log(message) {
|
||||
console.log(`[prepare-resources] ${message}`)
|
||||
}
|
||||
|
||||
function ensureServerBuild() {
|
||||
const distPath = join(serverRoot, "dist")
|
||||
const publicPath = join(serverRoot, "public")
|
||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(serverDepsMarker)) {
|
||||
return
|
||||
}
|
||||
|
||||
log("installing production server dependencies")
|
||||
const npmArgs = [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--workspaces=false",
|
||||
"--package-lock=false",
|
||||
"--install-strategy=shallow",
|
||||
"--fund=false",
|
||||
"--audit=false",
|
||||
]
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
npm_config_workspaces: "false",
|
||||
}
|
||||
|
||||
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||
const result = npmCli
|
||||
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
||||
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
|
||||
for (const name of serverSources) {
|
||||
const from = join(serverRoot, name)
|
||||
const to = join(serverDest, name)
|
||||
if (!fs.existsSync(from)) {
|
||||
throw new Error(`Missing required server artifact: ${from}`)
|
||||
}
|
||||
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||
log(`copied ${name} to Electron resources`)
|
||||
}
|
||||
}
|
||||
|
||||
function stripNodeModuleBins() {
|
||||
const root = join(serverDest, "node_modules")
|
||||
if (!fs.existsSync(root)) {
|
||||
return
|
||||
}
|
||||
|
||||
const stack = [root]
|
||||
let removed = 0
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = join(current, entry.name)
|
||||
if (entry.name === ".bin") {
|
||||
fs.rmSync(full, { recursive: true, force: true })
|
||||
removed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
log(`removed ${removed} node_modules/.bin directories`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureServerBuild()
|
||||
ensureServerDependencies()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[prepare-resources] failed:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -14,5 +14,5 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.53"
|
||||
"@opencode-ai/plugin": "1.3.7"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||
|
||||
let voiceModeEnabled = false
|
||||
|
||||
export async function CodeNomadPlugin(input: PluginInput) {
|
||||
const config = getCodeNomadConfig()
|
||||
const client = createCodeNomadClient(config)
|
||||
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
pingTs: (event.properties as any)?.ts,
|
||||
},
|
||||
}).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "codenomad.voiceMode") {
|
||||
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
tool: {
|
||||
...backgroundProcessTools,
|
||||
},
|
||||
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
|
||||
if (!voiceModeEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
|
||||
},
|
||||
async event(input: { event: any }) {
|
||||
const opencodeEvent = input?.event
|
||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildVoiceModePrompt(): string {
|
||||
return [
|
||||
"Voice conversation mode is enabled.",
|
||||
"Prepend your reply with a fenced code block using language `spoken`.",
|
||||
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
|
||||
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
|
||||
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
|
||||
"Do not add generic phrases about whether the user should read more.",
|
||||
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
|
||||
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
|
||||
"After the `spoken` block, continue with your normal detailed response.",
|
||||
"Example:",
|
||||
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
@@ -5,18 +5,21 @@
|
||||
## Features & Capabilities
|
||||
|
||||
### 🌍 Deployment Freedom
|
||||
|
||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||
|
||||
### ⚡️ Workspace Power
|
||||
|
||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- Node.js 18+ and npm (for running or building from source).
|
||||
- A workspace folder on disk you want to serve.
|
||||
@@ -25,6 +28,7 @@
|
||||
## Usage
|
||||
|
||||
### Run via npx (Recommended)
|
||||
|
||||
You can run CodeNomad directly without installing it:
|
||||
|
||||
```sh
|
||||
@@ -43,6 +47,7 @@ On startup, CodeNomad prints two URLs:
|
||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||
|
||||
### Install Globally
|
||||
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
```sh
|
||||
@@ -51,6 +56,7 @@ codenomad --launch
|
||||
```
|
||||
|
||||
### Install Locally (per-project)
|
||||
|
||||
If you prefer to install CodeNomad into a project and run the local binary:
|
||||
|
||||
```sh
|
||||
@@ -61,6 +67,7 @@ npx codenomad --launch
|
||||
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||
|
||||
### Common Flags
|
||||
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
@@ -74,7 +81,7 @@ You can configure the server using flags or environment variables:
|
||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. |
|
||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
@@ -87,10 +94,11 @@ You can configure the server using flags or environment variables:
|
||||
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
||||
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
|
||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
|
||||
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
||||
|
||||
### Dev Releases (Advanced)
|
||||
|
||||
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||
|
||||
```sh
|
||||
@@ -141,12 +149,14 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
|
||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||
|
||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||
@@ -158,5 +168,6 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
||||
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
|
||||
### Data Storage
|
||||
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -32,6 +32,7 @@
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
AgentModelSelection,
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ModelPreference,
|
||||
OpenCodeBinary,
|
||||
Preferences,
|
||||
@@ -183,9 +182,9 @@ export interface BinaryRecord {
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export type AppConfig = ConfigFile
|
||||
export type AppConfigResponse = AppConfig
|
||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||
export type SettingsOwner = string
|
||||
export type SettingsBucket = Record<string, unknown>
|
||||
export type SettingsDoc = Record<string, unknown>
|
||||
|
||||
export interface BinaryListResponse {
|
||||
binaries: BinaryRecord[]
|
||||
@@ -208,14 +207,77 @@ export interface BinaryValidationResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SpeechSegment {
|
||||
startMs: number
|
||||
endMs: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SpeechCapabilitiesResponse {
|
||||
available: boolean
|
||||
configured: boolean
|
||||
provider: string
|
||||
supportsStt: boolean
|
||||
supportsTts: boolean
|
||||
supportsStreamingTts: boolean
|
||||
baseUrl?: string
|
||||
sttModel: string
|
||||
ttsModel: string
|
||||
ttsVoice: string
|
||||
ttsFormats: string[]
|
||||
streamingTtsFormats: string[]
|
||||
}
|
||||
|
||||
export interface SpeechTranscriptionResponse {
|
||||
text: string
|
||||
language?: string
|
||||
durationMs?: number
|
||||
segments?: SpeechSegment[]
|
||||
}
|
||||
|
||||
export interface SpeechSynthesisResponse {
|
||||
audioBase64: string
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface VoiceModeStateResponse {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProfile {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
skipTlsVerify: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastConnectedAt?: string
|
||||
}
|
||||
|
||||
export interface RemoteServerProbeRequest {
|
||||
baseUrl: string
|
||||
skipTlsVerify?: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProbeResponse {
|
||||
ok: boolean
|
||||
reachable: boolean
|
||||
normalizedUrl: string
|
||||
skipTlsVerify: boolean
|
||||
requiresAuth: boolean
|
||||
authenticated: boolean
|
||||
error?: string
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
| "storage.configChanged"
|
||||
| "storage.stateChanged"
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
@@ -226,8 +288,8 @@ export type WorkspaceEventPayload =
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
|
||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
cookieName?: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore | null
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
private readonly cookieName: string
|
||||
private readonly authEnabled: boolean
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||
|
||||
if (!this.authEnabled) {
|
||||
@@ -139,6 +141,16 @@ export class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeCookieName(value: string | undefined): string {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return DEFAULT_AUTH_COOKIE_NAME
|
||||
}
|
||||
|
||||
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
|
||||
}
|
||||
|
||||
function resolveAuthFilePath(configPath: string) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
|
||||
128
packages/server/src/clients/connection-manager.ts
Normal file
128
packages/server/src/clients/connection-manager.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const STALE_CONNECTION_TIMEOUT_MS = 45000
|
||||
const STALE_SWEEP_INTERVAL_MS = 5000
|
||||
|
||||
export interface ClientConnectionRef {
|
||||
clientId: string
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
export interface ClientConnectionRecord extends ClientConnectionRef {
|
||||
key: string
|
||||
connectedAt: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
type ConnectionChangeEvent = {
|
||||
type: "connected" | "disconnected"
|
||||
connection: ClientConnectionRecord
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface RegisteredConnection extends ClientConnectionRecord {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export class ClientConnectionManager {
|
||||
private readonly connections = new Map<string, RegisteredConnection>()
|
||||
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
|
||||
private readonly sweepTimer: NodeJS.Timeout
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
|
||||
this.sweepTimer.unref?.()
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
clearInterval(this.sweepTimer)
|
||||
for (const connection of Array.from(this.connections.values())) {
|
||||
this.disconnect(connection.key, "shutdown", false)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
|
||||
this.subscribers.add(listener)
|
||||
return () => this.subscribers.delete(listener)
|
||||
}
|
||||
|
||||
register(input: ClientConnectionRef & { close: () => void }): () => void {
|
||||
const key = getConnectionKey(input)
|
||||
const now = Date.now()
|
||||
const existing = this.connections.get(key)
|
||||
|
||||
if (existing) {
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
|
||||
this.disconnect(key, "replaced")
|
||||
}
|
||||
|
||||
const connection: RegisteredConnection = {
|
||||
key,
|
||||
clientId: input.clientId,
|
||||
connectionId: input.connectionId,
|
||||
connectedAt: now,
|
||||
lastSeenAt: now,
|
||||
close: input.close,
|
||||
}
|
||||
this.connections.set(key, connection)
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
|
||||
this.notify({ type: "connected", connection })
|
||||
return () => this.disconnect(key, "closed")
|
||||
}
|
||||
|
||||
pong(input: ClientConnectionRef): boolean {
|
||||
const key = getConnectionKey(input)
|
||||
const connection = this.connections.get(key)
|
||||
if (!connection) {
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
|
||||
return false
|
||||
}
|
||||
|
||||
connection.lastSeenAt = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
isConnected(input: ClientConnectionRef): boolean {
|
||||
return this.connections.has(getConnectionKey(input))
|
||||
}
|
||||
|
||||
private sweepStaleConnections(): void {
|
||||
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
|
||||
for (const connection of Array.from(this.connections.values())) {
|
||||
if (connection.lastSeenAt > cutoff) continue
|
||||
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
|
||||
this.disconnect(connection.key, "timeout")
|
||||
}
|
||||
}
|
||||
|
||||
private disconnect(key: string, reason: string, invokeClose = true): void {
|
||||
const connection = this.connections.get(key)
|
||||
if (!connection) return
|
||||
this.connections.delete(key)
|
||||
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
|
||||
|
||||
if (invokeClose) {
|
||||
try {
|
||||
connection.close()
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
|
||||
}
|
||||
}
|
||||
|
||||
this.notify({ type: "disconnected", connection, reason })
|
||||
}
|
||||
|
||||
private notify(event: ConnectionChangeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
try {
|
||||
subscriber(event)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionKey(input: ClientConnectionRef): string {
|
||||
return `${input.clientId}:${input.connectionId}`
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import {
|
||||
BinaryCreateRequest,
|
||||
BinaryRecord,
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
} from "../api-types"
|
||||
import { spawnSync } from "child_process"
|
||||
import { ConfigStore } from "./store"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { ConfigFile } from "./schema"
|
||||
import { Logger } from "../logger"
|
||||
import { buildSpawnSpec } from "../workspaces/runtime"
|
||||
|
||||
export class BinaryRegistry {
|
||||
constructor(
|
||||
private readonly configStore: ConfigStore,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
list(): BinaryRecord[] {
|
||||
return this.mapRecords()
|
||||
}
|
||||
|
||||
resolveDefault(): BinaryRecord {
|
||||
const binaries = this.mapRecords()
|
||||
if (binaries.length === 0) {
|
||||
this.logger.warn("No configured binaries found, falling back to opencode")
|
||||
return this.buildFallbackRecord("opencode")
|
||||
}
|
||||
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
||||
}
|
||||
|
||||
create(request: BinaryCreateRequest): BinaryRecord {
|
||||
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
|
||||
const entry = {
|
||||
path: request.path,
|
||||
version: undefined,
|
||||
lastUsed: Date.now(),
|
||||
label: request.label,
|
||||
}
|
||||
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||
nextConfig.opencodeBinaries = [entry, ...deduped]
|
||||
|
||||
if (request.makeDefault) {
|
||||
nextConfig.preferences.lastUsedBinary = request.path
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
const record = this.getById(request.path)
|
||||
this.emitChange()
|
||||
return record
|
||||
}
|
||||
|
||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||
this.logger.debug({ id }, "Updating OpenCode binary")
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||
)
|
||||
|
||||
if (updates.makeDefault) {
|
||||
nextConfig.preferences.lastUsedBinary = id
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
const record = this.getById(id)
|
||||
this.emitChange()
|
||||
return record
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
this.logger.debug({ id }, "Removing OpenCode binary")
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||
nextConfig.opencodeBinaries = remaining
|
||||
|
||||
if (nextConfig.preferences.lastUsedBinary === id) {
|
||||
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
this.emitChange()
|
||||
}
|
||||
|
||||
validatePath(path: string): BinaryValidationResult {
|
||||
this.logger.debug({ path }, "Validating OpenCode binary path")
|
||||
return this.validateRecord({
|
||||
id: path,
|
||||
path,
|
||||
label: this.prettyLabel(path),
|
||||
isDefault: false,
|
||||
})
|
||||
}
|
||||
|
||||
private cloneConfig(config: ConfigFile): ConfigFile {
|
||||
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
||||
}
|
||||
|
||||
private mapRecords(): BinaryRecord[] {
|
||||
|
||||
const config = this.configStore.get()
|
||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
||||
id: binary.path,
|
||||
path: binary.path,
|
||||
label: binary.label ?? this.prettyLabel(binary.path),
|
||||
version: binary.version,
|
||||
isDefault: false,
|
||||
}))
|
||||
|
||||
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
||||
|
||||
const annotated = configuredBinaries.map((binary) => ({
|
||||
...binary,
|
||||
isDefault: binary.path === defaultPath,
|
||||
}))
|
||||
|
||||
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
||||
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
||||
}
|
||||
|
||||
return annotated
|
||||
}
|
||||
|
||||
private getById(id: string): BinaryRecord {
|
||||
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
||||
}
|
||||
|
||||
private emitChange() {
|
||||
this.logger.debug("Emitting binaries changed event")
|
||||
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
||||
}
|
||||
|
||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||
const inputPath = record.path
|
||||
if (!inputPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
const spec = buildSpawnSpec(inputPath, ["--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 stdout = (result.stdout ?? "").trim()
|
||||
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
||||
const normalized = firstLine?.trim()
|
||||
|
||||
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||
const version = versionMatch?.[1]
|
||||
|
||||
return { valid: true, version }
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
private buildFallbackRecord(path: string): BinaryRecord {
|
||||
return {
|
||||
id: path,
|
||||
path,
|
||||
label: this.prettyLabel(path),
|
||||
isDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
private prettyLabel(path: string) {
|
||||
const parts = path.split(/[\\/]/)
|
||||
const last = parts[parts.length - 1] || path
|
||||
return last || path
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: z.boolean().default(false),
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import {
|
||||
ConfigFile,
|
||||
ConfigFileSchema,
|
||||
ConfigYamlSchema,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CONFIG_YAML,
|
||||
DEFAULT_STATE,
|
||||
StateFile,
|
||||
StateFileSchema,
|
||||
} from "./schema"
|
||||
import type { ConfigLocation } from "./location"
|
||||
|
||||
export class ConfigStore {
|
||||
private cache: ConfigFile = DEFAULT_CONFIG
|
||||
private state: StateFile = DEFAULT_STATE
|
||||
private loaded = false
|
||||
|
||||
constructor(
|
||||
private readonly location: ConfigLocation,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
load(): ConfigFile {
|
||||
if (this.loaded) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
const configYamlPath = this.location.configYamlPath
|
||||
const stateYamlPath = this.location.stateYamlPath
|
||||
const legacyJsonPath = this.location.legacyJsonPath
|
||||
|
||||
if (fs.existsSync(configYamlPath)) {
|
||||
const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
|
||||
const stateDoc = fs.existsSync(stateYamlPath)
|
||||
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
|
||||
: DEFAULT_STATE
|
||||
|
||||
this.state = stateDoc
|
||||
this.cache = this.mergeDocs(configDoc, stateDoc)
|
||||
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
|
||||
} else if (fs.existsSync(legacyJsonPath)) {
|
||||
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
|
||||
this.state = migrated.state
|
||||
this.cache = migrated.config
|
||||
} else {
|
||||
// Fresh install: write defaults.
|
||||
this.state = DEFAULT_STATE
|
||||
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||
this.persist()
|
||||
this.logger.debug(
|
||||
{ configYamlPath, stateYamlPath },
|
||||
"No config files found, created default YAML config/state",
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
|
||||
this.state = DEFAULT_STATE
|
||||
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||
}
|
||||
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
|
||||
get(): ConfigFile {
|
||||
return this.load()
|
||||
}
|
||||
|
||||
replace(config: ConfigFile) {
|
||||
const validated = ConfigFileSchema.parse(config)
|
||||
this.commit(validated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a merge-patch update to the current config.
|
||||
* - Missing keys are preserved.
|
||||
* - Object values are merged recursively.
|
||||
* - Explicit `null` deletes keys.
|
||||
* - Arrays are replaced.
|
||||
*/
|
||||
mergePatch(patch: unknown) {
|
||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
||||
throw new Error("Config patch must be a JSON object")
|
||||
}
|
||||
const current = this.get()
|
||||
const next = applyMergePatch(current as any, patch as any)
|
||||
const validated = ConfigFileSchema.parse(next)
|
||||
this.commit(validated)
|
||||
}
|
||||
|
||||
private commit(next: ConfigFile) {
|
||||
this.cache = next
|
||||
this.loaded = true
|
||||
this.state = {
|
||||
...this.state,
|
||||
recentFolders: next.recentFolders,
|
||||
}
|
||||
this.persist()
|
||||
const published = Boolean(this.eventBus)
|
||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
||||
this.logger.trace({ config: this.cache }, "Config payload")
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
const configYamlPath = this.location.configYamlPath
|
||||
const stateYamlPath = this.location.stateYamlPath
|
||||
|
||||
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
|
||||
|
||||
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
|
||||
const stateYaml = stringifyYaml(this.state as any)
|
||||
|
||||
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
|
||||
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
|
||||
|
||||
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist config")
|
||||
}
|
||||
}
|
||||
|
||||
private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
|
||||
const merged = {
|
||||
...(configDoc as any),
|
||||
// State wins for recent folders.
|
||||
recentFolders: stateDoc.recentFolders ?? [],
|
||||
}
|
||||
|
||||
return ConfigFileSchema.parse(merged)
|
||||
}
|
||||
|
||||
private readYamlFile<T>(
|
||||
filePath: string,
|
||||
fallback: T,
|
||||
schema: { parse: (value: unknown) => T },
|
||||
label: string,
|
||||
): T {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
const parsed = parseYaml(content)
|
||||
return schema.parse(parsed ?? {})
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
|
||||
const configYamlPath = this.location.configYamlPath
|
||||
const stateYamlPath = this.location.stateYamlPath
|
||||
|
||||
const content = fs.readFileSync(legacyJsonPath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const legacy = ConfigFileSchema.parse(parsed)
|
||||
|
||||
const state: StateFile = StateFileSchema.parse({
|
||||
...DEFAULT_STATE,
|
||||
recentFolders: legacy.recentFolders ?? [],
|
||||
})
|
||||
|
||||
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
|
||||
|
||||
// Persist YAML docs first, then move legacy aside.
|
||||
try {
|
||||
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
|
||||
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
|
||||
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
|
||||
}
|
||||
|
||||
try {
|
||||
const bakPath = pickBackupPath(legacyJsonPath)
|
||||
fs.renameSync(legacyJsonPath, bakPath)
|
||||
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
|
||||
}
|
||||
|
||||
return { config: merged, state }
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(content: string): string {
|
||||
if (!content) return "\n"
|
||||
return content.endsWith("\n") ? content : `${content}\n`
|
||||
}
|
||||
|
||||
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
|
||||
const clone: Record<string, unknown> = { ...(config as any) }
|
||||
delete clone.recentFolders
|
||||
return clone as any
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (Array.isArray(value)) return false
|
||||
const proto = Object.getPrototypeOf(value)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
function applyMergePatch(current: any, patch: any): any {
|
||||
// RFC 7396-ish merge patch with explicit null deletes.
|
||||
if (!isPlainObject(patch)) {
|
||||
return patch
|
||||
}
|
||||
|
||||
const base = isPlainObject(current) ? { ...current } : {}
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete base[key]
|
||||
continue
|
||||
}
|
||||
|
||||
if (isPlainObject(value) && isPlainObject(base[key])) {
|
||||
base[key] = applyMergePatch(base[key], value)
|
||||
continue
|
||||
}
|
||||
|
||||
// Arrays and scalars replace.
|
||||
base[key] = value
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function pickBackupPath(legacyJsonPath: string): string {
|
||||
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
|
||||
const preferred = `${base}.json.bak`
|
||||
if (!fs.existsSync(preferred)) {
|
||||
return preferred
|
||||
}
|
||||
return `${base}.json.bak.${Date.now()}`
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export class EventBus extends EventEmitter {
|
||||
this.on("workspace.error", handler)
|
||||
this.on("workspace.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
this.on("storage.configChanged", handler)
|
||||
this.on("storage.stateChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
this.off("storage.configChanged", handler)
|
||||
this.off("storage.stateChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
|
||||
@@ -81,6 +81,14 @@ export class FileSystemBrowser {
|
||||
return { path: relativePath, absolutePath }
|
||||
}
|
||||
|
||||
writeFile(relativePath: string, contents: string): void {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("writeFile is not available in unrestricted mode")
|
||||
}
|
||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||
fs.writeFileSync(resolved, contents, "utf-8")
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
|
||||
@@ -8,9 +8,9 @@ import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
import { WorkspaceManager } from "./workspaces/manager"
|
||||
import { ConfigStore } from "./config/store"
|
||||
import { resolveConfigLocation } from "./config/location"
|
||||
import { BinaryRegistry } from "./config/binaries"
|
||||
import { SettingsService } from "./settings/service"
|
||||
import { BinaryResolver } from "./settings/binaries"
|
||||
import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
import { ServerMeta } from "./api-types"
|
||||
@@ -19,10 +19,11 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, 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 { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -54,6 +55,7 @@ interface CliOptions {
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
authCookieName: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
@@ -78,7 +80,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||
.addOption(
|
||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
)
|
||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||
@@ -99,6 +101,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||
.addOption(
|
||||
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
||||
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
||||
.default(DEFAULT_AUTH_COOKIE_NAME),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
@@ -138,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
@@ -184,6 +192,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
authCookieName: parsed.authCookieName,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
@@ -265,6 +274,7 @@ async function main() {
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
cookieName: options.authCookieName,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
@@ -291,21 +301,12 @@ async function main() {
|
||||
|
||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||
|
||||
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
|
||||
|
||||
// Eagerly load config at boot so migrations run immediately
|
||||
// (instead of waiting for the first /api/config request).
|
||||
try {
|
||||
configStore.get()
|
||||
} catch (error) {
|
||||
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
|
||||
}
|
||||
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const settings = new SettingsService(configLocation, eventBus, configLogger)
|
||||
const binaryResolver = new BinaryResolver(settings)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
settings,
|
||||
binaryResolver,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.localUrl,
|
||||
@@ -313,6 +314,7 @@ async function main() {
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
@@ -392,12 +394,12 @@ async function main() {
|
||||
defaultPort: options.httpPort,
|
||||
protocol: "http",
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
settings,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
@@ -413,12 +415,12 @@ async function main() {
|
||||
protocol: "https",
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
settings,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
@@ -449,18 +451,22 @@ async function main() {
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteAddresses = resolved.userVisible
|
||||
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
if (!remoteUrl) {
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
@@ -471,7 +477,9 @@ async function main() {
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
serverMeta.addresses = remoteAddresses.length
|
||||
? remoteAddresses
|
||||
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
@@ -479,6 +487,16 @@ async function main() {
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (serverMeta.remoteUrl) {
|
||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||
const additionalRemoteUrls = serverMeta.addresses
|
||||
.map((addr) => addr.remoteUrl)
|
||||
.filter((url) => url !== serverMeta.remoteUrl)
|
||||
|
||||
if (additionalRemoteUrls.length > 0) {
|
||||
console.log("Other Accessible URLs:")
|
||||
for (const url of additionalRemoteUrls) {
|
||||
console.log(` - ${url}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.launch) {
|
||||
|
||||
96
packages/server/src/plugins/voice-mode.ts
Normal file
96
packages/server/src/plugins/voice-mode.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Logger } from "../logger"
|
||||
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
|
||||
import type { PluginChannelManager } from "./channel"
|
||||
|
||||
interface VoiceModeManagerOptions {
|
||||
connections: ClientConnectionManager
|
||||
channel: PluginChannelManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export class VoiceModeManager {
|
||||
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
|
||||
private readonly aggregateByInstance = new Map<string, boolean>()
|
||||
|
||||
constructor(private readonly options: VoiceModeManagerOptions) {
|
||||
this.options.connections.subscribe((event) => {
|
||||
if (event.type !== "disconnected") return
|
||||
this.clearConnection(event.connection)
|
||||
})
|
||||
}
|
||||
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
||||
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||
this.options.logger.debug(
|
||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||
"Ignoring voice mode enable for disconnected client connection",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const key = getConnectionKey(connection)
|
||||
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
|
||||
|
||||
if (enabled) {
|
||||
current.add(key)
|
||||
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||
} else if (current.delete(key)) {
|
||||
if (current.size === 0) {
|
||||
this.enabledConnectionsByInstance.delete(instanceId)
|
||||
} else {
|
||||
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||
}
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||
this.publishIfChanged(instanceId)
|
||||
}
|
||||
|
||||
syncInstance(instanceId: string): void {
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
|
||||
}
|
||||
|
||||
isEnabled(instanceId: string): boolean {
|
||||
return this.aggregateByInstance.get(instanceId) === true
|
||||
}
|
||||
|
||||
private clearConnection(connection: ClientConnectionRef): void {
|
||||
const key = getConnectionKey(connection)
|
||||
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
|
||||
if (!enabledConnections.delete(key)) continue
|
||||
if (enabledConnections.size === 0) {
|
||||
this.enabledConnectionsByInstance.delete(instanceId)
|
||||
}
|
||||
this.publishIfChanged(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private publishIfChanged(instanceId: string): void {
|
||||
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
|
||||
const previous = this.aggregateByInstance.get(instanceId) === true
|
||||
if (enabled === previous) return
|
||||
|
||||
if (enabled) {
|
||||
this.aggregateByInstance.set(instanceId, true)
|
||||
} else {
|
||||
this.aggregateByInstance.delete(instanceId)
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
function buildVoiceModeEvent(enabled: boolean) {
|
||||
return {
|
||||
type: "codenomad.voiceMode",
|
||||
properties: {
|
||||
enabled,
|
||||
formatVersion: "v1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionKey(connection: ClientConnectionRef): string {
|
||||
return `${connection.clientId}:${connection.connectionId}`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict"
|
||||
import os from "node:os"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
||||
|
||||
describe("resolveNetworkAddresses", () => {
|
||||
it("preserves interface order among external addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "10.0.0.8", family: 4, internal: false },
|
||||
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.map((entry) => entry.ip),
|
||||
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveRemoteAddresses", () => {
|
||||
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers private LAN addresses over public addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("uses a public address when no private LAN address is available", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
||||
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function usingMockedNetworkInterfaces(
|
||||
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
||||
callback: () => void,
|
||||
) {
|
||||
const original = os.networkInterfaces
|
||||
os.networkInterfaces = (() => ({
|
||||
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
||||
})) as typeof os.networkInterfaces
|
||||
|
||||
try {
|
||||
callback()
|
||||
} finally {
|
||||
os.networkInterfaces = original
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,11 @@ import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||
import { registerConfigRoutes } from "./routes/config"
|
||||
import { registerSettingsRoutes } from "./routes/settings"
|
||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||
import { registerMetaRoutes } from "./routes/meta"
|
||||
import { registerEventRoutes } from "./routes/events"
|
||||
@@ -22,12 +21,18 @@ import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
import type { SpeechService } from "../speech/service"
|
||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||
import { PluginChannelManager } from "../plugins/channel"
|
||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||
|
||||
interface HttpServerDeps {
|
||||
bindHost: string
|
||||
@@ -37,12 +42,12 @@ interface HttpServerDeps {
|
||||
protocol: "http" | "https"
|
||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
workspaceManager: WorkspaceManager
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
settings: SettingsService
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
speechService: SpeechService
|
||||
authManager: AuthManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
@@ -172,6 +177,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: deps.logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
@@ -244,17 +256,30 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerEventRoutes(app, {
|
||||
eventBus: deps.eventBus,
|
||||
registerClient: registerSseClient,
|
||||
logger: sseLogger,
|
||||
connectionManager: clientConnectionManager,
|
||||
})
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerPluginRoutes(app, {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
logger: proxyLogger,
|
||||
channel: pluginChannel,
|
||||
voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
@@ -319,6 +344,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
clientConnectionManager.shutdown()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
@@ -369,6 +395,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
||||
|
||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||
|
||||
// Special-case OpenCode directory override.
|
||||
//
|
||||
// UI clients may need to scope certain requests to an arbitrary directory that is not
|
||||
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
|
||||
// injecting per-request headers, we encode an override into the *path* and strip it
|
||||
// before proxying to the instance.
|
||||
//
|
||||
// Example proxied request path:
|
||||
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
|
||||
//
|
||||
// The server will decode <base64url> -> absolute directory, validate it, then set
|
||||
// x-opencode-directory accordingly and forward the request to /session/create.
|
||||
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
|
||||
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
@@ -459,19 +500,43 @@ async function proxyWorkspaceRequest(args: {
|
||||
return
|
||||
}
|
||||
|
||||
const directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||
try {
|
||||
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
return
|
||||
}
|
||||
let directory: string | null = null
|
||||
let forwardedSuffix = extracted.forwardedSuffix
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
if (extracted.overrideDirectory) {
|
||||
try {
|
||||
directory = validateAndNormalizeOverrideDirectory({
|
||||
overrideDirectory: extracted.overrideDirectory,
|
||||
workspaceRoot: workspace.path,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
@@ -535,6 +600,89 @@ async function proxyWorkspaceRequest(args: {
|
||||
})
|
||||
}
|
||||
|
||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||
overrideDirectory: string | null
|
||||
forwardedSuffix: string | undefined
|
||||
} {
|
||||
if (!pathSuffix) {
|
||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||
}
|
||||
|
||||
// Fastify wildcard param does not include a leading slash.
|
||||
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
|
||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||
}
|
||||
|
||||
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
|
||||
const slashIndex = rest.indexOf("/")
|
||||
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
|
||||
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
|
||||
|
||||
if (!encoded) {
|
||||
throw new Error("Missing directory override")
|
||||
}
|
||||
|
||||
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
|
||||
throw new Error("Directory override too large")
|
||||
}
|
||||
|
||||
let overrideDirectory = ""
|
||||
try {
|
||||
overrideDirectory = decodeBase64Url(encoded)
|
||||
} catch {
|
||||
throw new Error("Invalid directory override")
|
||||
}
|
||||
const forwardedSuffix = remaining
|
||||
return { overrideDirectory, forwardedSuffix }
|
||||
}
|
||||
|
||||
function decodeBase64Url(input: string): string {
|
||||
// base64url -> base64
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
|
||||
const base64 = `${normalized}${padding}`
|
||||
return Buffer.from(base64, "base64").toString("utf-8")
|
||||
}
|
||||
|
||||
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
|
||||
const raw = params.overrideDirectory.trim()
|
||||
if (!raw) {
|
||||
throw new Error("Override directory is empty")
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(raw)) {
|
||||
throw new Error("Override directory must be an absolute path")
|
||||
}
|
||||
|
||||
if (!fs.existsSync(raw)) {
|
||||
throw new Error(`Override directory does not exist: ${raw}`)
|
||||
}
|
||||
|
||||
const stats = fs.statSync(raw)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Override path is not a directory: ${raw}`)
|
||||
}
|
||||
|
||||
const normalizedOverride = fs.realpathSync(raw)
|
||||
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
|
||||
|
||||
if (!isSubpath(normalizedOverride, normalizedRoot)) {
|
||||
throw new Error("Override directory must be within the workspace root")
|
||||
}
|
||||
|
||||
return normalizedOverride
|
||||
}
|
||||
|
||||
function isSubpath(candidate: string, root: string): boolean {
|
||||
const rel = path.relative(root, candidate)
|
||||
if (rel === "") return true
|
||||
if (rel === "..") return false
|
||||
if (rel.startsWith(`..${path.sep}`)) return false
|
||||
if (path.isAbsolute(rel)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export interface ResolvedRemoteAddresses {
|
||||
all: NetworkAddress[]
|
||||
userVisible: NetworkAddress[]
|
||||
primaryRemoteUrl?: string
|
||||
}
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRemoteAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
port: number
|
||||
}): ResolvedRemoteAddresses {
|
||||
const all = resolveNetworkAddresses(args)
|
||||
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
||||
return {
|
||||
all,
|
||||
userVisible,
|
||||
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
||||
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
||||
}
|
||||
|
||||
function getUserVisiblePriority(ip: string): number {
|
||||
if (isPrivateIPv4(ip)) return 0
|
||||
if (isLinkLocalIPv4(ip)) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function isLinkLocalIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
return first === 169 && second === 254
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
|
||||
if (first === 10) return true
|
||||
if (first === 192 && second === 168) return true
|
||||
return first === 172 && second >= 16 && second <= 31
|
||||
}
|
||||
|
||||
function parseIPv4(value: string): number[] | null {
|
||||
if (!isIPv4Address(value)) return null
|
||||
return value.split(".").map((part) => Number(part))
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
|
||||
@@ -119,7 +119,8 @@
|
||||
showError(message || `Login failed (${res.status})`)
|
||||
return
|
||||
}
|
||||
window.location.href = "/"
|
||||
// Replace history entry so Back doesn't return to /login.
|
||||
window.location.replace("/")
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : String(e))
|
||||
}
|
||||
|
||||
@@ -51,7 +51,19 @@ function getTokenHtml(): string {
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/login", async (_request, reply) => {
|
||||
app.get("/login", async (request, reply) => {
|
||||
// If already authenticated, don't show the login page.
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
if (session) {
|
||||
reply.redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid caching the login page (helps with bfcache/back behavior).
|
||||
reply.header("Cache-Control", "no-store")
|
||||
reply.header("Pragma", "no-cache")
|
||||
reply.header("Expires", "0")
|
||||
|
||||
const status = deps.authManager.getStatus()
|
||||
reply.type("text/html").send(getLoginHtml(status.username))
|
||||
})
|
||||
@@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid caching the token bootstrap page.
|
||||
reply.header("Cache-Control", "no-store")
|
||||
reply.header("Pragma", "no-cache")
|
||||
reply.header("Expires", "0")
|
||||
|
||||
reply.type("text/html").send(getTokenHtml())
|
||||
})
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { ConfigStore } from "../../config/store"
|
||||
import { BinaryRegistry } from "../../config/binaries"
|
||||
|
||||
interface RouteDeps {
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
}
|
||||
|
||||
const BinaryCreateSchema = z.object({
|
||||
path: z.string(),
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryUpdateSchema = z.object({
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryValidateSchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/config/app", async () => deps.configStore.get())
|
||||
|
||||
app.put("/api/config/app", async (request, reply) => {
|
||||
// Backwards compatible: treat PUT as a merge-patch update.
|
||||
try {
|
||||
deps.configStore.mergePatch(request.body ?? {})
|
||||
return deps.configStore.get()
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/api/config/app", async (request, reply) => {
|
||||
try {
|
||||
deps.configStore.mergePatch(request.body ?? {})
|
||||
return deps.configStore.get()
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/api/config/binaries", async () => {
|
||||
return { binaries: deps.binaryRegistry.list() }
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries", async (request, reply) => {
|
||||
const body = BinaryCreateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.create(body)
|
||||
reply.code(201)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
||||
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.update(request.params.id, body)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
||||
deps.binaryRegistry.remove(request.params.id)
|
||||
reply.code(204)
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries/validate", async (request) => {
|
||||
const body = BinaryValidateSchema.parse(request.body ?? {})
|
||||
return deps.binaryRegistry.validatePath(body.path)
|
||||
})
|
||||
}
|
||||
@@ -1,19 +1,32 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
import type { ClientConnectionManager } from "../../clients/connection-manager"
|
||||
import { Logger } from "../../logger"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
logger: Logger
|
||||
connectionManager: ClientConnectionManager
|
||||
}
|
||||
|
||||
let nextClientId = 0
|
||||
|
||||
const ConnectionQuerySchema = z.object({
|
||||
clientId: z.string().trim().min(1),
|
||||
connectionId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
const PongBodySchema = ConnectionQuerySchema.extend({
|
||||
pingTs: z.number().optional(),
|
||||
})
|
||||
|
||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const clientId = ++nextClientId
|
||||
const connection = ConnectionQuerySchema.parse(request.query ?? {})
|
||||
deps.logger.debug({ clientId }, "SSE client connected")
|
||||
|
||||
const origin = request.headers.origin ?? "*"
|
||||
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
const unsubscribe = deps.eventBus.onEvent(send)
|
||||
const heartbeat = setInterval(() => {
|
||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
||||
const ping = { ts: Date.now() }
|
||||
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
|
||||
}, 15000)
|
||||
|
||||
let closed = false
|
||||
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
const unregisterConnection = deps.connectionManager.register({
|
||||
...connection,
|
||||
close,
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
close()
|
||||
unregister()
|
||||
unregisterConnection()
|
||||
}
|
||||
|
||||
request.raw.on("close", handleClose)
|
||||
request.raw.on("error", handleClose)
|
||||
})
|
||||
|
||||
app.post("/api/client-connections/pong", (request, reply) => {
|
||||
const body = PongBodySchema.parse(request.body ?? {})
|
||||
if (!deps.connectionManager.pong(body)) {
|
||||
reply.code(404).send({ error: "Client connection not found" })
|
||||
return
|
||||
}
|
||||
reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import { resolveNetworkAddresses } from "../network-addresses"
|
||||
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const localPort = resolveLocalPort(meta)
|
||||
const remote = resolveRemote(meta)
|
||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
localPort,
|
||||
remotePort: remote?.port,
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { VoiceModeStateResponse } from "../../api-types"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import type { EventBus } from "../../events/bus"
|
||||
import type { Logger } from "../../logger"
|
||||
import { PluginChannelManager } from "../../plugins/channel"
|
||||
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||
import { VoiceModeManager } from "../../plugins/voice-mode"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
channel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
}
|
||||
|
||||
const PluginEventSchema = z.object({
|
||||
@@ -17,9 +21,13 @@ const PluginEventSchema = z.object({
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const VoiceModeStateSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
clientId: z.string().trim().min(1),
|
||||
connectionId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
@@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const registration = channel.register(request.params.id, reply)
|
||||
const registration = deps.channel.register(request.params.id, reply)
|
||||
deps.voiceModeManager.syncInstance(request.params.id)
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
channel.send(request.params.id, buildPingEvent())
|
||||
deps.channel.send(request.params.id, buildPingEvent())
|
||||
}, 15000)
|
||||
|
||||
const close = () => {
|
||||
@@ -49,6 +58,22 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
request.raw.on("error", close)
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||
deps.voiceModeManager.setEnabled(
|
||||
request.params.id,
|
||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||
payload.enabled,
|
||||
)
|
||||
return { enabled: payload.enabled }
|
||||
})
|
||||
|
||||
const handleWildcard = async (request: any, reply: any) => {
|
||||
const workspaceId = request.params.id as string
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
|
||||
166
packages/server/src/server/routes/remote-servers.ts
Normal file
166
packages/server/src/server/routes/remote-servers.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Agent, fetch } from "undici"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { Logger } from "../../logger"
|
||||
import type { RemoteServerProbeResponse } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const ProbeSchema = z.object({
|
||||
baseUrl: z.string().min(1),
|
||||
skipTlsVerify: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.post("/api/remote-servers/probe", async (request, reply) => {
|
||||
try {
|
||||
const body = ProbeSchema.parse(request.body ?? {})
|
||||
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||
} catch (error) {
|
||||
deps.logger.warn({ err: error }, "Failed to probe remote server")
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid request" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl)
|
||||
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||
|
||||
try {
|
||||
const response = await fetch(probeUrl, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: `Remote server returned HTTP ${response.status}`,
|
||||
errorCode: "http_error",
|
||||
}
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { authenticated?: unknown }
|
||||
if (typeof payload?.authenticated !== "boolean") {
|
||||
return {
|
||||
ok: false,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: "Remote server did not return a valid CodeNomad auth response",
|
||||
errorCode: "invalid_server",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: !payload.authenticated,
|
||||
authenticated: payload.authenticated,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = describeProbeError(error)
|
||||
return {
|
||||
ok: false,
|
||||
reachable: false,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: message.message,
|
||||
errorCode: message.code,
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await dispatcher?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(input: string): string {
|
||||
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(/\/+$/, "") || "/"
|
||||
const value = parsed.toString()
|
||||
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function describeProbeError(error: unknown): { code: string; message: string } {
|
||||
const chain = unwrapErrorChain(error)
|
||||
const detailed =
|
||||
chain.find((entry) => {
|
||||
const code = (entry?.code ?? "").toString()
|
||||
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
|
||||
}) ?? chain[0]
|
||||
|
||||
const code = (detailed?.code ?? "").toString()
|
||||
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
|
||||
|
||||
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
|
||||
return {
|
||||
code: "tls_error",
|
||||
message: "Certificate check failed while connecting to the remote server.",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code:
|
||||
code === "ERR_INVALID_URL"
|
||||
? "invalid_url"
|
||||
: code === "ECONNREFUSED"
|
||||
? "connection_refused"
|
||||
: code === "ENOTFOUND"
|
||||
? "dns_error"
|
||||
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
|
||||
? "timeout"
|
||||
: code
|
||||
? code.toLowerCase()
|
||||
: "probe_failed",
|
||||
message: exactMessage || "Failed to connect to the remote server.",
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
|
||||
const results: Array<{ code?: unknown; message?: string }> = []
|
||||
let current: unknown = error
|
||||
const seen = new Set<unknown>()
|
||||
|
||||
while (current && typeof current === "object" && !seen.has(current)) {
|
||||
seen.add(current)
|
||||
const entry = current as { code?: unknown; message?: string; cause?: unknown }
|
||||
results.push({ code: entry.code, message: entry.message })
|
||||
current = entry.cause
|
||||
}
|
||||
|
||||
if (results.length === 0 && error instanceof Error) {
|
||||
results.push({ message: error.message })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
84
packages/server/src/server/routes/settings.ts
Normal file
84
packages/server/src/server/routes/settings.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||
import type { SettingsService } from "../../settings/service"
|
||||
import type { Logger } from "../../logger"
|
||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||
|
||||
interface RouteDeps {
|
||||
settings: SettingsService
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const ValidateBinarySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
|
||||
const result = probeBinaryVersion(binaryPath)
|
||||
return { valid: result.valid, version: result.version, error: result.error }
|
||||
}
|
||||
|
||||
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
// Full-document access
|
||||
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
||||
app.patch("/api/storage/config", async (request, reply) => {
|
||||
try {
|
||||
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
||||
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
|
||||
})
|
||||
|
||||
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||
try {
|
||||
return sanitizeConfigOwner(
|
||||
request.params.owner,
|
||||
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
|
||||
)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/api/storage/state", async () => deps.settings.getDoc("state"))
|
||||
app.patch("/api/storage/state", async (request, reply) => {
|
||||
try {
|
||||
return deps.settings.mergePatchDoc("state", request.body ?? {})
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => {
|
||||
return deps.settings.getOwner("state", request.params.owner)
|
||||
})
|
||||
|
||||
app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => {
|
||||
try {
|
||||
return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {})
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||
}
|
||||
})
|
||||
|
||||
// Binary validation helper (used by UI when adding binaries)
|
||||
app.post("/api/storage/binaries/validate", async (request, reply) => {
|
||||
try {
|
||||
const body = ValidateBinarySchema.parse(request.body ?? {})
|
||||
return validateBinaryPath(body.path)
|
||||
} catch (error) {
|
||||
deps.logger.warn({ err: error }, "Failed to validate binary")
|
||||
reply.code(400)
|
||||
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
|
||||
}
|
||||
})
|
||||
}
|
||||
74
packages/server/src/server/routes/speech.ts
Normal file
74
packages/server/src/server/routes/speech.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { SpeechService } from "../../speech/service"
|
||||
|
||||
interface RouteDeps {
|
||||
speechService: SpeechService
|
||||
}
|
||||
|
||||
const TranscribeBodySchema = z.object({
|
||||
audioBase64: z.string().min(1, "Audio payload is required"),
|
||||
mimeType: z.string().min(1, "Audio MIME type is required"),
|
||||
filename: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
})
|
||||
|
||||
const SynthesizeBodySchema = z.object({
|
||||
text: z.string().trim().min(1, "Text is required"),
|
||||
format: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
||||
})
|
||||
|
||||
function getSpeechErrorStatus(error: unknown): number {
|
||||
if (error instanceof z.ZodError) {
|
||||
return 400
|
||||
}
|
||||
if (error instanceof Error && /not configured/i.test(error.message)) {
|
||||
return 503
|
||||
}
|
||||
return 502
|
||||
}
|
||||
|
||||
function getSpeechErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
||||
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
|
||||
|
||||
app.post("/api/speech/transcribe", async (request, reply) => {
|
||||
try {
|
||||
const body = TranscribeBodySchema.parse(request.body ?? {})
|
||||
return await deps.speechService.transcribe(body)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to transcribe audio")
|
||||
reply.code(getSpeechErrorStatus(error))
|
||||
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/api/speech/synthesize", async (request, reply) => {
|
||||
try {
|
||||
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
||||
return await deps.speechService.synthesize(body)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to synthesize audio")
|
||||
reply.code(getSpeechErrorStatus(error))
|
||||
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/api/speech/synthesize/stream", async (request, reply) => {
|
||||
try {
|
||||
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
||||
const result = await deps.speechService.synthesizeStream(body)
|
||||
reply.header("Content-Type", result.mimeType)
|
||||
reply.header("Cache-Control", "no-store")
|
||||
return reply.send(result.stream)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to stream synthesized audio")
|
||||
reply.code(getSpeechErrorStatus(error))
|
||||
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileContentBodySchema = z.object({
|
||||
contents: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
@@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
||||
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
55
packages/server/src/settings/binaries.ts
Normal file
55
packages/server/src/settings/binaries.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { SettingsService } from "./service"
|
||||
|
||||
export interface OpenCodeBinaryEntry {
|
||||
path: string
|
||||
version?: string
|
||||
lastUsed?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface ResolvedBinary {
|
||||
path: string
|
||||
label: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
function prettyLabel(p: string): string {
|
||||
const parts = p.split(/[\\/]/)
|
||||
const last = parts[parts.length - 1] || p
|
||||
return last || p
|
||||
}
|
||||
|
||||
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
|
||||
const ui = settings.getOwner("state", "ui")
|
||||
const list = (ui as any)?.opencodeBinaries
|
||||
if (!Array.isArray(list)) return []
|
||||
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
|
||||
}
|
||||
|
||||
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
|
||||
const server = settings.getOwner("config", "server")
|
||||
const value = (server as any)?.opencodeBinary
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
||||
}
|
||||
|
||||
export class BinaryResolver {
|
||||
constructor(private readonly settings: SettingsService) {}
|
||||
|
||||
list(): OpenCodeBinaryEntry[] {
|
||||
return readUiBinaries(this.settings)
|
||||
}
|
||||
|
||||
resolveDefault(): ResolvedBinary {
|
||||
const binaries = this.list()
|
||||
const configuredDefault = readDefaultBinaryPath(this.settings)
|
||||
const fallback = binaries[0]?.path
|
||||
const path = configuredDefault ?? fallback ?? "opencode"
|
||||
|
||||
const entry = binaries.find((b) => b.path === path)
|
||||
return {
|
||||
path,
|
||||
label: entry?.label ?? prettyLabel(path),
|
||||
version: entry?.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
39
packages/server/src/settings/merge-patch.ts
Normal file
39
packages/server/src/settings/merge-patch.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
type PlainObject = Record<string, unknown>
|
||||
|
||||
export function isPlainObject(value: unknown): value is PlainObject {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (Array.isArray(value)) return false
|
||||
const proto = Object.getPrototypeOf(value)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7396-ish merge patch with explicit null deletes.
|
||||
* - Objects merge recursively
|
||||
* - Arrays/scalars replace
|
||||
* - null deletes keys
|
||||
*/
|
||||
export function applyMergePatch(current: unknown, patch: unknown): unknown {
|
||||
if (!isPlainObject(patch)) {
|
||||
return patch
|
||||
}
|
||||
|
||||
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
|
||||
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete base[key]
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = base[key]
|
||||
if (isPlainObject(value) && isPlainObject(existing)) {
|
||||
base[key] = applyMergePatch(existing, value)
|
||||
continue
|
||||
}
|
||||
|
||||
base[key] = value
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
274
packages/server/src/settings/migrate.ts
Normal file
274
packages/server/src/settings/migrate.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import type { Logger } from "../logger"
|
||||
import type { ConfigLocation } from "../config/location"
|
||||
import { isPlainObject } from "./merge-patch"
|
||||
|
||||
type Doc = Record<string, unknown>
|
||||
|
||||
function ensureTrailingNewline(content: string): string {
|
||||
if (!content) return "\n"
|
||||
return content.endsWith("\n") ? content : `${content}\n`
|
||||
}
|
||||
|
||||
function safeReadYaml(filePath: string, logger: Logger): unknown {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
return parseYaml(content)
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadJson(filePath: string, logger: Logger): unknown {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
const yaml = stringifyYaml(doc as any)
|
||||
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
|
||||
}
|
||||
}
|
||||
|
||||
function pickBackupPath(filePath: string): string {
|
||||
const preferred = `${filePath}.bak`
|
||||
if (!fs.existsSync(preferred)) {
|
||||
return preferred
|
||||
}
|
||||
return `${filePath}.bak.${Date.now()}`
|
||||
}
|
||||
|
||||
function normalizeDoc(value: unknown): Doc {
|
||||
return isPlainObject(value) ? (value as Doc) : {}
|
||||
}
|
||||
|
||||
function looksLikeNewOwnerDoc(value: unknown): boolean {
|
||||
const doc = normalizeDoc(value)
|
||||
// Heuristic: owner-bucket docs have at least one of these roots.
|
||||
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
|
||||
}
|
||||
|
||||
function looksLikeLegacyConfig(value: unknown): boolean {
|
||||
const doc = normalizeDoc(value)
|
||||
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
|
||||
}
|
||||
|
||||
function looksLikeLegacyState(value: unknown): boolean {
|
||||
const doc = normalizeDoc(value)
|
||||
return Boolean(doc.recentFolders)
|
||||
}
|
||||
|
||||
function omitKeys(source: Doc, keys: Set<string>): Doc {
|
||||
const out: Doc = {}
|
||||
for (const [k, v] of Object.entries(source)) {
|
||||
if (keys.has(k)) continue
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
|
||||
const cfg = normalizeDoc(legacyConfig)
|
||||
const st = normalizeDoc(legacyState)
|
||||
|
||||
const outConfig: Doc = {}
|
||||
const outState: Doc = {}
|
||||
|
||||
const uiConfig: Doc = {}
|
||||
const uiSettings: Doc = {}
|
||||
const serverConfig: Doc = {}
|
||||
const uiState: Doc = {}
|
||||
|
||||
// theme -> config.ui.theme
|
||||
if (typeof cfg.theme === "string") {
|
||||
uiConfig.theme = cfg.theme
|
||||
}
|
||||
|
||||
const preferences = normalizeDoc(cfg.preferences)
|
||||
if (Object.keys(preferences).length > 0) {
|
||||
// Server-owned stable keys
|
||||
const envVars = preferences.environmentVariables
|
||||
if (isPlainObject(envVars)) {
|
||||
serverConfig.environmentVariables = envVars
|
||||
}
|
||||
const listeningMode = preferences.listeningMode
|
||||
if (typeof listeningMode === "string") {
|
||||
serverConfig.listeningMode = listeningMode
|
||||
}
|
||||
const logLevel = preferences.logLevel
|
||||
if (typeof logLevel === "string") {
|
||||
serverConfig.logLevel = logLevel
|
||||
}
|
||||
const lastUsedBinary = preferences.lastUsedBinary
|
||||
if (typeof lastUsedBinary === "string") {
|
||||
serverConfig.opencodeBinary = lastUsedBinary
|
||||
}
|
||||
|
||||
// UI-owned state keys (drop preferences)
|
||||
const modelRecents = preferences.modelRecents
|
||||
const modelFavorites = preferences.modelFavorites
|
||||
const modelThinkingSelections = preferences.modelThinkingSelections
|
||||
|
||||
const models: Doc = {}
|
||||
if (Array.isArray(modelRecents)) {
|
||||
models.recents = modelRecents
|
||||
}
|
||||
if (Array.isArray(modelFavorites)) {
|
||||
models.favorites = modelFavorites
|
||||
}
|
||||
if (isPlainObject(modelThinkingSelections)) {
|
||||
models.thinkingSelections = modelThinkingSelections
|
||||
}
|
||||
if (Object.keys(models).length > 0) {
|
||||
uiState.models = models
|
||||
}
|
||||
|
||||
// Remaining preferences are treated as stable UI settings.
|
||||
const moved = new Set([
|
||||
"environmentVariables",
|
||||
"listeningMode",
|
||||
"logLevel",
|
||||
"lastUsedBinary",
|
||||
"modelRecents",
|
||||
"modelFavorites",
|
||||
"modelThinkingSelections",
|
||||
])
|
||||
Object.assign(uiSettings, omitKeys(preferences, moved))
|
||||
}
|
||||
|
||||
// recentFolders lives in legacy state (yaml) or legacy config.json
|
||||
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
|
||||
if (Array.isArray(recentFolders)) {
|
||||
uiState.recentFolders = recentFolders
|
||||
}
|
||||
|
||||
// opencodeBinaries -> state.ui.opencodeBinaries
|
||||
if (Array.isArray(cfg.opencodeBinaries)) {
|
||||
uiState.opencodeBinaries = cfg.opencodeBinaries
|
||||
}
|
||||
|
||||
if (Object.keys(uiSettings).length > 0) {
|
||||
uiConfig.settings = uiSettings
|
||||
}
|
||||
|
||||
if (Object.keys(uiConfig).length > 0) {
|
||||
outConfig.ui = uiConfig
|
||||
}
|
||||
if (Object.keys(serverConfig).length > 0) {
|
||||
outConfig.server = serverConfig
|
||||
}
|
||||
if (Object.keys(uiState).length > 0) {
|
||||
outState.ui = uiState
|
||||
}
|
||||
|
||||
// Unknown top-level keys -> legacy.unknown
|
||||
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
|
||||
const unknownConfig = omitKeys(cfg, knownConfigKeys)
|
||||
if (Object.keys(unknownConfig).length > 0) {
|
||||
outConfig.legacy = { unknown: unknownConfig }
|
||||
}
|
||||
|
||||
const knownStateKeys = new Set(["recentFolders"])
|
||||
const unknownState = omitKeys(st, knownStateKeys)
|
||||
if (Object.keys(unknownState).length > 0) {
|
||||
outState.legacy = { unknown: unknownState }
|
||||
}
|
||||
|
||||
return { config: outConfig, state: outState }
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate older config/state layouts into owner-bucket YAML docs.
|
||||
*
|
||||
* Legacy inputs supported:
|
||||
* - config.yaml with { preferences, opencodeBinaries, theme }
|
||||
* - state.yaml with { recentFolders }
|
||||
* - legacy config.json with full ConfigFile schema
|
||||
*/
|
||||
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
|
||||
const configYamlPath = location.configYamlPath
|
||||
const stateYamlPath = location.stateYamlPath
|
||||
const legacyJsonPath = location.legacyJsonPath
|
||||
|
||||
const configExists = fs.existsSync(configYamlPath)
|
||||
const stateExists = fs.existsSync(stateYamlPath)
|
||||
|
||||
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
|
||||
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
|
||||
|
||||
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
|
||||
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
|
||||
|
||||
if (configIsNew && stateIsNew) {
|
||||
return
|
||||
}
|
||||
|
||||
const legacyJsonExists = fs.existsSync(legacyJsonPath)
|
||||
|
||||
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
|
||||
const shouldMigrateFromJson = !configExists && legacyJsonExists
|
||||
|
||||
if (!hasLegacyYaml && !shouldMigrateFromJson) {
|
||||
// Either fresh install or partially written docs; let stores create on first write.
|
||||
return
|
||||
}
|
||||
|
||||
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
|
||||
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
|
||||
|
||||
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(location.baseDir, { recursive: true })
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
|
||||
}
|
||||
|
||||
// Backup legacy files before rewriting.
|
||||
if (configExists) {
|
||||
try {
|
||||
const bak = pickBackupPath(configYamlPath)
|
||||
fs.renameSync(configYamlPath, bak)
|
||||
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
if (stateExists) {
|
||||
try {
|
||||
const bak = pickBackupPath(stateYamlPath)
|
||||
fs.renameSync(stateYamlPath, bak)
|
||||
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldMigrateFromJson) {
|
||||
try {
|
||||
const bak = pickBackupPath(legacyJsonPath)
|
||||
fs.renameSync(legacyJsonPath, bak)
|
||||
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
|
||||
}
|
||||
}
|
||||
|
||||
writeYaml(configYamlPath, config, logger)
|
||||
writeYaml(stateYamlPath, state, logger)
|
||||
|
||||
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
|
||||
}
|
||||
40
packages/server/src/settings/public-config.ts
Normal file
40
packages/server/src/settings/public-config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { SettingsDoc } from "./yaml-doc-store"
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
|
||||
const next: SettingsDoc = { ...value }
|
||||
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
|
||||
|
||||
if (!speech) {
|
||||
return next
|
||||
}
|
||||
|
||||
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
|
||||
if (rawApiKey) {
|
||||
delete speech.apiKey
|
||||
speech.hasApiKey = true
|
||||
} else if (!("hasApiKey" in speech)) {
|
||||
speech.hasApiKey = false
|
||||
}
|
||||
|
||||
next.speech = speech
|
||||
return next
|
||||
}
|
||||
|
||||
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
|
||||
if (owner !== "server") {
|
||||
return value
|
||||
}
|
||||
return sanitizeServerOwner(value)
|
||||
}
|
||||
|
||||
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
|
||||
const next: SettingsDoc = { ...value }
|
||||
if (isPlainObject(next.server)) {
|
||||
next.server = sanitizeServerOwner(next.server)
|
||||
}
|
||||
return next
|
||||
}
|
||||
128
packages/server/src/settings/service.ts
Normal file
128
packages/server/src/settings/service.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Logger } from "../logger"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { ConfigLocation } from "../config/location"
|
||||
import { z } from "zod"
|
||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
import { sanitizeConfigOwner } from "./public-config"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
const CanonicalLogLevelSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
|
||||
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
|
||||
)
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
|
||||
if (!isPlainObject(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const next: SettingsDoc = { ...value }
|
||||
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
|
||||
if (parsedLogLevel.success) {
|
||||
next.logLevel = parsedLogLevel.data
|
||||
} else if (next.logLevel !== undefined) {
|
||||
next.logLevel = "DEBUG"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
|
||||
if (!isPlainObject(doc)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (!isPlainObject(doc.server)) {
|
||||
return doc
|
||||
}
|
||||
|
||||
return {
|
||||
...doc,
|
||||
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsService {
|
||||
private readonly configStore: YamlDocStore
|
||||
private readonly stateStore: YamlDocStore
|
||||
|
||||
constructor(
|
||||
private readonly location: ConfigLocation,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
migrateSettingsLayout(location, logger)
|
||||
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
if (kind !== "config") {
|
||||
return this.stateStore.get()
|
||||
}
|
||||
|
||||
const current = this.configStore.get()
|
||||
const normalized = normalizeConfigDoc(current)
|
||||
if (!isDeepEqual(current, normalized)) {
|
||||
this.configStore.replace(normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
const updated =
|
||||
kind === "config"
|
||||
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||
: this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
}
|
||||
|
||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||
if (kind !== "config") {
|
||||
return this.stateStore.getOwner(owner)
|
||||
}
|
||||
|
||||
return owner === "server"
|
||||
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
|
||||
: this.getDoc("config")[owner] as SettingsDoc
|
||||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
const updated =
|
||||
kind === "config"
|
||||
? owner === "server"
|
||||
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||
: this.configStore.mergePatchOwner(owner, patch)
|
||||
: this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||
if (!this.eventBus) return
|
||||
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||
const nextValue = value ?? this.getOwner(kind, owner)
|
||||
const payload: WorkspaceEventPayload = {
|
||||
type,
|
||||
owner,
|
||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||
} as any
|
||||
this.eventBus.publish(payload)
|
||||
}
|
||||
}
|
||||
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import type { Logger } from "../logger"
|
||||
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||
|
||||
export type SettingsDoc = Record<string, unknown>
|
||||
|
||||
function ensureTrailingNewline(content: string): string {
|
||||
if (!content) return "\n"
|
||||
return content.endsWith("\n") ? content : `${content}\n`
|
||||
}
|
||||
|
||||
function normalizeDoc(input: unknown): SettingsDoc {
|
||||
if (!isPlainObject(input)) {
|
||||
return {}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
export class YamlDocStore {
|
||||
private cache: SettingsDoc = {}
|
||||
private loaded = false
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
load(): SettingsDoc {
|
||||
if (this.loaded) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
this.cache = {}
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.filePath, "utf-8")
|
||||
const parsed = parseYaml(content)
|
||||
this.cache = normalizeDoc(parsed)
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
|
||||
this.cache = {}
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
}
|
||||
|
||||
get(): SettingsDoc {
|
||||
return this.load()
|
||||
}
|
||||
|
||||
replace(next: unknown): SettingsDoc {
|
||||
const normalized = normalizeDoc(next)
|
||||
this.cache = normalized
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
return this.cache
|
||||
}
|
||||
|
||||
mergePatch(patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const current = this.get()
|
||||
const next = applyMergePatch(current, patch)
|
||||
return this.replace(next)
|
||||
}
|
||||
|
||||
getOwner(owner: string): SettingsDoc {
|
||||
const doc = this.get()
|
||||
const value = (doc as any)?.[owner]
|
||||
return normalizeDoc(value)
|
||||
}
|
||||
|
||||
replaceOwner(owner: string, value: unknown): SettingsDoc {
|
||||
const doc = this.get()
|
||||
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
|
||||
this.replace(nextDoc)
|
||||
return nextDoc[owner] as SettingsDoc
|
||||
}
|
||||
|
||||
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const doc = this.get()
|
||||
const currentOwner = normalizeDoc((doc as any)?.[owner])
|
||||
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
|
||||
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
|
||||
this.replace(nextDoc)
|
||||
return nextOwner
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
const yaml = stringifyYaml(this.cache as any)
|
||||
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
234
packages/server/src/speech/providers/openai-compatible.ts
Normal file
234
packages/server/src/speech/providers/openai-compatible.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Readable } from "node:stream"
|
||||
import OpenAI from "openai"
|
||||
import { toFile } from "openai/uploads"
|
||||
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
|
||||
import type { Logger } from "../../logger"
|
||||
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
|
||||
|
||||
interface OpenAICompatibleSpeechProviderOptions {
|
||||
settings: NormalizedSpeechSettings
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export class OpenAICompatibleSpeechProvider {
|
||||
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
|
||||
|
||||
getCapabilities() {
|
||||
const { settings } = this.options
|
||||
return {
|
||||
available: true,
|
||||
configured: Boolean(settings.apiKey),
|
||||
provider: settings.provider,
|
||||
supportsStt: true,
|
||||
supportsTts: true,
|
||||
supportsStreamingTts: true,
|
||||
baseUrl: settings.baseUrl,
|
||||
sttModel: settings.sttModel,
|
||||
ttsModel: settings.ttsModel,
|
||||
ttsVoice: settings.ttsVoice,
|
||||
ttsFormats: ["mp3", "wav", "opus", "aac"],
|
||||
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
|
||||
}
|
||||
}
|
||||
|
||||
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||
const client = this.createClient()
|
||||
const startedAt = Date.now()
|
||||
const extension = extensionForMime(input.mimeType)
|
||||
const buffer = Buffer.from(input.audioBase64, "base64")
|
||||
const filename = input.filename?.trim() || `prompt-input.${extension}`
|
||||
|
||||
this.options.logger.info(
|
||||
{
|
||||
mimeType: input.mimeType,
|
||||
bytes: buffer.byteLength,
|
||||
language: input.language,
|
||||
model: this.options.settings.sttModel,
|
||||
},
|
||||
"speech.transcribe",
|
||||
)
|
||||
|
||||
const response = await this.requestTranscription(client, buffer, filename, input)
|
||||
|
||||
return {
|
||||
text: typeof response?.text === "string" ? response.text : "",
|
||||
language: typeof response?.language === "string" ? response.language : input.language,
|
||||
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
|
||||
segments: Array.isArray(response?.segments)
|
||||
? response.segments
|
||||
.filter((segment: any) => typeof segment?.text === "string")
|
||||
.map((segment: any) => ({
|
||||
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
|
||||
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
|
||||
text: String(segment.text),
|
||||
}))
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
private async requestTranscription(
|
||||
client: OpenAI,
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
input: TranscribeAudioInput,
|
||||
): Promise<any> {
|
||||
const baseRequest = {
|
||||
model: this.options.settings.sttModel,
|
||||
...(input.language ? { language: input.language } : {}),
|
||||
...(input.prompt ? { prompt: input.prompt } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await toFile(buffer, filename, { type: input.mimeType })
|
||||
return (await client.audio.transcriptions.create({
|
||||
...baseRequest,
|
||||
file,
|
||||
response_format: "verbose_json" as any,
|
||||
} as any)) as any
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
|
||||
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
|
||||
return (await client.audio.transcriptions.create({
|
||||
...baseRequest,
|
||||
file: retryFile,
|
||||
} as any)) as any
|
||||
}
|
||||
}
|
||||
|
||||
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||
const format = input.format ?? this.options.settings.ttsFormat
|
||||
|
||||
this.options.logger.info(
|
||||
{
|
||||
model: this.options.settings.ttsModel,
|
||||
voice: this.options.settings.ttsVoice,
|
||||
format,
|
||||
},
|
||||
"speech.synthesize",
|
||||
)
|
||||
|
||||
const response = await this.requestSpeechAudio(input.text, format)
|
||||
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
|
||||
|
||||
const audioBuffer = Buffer.from(await response.arrayBuffer())
|
||||
return {
|
||||
audioBase64: audioBuffer.toString("base64"),
|
||||
mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||
const format = input.format ?? this.options.settings.ttsFormat
|
||||
|
||||
this.options.logger.info(
|
||||
{
|
||||
model: this.options.settings.ttsModel,
|
||||
voice: this.options.settings.ttsVoice,
|
||||
format,
|
||||
},
|
||||
"speech.synthesize.stream",
|
||||
)
|
||||
|
||||
const response = await this.requestSpeechAudio(input.text, format)
|
||||
if (!response.body) {
|
||||
throw new Error("Speech provider did not return a stream.")
|
||||
}
|
||||
|
||||
return {
|
||||
stream: Readable.fromWeb(response.body as any),
|
||||
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
|
||||
}
|
||||
}
|
||||
|
||||
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
|
||||
const { settings } = this.options
|
||||
if (!settings.apiKey) {
|
||||
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
||||
}
|
||||
|
||||
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.ttsModel,
|
||||
voice: settings.ttsVoice,
|
||||
input: text,
|
||||
response_format: format,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
const detailedError = error as Error & {
|
||||
cause?: unknown
|
||||
code?: string
|
||||
errno?: number | string
|
||||
syscall?: string
|
||||
address?: string
|
||||
port?: number
|
||||
}
|
||||
this.options.logger.error(
|
||||
{
|
||||
err: error,
|
||||
endpoint: endpoint.toString(),
|
||||
baseUrl: settings.baseUrl,
|
||||
model: settings.ttsModel,
|
||||
voice: settings.ttsVoice,
|
||||
format,
|
||||
cause: detailedError.cause,
|
||||
code: detailedError.code,
|
||||
errno: detailedError.errno,
|
||||
syscall: detailedError.syscall,
|
||||
address: detailedError.address,
|
||||
port: detailedError.port,
|
||||
},
|
||||
"speech.synthesize fetch failed",
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private createClient(): OpenAI {
|
||||
const { settings } = this.options
|
||||
if (!settings.apiKey) {
|
||||
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function extensionForMime(mimeType: string): string {
|
||||
const normalized = mimeType.toLowerCase()
|
||||
if (normalized.includes("webm")) return "webm"
|
||||
if (normalized.includes("ogg")) return "ogg"
|
||||
if (normalized.includes("wav")) return "wav"
|
||||
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
|
||||
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
|
||||
return "webm"
|
||||
}
|
||||
|
||||
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
|
||||
if (format === "wav") return "audio/wav"
|
||||
if (format === "opus") return 'audio/ogg; codecs="opus"'
|
||||
if (format === "aac") return "audio/aac"
|
||||
return "audio/mpeg"
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
return value.endsWith("/") ? value : `${value}/`
|
||||
}
|
||||
106
packages/server/src/speech/service.ts
Normal file
106
packages/server/src/speech/service.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { z } from "zod"
|
||||
import type { Readable } from "node:stream"
|
||||
import type { Logger } from "../logger"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
|
||||
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
|
||||
|
||||
const ServerSpeechSettingsSchema = z.object({
|
||||
speech: z
|
||||
.object({
|
||||
provider: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
sttModel: z.string().optional(),
|
||||
ttsModel: z.string().optional(),
|
||||
ttsVoice: z.string().optional(),
|
||||
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export interface TranscribeAudioInput {
|
||||
audioBase64: string
|
||||
mimeType: string
|
||||
filename?: string
|
||||
language?: string
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
export interface SynthesizeSpeechInput {
|
||||
text: string
|
||||
format?: "mp3" | "wav" | "opus" | "aac"
|
||||
}
|
||||
|
||||
export interface SpeechSynthesisStreamResponse {
|
||||
stream: Readable
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface SpeechProvider {
|
||||
getCapabilities(): SpeechCapabilitiesResponse
|
||||
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
||||
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
||||
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
|
||||
}
|
||||
|
||||
export interface NormalizedSpeechSettings {
|
||||
provider: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
sttModel: string
|
||||
ttsModel: string
|
||||
ttsVoice: string
|
||||
ttsFormat: "mp3" | "wav" | "opus" | "aac"
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER = "openai-compatible"
|
||||
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
|
||||
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
|
||||
const DEFAULT_TTS_VOICE = "alloy"
|
||||
const DEFAULT_TTS_FORMAT = "mp3"
|
||||
export class SpeechService {
|
||||
constructor(
|
||||
private readonly settings: SettingsService,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
getCapabilities(): SpeechCapabilitiesResponse {
|
||||
return this.createProvider().getCapabilities()
|
||||
}
|
||||
|
||||
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||
return this.createProvider().transcribe(input)
|
||||
}
|
||||
|
||||
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||
return this.createProvider().synthesize(input)
|
||||
}
|
||||
|
||||
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||
return this.createProvider().synthesizeStream(input)
|
||||
}
|
||||
|
||||
private createProvider(): SpeechProvider {
|
||||
const settings = this.resolveSettings()
|
||||
return new OpenAICompatibleSpeechProvider({
|
||||
settings,
|
||||
logger: this.logger.child({ provider: settings.provider }),
|
||||
})
|
||||
}
|
||||
|
||||
private resolveSettings(): NormalizedSpeechSettings {
|
||||
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
|
||||
const speech = parsed.speech ?? {}
|
||||
|
||||
return {
|
||||
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
||||
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
|
||||
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
|
||||
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
||||
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
||||
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
||||
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
assert.equal(result.uiVersion, "0.8.1")
|
||||
})
|
||||
|
||||
it("prefers bundled when bundled and downloaded versions are equal", async () => {
|
||||
const bundledDir = path.join(tempRoot, "bundled")
|
||||
const configDir = path.join(tempRoot, "config")
|
||||
const currentDir = path.join(configDir, "ui", "current")
|
||||
|
||||
await mkdir(bundledDir, { recursive: true })
|
||||
await mkdir(currentDir, { recursive: true })
|
||||
|
||||
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
||||
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
const result = await resolveUi({
|
||||
serverVersion: "0.8.1",
|
||||
bundledUiDir: bundledDir,
|
||||
autoUpdate: false,
|
||||
configDir,
|
||||
logger: noopLogger,
|
||||
})
|
||||
|
||||
assert.equal(result.source, "bundled")
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
assert.equal(result.uiVersion, "0.8.1")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: currentResolved,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(currentResolved),
|
||||
priority: 2,
|
||||
priority: 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: bundledResolved,
|
||||
source: "bundled",
|
||||
uiVersion: await readUiVersion(bundledResolved),
|
||||
priority: 1,
|
||||
priority: 2,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { connect } from "net"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import type { BinaryResolver } from "../settings/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
settings: SettingsService
|
||||
binaryResolver: BinaryResolver
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
getServerBaseUrl: () => string
|
||||
@@ -83,10 +83,16 @@ export class WorkspaceManager {
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
browser.writeFile(relativePath, contents)
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
const binary = this.options.binaryResolver.resolveDefault()
|
||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
@@ -109,17 +115,14 @@ export class WorkspaceManager {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||
const envVars = (serverConfig as any)?.environmentVariables
|
||||
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||
|
||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||
const opencodePassword = generateOpencodeServerPassword()
|
||||
@@ -139,16 +142,22 @@ export class WorkspaceManager {
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
const logLevel = (serverConfig as any)?.logLevel
|
||||
|
||||
try {
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
logLevel,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
if (runtimeVersion) {
|
||||
descriptor.binaryVersion = runtimeVersion
|
||||
}
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
@@ -277,42 +286,12 @@ export class WorkspaceManager {
|
||||
return candidates[0] ?? ""
|
||||
}
|
||||
|
||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||
if (!resolvedPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
|
||||
if (line) {
|
||||
const normalized = line.trim()
|
||||
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1]
|
||||
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
|
||||
return version
|
||||
}
|
||||
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
|
||||
return normalized
|
||||
}
|
||||
} else if (result.error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async waitForWorkspaceReadiness(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
}): Promise<string | undefined> {
|
||||
|
||||
await Promise.race([
|
||||
this.waitForPortAvailability(params.port),
|
||||
@@ -326,7 +305,7 @@ export class WorkspaceManager {
|
||||
}),
|
||||
])
|
||||
|
||||
await this.waitForInstanceHealth(params)
|
||||
const version = await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
@@ -339,6 +318,8 @@ export class WorkspaceManager {
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
@@ -346,7 +327,7 @@ export class WorkspaceManager {
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
}): Promise<string | undefined> {
|
||||
const probeResult = await Promise.race([
|
||||
this.probeInstance(params.workspaceId, params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
@@ -360,7 +341,7 @@ export class WorkspaceManager {
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return
|
||||
return probeResult.version
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
@@ -371,8 +352,11 @@ export class WorkspaceManager {
|
||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||
}
|
||||
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
private async probeInstance(
|
||||
workspaceId: string,
|
||||
port: number,
|
||||
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/global/health`
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
@@ -383,11 +367,22 @@ export class WorkspaceManager {
|
||||
|
||||
const response = await fetch(url, { headers })
|
||||
if (!response.ok) {
|
||||
const reason = `health probe returned HTTP ${response.status}`
|
||||
const reason = `/global/health returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
return { ok: true }
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
|
||||
const healthy = payload?.healthy === true
|
||||
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
|
||||
|
||||
if (!healthy) {
|
||||
const reason = "Instance reported unhealthy"
|
||||
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
|
||||
return { ok: true, version: version || undefined }
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Logger } from "../logger"
|
||||
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 }
|
||||
@@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||
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
|
||||
|
||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
@@ -59,6 +116,7 @@ interface LaunchOptions {
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
logLevel?: string
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
@@ -82,7 +140,8 @@ export class WorkspaceRuntime {
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
|
||||
2474
packages/tauri-app/Cargo.lock
generated
2474
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
"dev:prep": "node ./scripts/dev-prep.js",
|
||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||
"sync:version": "node ./scripts/sync-tauri-version.js",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "tauri build"
|
||||
|
||||
@@ -20,6 +20,7 @@ const serverDevInstallCommand =
|
||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const uiDevInstallCommand =
|
||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||
|
||||
const envWithRootBin = {
|
||||
...process.env,
|
||||
@@ -55,11 +56,7 @@ async function ensureMonacoAssets() {
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] server build missing; running workspace build...")
|
||||
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
@@ -91,6 +88,15 @@ function ensureUiBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncServerUiBundle() {
|
||||
console.log("[prebuild] syncing server public UI bundle...")
|
||||
execSync(serverPrepareUiCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
@@ -246,6 +252,7 @@ function copyUiLoadingAssets() {
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
syncServerUiBundle()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
|
||||
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const packageJsonPath = path.join(root, "package.json")
|
||||
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
|
||||
const cargoLockPath = path.join(root, "Cargo.lock")
|
||||
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
|
||||
|
||||
function readPackageVersion() {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error("Missing version in packages/tauri-app/package.json")
|
||||
}
|
||||
return packageJson.version
|
||||
}
|
||||
|
||||
function syncCargoToml(version) {
|
||||
const current = fs.readFileSync(cargoTomlPath, "utf8")
|
||||
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
|
||||
const match = current.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||
fs.writeFileSync(cargoTomlPath, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncCargoLock(version) {
|
||||
if (!fs.existsSync(cargoLockPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const current = fs.readFileSync(cargoLockPath, "utf8")
|
||||
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
|
||||
const match = current.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||
fs.writeFileSync(cargoLockPath, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncTauriConfig(version) {
|
||||
const current = fs.readFileSync(tauriConfigPath, "utf8")
|
||||
const config = JSON.parse(current)
|
||||
if (config.version === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
config.version = version
|
||||
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
return true
|
||||
}
|
||||
|
||||
function main() {
|
||||
const version = readPackageVersion()
|
||||
const changed = []
|
||||
|
||||
if (syncCargoToml(version)) {
|
||||
changed.push(path.relative(root, cargoTomlPath))
|
||||
}
|
||||
|
||||
if (syncCargoLock(version)) {
|
||||
changed.push(path.relative(root, cargoLockPath))
|
||||
}
|
||||
|
||||
if (syncTauriConfig(version)) {
|
||||
changed.push(path.relative(root, tauriConfigPath))
|
||||
}
|
||||
|
||||
if (changed.length === 0) {
|
||||
console.log(`[sync-tauri-version] already aligned to ${version}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[sync-tauri-version] failed: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
version = "0.13.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
@@ -19,9 +19,13 @@ thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
|
||||
10
packages/tauri-app/src-tauri/Info.plist
Normal file
10
packages/tauri-app/src-tauri/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"opener:allow-open-url",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -2379,34 +2379,70 @@
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
|
||||
@@ -2379,34 +2379,70 @@
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
|
||||
@@ -9,18 +9,34 @@ use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn configure_spawn(command: &mut Command) {
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn configure_spawn(_command: &mut Command) {}
|
||||
|
||||
fn workspace_root() -> Option<PathBuf> {
|
||||
std::env::current_dir().ok().and_then(|mut dir| {
|
||||
for _ in 0..3 {
|
||||
@@ -32,10 +48,52 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
#[cfg(windows)]
|
||||
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn configure_posix_process_group(command: &mut Command) {
|
||||
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
||||
// processes (login shell/tsx) without leaving the server orphaned.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if libc::setpgid(0, 0) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
||||
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
||||
if force {
|
||||
args.push("/F".to_string());
|
||||
}
|
||||
|
||||
let mut command = Command::new("taskkill");
|
||||
command.args(&args);
|
||||
configure_spawn(&mut command);
|
||||
|
||||
match command.output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||
let combined = format!("{stdout}\n{stderr}");
|
||||
combined.contains("not found") || combined.contains("no running instance")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
let mut display = url.to_string();
|
||||
@@ -66,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||
fn exchange_bootstrap_token(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
cookie_name: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
@@ -101,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
for line in lines {
|
||||
// handle case-insensitive header name
|
||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
}
|
||||
@@ -114,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||
fn set_session_cookie(
|
||||
app: &AppHandle,
|
||||
base_url: &str,
|
||||
cookie_name: &str,
|
||||
session_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
||||
.domain(domain)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
@@ -132,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_auth_cookie_name() -> String {
|
||||
let pid = std::process::id();
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -140,9 +217,16 @@ struct PreferencesConfig {
|
||||
listening_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerConfig {
|
||||
#[serde(rename = "listeningMode")]
|
||||
listening_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppConfig {
|
||||
preferences: Option<PreferencesConfig>,
|
||||
server: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
||||
@@ -188,11 +272,18 @@ fn resolve_listening_mode() -> String {
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
let mode = config
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
{
|
||||
.and_then(|srv| srv.listening_mode.as_ref())
|
||||
.or_else(|| {
|
||||
config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
});
|
||||
|
||||
if let Some(mode) = mode {
|
||||
if mode == "local" {
|
||||
return "local".to_string();
|
||||
}
|
||||
@@ -206,11 +297,17 @@ fn resolve_listening_mode() -> String {
|
||||
// Legacy fallback.
|
||||
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
let mode = config
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
{
|
||||
.and_then(|srv| srv.listening_mode.as_ref())
|
||||
.or_else(|| {
|
||||
config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
});
|
||||
if let Some(mode) = mode {
|
||||
if mode == "local" {
|
||||
return "local".to_string();
|
||||
}
|
||||
@@ -326,13 +423,21 @@ impl CliProcessManager {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(windows)]
|
||||
let mut forced_tree_shutdown = false;
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
let pid = child.id() as i32;
|
||||
// Prefer signaling the process group to avoid orphaning children
|
||||
// when the CLI was launched via a wrapper shell.
|
||||
let group_res = libc::kill(-pid, libc::SIGTERM);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
let _ = kill_process_tree_windows(child.id(), false);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
@@ -340,6 +445,21 @@ impl CliProcessManager {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
#[cfg(windows)]
|
||||
if !forced_tree_shutdown
|
||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
||||
{
|
||||
log_line(&format!(
|
||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||
child.id()
|
||||
));
|
||||
forced_tree_shutdown = true;
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
log_line(&format!(
|
||||
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||
@@ -348,11 +468,21 @@ impl CliProcessManager {
|
||||
));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
let pid = child.id() as i32;
|
||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
if !forced_tree_shutdown
|
||||
&& !kill_process_tree_windows(child.id(), true)
|
||||
{
|
||||
let _ = child.kill();
|
||||
} else if forced_tree_shutdown {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -392,7 +522,8 @@ impl CliProcessManager {
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev, &host);
|
||||
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
||||
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
@@ -403,7 +534,9 @@ impl CliProcessManager {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let command_info = if supports_user_shell() {
|
||||
let use_user_shell = supports_user_shell();
|
||||
|
||||
let command_info = if use_user_shell {
|
||||
log_line("spawning via user shell");
|
||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||
} else {
|
||||
@@ -414,7 +547,7 @@ impl CliProcessManager {
|
||||
})
|
||||
};
|
||||
|
||||
if !supports_user_shell() {
|
||||
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."
|
||||
@@ -428,11 +561,16 @@ impl CliProcessManager {
|
||||
let mut c = Command::new(&cmd.shell);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.env_remove("npm_config_prefix")
|
||||
.env_remove("NPM_CONFIG_PREFIX")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
ShellCommandType::Direct(cmd) => {
|
||||
@@ -442,9 +580,12 @@ impl CliProcessManager {
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
};
|
||||
@@ -467,6 +608,7 @@ impl CliProcessManager {
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let token_clone = bootstrap_token.clone();
|
||||
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
@@ -481,24 +623,41 @@ impl CliProcessManager {
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -517,7 +676,24 @@ impl CliProcessManager {
|
||||
locked.error = Some("CLI did not start in time".to_string());
|
||||
log_line("timeout waiting for CLI readiness");
|
||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||
let _ = child.kill();
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
let pid = child.id() as i32;
|
||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
@@ -597,10 +773,10 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
loop {
|
||||
@@ -632,39 +808,17 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.map(|m| m.as_str().to_string())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
auth_cookie_name,
|
||||
url,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.to_lowercase().contains("http server listening") {
|
||||
if let Some(port) = http_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{port}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{}", port),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -677,6 +831,7 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
base_url: String,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
@@ -700,9 +855,11 @@ impl CliProcessManager {
|
||||
if scheme.as_deref() != Some("http") {
|
||||
navigate_main(app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
if let Err(err) =
|
||||
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
||||
{
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
@@ -798,24 +955,43 @@ impl CliEntry {
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--auth-cookie-name".to_string(),
|
||||
auth_cookie_name.to_string(),
|
||||
"--generate-token".to_string(),
|
||||
];
|
||||
|
||||
if dev {
|
||||
// Dev: plain HTTP + Vite dev server proxy.
|
||||
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("ELECTRON_RENDERER_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
||||
let log_level = std::env::var("CLI_LOG_LEVEL")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "info".to_string());
|
||||
|
||||
args.push("--https".to_string());
|
||||
args.push("false".to_string());
|
||||
args.push("--http".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--http-port".to_string());
|
||||
args.push("0".to_string());
|
||||
args.push("--ui-dev-server".to_string());
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push(ui_dev_server);
|
||||
args.push("--log-level".to_string());
|
||||
args.push("debug".to_string());
|
||||
args.push(log_level);
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https".to_string());
|
||||
@@ -842,27 +1018,50 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let mut candidates = vec![
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
std::env::current_exe().ok().and_then(|ex| {
|
||||
ex.parent()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||
}),
|
||||
];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
||||
}
|
||||
}
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("packages/server/src/index.ts")),
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
@@ -880,6 +1079,11 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
||||
|
||||
let resources = dir.join("../Resources");
|
||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||
@@ -959,11 +1163,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if shell_name.contains("zsh") {
|
||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||
} else {
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
let _ = shell_name;
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
|
||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
@@ -975,9 +1176,18 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn normalize_path(path: PathBuf) -> String {
|
||||
if let Ok(clean) = path.canonicalize() {
|
||||
clean.to_string_lossy().to_string()
|
||||
let resolved = if let Ok(clean) = path.canonicalize() {
|
||||
clean
|
||||
} else {
|
||||
path.to_string_lossy().to_string()
|
||||
path
|
||||
};
|
||||
|
||||
let rendered = resolved.to_string_lossy().to_string();
|
||||
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
|
||||
format!("\\\\{}", stripped)
|
||||
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
|
||||
stripped.to_string()
|
||||
} else {
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,63 @@
|
||||
mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry};
|
||||
use tauri_plugin_global_shortcut::{
|
||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
#[cfg(windows)]
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(windows)]
|
||||
use std::iter;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||
const ZOOM_STEP: f64 = 0.2;
|
||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub zoom_level: Mutex<f64>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RemoteWindowPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
base_url: String,
|
||||
skip_tls_verify: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct WakeLockConfig {
|
||||
display: bool,
|
||||
idle: bool,
|
||||
sleep: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -35,6 +78,38 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_start(
|
||||
state: tauri::State<AppState>,
|
||||
config: Option<WakeLockConfig>,
|
||||
) -> Result<(), String> {
|
||||
let config = config.unwrap_or(WakeLockConfig {
|
||||
display: true,
|
||||
idle: false,
|
||||
sleep: false,
|
||||
});
|
||||
|
||||
let mut builder = keepawake::Builder::default();
|
||||
builder
|
||||
.display(config.display)
|
||||
.idle(config.idle)
|
||||
.sleep(config.sleep)
|
||||
.reason("CodeNomad active session")
|
||||
.app_name("CodeNomad")
|
||||
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
||||
|
||||
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||
*state_lock = Some(wake_lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||
state_lock.take();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
@@ -46,16 +121,36 @@ fn should_allow_internal(url: &Url) -> bool {
|
||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||
// 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.
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
||||
"http" | "https" => matches!(
|
||||
url.host_str(),
|
||||
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
fn should_allow_window_origin<R: Runtime>(app_handle: &AppHandle<R>, window_label: &str, url: &Url) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let Ok(allowed) = state.remote_origins.lock() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(origin) = allowed.get(window_label) {
|
||||
return origin == &url.origin().ascii_serialization();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
let window_label = webview.label().to_string();
|
||||
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Err(err) = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
@@ -66,6 +161,179 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
|
||||
return Err(
|
||||
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
|
||||
let label = format!("remote-{}", payload.id);
|
||||
let title = format!("{} - {}", payload.name, parsed.host_str().unwrap_or(payload.base_url.as_str()));
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
let _ = existing.navigate(parsed.clone());
|
||||
let _ = existing.set_title(&title);
|
||||
let _ = existing.show();
|
||||
let _ = existing.unminimize();
|
||||
let _ = existing.set_focus();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.state::<AppState>()
|
||||
.remote_origins
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), parsed.origin().ascii_serialization());
|
||||
|
||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let app_handle = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||
origins.remove(&label);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(|path| match std::fs::metadata(path) {
|
||||
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
|
||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||
let _ = window.emit(event_name, ());
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_folder_drop_event(
|
||||
app_handle: &AppHandle,
|
||||
window_label: &str,
|
||||
event_name: &str,
|
||||
paths: &[std::path::PathBuf],
|
||||
) {
|
||||
let directories = collect_directory_paths(paths);
|
||||
|
||||
if directories.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||
let _ = window.emit(event_name, json!({ "paths": directories }));
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_zoom_level(value: f64) -> f64 {
|
||||
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
|
||||
}
|
||||
|
||||
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let normalized = clamp_zoom_level(next_zoom);
|
||||
if window.set_zoom(normalized).is_ok() {
|
||||
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
*zoom_level = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_main_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.reload();
|
||||
}
|
||||
}
|
||||
|
||||
fn force_reload_main_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if let Ok(mut url) = window.url() {
|
||||
if should_allow_internal(&url) {
|
||||
let reload_token = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.to_string();
|
||||
|
||||
let existing_pairs: Vec<(String, String)> = url
|
||||
.query_pairs()
|
||||
.into_owned()
|
||||
.filter(|(key, _)| key != "__codenomad_force_reload")
|
||||
.collect();
|
||||
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
pairs.clear();
|
||||
for (key, value) in existing_pairs {
|
||||
pairs.append_pair(&key, &value);
|
||||
}
|
||||
pairs.append_pair("__codenomad_force_reload", &reload_token);
|
||||
}
|
||||
|
||||
let _ = window.navigate(url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = window.reload();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_fullscreen_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
|
||||
let _ = window.set_fullscreen(next_fullscreen);
|
||||
if cfg!(not(target_os = "macos")) {
|
||||
if next_fullscreen {
|
||||
let _ = window.hide_menu();
|
||||
} else {
|
||||
let _ = window.show_menu();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fullscreen_shortcut() -> Option<Shortcut> {
|
||||
if cfg!(target_os = "macos") {
|
||||
None
|
||||
} else {
|
||||
Some(Shortcut::new(None, ShortcutCode::F11))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_windows_app_user_model_id() {
|
||||
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
||||
.encode_wide()
|
||||
.chain(iter::once(0))
|
||||
.collect();
|
||||
|
||||
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
||||
if result < 0 {
|
||||
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn set_windows_app_user_model_id() {}
|
||||
|
||||
fn main() {
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
@@ -74,14 +342,49 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, shortcut, event| {
|
||||
if event.state() != ShortcutState::Pressed {
|
||||
return;
|
||||
}
|
||||
|
||||
if fullscreen_shortcut().as_ref() == Some(shortcut) {
|
||||
toggle_fullscreen_window(app);
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
wake_lock: Mutex::new(None),
|
||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||
remote_origins: Mutex::new(HashMap::new()),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
build_menu(&app.handle())?;
|
||||
if let Some(shortcut) = fullscreen_shortcut() {
|
||||
let shortcut_manager = app.handle().global_shortcut();
|
||||
let _ = shortcut_manager.register(shortcut.clone());
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Focused(focused) = event {
|
||||
let shortcut_manager = app_handle.global_shortcut();
|
||||
if *focused {
|
||||
let _ = shortcut_manager.register(shortcut.clone());
|
||||
} else {
|
||||
let _ = shortcut_manager.unregister(shortcut.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let dev_mode = is_dev_mode();
|
||||
let app_handle = app.handle().clone();
|
||||
let manager = app.state::<AppState>().manager.clone();
|
||||
@@ -92,7 +395,13 @@ fn main() {
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop,
|
||||
open_remote_window
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
@@ -101,36 +410,42 @@ fn main() {
|
||||
let _ = window.emit("menu:newInstance", ());
|
||||
}
|
||||
}
|
||||
"close" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
// View menu
|
||||
"reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload()");
|
||||
}
|
||||
reload_main_window(app_handle);
|
||||
}
|
||||
"force_reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload(true)");
|
||||
}
|
||||
force_reload_main_window(app_handle);
|
||||
}
|
||||
"toggle_devtools" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
}
|
||||
"reset_zoom" => {
|
||||
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
|
||||
}
|
||||
"zoom_in" => {
|
||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
|
||||
}
|
||||
}
|
||||
"zoom_out" => {
|
||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
|
||||
}
|
||||
}
|
||||
|
||||
"toggle_fullscreen" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
||||
}
|
||||
toggle_fullscreen_window(app_handle);
|
||||
}
|
||||
|
||||
// Window menu
|
||||
@@ -144,6 +459,11 @@ fn main() {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
"close_window" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
|
||||
// App menu (macOS)
|
||||
"about" => {
|
||||
@@ -187,15 +507,49 @@ fn main() {
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
|
||||
..
|
||||
} => {
|
||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
|
||||
..
|
||||
} => {
|
||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
|
||||
..
|
||||
} => {
|
||||
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} => {
|
||||
// Ensure we have time to stop the CLI process before the app exits.
|
||||
// Let windows close normally. App shutdown is handled only after the
|
||||
// last window is actually gone so remote windows can outlive `main`.
|
||||
let _ = api;
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
..
|
||||
} => {
|
||||
if !app_handle.webview_windows().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the CLI only when the final window is gone and the app is
|
||||
// truly exiting.
|
||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
api.prevent_close();
|
||||
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
@@ -210,6 +564,7 @@ fn main() {
|
||||
|
||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let is_mac = cfg!(target_os = "macos");
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
|
||||
// Create submenus
|
||||
let mut submenus = Vec::new();
|
||||
@@ -234,16 +589,77 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
"new_instance",
|
||||
"New Instance",
|
||||
true,
|
||||
Some("CmdOrCtrl+N")
|
||||
Some("CmdOrCtrl+N"),
|
||||
)?;
|
||||
|
||||
let file_menu = SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
||||
.build()?;
|
||||
|
||||
let file_menu = if is_mac {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.close_window()
|
||||
.build()?
|
||||
} else {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text("quit", "Quit")
|
||||
.build()?
|
||||
};
|
||||
submenus.push(file_menu);
|
||||
|
||||
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
|
||||
let force_reload_item = MenuItem::with_id(
|
||||
app,
|
||||
"force_reload",
|
||||
"Force Reload",
|
||||
true,
|
||||
Some("CmdOrCtrl+Shift+R"),
|
||||
)?;
|
||||
let toggle_devtools_item = MenuItem::with_id(
|
||||
app,
|
||||
"toggle_devtools",
|
||||
"Toggle Developer Tools",
|
||||
true,
|
||||
Some("Alt+CmdOrCtrl+I"),
|
||||
)?;
|
||||
let reset_zoom_item =
|
||||
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
|
||||
let zoom_in_item = MenuItem::with_id(
|
||||
app,
|
||||
"zoom_in",
|
||||
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let zoom_out_item = MenuItem::with_id(
|
||||
app,
|
||||
"zoom_out",
|
||||
if is_mac {
|
||||
"Zoom Out"
|
||||
} else {
|
||||
"Zoom Out\tCtrl+-"
|
||||
},
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle_fullscreen_item = MenuItem::with_id(
|
||||
app,
|
||||
"toggle_fullscreen",
|
||||
if is_mac {
|
||||
"Toggle Full Screen"
|
||||
} else {
|
||||
"Toggle Full Screen\tF11"
|
||||
},
|
||||
true,
|
||||
if is_mac {
|
||||
Some("Ctrl+Cmd+F")
|
||||
} else {
|
||||
None::<&str>
|
||||
},
|
||||
)?;
|
||||
let close_window_item =
|
||||
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
|
||||
|
||||
// Edit menu with predefined items for standard functionality
|
||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
@@ -259,27 +675,48 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
|
||||
// View menu
|
||||
let view_menu = SubmenuBuilder::new(app, "View")
|
||||
.text("reload", "Reload")
|
||||
.text("force_reload", "Force Reload")
|
||||
.text("toggle_devtools", "Toggle Developer Tools")
|
||||
.item(&reload_item)
|
||||
.item(&force_reload_item)
|
||||
.item(&toggle_devtools_item)
|
||||
.separator()
|
||||
|
||||
.item(&reset_zoom_item)
|
||||
.item(&zoom_in_item)
|
||||
.item(&zoom_out_item)
|
||||
.separator()
|
||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||
.item(&toggle_fullscreen_item)
|
||||
.build()?;
|
||||
submenus.push(view_menu);
|
||||
|
||||
// Window menu
|
||||
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||
.text("minimize", "Minimize")
|
||||
.text("zoom", "Zoom")
|
||||
.build()?;
|
||||
let window_menu = if is_linux {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.text("minimize", "Minimize")
|
||||
.text("zoom", "Zoom")
|
||||
.separator()
|
||||
.item(&close_window_item)
|
||||
.build()?
|
||||
} else if is_mac {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.minimize()
|
||||
.maximize()
|
||||
.build()?
|
||||
} else {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.minimize()
|
||||
.maximize()
|
||||
.separator()
|
||||
.close_window()
|
||||
.build()?
|
||||
};
|
||||
submenus.push(window_menu);
|
||||
|
||||
// Build the main menu with all submenus
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
||||
.iter()
|
||||
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
||||
.collect();
|
||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||
|
||||
|
||||
app.set_menu(menu)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"version": "0.13.3",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
@@ -33,9 +30,13 @@
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["**"]
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"capabilities": ["main-window-native-dialogs"]
|
||||
"capabilities": [
|
||||
"main-window-native-dialogs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -44,7 +45,17 @@
|
||||
"resources/server",
|
||||
"resources/ui-loading"
|
||||
],
|
||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||
"icon": [
|
||||
"icon.icns",
|
||||
"icon.ico",
|
||||
"icon.png"
|
||||
],
|
||||
"targets": [
|
||||
"app",
|
||||
"appimage",
|
||||
"deb",
|
||||
"rpm",
|
||||
"nsis"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.10.3",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -13,13 +13,15 @@
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.1.11",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
@@ -30,7 +32,8 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Toaster } from "solid-toast"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import { Minimize2 } from "lucide-solid"
|
||||
import AlertDialog from "./components/alert-dialog"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import { showConfirmDialog } from "./stores/alerts"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { SettingsScreen } from "./components/settings-screen"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
||||
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
@@ -50,14 +52,15 @@ import {
|
||||
} from "./stores/sessions"
|
||||
|
||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||
import { openSettings } from "./stores/settings-screen"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
serverSettings,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
@@ -65,22 +68,56 @@ const App: Component = () => {
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
setToolInputsVisibility,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
interface LaunchErrorState {
|
||||
message: string
|
||||
binaryPath: string
|
||||
missingBinary: boolean
|
||||
}
|
||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||
|
||||
// In-memory only: hides chrome on phone; may also request browser fullscreen.
|
||||
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
|
||||
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
|
||||
|
||||
const fullscreenSupported = () => {
|
||||
if (typeof document === "undefined") return false
|
||||
const el = document.documentElement as any
|
||||
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
|
||||
}
|
||||
|
||||
const syncBrowserFullscreenState = () => {
|
||||
if (typeof document === "undefined") return
|
||||
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
|
||||
}
|
||||
|
||||
const enterMobileFullscreen = async () => {
|
||||
if (!isPhoneLayout()) return
|
||||
setMobileFullscreenMode(true)
|
||||
if (!fullscreenSupported()) return
|
||||
try {
|
||||
await document.documentElement.requestFullscreen()
|
||||
} catch {
|
||||
// Ignore: immersive mode still works without browser fullscreen.
|
||||
}
|
||||
}
|
||||
|
||||
const exitMobileFullscreen = async () => {
|
||||
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
|
||||
try {
|
||||
await document.exitFullscreen()
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
setMobileFullscreenMode(false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const shouldShow =
|
||||
@@ -94,8 +131,54 @@ const App: Component = () => {
|
||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof document === "undefined") return
|
||||
syncBrowserFullscreenState()
|
||||
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
|
||||
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
|
||||
const updateKeyboardOffset = () => {
|
||||
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
|
||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
|
||||
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
|
||||
}
|
||||
|
||||
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
|
||||
schedule()
|
||||
vv.addEventListener("resize", schedule)
|
||||
vv.addEventListener("scroll", schedule)
|
||||
window.addEventListener("orientationchange", schedule)
|
||||
|
||||
onCleanup(() => {
|
||||
vv.removeEventListener("resize", schedule)
|
||||
vv.removeEventListener("scroll", schedule)
|
||||
window.removeEventListener("orientationchange", schedule)
|
||||
document.documentElement.style.removeProperty("--keyboard-offset")
|
||||
})
|
||||
})
|
||||
|
||||
// If the user exits browser fullscreen via browser UI, restore chrome.
|
||||
let lastBrowserFullscreen = false
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
const active = browserFullscreenActive()
|
||||
const mode = mobileFullscreenMode()
|
||||
if (mode && lastBrowserFullscreen && !active) {
|
||||
setMobileFullscreenMode(false)
|
||||
}
|
||||
lastBrowserFullscreen = active
|
||||
})
|
||||
|
||||
// If we leave phone layout (rotation / resize), restore chrome.
|
||||
createEffect(() => {
|
||||
if (!isPhoneLayout() && mobileFullscreenMode()) {
|
||||
void exitMobileFullscreen()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -151,60 +234,26 @@ const App: Component = () => {
|
||||
|
||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||
|
||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||
if (!error) {
|
||||
return t("app.launchError.fallbackMessage")
|
||||
}
|
||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.error === "string") {
|
||||
return parsed.error
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const isMissingBinaryMessage = (message: string): boolean => {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("opencode binary not found") ||
|
||||
normalized.includes("binary not found") ||
|
||||
normalized.includes("no such file or directory") ||
|
||||
normalized.includes("binary is not executable") ||
|
||||
normalized.includes("enoent")
|
||||
)
|
||||
}
|
||||
|
||||
const clearLaunchError = () => setLaunchError(null)
|
||||
|
||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||
if (!folderPath) {
|
||||
return
|
||||
}
|
||||
setIsSelectingFolder(true)
|
||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
||||
try {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
instanceId,
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = formatLaunchErrorMessage(error)
|
||||
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
setLaunchError({
|
||||
message,
|
||||
binaryPath: selectedBinary,
|
||||
missingBinary,
|
||||
})
|
||||
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
@@ -217,7 +266,7 @@ const App: Component = () => {
|
||||
|
||||
function handleLaunchErrorAdvanced() {
|
||||
clearLaunchError()
|
||||
setIsAdvancedSettingsOpen(true)
|
||||
openSettings("opencode")
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
@@ -305,10 +354,12 @@ const App: Component = () => {
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
setToolInputsVisibility,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
@@ -404,37 +455,61 @@ const App: Component = () => {
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
<div class="h-screen w-screen flex flex-col">
|
||||
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
|
||||
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
|
||||
<div class="mobile-fullscreen-exit-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button mobile-fullscreen-exit-button"
|
||||
onClick={() => void exitMobileFullscreen()}
|
||||
aria-label={t("instanceShell.fullscreen.exit")}
|
||||
title={t("instanceShell.fullscreen.exit")}
|
||||
>
|
||||
<Minimize2 class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!hasInstances()}
|
||||
fallback={
|
||||
<>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={instanceTabBarHeight()}
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-instance-id={instance.id}
|
||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||
data-instance-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
isActiveInstance={isActiveInstance()}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
@@ -450,41 +525,25 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={showFolderSelection()}>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||
<div class="w-full h-full relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
clearLaunchError()
|
||||
}}
|
||||
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={t("app.launchError.closeTitle")}
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
onClose={() => {
|
||||
setShowFolderSelection(false)
|
||||
clearLaunchError()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||
<SettingsScreen />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
|
||||
@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
const availableAgents = createMemo(() => {
|
||||
const allAgents = instanceAgents()
|
||||
if (isChildSession()) {
|
||||
return allAgents
|
||||
return allAgents.filter((agent) => !agent.hidden)
|
||||
}
|
||||
|
||||
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
|
||||
const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent")
|
||||
|
||||
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
|
||||
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
|
||||
@@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
{() => (
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
|
||||
{t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -108,42 +108,43 @@ const AlertDialog: Component = () => {
|
||||
open
|
||||
modal
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Only handle dismiss if dialog is dismissible (default: true)
|
||||
if (!open && payload.dismissible !== false) {
|
||||
dismiss(false, payload)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
"background-color": accent.badgeBg,
|
||||
"border-color": accent.badgeBorder,
|
||||
color: accent.badgeText,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||
{payload.message}
|
||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Overlay class="modal-overlay z-[60]" />
|
||||
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
"background-color": accent.badgeBg,
|
||||
"border-color": accent.badgeBorder,
|
||||
color: accent.badgeText,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||
{payload.message}
|
||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-medium text-secondary">
|
||||
<label for="prompt-input" class="text-sm font-medium text-secondary">
|
||||
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="prompt-input"
|
||||
ref={(el) => {
|
||||
promptInputRef = el
|
||||
}}
|
||||
@@ -184,15 +185,14 @@ const AlertDialog: Component = () => {
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertDialog
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
import { getSharedHighlighter } from "../lib/markdown"
|
||||
import { escapeHtml } from "../lib/text-render-utils"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
|
||||
123
packages/ui/src/components/context-meter.tsx
Normal file
123
packages/ui/src/components/context-meter.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
interface ContextMeterProps {
|
||||
usedTokens: number
|
||||
availableTokens: number | null
|
||||
formatTokens: (value: number) => string
|
||||
usedLabel: string
|
||||
availableLabel: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function resolveFillColor(percent: number): string {
|
||||
if (percent >= 0.8) return "var(--status-error)"
|
||||
if (percent >= 0.6) return "var(--status-warning)"
|
||||
return "var(--status-success)"
|
||||
}
|
||||
|
||||
export const ContextMeter: Component<ContextMeterProps> = (props) => {
|
||||
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
|
||||
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
|
||||
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
|
||||
|
||||
const percent = () => {
|
||||
const usedValue = used()
|
||||
const availableValue = available()
|
||||
if (availableValue === null || availableValue <= 0) return null
|
||||
|
||||
// Heuristic: if available >= used, treat it like a capacity/limit.
|
||||
// Otherwise treat it like remaining tokens.
|
||||
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
|
||||
return clamp(ratio, 0, 1)
|
||||
}
|
||||
|
||||
const fillColor = () => {
|
||||
const value = percent()
|
||||
if (value === null) return "var(--border-base)"
|
||||
return resolveFillColor(value)
|
||||
}
|
||||
|
||||
const percentLabel = () => {
|
||||
const value = percent()
|
||||
if (value === null) return "--"
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
|
||||
const containerClass =
|
||||
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
|
||||
|
||||
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
|
||||
const rad = (angleDeg * Math.PI) / 180
|
||||
return {
|
||||
x: cx + r * Math.cos(rad),
|
||||
y: cy + r * Math.sin(rad),
|
||||
}
|
||||
}
|
||||
|
||||
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
|
||||
const start = polarToCartesian(cx, cy, r, startAngle)
|
||||
const end = polarToCartesian(cx, cy, r, endAngle)
|
||||
const delta = ((endAngle - startAngle) % 360 + 360) % 360
|
||||
const largeArc = delta > 180 ? 1 : 0
|
||||
|
||||
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
||||
}
|
||||
|
||||
const circle = () => {
|
||||
const value = percent()
|
||||
const size = 22
|
||||
const r = 9
|
||||
const cx = 11
|
||||
const cy = 11
|
||||
const progress = value === null ? 0 : value
|
||||
const startAngle = -90
|
||||
const endAngle = startAngle + progress * 360
|
||||
const isFull = progress >= 0.999
|
||||
const hasFill = progress > 0.001
|
||||
|
||||
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 22 22"
|
||||
aria-hidden="true"
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
|
||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
|
||||
{isFull ? (
|
||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
|
||||
) : sectorPath ? (
|
||||
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
|
||||
) : null}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipText = () => `Context Used: ${percentLabel()}`
|
||||
|
||||
return (
|
||||
<div class="inline-flex items-center gap-2" title={tooltipText()}>
|
||||
{circle()}
|
||||
<div class={containerClass}>
|
||||
<span class={LABEL_CLASS}>{props.usedLabel}</span>
|
||||
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
|
||||
<span class="text-muted">/</span>
|
||||
<span class={LABEL_CLASS}>{props.availableLabel}</span>
|
||||
<span class="font-semibold text-primary tabular-nums">
|
||||
{available() !== null ? props.formatTokens(available() as number) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContextMeter
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { createMemo, Show, createEffect } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
import { disableCache } from "@git-diff-view/core"
|
||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { ErrorBoundary } from "solid-js"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { getLanguageFromPath } from "../lib/text-render-utils"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
@@ -19,6 +20,7 @@ interface ToolCallDiffViewerProps {
|
||||
filePath?: string
|
||||
theme: "light" | "dark"
|
||||
mode: DiffViewMode
|
||||
wrap?: boolean
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
@@ -30,11 +32,183 @@ type DiffData = {
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
type CaptureContext = {
|
||||
theme: ToolCallDiffViewerProps["theme"]
|
||||
mode: DiffViewMode
|
||||
diffText: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
|
||||
const computed = window.getComputedStyle(source)
|
||||
const probe = document.createElement("span")
|
||||
probe.textContent = text || ""
|
||||
probe.style.position = "absolute"
|
||||
probe.style.visibility = "hidden"
|
||||
probe.style.pointerEvents = "none"
|
||||
probe.style.display = "inline-block"
|
||||
probe.style.width = "auto"
|
||||
probe.style.maxWidth = "none"
|
||||
probe.style.whiteSpace = "nowrap"
|
||||
probe.style.fontFamily = computed.fontFamily
|
||||
probe.style.fontSize = computed.fontSize
|
||||
probe.style.fontWeight = computed.fontWeight
|
||||
probe.style.fontStyle = computed.fontStyle
|
||||
probe.style.letterSpacing = computed.letterSpacing
|
||||
probe.style.fontVariant = computed.fontVariant
|
||||
probe.style.textTransform = computed.textTransform
|
||||
probe.style.lineHeight = computed.lineHeight
|
||||
container.appendChild(probe)
|
||||
const width = Math.ceil(probe.getBoundingClientRect().width)
|
||||
probe.remove()
|
||||
return width
|
||||
}
|
||||
|
||||
function computeCompactWidth(
|
||||
container: HTMLElement,
|
||||
entries: Array<{ text: string; source: HTMLElement }>,
|
||||
maxWidthPx = 40,
|
||||
) {
|
||||
const measuredLabelWidthPx = entries.reduce((max, entry) => {
|
||||
return Math.max(max, measureTextWidth(container, entry.text, entry.source))
|
||||
}, 0)
|
||||
const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1)
|
||||
const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4)
|
||||
return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx))
|
||||
}
|
||||
|
||||
function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) {
|
||||
const tableWrapper = container.querySelector<HTMLElement>(".unified-diff-table-wrapper")
|
||||
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
|
||||
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
|
||||
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
|
||||
const hunkGutters = container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")
|
||||
const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = []
|
||||
|
||||
if (table) {
|
||||
if (wrap) {
|
||||
table.classList.add("table-fixed")
|
||||
table.style.tableLayout = "fixed"
|
||||
table.style.width = "100%"
|
||||
table.style.minWidth = "100%"
|
||||
} else {
|
||||
table.classList.remove("table-fixed")
|
||||
table.style.tableLayout = "auto"
|
||||
table.style.width = "max-content"
|
||||
table.style.minWidth = "100%"
|
||||
}
|
||||
}
|
||||
|
||||
gutterRows.forEach((gutter) => {
|
||||
const oldSpan = gutter.querySelector<HTMLElement>("[data-line-old-num]")
|
||||
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
|
||||
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
|
||||
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
|
||||
const currentLabel = gutter.querySelector<HTMLElement>(":scope > .tool-call-diff-compact-line-number")
|
||||
|
||||
const oldText = oldSpan?.textContent?.trim() ?? ""
|
||||
const newText = newSpan?.textContent?.trim() ?? ""
|
||||
const hasUsableNew = newText.length > 0 && newText !== "0"
|
||||
const hasUsableOld = oldText.length > 0 && oldText !== "0"
|
||||
const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText
|
||||
|
||||
if (flexWrapper) flexWrapper.style.display = "none"
|
||||
if (spacer) spacer.style.display = "none"
|
||||
if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" }
|
||||
if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" }
|
||||
|
||||
gutter.style.paddingLeft = "1px"
|
||||
gutter.style.paddingRight = "1px"
|
||||
gutter.style.textAlign = "left"
|
||||
|
||||
const label = currentLabel ?? document.createElement("span")
|
||||
label.className = "tool-call-diff-compact-line-number"
|
||||
label.textContent = visibleText
|
||||
label.setAttribute("aria-hidden", visibleText ? "false" : "true")
|
||||
if (!currentLabel) gutter.appendChild(label)
|
||||
|
||||
entries.push({ gutter, label, text: visibleText })
|
||||
})
|
||||
|
||||
const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label })))
|
||||
const gutterWidth = `${gutterWidthPx}px`
|
||||
const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px`
|
||||
|
||||
if (tableWrapper) {
|
||||
tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth)
|
||||
tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth)
|
||||
}
|
||||
if (numberCol) {
|
||||
numberCol.style.width = gutterWidth
|
||||
}
|
||||
|
||||
entries.forEach(({ gutter, label }) => {
|
||||
gutter.style.width = gutterWidth
|
||||
gutter.style.minWidth = gutterWidth
|
||||
gutter.style.maxWidth = gutterWidth
|
||||
label.style.width = "auto"
|
||||
label.style.maxWidth = "none"
|
||||
})
|
||||
|
||||
hunkGutters.forEach((gutter) => {
|
||||
gutter.style.width = gutterWidth
|
||||
gutter.style.minWidth = gutterWidth
|
||||
gutter.style.maxWidth = gutterWidth
|
||||
gutter.style.paddingLeft = "0"
|
||||
gutter.style.paddingRight = "0"
|
||||
})
|
||||
}
|
||||
|
||||
function applyCompactSplitGutter(container: HTMLElement) {
|
||||
const oldWrapper = container.querySelector<HTMLElement>(".old-diff-table-wrapper")
|
||||
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
|
||||
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
|
||||
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
|
||||
const numberSpans = numberCells
|
||||
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[data-line-num]") }))
|
||||
.filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span))
|
||||
|
||||
const gutterWidthPx = computeCompactWidth(
|
||||
container,
|
||||
numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })),
|
||||
64,
|
||||
)
|
||||
const gutterWidth = `${gutterWidthPx}px`
|
||||
|
||||
;[oldWrapper, newWrapper].forEach((wrapper) => {
|
||||
if (wrapper) {
|
||||
wrapper.style.setProperty("--diff-aside-width", gutterWidth)
|
||||
}
|
||||
})
|
||||
|
||||
numberCells.forEach((cell) => {
|
||||
cell.style.width = gutterWidth
|
||||
cell.style.minWidth = gutterWidth
|
||||
cell.style.maxWidth = gutterWidth
|
||||
cell.style.paddingLeft = "2px"
|
||||
cell.style.paddingRight = "2px"
|
||||
cell.style.textAlign = "left"
|
||||
cell.style.whiteSpace = "nowrap"
|
||||
cell.style.overflowWrap = "normal"
|
||||
cell.style.wordBreak = "normal"
|
||||
})
|
||||
|
||||
numberSpans.forEach(({ span }) => {
|
||||
span.style.whiteSpace = "nowrap"
|
||||
span.style.overflowWrap = "normal"
|
||||
span.style.wordBreak = "normal"
|
||||
})
|
||||
|
||||
hunkActions.forEach((cell) => {
|
||||
cell.style.width = gutterWidth
|
||||
cell.style.minWidth = gutterWidth
|
||||
cell.style.maxWidth = gutterWidth
|
||||
cell.style.paddingLeft = "0"
|
||||
cell.style.paddingRight = "0"
|
||||
})
|
||||
}
|
||||
|
||||
function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) {
|
||||
if (mode === "unified") {
|
||||
applyCompactUnifiedGutter(container, wrap)
|
||||
return
|
||||
}
|
||||
if (mode === "split") {
|
||||
applyCompactSplitGutter(container)
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
@@ -66,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
const contextKey = createMemo(() => {
|
||||
const data = diffData()
|
||||
if (!data) return ""
|
||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
||||
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const cachedHtml = props.cachedHtml
|
||||
if (cachedHtml) {
|
||||
if (diffContainerRef) {
|
||||
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||
}
|
||||
// When we are given cached HTML, we rely on the caller's cache
|
||||
// and simply notify once rendered.
|
||||
props.onRendered?.()
|
||||
@@ -82,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
if (!key) return
|
||||
if (!diffContainerRef) return
|
||||
if (lastCapturedKey === key) return
|
||||
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!diffContainerRef) return
|
||||
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||
const markup = diffContainerRef.innerHTML
|
||||
if (!markup) return
|
||||
lastCapturedKey = key
|
||||
@@ -94,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
html: markup,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
wrap: props.wrap,
|
||||
})
|
||||
}
|
||||
props.onRendered?.()
|
||||
@@ -121,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewWrap={Boolean(props.wrap)}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
@@ -130,8 +309,8 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div innerHTML={props.cachedHtml} />
|
||||
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
|
||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
serverSettings,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateEnvironmentVariables,
|
||||
} = useConfig()
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
|
||||
const [newKey, setNewKey] = createSignal("")
|
||||
const [newValue, setNewValue] = createSignal("")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ interface MonacoDiffViewerProps {
|
||||
after: string
|
||||
viewMode?: "split" | "unified"
|
||||
contextMode?: "expanded" | "collapsed"
|
||||
wordWrap?: "on" | "off"
|
||||
}
|
||||
|
||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
@@ -54,12 +55,17 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: "selection",
|
||||
fontSize: 13,
|
||||
wordWrap: "off",
|
||||
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||
lineNumbersMinChars: 4,
|
||||
lineDecorationsWidth: 12,
|
||||
// Use legacy diff algorithm for better performance with large files
|
||||
// See: https://github.com/microsoft/vscode/issues/184037
|
||||
diffAlgorithm: "legacy",
|
||||
// Limit computation time to avoid freezing on large files
|
||||
maxComputationTime: 10000,
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
@@ -81,6 +87,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: viewMode === "split",
|
||||
@@ -89,7 +96,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
contextMode === "collapsed"
|
||||
? { enabled: true }
|
||||
: { enabled: false },
|
||||
wordWrap,
|
||||
})
|
||||
|
||||
try {
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -9,6 +9,8 @@ interface MonacoFileViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
content: string
|
||||
onSave?: (content: string) => void
|
||||
onContentChange?: (content: string) => void
|
||||
}
|
||||
|
||||
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
@@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
editor = null
|
||||
}
|
||||
|
||||
const saveContent = () => {
|
||||
if (!editor || !props.onSave) return
|
||||
props.onSave(editor.getValue())
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
editor = monaco.editor.create(host, {
|
||||
value: "",
|
||||
language: "plaintext",
|
||||
readOnly: true,
|
||||
readOnly: false,
|
||||
automaticLayout: true,
|
||||
lineNumbers: "on",
|
||||
minimap: { enabled: false },
|
||||
@@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
fontSize: 13,
|
||||
})
|
||||
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
if (props.onContentChange) {
|
||||
props.onContentChange(editor.getValue())
|
||||
}
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
})()
|
||||
|
||||
|
||||
@@ -1,37 +1,61 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Select } from "@kobalte/core/select"
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
import { useI18n, type Locale } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
import { openExternalUrl } from "../lib/external-url"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
|
||||
type HomeTab = "local" | "servers"
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
onAdvancedSettingsClose?: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
|
||||
const {
|
||||
recentFolders,
|
||||
removeRecentFolder,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
serverSettings,
|
||||
remoteServers,
|
||||
saveRemoteServerProfile,
|
||||
markRemoteServerConnected,
|
||||
removeRemoteServerProfile,
|
||||
} = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
|
||||
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
|
||||
const [serverName, setServerName] = createSignal("")
|
||||
const [serverUrl, setServerUrl] = createSignal("")
|
||||
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
|
||||
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -44,16 +68,22 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "zh-Hans", label: "简体中文" },
|
||||
{ value: "he", label: "עברית" },
|
||||
]
|
||||
|
||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||
|
||||
|
||||
const folders = () => recentFolders()
|
||||
const serverList = () => remoteServers()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
|
||||
function getActiveListLength() {
|
||||
return activeTab() === "local" ? folders().length : serverList().length
|
||||
}
|
||||
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
const lastUsed = preferences().lastUsedBinary
|
||||
const lastUsed = serverSettings().opencodeBinary
|
||||
if (!lastUsed) return
|
||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||
})
|
||||
@@ -62,7 +92,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
function scrollToIndex(index: number) {
|
||||
const container = recentListRef
|
||||
if (!container) return
|
||||
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
|
||||
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
|
||||
if (!element) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
@@ -111,19 +141,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
const folderList = folders()
|
||||
|
||||
if (isBrowseShortcut) {
|
||||
e.preventDefault()
|
||||
void handleBrowse()
|
||||
return
|
||||
}
|
||||
|
||||
if (folderList.length === 0) return
|
||||
const listLength = getActiveListLength()
|
||||
if (listLength === 0) return
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
||||
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -136,7 +165,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
} else if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -154,7 +183,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = folderList.length - 1
|
||||
const newIndex = listLength - 1
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -163,10 +192,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
handleEnterKey()
|
||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault()
|
||||
if (folderList.length > 0 && focusMode() === "recent") {
|
||||
const folder = folderList[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,15 +211,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
function handleEnterKey() {
|
||||
if (isLoading()) return
|
||||
const folderList = folders()
|
||||
const index = selectedIndex()
|
||||
|
||||
const folder = folderList[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
if (activeTab() === "local") {
|
||||
const folder = folders()[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const server = serverList()[index]
|
||||
if (server) {
|
||||
void handleConnectSavedServer(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
activeTab()
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("recent")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const length = getActiveListLength()
|
||||
if (length === 0) {
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedIndex() >= length) {
|
||||
setSelectedIndex(length - 1)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
@@ -192,6 +253,31 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
})
|
||||
})
|
||||
|
||||
function dropTargetBlocked() {
|
||||
return isLoading() || isFolderBrowserOpen() || settingsOpen()
|
||||
}
|
||||
|
||||
function showInvalidFolderDropAlert() {
|
||||
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
|
||||
title: t("folderSelection.drop.invalidTitle"),
|
||||
variant: "warning",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const folderDrop = useFolderDrop({
|
||||
enabled: () => !dropTargetBlocked(),
|
||||
onInvalidDrop: showInvalidFolderDropAlert,
|
||||
onDrop: async (paths) => {
|
||||
const firstPath = paths[0]
|
||||
if (!firstPath) {
|
||||
showInvalidFolderDropAlert()
|
||||
return
|
||||
}
|
||||
handleFolderSelect(firstPath)
|
||||
},
|
||||
})
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -209,11 +295,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
function resetServerDialog() {
|
||||
setServerName("")
|
||||
setServerUrl("")
|
||||
setSkipTlsVerify(false)
|
||||
setServerDialogError(null)
|
||||
}
|
||||
|
||||
|
||||
function openServerDialog() {
|
||||
resetServerDialog()
|
||||
setIsServerDialogOpen(true)
|
||||
}
|
||||
|
||||
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||
const trimmedName = input.name.trim()
|
||||
const trimmedUrl = input.baseUrl.trim()
|
||||
if (!trimmedName || !trimmedUrl) {
|
||||
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
|
||||
}
|
||||
|
||||
const probe = await serverApi.probeRemoteServer({
|
||||
baseUrl: trimmedUrl,
|
||||
skipTlsVerify: input.skipTlsVerify,
|
||||
})
|
||||
|
||||
if (!probe.ok) {
|
||||
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
|
||||
}
|
||||
|
||||
const profile = await saveRemoteServerProfile({
|
||||
id: input.id,
|
||||
name: trimmedName,
|
||||
baseUrl: probe.normalizedUrl,
|
||||
skipTlsVerify: input.skipTlsVerify,
|
||||
})
|
||||
|
||||
if (openWindow) {
|
||||
await openRemoteServerWindow(profile)
|
||||
await markRemoteServerConnected(profile.id)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
async function handleSaveServer(openWindow: boolean) {
|
||||
if (isSavingServer()) return
|
||||
setIsSavingServer(true)
|
||||
setServerDialogError(null)
|
||||
try {
|
||||
await probeAndOpenServer(
|
||||
{
|
||||
name: serverName(),
|
||||
baseUrl: serverUrl(),
|
||||
skipTlsVerify: skipTlsVerify(),
|
||||
},
|
||||
openWindow,
|
||||
)
|
||||
setIsServerDialogOpen(false)
|
||||
resetServerDialog()
|
||||
} catch (error) {
|
||||
setServerDialogError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setIsSavingServer(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnectSavedServer(id: string) {
|
||||
const target = remoteServers().find((entry) => entry.id === id)
|
||||
if (!target || connectingServerId()) return
|
||||
setConnectingServerId(id)
|
||||
try {
|
||||
await probeAndOpenServer(target, true)
|
||||
} catch (error) {
|
||||
showAlertDialog(error instanceof Error ? error.message : String(error), {
|
||||
title: t("folderSelection.servers.errorTitle"),
|
||||
variant: "warning",
|
||||
})
|
||||
} finally {
|
||||
setConnectingServerId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
@@ -236,11 +398,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
handleFolderSelect(path)
|
||||
}
|
||||
|
||||
function handleBinaryChange(binary: string) {
|
||||
|
||||
setSelectedBinary(binary)
|
||||
}
|
||||
|
||||
function handleRemove(path: string, e?: Event) {
|
||||
if (isLoading()) return
|
||||
e?.stopPropagation()
|
||||
@@ -316,12 +473,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div
|
||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||
style="background-color: var(--surface-secondary)"
|
||||
onDragEnter={folderDrop.bind.onDragEnter}
|
||||
onDragOver={folderDrop.bind.onDragOver}
|
||||
onDragLeave={folderDrop.bind.onDragLeave}
|
||||
onDrop={folderDrop.bind.onDrop}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<div class="absolute top-4 left-6">
|
||||
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
|
||||
<Select<LanguageOption>
|
||||
value={selectedLanguageOption()}
|
||||
onChange={(value) => {
|
||||
@@ -365,15 +526,34 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => openSettings("appearance")}
|
||||
aria-label={t("settings.open.title")}
|
||||
title={t("settings.open.title")}
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => openSettings("remote")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
<Show when={props.onClose}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
onClick={() => props.onClose?.()}
|
||||
aria-label={t("app.launchError.close")}
|
||||
title={t("app.launchError.closeTitle")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -384,7 +564,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -392,21 +572,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.github")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label={t("folderSelection.links.githubStars")}
|
||||
title={t("folderSelection.links.githubStars")}
|
||||
title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
@@ -415,7 +595,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -423,9 +603,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.discord")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
@@ -438,90 +616,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||
{/* Right column: recent folders */}
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
||||
<p class="panel-subtitle">
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "local",
|
||||
"text-muted hover:text-secondary": activeTab() !== "local",
|
||||
}}
|
||||
style={{
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("local")}
|
||||
>
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
{t("folderSelection.recent.title")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"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={{
|
||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={activeTab() === "local"}
|
||||
fallback={
|
||||
<Show
|
||||
when={remoteServers().length > 0}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<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
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
type="button"
|
||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||
onClick={openServerDialog}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={remoteServers()}>
|
||||
{(server, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-list-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
onClick={() => void handleConnectSavedServer(server.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd">↵</kbd></Show>}>
|
||||
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeRemoteServerProfile(server.id)}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.servers.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-list-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -529,35 +840,45 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
||||
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>
|
||||
{props.isLoading
|
||||
? t("folderSelection.browse.buttonOpening")
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
<div class="panel-body flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>
|
||||
{props.isLoading
|
||||
? t("folderSelection.browse.buttonOpening")
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
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" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
{/* OpenCode settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
@@ -607,16 +928,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
|
||||
<div class="folder-drop-overlay" aria-hidden="true">
|
||||
<div class="folder-drop-card">
|
||||
<FolderPlus class="w-8 h-8 icon-muted" />
|
||||
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
|
||||
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<AdvancedSettingsModal
|
||||
open={Boolean(props.advancedSettingsOpen)}
|
||||
onClose={() => props.onAdvancedSettingsClose?.()}
|
||||
selectedBinary={selectedBinary()}
|
||||
onBinaryChange={handleBinaryChange}
|
||||
isLoading={props.isLoading}
|
||||
/>
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={isFolderBrowserOpen()}
|
||||
title={t("folderSelection.dialog.title")}
|
||||
@@ -624,6 +946,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">
|
||||
{t("folderSelection.servers.dialog.title")}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
{t("folderSelection.servers.dialog.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||
<span>{t("folderSelection.servers.dialog.name")}</span>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
value={serverName()}
|
||||
onInput={(event) => setServerName(event.currentTarget.value)}
|
||||
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||
<span>{t("folderSelection.servers.dialog.url")}</span>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
value={serverUrl()}
|
||||
onInput={(event) => setServerUrl(event.currentTarget.value)}
|
||||
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipTlsVerify()}
|
||||
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
|
||||
</label>
|
||||
|
||||
<Show when={serverDialogError()}>
|
||||
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
|
||||
{t("folderSelection.servers.dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto px-4"
|
||||
disabled={isSavingServer()}
|
||||
onClick={() => void handleSaveServer(false)}
|
||||
>
|
||||
{t("folderSelection.servers.dialog.save")}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto px-4"
|
||||
disabled={isSavingServer()}
|
||||
onClick={() => void handleSaveServer(true)}
|
||||
>
|
||||
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
{t("folderSelection.servers.dialog.connecting")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
||||
return (
|
||||
<div class="log-container">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
||||
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { disposeInstance } from "../stores/instances"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
compact?: boolean
|
||||
showDisposeButton?: boolean
|
||||
}
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||
|
||||
const [isDisposing, setIsDisposing] = createSignal(false)
|
||||
|
||||
const currentInstance = () => instanceAccessor()
|
||||
const metadata = () => metadataAccessor()
|
||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||
@@ -25,6 +34,47 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
return env ? Object.entries(env) : []
|
||||
})
|
||||
|
||||
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
|
||||
|
||||
const handleDisposeInstance = async () => {
|
||||
if (!disposeEnabled()) return
|
||||
|
||||
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
|
||||
title: t("infoView.dispose.confirm.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setIsDisposing(true)
|
||||
try {
|
||||
const ok = await disposeInstance(currentInstance().id)
|
||||
if (ok) {
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.success"),
|
||||
variant: "success",
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.error"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to dispose instance", error)
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.error"),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setIsDisposing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
@@ -33,7 +83,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="panel-body space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
{currentInstance().folder}
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +95,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
{t("instanceInfo.labels.project")}
|
||||
</div>
|
||||
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
{project().id}
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,7 +138,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
{t("instanceInfo.labels.binaryPath")}
|
||||
</div>
|
||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
{currentInstance().binaryPath}
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +152,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="space-y-1">
|
||||
<For each={environmentEntries()}>
|
||||
{([key, value]) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||
{key}
|
||||
</span>
|
||||
@@ -156,6 +206,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.showDisposeButton}>
|
||||
<div class="pt-3 border-t border-base">
|
||||
<button
|
||||
type="button"
|
||||
class="button-danger button-small w-full"
|
||||
onClick={handleDisposeInstance}
|
||||
disabled={!disposeEnabled()}
|
||||
>
|
||||
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { openSettings } from "../stores/settings-screen"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -17,13 +16,11 @@ interface InstanceTabsProps {
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { preferences } = useConfig()
|
||||
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||
|
||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||
@@ -33,8 +30,10 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
})
|
||||
|
||||
const notificationTitle = createMemo(() => {
|
||||
if (!notificationsSupported()) return "Notifications unsupported"
|
||||
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
|
||||
return notificationsEnabled()
|
||||
? t("settings.notifications.status.enabled")
|
||||
: t("settings.notifications.status.disabled")
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -72,32 +71,35 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
<button
|
||||
class="new-tab-button"
|
||||
onClick={() => openSettings("appearance")}
|
||||
title={t("settings.open.title")}
|
||||
aria-label={t("settings.open.ariaLabel")}
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => setNotificationsOpen(true)}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => openSettings("notifications")}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => openSettings("remote")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
@@ -404,6 +404,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||
dir="auto"
|
||||
classList={{
|
||||
"text-accent": isFocused(),
|
||||
}}
|
||||
|
||||
@@ -29,19 +29,20 @@ import PermissionNotificationBanner from "../permission-notification-banner"
|
||||
import PermissionApprovalModal from "../permission-approval-modal"
|
||||
import SessionView from "../session/session-view"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
import ContextMeter from "../context-meter"
|
||||
import { sseManager } from "../../lib/sse-manager"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { loadBackgroundProcesses } from "../../stores/background-processes"
|
||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
|
||||
import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
|
||||
import SessionSidebar from "./shell/SessionSidebar"
|
||||
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
||||
import RightPanel from "./shell/right-panel/RightPanel"
|
||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||
import { getSessionStatus } from "../../stores/session-status"
|
||||
import { ShieldAlert } from "lucide-solid"
|
||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||
|
||||
import type { LayoutMode } from "./shell/types"
|
||||
import {
|
||||
@@ -56,11 +57,21 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
|
||||
import { useDrawerResize } from "./shell/useDrawerResize"
|
||||
import { useSessionCache } from "./shell/useSessionCache"
|
||||
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
|
||||
import { getPermissionSessionId } from "../../types/permission"
|
||||
import {
|
||||
canAutoRespondPermission,
|
||||
finishAutoRespondPermission,
|
||||
getPermissionAutoAcceptInFlightVersion,
|
||||
isPermissionAutoAcceptEnabled,
|
||||
} from "../../stores/permission-auto-accept"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
interface InstanceShellProps {
|
||||
instance: Instance
|
||||
// Provided by App-level instance tabs; lets us pause heavy rendering
|
||||
// work for inactive instances while keeping them mounted for fast switching.
|
||||
isActiveInstance?: boolean
|
||||
escapeInDebounce: boolean
|
||||
paletteCommands: Accessor<Command[]>
|
||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||
@@ -69,10 +80,16 @@ interface InstanceShellProps {
|
||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onExecuteCommand: (command: Command) => void
|
||||
tabBarOffset: number
|
||||
|
||||
// In-memory only: mobile immersive/fullscreen mode.
|
||||
mobileFullscreenMode: boolean
|
||||
onEnterMobileFullscreen: () => void
|
||||
onExitMobileFullscreen: () => void
|
||||
}
|
||||
|
||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const isRTL = () => locale() === "he"
|
||||
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
||||
@@ -87,6 +104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
|
||||
// Worktree selector manages its own dialogs.
|
||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||
@@ -109,6 +127,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
|
||||
const tabletQuery = useMediaQuery("(min-width: 768px)")
|
||||
const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
|
||||
|
||||
const layoutMode = createMemo<LayoutMode>(() => {
|
||||
if (desktopQuery()) return "desktop"
|
||||
@@ -117,6 +136,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
||||
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
|
||||
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
|
||||
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
|
||||
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||
|
||||
@@ -216,6 +238,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
onCleanup(() => window.clearInterval(timer))
|
||||
})
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instance.id)
|
||||
const connectionStatusClass = () => {
|
||||
const status = connectionStatus()
|
||||
@@ -238,6 +266,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return permissions + questions > 0
|
||||
})
|
||||
|
||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
||||
|
||||
createEffect(() => {
|
||||
getPermissionAutoAcceptInFlightVersion()
|
||||
|
||||
for (const permission of permissionQueue()) {
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
if (!sessionId) continue
|
||||
if (!permission?.id) continue
|
||||
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
|
||||
|
||||
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
|
||||
.catch((error) => {
|
||||
log.error("Failed to auto-accept permission", error)
|
||||
})
|
||||
.finally(() => {
|
||||
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const yoloModeEnabled = createMemo(() => {
|
||||
const session = activeSessionForInstance()
|
||||
if (!session) return false
|
||||
return isPermissionAutoAcceptEnabled(props.instance.id, session.id)
|
||||
})
|
||||
|
||||
const activeSessionStatusPill = createMemo(() => {
|
||||
const activeSessionId = activeSessionIdForInstance()
|
||||
if (!activeSessionId || activeSessionId === "info") return null
|
||||
@@ -258,17 +313,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
}
|
||||
|
||||
const status = getSessionStatus(props.instance.id, activeSessionId)
|
||||
const text =
|
||||
status === "working"
|
||||
const retry = getSessionRetry(props.instance.id, activeSessionId)
|
||||
const text = retry
|
||||
? (() => {
|
||||
const seconds = getRetrySeconds(retry.next, now())
|
||||
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||
})()
|
||||
: status === "working"
|
||||
? t("sessionList.status.working")
|
||||
: status === "compacting"
|
||||
? t("sessionList.status.compacting")
|
||||
: t("sessionList.status.idle")
|
||||
|
||||
return {
|
||||
className: `session-${status}`,
|
||||
className: `session-${retry ? "retrying" : status}`,
|
||||
text,
|
||||
showAlertIcon: false,
|
||||
title: retry
|
||||
? t("sessionList.status.retryTooltip", {
|
||||
message: retry.message,
|
||||
attempt: String(retry.attempt),
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -276,13 +342,39 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const pill = activeSessionStatusPill()
|
||||
if (!pill) return null
|
||||
return (
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`}>
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
|
||||
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{pill.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderYoloModePill = () => {
|
||||
if (!yoloModeEnabled()) return null
|
||||
return (
|
||||
<span
|
||||
class="status-indicator session-status session-status-list session-yolo-mode"
|
||||
aria-label={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||
title={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
{t("instanceShell.yoloMode.badge")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSessionHeaderIndicators = () => (
|
||||
<div class="flex items-center flex-wrap justify-center gap-2">
|
||||
{renderYoloModePill()}
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instance.id)
|
||||
}
|
||||
@@ -349,16 +441,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
measureDrawerHost,
|
||||
})
|
||||
|
||||
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
|
||||
|
||||
|
||||
const formattedAvailableTokens = () => {
|
||||
const avail = tokenStats().avail
|
||||
if (typeof avail === "number") {
|
||||
return formatTokenTotal(avail)
|
||||
}
|
||||
return "--"
|
||||
}
|
||||
|
||||
const renderLeftPanel = () => {
|
||||
if (leftPinned()) {
|
||||
@@ -368,7 +450,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
sx={{
|
||||
width: `${sessionSidebarWidth()}px`,
|
||||
flexShrink: 0,
|
||||
borderRight: "1px solid var(--border-base)",
|
||||
borderInlineEnd: "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
@@ -410,16 +492,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const modalProps = container ? { container: container as Element } : undefined
|
||||
return (
|
||||
<Drawer
|
||||
anchor="left"
|
||||
anchor={isRTL() ? "right" : "left"}
|
||||
variant="temporary"
|
||||
open={leftOpen()}
|
||||
onClose={closeLeftDrawer}
|
||||
ModalProps={modalProps}
|
||||
sx={{
|
||||
zIndex: 60,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--text-primary)",
|
||||
@@ -477,7 +560,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
sx={{
|
||||
width: `${rightDrawerWidth()}px`,
|
||||
flexShrink: 0,
|
||||
borderLeft: "1px solid var(--border-base)",
|
||||
borderInlineStart: "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
@@ -520,16 +603,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const modalProps = container ? { container: container as Element } : undefined
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
anchor={isRTL() ? "left" : "right"}
|
||||
variant="temporary"
|
||||
open={rightOpen()}
|
||||
onClose={closeRightDrawer}
|
||||
ModalProps={modalProps}
|
||||
sx={{
|
||||
zIndex: 60,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--text-primary)",
|
||||
@@ -594,13 +678,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
{renderLeftPanel()}
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<Show when={!mobileFullscreen()}>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!compactHeaderLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<Show when={leftDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
@@ -615,18 +700,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
{renderSessionHeaderIndicators()}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
class="connection-status-button command-palette-button"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
@@ -635,8 +715,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint kbd-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||
<span
|
||||
@@ -647,6 +727,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={props.onEnterMobileFullscreen}
|
||||
aria-label={t("instanceShell.fullscreen.enter")}
|
||||
title={t("instanceShell.fullscreen.enter")}
|
||||
size="small"
|
||||
>
|
||||
<Maximize2 class="w-5 h-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
|
||||
<Show when={rightDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
@@ -659,22 +751,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<Show when={!showingInfoView()}>
|
||||
<ContextMeter
|
||||
usedTokens={tokenStats().used}
|
||||
availableTokens={tokenStats().avail}
|
||||
formatTokens={formatTokenTotal}
|
||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -693,34 +782,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<Show when={!showingInfoView()}>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||
{t("instanceShell.metrics.availableLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||
</div>
|
||||
<ContextMeter
|
||||
usedTokens={tokenStats().used}
|
||||
availableTokens={tokenStats().avail}
|
||||
formatTokens={formatTokenTotal}
|
||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="ml-auto flex items-center session-header-hints">
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
{renderSessionHeaderIndicators()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
class="connection-status-button command-palette-button"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
@@ -734,7 +813,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<div class="ms-auto flex items-center gap-3">
|
||||
<div class="connection-status-meta flex items-center gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
@@ -769,9 +848,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Show>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
@@ -794,12 +874,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
>
|
||||
<For each={cachedSessionIds()}>
|
||||
{(sessionId) => {
|
||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||
const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
|
||||
return (
|
||||
<div
|
||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||
style={{ display: isActive() ? "flex" : "none" }}
|
||||
data-session-id={sessionId}
|
||||
data-instance-id={props.instance.id}
|
||||
data-session-active={isActive() ? "true" : "false"}
|
||||
aria-hidden={!isActive()}
|
||||
>
|
||||
<SessionView
|
||||
@@ -808,6 +890,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isPhoneLayout={isPhoneLayout()}
|
||||
compactPromptLayout={compactPromptLayout()}
|
||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||
onSidebarToggle={() => setLeftOpen(true)}
|
||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||
@@ -833,7 +917,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="instance-shell2 flex flex-col flex-1 min-h-0">
|
||||
<div
|
||||
class="instance-shell2 flex flex-col flex-1 min-h-0"
|
||||
data-instance-id={props.instance.id}
|
||||
>
|
||||
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||
{sessionLayout}
|
||||
</Show>
|
||||
|
||||
@@ -48,104 +48,103 @@ interface SessionSidebarProps {
|
||||
}
|
||||
|
||||
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
||||
|
||||
@@ -177,11 +176,10 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default SessionSidebar
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user