Compare commits
10 Commits
v0.14.0-de
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e708c565ef | ||
|
|
4a1147788c | ||
|
|
1c317df6c0 | ||
|
|
6381934661 | ||
|
|
67a10d12e0 | ||
|
|
68551f6731 | ||
|
|
662a6b94b0 | ||
|
|
77df40169a | ||
|
|
3b411e2e73 | ||
|
|
016c7bda4a |
11
.github/workflows/build-and-upload.yml
vendored
11
.github/workflows/build-and-upload.yml
vendored
@@ -53,7 +53,7 @@ on:
|
||||
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-x64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
@@ -456,7 +456,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-arm64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-win32-x64-msvc@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
@@ -614,6 +614,7 @@ jobs:
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
xdg-utils \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
@@ -642,6 +643,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
# Tauri CLI 2.10.1 regresses Linux AppImage bundling in CI; keep Linux on the last known-good CLI.
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
@@ -741,6 +743,7 @@ jobs:
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
xdg-utils \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
libgtk-3-dev:arm64 \
|
||||
|
||||
24
.github/workflows/manual-npm-publish.yml
vendored
24
.github/workflows/manual-npm-publish.yml
vendored
@@ -46,7 +46,8 @@ jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
PUBLISH_NPM_VERSION: 11.5.1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -59,17 +60,24 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Ensure npm >=11.5.1
|
||||
run: npm install -g npm@latest
|
||||
- name: Prepare pinned npm CLI
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tool_dir="$RUNNER_TEMP/publish-npm"
|
||||
mkdir -p "$tool_dir"
|
||||
npm install --prefix "$tool_dir" "npm@${PUBLISH_NPM_VERSION}" --no-audit --no-fund
|
||||
echo "PINNED_NPM_CLI=$tool_dir/node_modules/npm/bin/npm-cli.js" >> "$GITHUB_ENV"
|
||||
node "$tool_dir/node_modules/npm/bin/npm-cli.js" --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
run: node "$PINNED_NPM_CLI" ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
run: node "$PINNED_NPM_CLI" install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace packages/server
|
||||
run: node "$PINNED_NPM_CLI" run build --workspace packages/server
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
@@ -83,7 +91,7 @@ jobs:
|
||||
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
run: node "$PINNED_NPM_CLI" version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Set server package name for publish
|
||||
shell: bash
|
||||
@@ -107,4 +115,4 @@ jobs:
|
||||
else
|
||||
echo "Using NPM_TOKEN authentication"
|
||||
fi
|
||||
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||
node "$PINNED_NPM_CLI" publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||
|
||||
2
.github/workflows/release-ui.yml
vendored
2
.github/workflows/release-ui.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
release-ui:
|
||||
|
||||
2
.github/workflows/reusable-release.yml
vendored
2
.github/workflows/reusable-release.yml
vendored
@@ -39,7 +39,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
|
||||
17
docs/features/wake-lock/SPECIFICATION.md
Normal file
17
docs/features/wake-lock/SPECIFICATION.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Wake Lock Behavior
|
||||
|
||||
## Product Rule
|
||||
|
||||
CodeNomad only requests a wake lock for qualifying active work that is already running and can continue without continuous foreground interaction. The goal is to prevent idle system sleep where the platform supports that behavior without intentionally keeping the display awake.
|
||||
|
||||
Wake lock must not be held when work is idle, paused, completed, cancelled, failed, or waiting for new user input or permission before it can continue.
|
||||
|
||||
## Platform Behavior
|
||||
|
||||
- **Electron:** request system-sleep-only behavior with `prevent-app-suspension`.
|
||||
- **Tauri:** request the native keep-awake mode with `display: false`, `idle: true`, and `sleep: false`.
|
||||
- **Web:** do not fall back to `navigator.wakeLock.request("screen")`; if a true system-sleep-only primitive is unavailable, CodeNomad degrades to no wake lock.
|
||||
|
||||
## Release Expectations
|
||||
|
||||
Wake lock should be released promptly when qualifying active work ends or when the app cleans up the active session lifecycle.
|
||||
79
docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md
Normal file
79
docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: SCR-2026-04-21-001
|
||||
title: Wake lock should allow screen lock while preventing system sleep
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
Refine wake-lock behavior so the product protects long-running active work from device/system sleep without intentionally keeping the display awake. The desired product experience is: users may lock the screen or let the display sleep, and in-platform work should continue whenever the platform can support that behavior.
|
||||
|
||||
# Problem
|
||||
|
||||
Current wake-lock behavior on desktop is oriented around display wake, which prevents normal screen lock or display sleep behavior on macOS and does not match the requested product outcome. The Product Owner wants wake lock to protect only against system/device sleep during active work, not against display sleep or screen lock. Scope includes Electron, Tauri, and web, with documented best-effort degradation where platform APIs cannot provide a system-sleep-only capability.
|
||||
|
||||
# Requested Outcome
|
||||
|
||||
- Allow the screen/display to sleep or lock normally while qualifying work is in progress.
|
||||
- Prevent only system/device sleep during qualifying active work on platforms that support a system-sleep-only hold.
|
||||
- Keep platform behavior aligned to a single product rule: never intentionally keep the display awake as a fallback for this feature.
|
||||
- Apply the behavior across Electron, Tauri, and web using best-effort platform support with explicit limitation handling.
|
||||
|
||||
# Product Scope
|
||||
|
||||
## Active Work Definition
|
||||
|
||||
For this change, **active work** means a user-initiated or product-initiated in-app operation that:
|
||||
|
||||
- has started execution,
|
||||
- is represented by the product as still in progress,
|
||||
- is expected to continue without continuous foreground interaction, and
|
||||
- would lose reliability or stop early if the device enters normal system sleep.
|
||||
|
||||
Active work does **not** include:
|
||||
|
||||
- the app merely being open or focused,
|
||||
- idle viewing or reading states,
|
||||
- paused, completed, failed, or cancelled work,
|
||||
- states waiting indefinitely for new user input before further execution, or
|
||||
- generic background presence without a currently running task.
|
||||
|
||||
## Product Behavior Rule
|
||||
|
||||
- When active work starts, the product may request a wake lock only if the platform can do so **without intentionally blocking screen lock or display sleep**.
|
||||
- When active work ends, pauses, fails, is cancelled, or no longer needs protection, the product must release the wake lock promptly.
|
||||
- The product intent is consistent across platforms, but implementation is **best-effort by platform capability**, not strict-identical by mechanism.
|
||||
|
||||
## Fallback Policy
|
||||
|
||||
- If a platform can provide **system-sleep-only** protection, the product should use it.
|
||||
- If a platform can only provide a **display/screen wake** lock that keeps the screen awake, the product must **not** use that mode as a fallback for this feature.
|
||||
- In unsupported or partially supported environments, the product should fall back to **no wake lock** rather than preserving the old display-wake behavior.
|
||||
- Unsupported behavior must be treated as a documented platform limitation, not as a product failure.
|
||||
|
||||
## Platform Expectations
|
||||
|
||||
- **Electron:** In scope to use a system-sleep-only mode if available.
|
||||
- **Tauri:** In scope to use a system-sleep-only mode if available through the chosen Tauri/native path.
|
||||
- **Web:** Default expectation is unsupported or partially supported for this exact behavior unless a browser/runtime exposes a true system-sleep-only primitive. A screen wake lock that keeps the display awake is not an acceptable substitute.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Keeping the display continuously awake during long-running work.
|
||||
- Preserving current display-wake behavior on platforms where that is the only available wake-lock mode.
|
||||
- Inventing platform-specific user settings to choose between display wake and system-sleep-only behavior as part of this SCR.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- AC-1: The specification defines **active work** in user-observable product terms, including the states that do and do not qualify for wake-lock protection.
|
||||
- AC-2: The specification defines a single cross-platform product rule: qualifying active work should protect against system sleep where possible, while screen lock and display sleep remain allowed.
|
||||
- AC-3: The specification defines the fallback policy for unsupported platforms: if system-sleep-only protection is unavailable, the product must not substitute display/screen wake behavior and must instead degrade to no wake lock.
|
||||
- AC-4: Platform expectations are documented for Electron, Tauri, and web, including the explicit expectation that web is best-effort and may remain unsupported for this exact behavior.
|
||||
- AC-5: The specification defines wake-lock release expectations so protection ends promptly when qualifying active work is no longer running.
|
||||
- AC-6: Any implementation derived from this SCR must document user-visible limitations for unsupported platforms in the appropriate product-facing documentation if final technical validation confirms those limitations.
|
||||
|
||||
# Implementation Notes For Follow-On Technical Assessment
|
||||
|
||||
- Electron and Tauri feasibility still requires technical validation of the exact API mode, lifecycle reliability, and background-execution behavior.
|
||||
- Web feasibility still requires confirmation of browser/runtime support, permission constraints, visibility restrictions, and whether any supported runtime offers a true system-sleep-only primitive.
|
||||
- If technical validation shows a desktop platform cannot provide system-sleep-only behavior safely, implementation should follow the fallback policy above rather than retaining display-wake behavior.
|
||||
1216
package-lock.json
generated
1216
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -92,7 +92,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
return { enabled: true }
|
||||
}
|
||||
try {
|
||||
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
|
||||
wakeLockId = powerSaveBlocker.start("prevent-app-suspension")
|
||||
} catch {
|
||||
wakeLockId = null
|
||||
return { enabled: false }
|
||||
|
||||
@@ -118,6 +118,8 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
loader.catch((error) => {
|
||||
console.error("[cli] failed to load loading screen:", error)
|
||||
})
|
||||
|
||||
return loader
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||
@@ -277,6 +279,7 @@ function createWindow() {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
additionalArguments: ["--codenomad-window-context=local"],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -291,7 +294,7 @@ function createWindow() {
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
clearWindowAllowedOrigin(window)
|
||||
loadLoadingScreen(window)
|
||||
const loadingReady = loadLoadingScreen(window)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
window.webContents.openDevTools({ mode: "detach" })
|
||||
@@ -310,11 +313,7 @@ function createWindow() {
|
||||
showingLoadingScreen = false
|
||||
})
|
||||
|
||||
if (pendingCliUrl) {
|
||||
const url = pendingCliUrl
|
||||
pendingCliUrl = null
|
||||
startCliPreload(url)
|
||||
}
|
||||
return loadingReady
|
||||
}
|
||||
|
||||
function showLoadingScreen(force = false) {
|
||||
@@ -440,6 +439,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
additionalArguments: ["--codenomad-window-context=remote"],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -620,7 +620,8 @@ app.whenReady().then(() => {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startCli()
|
||||
const loadingReady = createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
@@ -637,8 +638,11 @@ app.whenReady().then(() => {
|
||||
}
|
||||
}
|
||||
|
||||
createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
void loadingReady.finally(() => {
|
||||
setTimeout(() => {
|
||||
void startCli()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||
if (isInsecureOriginAllowed(url)) {
|
||||
|
||||
@@ -38,7 +38,7 @@ interface StartOptions {
|
||||
|
||||
interface CliEntryResolution {
|
||||
entry: string
|
||||
runner: "node" | "tsx"
|
||||
runner: "node" | "tsx" | "standalone"
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
@@ -148,15 +148,15 @@ export class CliProcessManager extends EventEmitter {
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
|
||||
let child: ManagedChild
|
||||
|
||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||
const runtimePath = this.resolveShellNodeCommand()
|
||||
const entryPath = this.resolveBundledProdEntry()
|
||||
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
|
||||
const supervisorPath = this.resolveCliSupervisorPath()
|
||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||
const shellTarget = cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
|
||||
const supervisorPayload = JSON.stringify({
|
||||
command: shellCommand.command,
|
||||
args: shellCommand.args,
|
||||
@@ -164,28 +164,33 @@ export class CliProcessManager extends EventEmitter {
|
||||
})
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||
|
||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||
env: shellEnv,
|
||||
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
|
||||
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})`,
|
||||
)
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
if (cliEntry.runner !== "standalone") {
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
}
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
? buildUserShellCommand(
|
||||
`${cliEntry.runner === "standalone" ? "" : "ELECTRON_RUN_AS_NODE=1 "}exec ${
|
||||
cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||
}`,
|
||||
)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
@@ -563,6 +568,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||
if (cliEntry.runner === "standalone") {
|
||||
return this.buildExecutableCommand(cliEntry.entry, args)
|
||||
}
|
||||
|
||||
const parts = [JSON.stringify(process.execPath)]
|
||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||
@@ -577,6 +586,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||
if (cliEntry.runner === "standalone") {
|
||||
return { command: cliEntry.entry, args }
|
||||
}
|
||||
|
||||
if (cliEntry.runner === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
}
|
||||
@@ -593,9 +606,8 @@ export class CliProcessManager extends EventEmitter {
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
|
||||
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
|
||||
}
|
||||
|
||||
private resolveTsx(): string | null {
|
||||
@@ -635,20 +647,25 @@ export class CliProcessManager extends EventEmitter {
|
||||
return entry
|
||||
}
|
||||
|
||||
private resolveProdEntry(): string {
|
||||
try {
|
||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||
if (existsSync(entry)) {
|
||||
return entry
|
||||
private resolveStandaloneProdEntry(): string {
|
||||
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "server", "dist", executableName),
|
||||
path.join(mainDirname, "../resources/server/dist", executableName),
|
||||
path.resolve(process.cwd(), "..", "server", "dist", executableName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
|
||||
throw new Error(`Unable to locate standalone CodeNomad server executable (${executableName}). Run npm run build:standalone --workspace @neuralnomads/codenomad.`)
|
||||
}
|
||||
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
|
||||
}
|
||||
|
||||
private resolveCliSupervisorPath(): string {
|
||||
@@ -666,26 +683,6 @@ export class CliProcessManager extends EventEmitter {
|
||||
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
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
function resolveWindowContext() {
|
||||
const prefix = "--codenomad-window-context="
|
||||
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
|
||||
const context = arg ? arg.slice(prefix.length) : "local"
|
||||
return context === "remote" ? "remote" : "local"
|
||||
}
|
||||
|
||||
function resolveRuntimeHost(windowContext) {
|
||||
return "electron"
|
||||
}
|
||||
|
||||
const windowContext = resolveWindowContext()
|
||||
|
||||
const localElectronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
@@ -26,4 +39,15 @@ const electronAPI = {
|
||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
const remoteElectronAPI = {
|
||||
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
|
||||
setWakeLock: localElectronAPI.setWakeLock,
|
||||
showNotification: localElectronAPI.showNotification,
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld(
|
||||
"electronAPI",
|
||||
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
|
||||
)
|
||||
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
|
||||
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import path, { join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -14,6 +14,46 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
const nodeModulesPath = join(appDir, "node_modules")
|
||||
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||
|
||||
function getPlatformEsbuildPackage() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@esbuild/linux-x64",
|
||||
"linux-arm64": "@esbuild/linux-arm64",
|
||||
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||
"darwin-x64": "@esbuild/darwin-x64",
|
||||
"win32-arm64": "@esbuild/win32-arm64",
|
||||
"win32-x64": "@esbuild/win32-x64",
|
||||
}
|
||||
|
||||
return platformPackages[platformKey] ?? null
|
||||
}
|
||||
|
||||
async function ensureEsbuildPlatformBinary() {
|
||||
const pkgName = getPlatformEsbuildPackage()
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = join(workspaceNodeModulesPath, ...pkgName.split("/"))
|
||||
if (existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let esbuildVersion = ""
|
||||
try {
|
||||
esbuildVersion = JSON.parse(readFileSync(join(workspaceNodeModulesPath, "esbuild", "package.json"), "utf-8")).version ?? ""
|
||||
} catch {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
|
||||
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||
console.log("📦 Step 0/3: Restoring esbuild platform binary...\n")
|
||||
await run(npmCmd, ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
}
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
args: ["--mac", "--x64", "--arm64"],
|
||||
@@ -105,6 +145,8 @@ async function build(platform) {
|
||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||
|
||||
try {
|
||||
await ensureEsbuildPlatformBinary()
|
||||
|
||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||
cwd: workspaceRoot,
|
||||
|
||||
@@ -16,6 +16,7 @@ const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||
const standaloneMarker = join(serverRoot, "dist", process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server")
|
||||
|
||||
function log(message) {
|
||||
console.log(`[prepare-resources] ${message}`)
|
||||
@@ -29,6 +30,34 @@ function ensureServerBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStandaloneServerBuild() {
|
||||
log("building standalone server executable")
|
||||
const result = spawnSync(
|
||||
"npm",
|
||||
["run", "build:standalone", "--workspace", "@neuralnomads/codenomad"],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
shell: process.platform === "win32",
|
||||
},
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`standalone server build exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(standaloneMarker)) {
|
||||
throw new Error(`Standalone server executable missing after build: ${standaloneMarker}`)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(serverDepsMarker)) {
|
||||
return
|
||||
@@ -65,6 +94,51 @@ function ensureServerDependencies() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureEsbuildPlatformBinary() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@esbuild/linux-x64",
|
||||
"linux-arm64": "@esbuild/linux-arm64",
|
||||
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||
"darwin-x64": "@esbuild/darwin-x64",
|
||||
"win32-arm64": "@esbuild/win32-arm64",
|
||||
"win32-x64": "@esbuild/win32-x64",
|
||||
}
|
||||
|
||||
const pkgName = platformPackages[platformKey]
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = join(workspaceRoot, "node_modules", ...pkgName.split("/"))
|
||||
if (fs.existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let esbuildVersion = ""
|
||||
try {
|
||||
esbuildVersion = JSON.parse(fs.readFileSync(join(workspaceRoot, "node_modules", "esbuild", "package.json"), "utf-8")).version ?? ""
|
||||
} catch {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
|
||||
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||
log("installing esbuild platform binary (optional dep workaround)")
|
||||
|
||||
const result = spawnSync("npm", ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`esbuild platform install exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
@@ -121,7 +195,9 @@ function stripNodeModuleBins() {
|
||||
|
||||
async function main() {
|
||||
ensureServerBuild()
|
||||
ensureStandaloneServerBuild()
|
||||
ensureServerDependencies()
|
||||
ensureEsbuildPlatformBinary()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.7"
|
||||
"@opencode-ai/plugin": "1.14.19"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||
"build:standalone": "node ./scripts/build-standalone.mjs",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
@@ -25,16 +26,16 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/reply-from": "^12.6.2",
|
||||
"@fastify/static": "^9.1.1",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fastify": "^5.8.5",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"undici": "^8.1.0",
|
||||
"yaml": "^2.4.2",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
@@ -42,6 +43,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"bun": "^1.3.13",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
|
||||
99
packages/server/scripts/build-standalone.mjs
Normal file
99
packages/server/scripts/build-standalone.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const distDir = path.join(cliRoot, "dist")
|
||||
const publicDir = path.join(cliRoot, "public")
|
||||
const authPagesSourceDir = path.join(distDir, "server", "routes", "auth-pages")
|
||||
const authPagesTargetDir = path.join(distDir, "auth-pages")
|
||||
const explicitTarget = process.env.CODENOMAD_STANDALONE_TARGET?.trim()
|
||||
const outputName = (explicitTarget?.includes("windows") || process.platform === "win32") ? "codenomad-server.exe" : "codenomad-server"
|
||||
const outputPath = path.join(distDir, outputName)
|
||||
const packageJsonPath = path.join(cliRoot, "package.json")
|
||||
|
||||
function resolveBunCommand() {
|
||||
const executableName = process.platform === "win32" ? "bun.exe" : "bun"
|
||||
const localBinName = process.platform === "win32" ? "bun.cmd" : "bun"
|
||||
const candidates = [
|
||||
path.join(cliRoot, "node_modules", ".bin", localBinName),
|
||||
path.join(cliRoot, "..", "..", "node_modules", ".bin", localBinName),
|
||||
path.join(cliRoot, "node_modules", "bun", "bin", executableName),
|
||||
path.join(cliRoot, "..", "..", "node_modules", "bun", "bin", executableName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return "bun"
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[build-standalone] ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function ensureArtifacts() {
|
||||
const requiredPaths = [distDir, publicDir, authPagesSourceDir, packageJsonPath]
|
||||
const missing = requiredPaths.filter((filePath) => !fs.existsSync(filePath))
|
||||
if (missing.length > 0) {
|
||||
fail(`Missing required build artifacts: ${missing.join(", ")}. Run npm run build first.`)
|
||||
}
|
||||
|
||||
const bunResult = spawnSync(resolveBunCommand(), ["-v"], { cwd: cliRoot, encoding: "utf-8", shell: process.platform === "win32" })
|
||||
if (bunResult.status !== 0) {
|
||||
fail("Bun is required to build the standalone server executable. Install dependencies so the local Bun binary is available.")
|
||||
}
|
||||
}
|
||||
|
||||
function syncStandaloneAuthPages() {
|
||||
fs.rmSync(authPagesTargetDir, { recursive: true, force: true })
|
||||
fs.mkdirSync(path.dirname(authPagesTargetDir), { recursive: true })
|
||||
fs.cpSync(authPagesSourceDir, authPagesTargetDir, { recursive: true })
|
||||
}
|
||||
|
||||
function buildStandaloneExecutable() {
|
||||
fs.rmSync(outputPath, { force: true })
|
||||
const bunCommand = resolveBunCommand()
|
||||
|
||||
const args = ["build", "--compile"]
|
||||
if (explicitTarget) {
|
||||
args.push(`--target=${explicitTarget}`)
|
||||
}
|
||||
args.push(path.join(cliRoot, "src", "index.ts"), "--outfile", outputPath)
|
||||
|
||||
const result = spawnSync(bunCommand, args, {
|
||||
cwd: cliRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`bun build --compile exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
ensureArtifacts()
|
||||
syncStandaloneAuthPages()
|
||||
|
||||
buildStandaloneExecutable()
|
||||
console.log(`[build-standalone] built ${outputPath}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error("[build-standalone] failed:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "child_process"
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -14,6 +14,67 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
function stripNodeModuleBins(rootDir) {
|
||||
const root = path.join(rootDir, "node_modules")
|
||||
if (!existsSync(root)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const stack = [root]
|
||||
let removed = 0
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name)
|
||||
if (entry.name === ".bin") {
|
||||
rmSync(full, { recursive: true, force: true })
|
||||
removed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
function stripOptionalNativeAddons(rootDir) {
|
||||
const nodeModulesRoot = path.join(rootDir, "node_modules")
|
||||
if (!existsSync(nodeModulesRoot)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const removablePaths = [
|
||||
path.join(nodeModulesRoot, "@msgpackr-extract"),
|
||||
path.join(nodeModulesRoot, "msgpackr-extract"),
|
||||
]
|
||||
|
||||
let removed = 0
|
||||
for (const targetPath of removablePaths) {
|
||||
if (!existsSync(targetPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
rmSync(targetPath, { recursive: true, force: true })
|
||||
removed += 1
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
@@ -58,4 +119,14 @@ rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
const removedBins = stripNodeModuleBins(targetDir)
|
||||
if (removedBins > 0) {
|
||||
console.log(`[copy-opencode-config] Removed ${removedBins} node_modules/.bin directories`)
|
||||
}
|
||||
|
||||
const removedNativeAddons = stripOptionalNativeAddons(targetDir)
|
||||
if (removedNativeAddons > 0) {
|
||||
console.log(`[copy-opencode-config] Removed ${removedNativeAddons} optional native addon package paths`)
|
||||
}
|
||||
|
||||
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||
|
||||
@@ -29,13 +29,14 @@ import { SideCarManager } from "./sidecars/manager"
|
||||
import { ClientConnectionManager } from "./clients/connection-manager"
|
||||
import { PluginChannelManager } from "./plugins/channel"
|
||||
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
const packageJson = { version: readServerPackageVersion(import.meta.url) }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url)
|
||||
|
||||
interface CliOptions {
|
||||
host: string
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createLogger } from "./logger"
|
||||
import { resolveOpencodeTemplateDir } from "./runtime-paths"
|
||||
|
||||
const log = createLogger({ component: "opencode-config" })
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
const prodTemplateDirs = [
|
||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
|
||||
path.resolve(__dirname, "opencode-config"),
|
||||
].filter((dir): dir is string => Boolean(dir))
|
||||
const templateDir = resolveOpencodeTemplateDir(import.meta.url)
|
||||
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
||||
const templateDir = isDevBuild
|
||||
? devTemplateDir
|
||||
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
|
||||
|
||||
export function getOpencodeConfigDir(): string {
|
||||
if (!existsSync(templateDir)) {
|
||||
|
||||
79
packages/server/src/runtime-paths.ts
Normal file
79
packages/server/src/runtime-paths.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
function safeModuleDir(importMetaUrl: string): string | null {
|
||||
try {
|
||||
return path.dirname(fileURLToPath(importMetaUrl))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function firstExistingPath(candidates: Array<string | null | undefined>, predicate: (value: string) => boolean): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
if (predicate(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getPackagedDistDir(): string {
|
||||
return path.dirname(process.execPath)
|
||||
}
|
||||
|
||||
export function resolveServerPackageRoot(importMetaUrl: string): string {
|
||||
const moduleDir = safeModuleDir(importMetaUrl)
|
||||
const configuredRoot = process.env.CODENOMAD_SERVER_ROOT?.trim()
|
||||
const candidates = [
|
||||
configuredRoot ? path.resolve(configuredRoot) : null,
|
||||
moduleDir ? path.resolve(moduleDir, "..") : null,
|
||||
path.resolve(getPackagedDistDir(), ".."),
|
||||
]
|
||||
|
||||
return (
|
||||
firstExistingPath(candidates, (value) => fs.existsSync(path.join(value, "package.json"))) ??
|
||||
candidates.find((value): value is string => Boolean(value)) ??
|
||||
process.cwd()
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveServerPublicDir(importMetaUrl: string): string {
|
||||
const moduleDir = safeModuleDir(importMetaUrl)
|
||||
const candidates = [moduleDir ? path.resolve(moduleDir, "../public") : null, path.join(resolveServerPackageRoot(importMetaUrl), "public")]
|
||||
|
||||
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
|
||||
}
|
||||
|
||||
export function resolveAuthTemplatePath(importMetaUrl: string, fileName: string): string {
|
||||
const moduleDir = safeModuleDir(importMetaUrl)
|
||||
const distDir = getPackagedDistDir()
|
||||
const candidates = [
|
||||
moduleDir ? path.join(moduleDir, "auth-pages", fileName) : null,
|
||||
path.join(distDir, "auth-pages", fileName),
|
||||
path.join(distDir, "server", "routes", "auth-pages", fileName),
|
||||
]
|
||||
|
||||
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[0]!
|
||||
}
|
||||
|
||||
export function resolveOpencodeTemplateDir(importMetaUrl: string): string {
|
||||
const moduleDir = safeModuleDir(importMetaUrl)
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
const candidates = [
|
||||
moduleDir ? path.resolve(moduleDir, "../../opencode-config") : null,
|
||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : null,
|
||||
moduleDir ? path.resolve(moduleDir, "opencode-config") : null,
|
||||
path.join(getPackagedDistDir(), "opencode-config"),
|
||||
]
|
||||
|
||||
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
|
||||
}
|
||||
|
||||
export function readServerPackageVersion(importMetaUrl: string): string {
|
||||
const packageJsonPath = path.join(resolveServerPackageRoot(importMetaUrl), "package.json")
|
||||
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { version?: unknown }
|
||||
return typeof parsed.version === "string" && parsed.version.trim().length > 0 ? parsed.version : "0.0.0"
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import replyFrom from "@fastify/reply-from"
|
||||
import fs from "fs"
|
||||
import { connect as connectTcp, type Socket } from "net"
|
||||
import path from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
import { connect as connectTls, type TLSSocket } from "tls"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
@@ -626,57 +628,57 @@ async function proxyWorkspaceRequest(args: {
|
||||
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||
}
|
||||
|
||||
return reply.from(targetUrl, {
|
||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||
if (instanceAuthHeader) {
|
||||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
|
||||
|
||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
logger.trace(
|
||||
{
|
||||
workspaceId,
|
||||
method: request.method,
|
||||
targetUrl,
|
||||
worktreeSlug,
|
||||
directory,
|
||||
contentType: request.headers["content-type"],
|
||||
body: bodyToJson(request.body),
|
||||
headers: redactProxyHeadersForLogs(headers),
|
||||
},
|
||||
"Proxy -> OpenCode request",
|
||||
)
|
||||
}
|
||||
|
||||
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
||||
const init: any = {
|
||||
method: request.method,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
}
|
||||
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
const outgoing: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
outgoing[key] = value
|
||||
}
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
const body = toProxyRequestBody(request.body)
|
||||
if (body !== undefined) {
|
||||
init.body = body
|
||||
init.duplex = "half"
|
||||
}
|
||||
}
|
||||
|
||||
// Redact sensitive headers.
|
||||
for (const key of Object.keys(outgoing)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
||||
outgoing[key] = "<redacted>"
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await fetch(targetUrl, init)
|
||||
reply.code(response.status)
|
||||
applyInstanceProxyResponseHeaders(reply, response)
|
||||
|
||||
logger.trace(
|
||||
{
|
||||
workspaceId,
|
||||
method: request.method,
|
||||
targetUrl,
|
||||
worktreeSlug,
|
||||
directory,
|
||||
contentType: request.headers["content-type"],
|
||||
body: bodyToJson(request.body),
|
||||
headers: outgoing,
|
||||
},
|
||||
"Proxy -> OpenCode request",
|
||||
)
|
||||
}
|
||||
if (!response.body || request.method === "HEAD") {
|
||||
reply.send()
|
||||
return
|
||||
}
|
||||
|
||||
return headers
|
||||
},
|
||||
onError: (proxyReply, { error }) => {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
if (!proxyReply.sent) {
|
||||
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
reply.hijack()
|
||||
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
|
||||
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
|
||||
} catch (error) {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send({ error: "Workspace instance proxy failed" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||
@@ -867,12 +869,90 @@ function isApiRequest(rawUrl: string | null | undefined) {
|
||||
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||
if (!value || key.toLowerCase() === "host") continue
|
||||
const lower = key.toLowerCase()
|
||||
if (!value || lower === "host" || isHopByHopHeader(lower)) continue
|
||||
result[key] = Array.isArray(value) ? value.join(",") : value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function toProxyRequestBody(body: unknown): any {
|
||||
if (body == null) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof (body as { pipe?: unknown }).pipe === "function") {
|
||||
return body
|
||||
}
|
||||
if (typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
|
||||
return body
|
||||
}
|
||||
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
|
||||
return body
|
||||
}
|
||||
return JSON.stringify(body)
|
||||
}
|
||||
|
||||
function buildWorkspaceInstanceProxyHeaders(
|
||||
headers: FastifyRequest["headers"],
|
||||
instanceAuthHeader: string | undefined,
|
||||
directory: string,
|
||||
): Record<string, string> {
|
||||
const next = buildProxyHeaders(headers)
|
||||
if (instanceAuthHeader) {
|
||||
next.authorization = instanceAuthHeader
|
||||
}
|
||||
|
||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||
next["x-opencode-directory"] = isNonASCII ? encodeURIComponent(directory) : directory
|
||||
return next
|
||||
}
|
||||
|
||||
function redactProxyHeadersForLogs(headers: Record<string, string>): Record<string, string> {
|
||||
const outgoing = { ...headers }
|
||||
for (const key of Object.keys(outgoing)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
||||
outgoing[key] = "<redacted>"
|
||||
}
|
||||
}
|
||||
return outgoing
|
||||
}
|
||||
|
||||
function applyInstanceProxyResponseHeaders(reply: FastifyReply, response: any) {
|
||||
response.headers.forEach((value: string, key: string) => {
|
||||
const lower = key.toLowerCase()
|
||||
if (isHopByHopHeader(lower) || lower === "content-length" || lower === "content-encoding") {
|
||||
return
|
||||
}
|
||||
|
||||
reply.header(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
|
||||
const next: Record<string, string | string[]> = {}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
next[key] = Array.isArray(value) ? value.map(String) : String(value)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function isHopByHopHeader(name: string): boolean {
|
||||
return new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]).has(name)
|
||||
}
|
||||
|
||||
async function proxySideCarRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs"
|
||||
import { z } from "zod"
|
||||
import type { AuthManager } from "../../auth/manager"
|
||||
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||
import { resolveAuthTemplatePath } from "../../runtime-paths"
|
||||
|
||||
interface RouteDeps {
|
||||
authManager: AuthManager
|
||||
@@ -21,21 +22,21 @@ const PasswordSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
})
|
||||
|
||||
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
||||
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
||||
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
|
||||
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
|
||||
|
||||
let cachedLoginTemplate: string | null = null
|
||||
let cachedTokenTemplate: string | null = null
|
||||
|
||||
function readTemplate(url: URL, cache: string | null): string {
|
||||
function readTemplate(filePath: string, cache: string | null): string {
|
||||
if (cache) return cache
|
||||
const content = fs.readFileSync(url, "utf-8")
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
function getLoginHtml(defaultUsername: string): string {
|
||||
if (!cachedLoginTemplate) {
|
||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
|
||||
}
|
||||
|
||||
const escapedUsername = escapeHtml(defaultUsername)
|
||||
@@ -44,7 +45,7 @@ function getLoginHtml(defaultUsername: string): string {
|
||||
|
||||
function getTokenHtml(): string {
|
||||
if (!cachedTokenTemplate) {
|
||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
|
||||
}
|
||||
|
||||
return cachedTokenTemplate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||
import { probeBinaryVersion } from "../../workspaces/spawn"
|
||||
import type { SettingsService } from "../../settings/service"
|
||||
import type { Logger } from "../../logger"
|
||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||
|
||||
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
|
||||
|
||||
describe("parseWslUncPath", () => {
|
||||
it("parses WSL UNC paths into distro and linux path", () => {
|
||||
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
|
||||
distro: "Ubuntu",
|
||||
linuxPath: "/home/dev/.opencode/bin/opencode",
|
||||
})
|
||||
})
|
||||
|
||||
it("supports the legacy wsl$ UNC prefix", () => {
|
||||
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
|
||||
distro: "Ubuntu",
|
||||
linuxPath: "/home/dev",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveWslWorkingDirectory", () => {
|
||||
it("keeps WSL workspace folders in the same distro", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects WSL workspace folders from a different distro", () => {
|
||||
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildWindowsSpawnSpec", () => {
|
||||
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve", "--port", "0"],
|
||||
{
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||
CODENOMAD_INSTANCE_ID: "workspace-123",
|
||||
OPENCODE_SERVER_PASSWORD: "secret",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--cd",
|
||||
"/home/dev/workspace",
|
||||
"--exec",
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
"0",
|
||||
])
|
||||
assert.equal(spec.cwd, undefined)
|
||||
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
|
||||
})
|
||||
|
||||
it("upgrades existing WSLENV path entries to include /p", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
|
||||
})
|
||||
|
||||
it("propagates inherited known path variables even when they are not explicitly requested", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
env: {
|
||||
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
|
||||
})
|
||||
|
||||
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve", "--port", "0"],
|
||||
{
|
||||
cwd: String.raw`C:\Users\dev\workspace`,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||
"codenomad-wsl-launch",
|
||||
String.raw`C:\Users\dev\workspace`,
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
"0",
|
||||
])
|
||||
})
|
||||
|
||||
it("uses wslpath for UNC network workspace folders", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
cwd: String.raw`\\server\share\workspace`,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||
"codenomad-wsl-launch",
|
||||
String.raw`\\server\share\workspace`,
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
])
|
||||
})
|
||||
|
||||
it("can wrap WSL launches to emit the Linux PID marker", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||
wslPidMarker: "__CODENOMAD_WSL_PID__:",
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
|
||||
"codenomad-wsl-launch",
|
||||
"/home/dev/workspace",
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
])
|
||||
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
|
||||
})
|
||||
|
||||
it("builds the WSL kill command for tracked Linux PIDs", () => {
|
||||
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,70 @@ import {
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
function defaultShellPath(): string {
|
||||
const configured = process.env.SHELL?.trim()
|
||||
if (configured) {
|
||||
return configured
|
||||
}
|
||||
|
||||
return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"
|
||||
}
|
||||
|
||||
function shellEscape(input: string): string {
|
||||
if (!input) return "''"
|
||||
return `'${input.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function wrapCommandForShell(command: string, shellPath: string): string {
|
||||
const shellName = path.basename(shellPath).toLowerCase()
|
||||
|
||||
if (shellName.includes("bash")) {
|
||||
return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`
|
||||
}
|
||||
|
||||
if (shellName.includes("zsh")) {
|
||||
return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
function buildShellArgs(shellPath: string, command: string): string[] {
|
||||
const shellName = path.basename(shellPath).toLowerCase()
|
||||
if (shellName.includes("zsh")) {
|
||||
return ["-l", "-i", "-c", command]
|
||||
}
|
||||
return ["-l", "-c", command]
|
||||
}
|
||||
|
||||
function resolveBinaryPathFromUserShell(identifier: string): string | null {
|
||||
if (process.platform === "win32") {
|
||||
return null
|
||||
}
|
||||
|
||||
const shellPath = defaultShellPath()
|
||||
const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath)
|
||||
const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_prefix: undefined,
|
||||
NPM_CONFIG_PREFIX: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = String(result.stdout ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
|
||||
return resolved ?? null
|
||||
}
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
settings: SettingsService
|
||||
@@ -266,6 +330,12 @@ export class WorkspaceManager {
|
||||
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
||||
}
|
||||
|
||||
const shellResolved = resolveBinaryPathFromUserShell(identifier)
|
||||
if (shellResolved) {
|
||||
this.options.logger.debug({ identifier, resolved: shellResolved }, "Resolved binary path from user shell")
|
||||
return shellResolved
|
||||
}
|
||||
|
||||
return identifier
|
||||
}
|
||||
|
||||
|
||||
@@ -4,100 +4,10 @@ import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
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 }
|
||||
}
|
||||
|
||||
const extension = path.extname(binaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
// cmd.exe requires the full command as a single string.
|
||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||
|
||||
return {
|
||||
command: comspec,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
options: { windowsVerbatimArguments: true } as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||
// powershell.exe ships with Windows. (pwsh may not.)
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||
options: {} as const,
|
||||
}
|
||||
}
|
||||
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
}
|
||||
|
||||
export function probeBinaryVersion(binaryPath: string): {
|
||||
valid: boolean
|
||||
version?: string
|
||||
reported?: string
|
||||
error?: string
|
||||
} {
|
||||
if (!binaryPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||
|
||||
try {
|
||||
const result = spawnSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
windowsVerbatimArguments: Boolean(
|
||||
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
|
||||
),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { valid: false, error: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim()
|
||||
const stdout = result.stdout?.trim()
|
||||
const combined = stderr || stdout
|
||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||
return { valid: false, error }
|
||||
}
|
||||
|
||||
const stdoutLines = String(result.stdout ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
const stderrLines = String(result.stderr ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||
if (!reported) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const versionMatch = reported.match(VERSION_REGEX)
|
||||
const version = versionMatch?.[1]
|
||||
return { valid: true, version, reported }
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
|
||||
|
||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
|
||||
|
||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const redacted: Record<string, string | undefined> = {}
|
||||
@@ -130,6 +40,10 @@ export interface ProcessExitInfo {
|
||||
interface ManagedProcess {
|
||||
child: ChildProcess
|
||||
requestedStop: boolean
|
||||
wsl?: {
|
||||
distro: string
|
||||
linuxPid: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
@@ -167,7 +81,13 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||
const propagatedEnvKeys = Object.keys(options.environment ?? {})
|
||||
const spec = buildSpawnSpec(options.binaryPath, args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
propagateEnvKeys: propagatedEnvKeys,
|
||||
wslPidMarker: WSL_PID_MARKER,
|
||||
})
|
||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||
this.logger.info(
|
||||
{
|
||||
@@ -197,14 +117,18 @@ export class WorkspaceRuntime {
|
||||
)
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached,
|
||||
...spec.options,
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
const managed: ManagedProcess = {
|
||||
child,
|
||||
requestedStop: false,
|
||||
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
|
||||
}
|
||||
this.processes.set(options.workspaceId, managed)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
@@ -284,6 +208,15 @@ export class WorkspaceRuntime {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
|
||||
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
|
||||
if (Number.isFinite(linuxPid) && linuxPid > 0) {
|
||||
managed.wsl.linuxPid = linuxPid
|
||||
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
recentStdout.push(trimmed)
|
||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||
recentStdout.shift()
|
||||
@@ -398,11 +331,44 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
const trySignalWslProcess = (signal: NodeJS.Signals) => {
|
||||
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
|
||||
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("no such process") || combined.includes("not found")) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
|
||||
"WSL kill failed",
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
// Best-effort: terminate the whole process tree rooted at pid.
|
||||
// Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
|
||||
if (!trySignalWslProcess(signal)) {
|
||||
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
307
packages/server/src/workspaces/spawn.ts
Normal file
307
packages/server/src/workspaces/spawn.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||
|
||||
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
||||
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
|
||||
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
|
||||
|
||||
export interface SpawnSpec {
|
||||
command: string
|
||||
args: string[]
|
||||
options: {
|
||||
windowsVerbatimArguments?: boolean
|
||||
}
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
wsl?: {
|
||||
distro: string
|
||||
pidMarker?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildSpawnSpecOptions {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
propagateEnvKeys?: string[]
|
||||
wslPidMarker?: string
|
||||
}
|
||||
|
||||
interface WslPath {
|
||||
distro: string
|
||||
linuxPath: string
|
||||
}
|
||||
|
||||
export type WslWorkingDirectory =
|
||||
| { kind: "linux"; path: string }
|
||||
| { kind: "windows"; path: string }
|
||||
|
||||
export function parseWslUncPath(input: string): WslPath | null {
|
||||
const normalized = input.trim().replace(/\//g, "\\")
|
||||
const match = normalized.match(WSL_UNC_PATH_REGEX)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const distro = match[1] ?? ""
|
||||
const remainder = match[2] ?? ""
|
||||
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
|
||||
|
||||
return {
|
||||
distro,
|
||||
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
|
||||
const wslFolder = parseWslUncPath(folder)
|
||||
if (wslFolder) {
|
||||
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
|
||||
}
|
||||
|
||||
const windowsFolder = normalizeWindowsPath(folder)
|
||||
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
|
||||
}
|
||||
|
||||
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||
const wslPath = parseWslUncPath(binaryPath)
|
||||
if (wslPath) {
|
||||
return buildWslSpawnSpec(wslPath, args, options)
|
||||
}
|
||||
|
||||
const extension = path.extname(binaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
// cmd.exe requires the full command as a single string.
|
||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||
|
||||
return {
|
||||
command: comspec,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
options: { windowsVerbatimArguments: true },
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||
// powershell.exe ships with Windows. (pwsh may not.)
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: binaryPath,
|
||||
args,
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||
if (process.platform !== "win32") {
|
||||
return {
|
||||
command: binaryPath,
|
||||
args,
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
return buildWindowsSpawnSpec(binaryPath, args, options)
|
||||
}
|
||||
|
||||
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
|
||||
return {
|
||||
command: "wsl.exe",
|
||||
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
|
||||
options: {},
|
||||
wsl: { distro },
|
||||
}
|
||||
}
|
||||
|
||||
export function probeBinaryVersion(binaryPath: string): {
|
||||
valid: boolean
|
||||
version?: string
|
||||
reported?: string
|
||||
error?: string
|
||||
} {
|
||||
if (!binaryPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||
const result = spawnSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { valid: false, error: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim()
|
||||
const stdout = result.stdout?.trim()
|
||||
const combined = stderr || stdout
|
||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||
return { valid: false, error }
|
||||
}
|
||||
|
||||
const stdoutLines = String(result.stdout ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
const stderrLines = String(result.stderr ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||
if (!reported) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const versionMatch = reported.match(VERSION_REGEX)
|
||||
const version = versionMatch?.[1]
|
||||
return { valid: true, version, reported }
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
|
||||
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
|
||||
if (options.cwd && !workingDirectory) {
|
||||
throw new Error(
|
||||
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
|
||||
)
|
||||
}
|
||||
|
||||
const wslArgs = ["--distribution", wslPath.distro]
|
||||
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
|
||||
|
||||
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
|
||||
wslArgs.push("--cd", workingDirectory.path)
|
||||
}
|
||||
|
||||
if (shouldWrapWithShell) {
|
||||
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
|
||||
wslArgs.push(
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
launchScript,
|
||||
"codenomad-wsl-launch",
|
||||
)
|
||||
if (workingDirectory) {
|
||||
wslArgs.push(workingDirectory.path)
|
||||
}
|
||||
wslArgs.push(
|
||||
wslPath.linuxPath,
|
||||
...args,
|
||||
)
|
||||
} else {
|
||||
wslArgs.push("--exec", wslPath.linuxPath, ...args)
|
||||
}
|
||||
|
||||
return {
|
||||
command: "wsl.exe",
|
||||
args: wslArgs,
|
||||
options: {},
|
||||
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
|
||||
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
|
||||
const steps: string[] = []
|
||||
|
||||
if (pidMarker) {
|
||||
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
|
||||
}
|
||||
|
||||
if (workingDirectory?.kind === "linux") {
|
||||
steps.push('cd "$1"')
|
||||
steps.push("shift")
|
||||
} else if (workingDirectory?.kind === "windows") {
|
||||
steps.push('cd "$(wslpath -au "$1")"')
|
||||
steps.push("shift")
|
||||
}
|
||||
|
||||
steps.push('exec "$@"')
|
||||
return steps.join(" && ")
|
||||
}
|
||||
|
||||
function normalizeWindowsPath(input: string): string | null {
|
||||
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
|
||||
if (!env) {
|
||||
return env
|
||||
}
|
||||
|
||||
const keysToPropagate = Array.from(
|
||||
new Set([
|
||||
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
|
||||
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
|
||||
]),
|
||||
)
|
||||
if (keysToPropagate.length === 0) {
|
||||
return env
|
||||
}
|
||||
|
||||
const next = { ...env }
|
||||
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
|
||||
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
|
||||
|
||||
for (const key of keysToPropagate) {
|
||||
const existingEntry = byName.get(key)
|
||||
if (existingEntry) {
|
||||
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
|
||||
continue
|
||||
}
|
||||
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
|
||||
}
|
||||
|
||||
next.WSLENV = Array.from(byName.values()).join(":")
|
||||
return next
|
||||
}
|
||||
|
||||
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
|
||||
if (!requiresPathTranslation) {
|
||||
return entry
|
||||
}
|
||||
|
||||
const [name, rawFlags = ""] = entry.split("/")
|
||||
if (rawFlags.includes("p")) {
|
||||
return entry
|
||||
}
|
||||
|
||||
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
|
||||
}
|
||||
@@ -14,6 +14,6 @@
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
"@tauri-apps/cli": "^2.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const serverDevInstallCommand =
|
||||
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 serverStandaloneBuildCommand = "npm run build:standalone --workspace @neuralnomads/codenomad"
|
||||
|
||||
const envWithRootBin = {
|
||||
...process.env,
|
||||
@@ -77,6 +78,15 @@ function ensureServerBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStandaloneServerBuild() {
|
||||
console.log("[prebuild] building standalone server executable...")
|
||||
execSync(serverStandaloneBuildCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
@@ -117,15 +127,19 @@ function ensureServerDevDependencies() {
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring server production dependencies...")
|
||||
execSync(serverInstallCommand, {
|
||||
console.log("[prebuild] pruning server to production dependencies...")
|
||||
execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
|
||||
cwd: serverRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (!fs.existsSync(braceExpansionPath)) {
|
||||
console.log("[prebuild] restoring missing server production dependencies...")
|
||||
execSync(serverInstallCommand, {
|
||||
cwd: serverRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUiDevDependencies() {
|
||||
@@ -178,6 +192,47 @@ function ensureRollupPlatformBinary() {
|
||||
})
|
||||
}
|
||||
|
||||
function ensureEsbuildPlatformBinary() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@esbuild/linux-x64",
|
||||
"linux-arm64": "@esbuild/linux-arm64",
|
||||
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||
"darwin-x64": "@esbuild/darwin-x64",
|
||||
"win32-arm64": "@esbuild/win32-arm64",
|
||||
"win32-x64": "@esbuild/win32-x64",
|
||||
}
|
||||
|
||||
const pkgName = platformPackages[platformKey]
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = path.join(workspaceRoot, "node_modules", ...pkgName.split("/"))
|
||||
if (fs.existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let esbuildVersion = ""
|
||||
try {
|
||||
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "esbuild", "package.json")).version
|
||||
} catch {
|
||||
try {
|
||||
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "vite", "node_modules", "esbuild", "package.json")).version
|
||||
} catch {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
}
|
||||
|
||||
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||
|
||||
console.log("[prebuild] installing esbuild platform binary (optional dep workaround)...")
|
||||
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
@@ -256,8 +311,10 @@ function copyUiLoadingAssets() {
|
||||
ensureUiDevDependencies()
|
||||
await ensureMonacoAssets()
|
||||
ensureRollupPlatformBinary()
|
||||
ensureServerDependencies()
|
||||
ensureEsbuildPlatformBinary()
|
||||
ensureServerBuild()
|
||||
ensureStandaloneServerBuild()
|
||||
ensureServerDependencies()
|
||||
ensureUiBuild()
|
||||
syncServerUiBundle()
|
||||
copyServerArtifacts()
|
||||
|
||||
@@ -5,10 +5,10 @@ edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
tauri-build = { version = "2.5.6", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
tauri = { version = "2.10.1", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
@@ -136,6 +136,10 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
fn launch_cwd() -> Option<PathBuf> {
|
||||
std::env::current_dir().ok()
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
@@ -624,16 +628,19 @@ impl CliProcessManager {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
}
|
||||
|
||||
let cwd = workspace_root();
|
||||
let cwd = launch_cwd();
|
||||
if let Some(ref c) = cwd {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let use_user_shell = supports_user_shell();
|
||||
|
||||
if !use_user_shell && which::which(&resolution.node_binary).is_err() {
|
||||
if resolution.runner == Runner::Tsx
|
||||
&& !use_user_shell
|
||||
&& which::which(&resolution.node_binary).is_err()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Node binary '{}' not found. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
||||
"Node binary '{}' not found. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
||||
resolution.node_binary
|
||||
));
|
||||
}
|
||||
@@ -642,9 +649,17 @@ impl CliProcessManager {
|
||||
log_line("spawning via user shell");
|
||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||
} else {
|
||||
log_line("spawning directly with node");
|
||||
log_line(if resolution.runner == Runner::Standalone {
|
||||
"spawning directly with standalone executable"
|
||||
} else {
|
||||
"spawning directly with node"
|
||||
});
|
||||
ShellCommandType::Direct(DirectCommand {
|
||||
program: resolution.node_binary.clone(),
|
||||
program: if resolution.runner == Runner::Standalone {
|
||||
resolution.entry.clone()
|
||||
} else {
|
||||
resolution.node_binary.clone()
|
||||
},
|
||||
args: resolution.runner_args(&args),
|
||||
})
|
||||
};
|
||||
@@ -654,11 +669,13 @@ impl CliProcessManager {
|
||||
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||
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());
|
||||
if resolution.runner != Runner::Standalone {
|
||||
c.env("ELECTRON_RUN_AS_NODE", "1");
|
||||
}
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
@@ -671,9 +688,11 @@ impl CliProcessManager {
|
||||
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
||||
let mut c = Command::new(&cmd.program);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if resolution.runner != Runner::Standalone {
|
||||
c.env("ELECTRON_RUN_AS_NODE", "1");
|
||||
}
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
@@ -924,7 +943,7 @@ impl CliProcessManager {
|
||||
let mut locked = status.lock();
|
||||
if locked.error.is_none() {
|
||||
locked.error = Some(format!(
|
||||
"Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
||||
"Node binary '{}' not found in the desktop shell environment. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
||||
node_binary.trim()
|
||||
));
|
||||
}
|
||||
@@ -1047,7 +1066,7 @@ struct CliEntry {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Runner {
|
||||
Node,
|
||||
Standalone,
|
||||
Tsx,
|
||||
}
|
||||
|
||||
@@ -1068,17 +1087,17 @@ impl CliEntry {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = resolve_dist_entry(app) {
|
||||
if let Some(entry) = resolve_standalone_entry(app) {
|
||||
return Ok(Self {
|
||||
entry,
|
||||
runner: Runner::Node,
|
||||
runner: Runner::Standalone,
|
||||
runner_path: None,
|
||||
node_binary,
|
||||
node_binary: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
||||
"Unable to locate the packaged CodeNomad standalone server. Please rebuild the desktop bundle."
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1132,6 +1151,10 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||
if self.runner == Runner::Standalone {
|
||||
return cli_args.to_vec();
|
||||
}
|
||||
|
||||
let mut args = VecDeque::new();
|
||||
if self.runner == Runner::Tsx {
|
||||
if let Some(path) = &self.runner_path {
|
||||
@@ -1204,45 +1227,37 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
fn resolve_standalone_entry(_app: &AppHandle) -> Option<String> {
|
||||
let executable_name = if cfg!(windows) {
|
||||
"codenomad-server.exe"
|
||||
} else {
|
||||
"codenomad-server"
|
||||
};
|
||||
let base = workspace_root();
|
||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||
base.as_ref()
|
||||
.map(|p| p.join("packages/server/dist/index.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||
];
|
||||
let mut candidates = vec![base
|
||||
.as_ref()
|
||||
.map(|p| p.join("packages/server/dist").join(executable_name))];
|
||||
|
||||
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")));
|
||||
candidates.push(Some(
|
||||
dir.join("resources/server/dist").join(executable_name),
|
||||
));
|
||||
|
||||
let resources = dir.join("../Resources");
|
||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/server/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist").join(executable_name)));
|
||||
candidates.push(Some(
|
||||
resources.join("resources/server/dist/server/index.js"),
|
||||
resources
|
||||
.join("resources/server/dist")
|
||||
.join(executable_name),
|
||||
));
|
||||
|
||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||
for root in linux_resource_roots {
|
||||
candidates.push(Some(root.join("server/dist/bin.js")));
|
||||
candidates.push(Some(root.join("server/dist/index.js")));
|
||||
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
||||
candidates.push(Some(root.join("server/dist/server/index.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
|
||||
candidates.push(Some(root.join("server/dist").join(executable_name)));
|
||||
candidates.push(Some(
|
||||
root.join("resources/server/dist").join(executable_name),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1256,22 +1271,55 @@ fn build_shell_command_string(
|
||||
) -> anyhow::Result<ShellCommand> {
|
||||
let shell = default_shell();
|
||||
let mut quoted: Vec<String> = Vec::new();
|
||||
quoted.push(shell_escape(&entry.node_binary));
|
||||
for arg in entry.runner_args(cli_args) {
|
||||
quoted.push(shell_escape(&arg));
|
||||
}
|
||||
let command = format!(
|
||||
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
|
||||
shell_escape(&entry.node_binary),
|
||||
quoted.join(" "),
|
||||
MISSING_NODE_PREFIX,
|
||||
shell_escape(&entry.node_binary),
|
||||
);
|
||||
let args = build_shell_args(&shell, &command);
|
||||
let command = if entry.runner == Runner::Standalone {
|
||||
quoted.push(shell_escape(&entry.entry));
|
||||
for arg in cli_args {
|
||||
quoted.push(shell_escape(arg));
|
||||
}
|
||||
format!("exec {}", quoted.join(" "))
|
||||
} else {
|
||||
quoted.push(shell_escape(&entry.node_binary));
|
||||
for arg in entry.runner_args(cli_args) {
|
||||
quoted.push(shell_escape(&arg));
|
||||
}
|
||||
format!(
|
||||
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
|
||||
shell_escape(&entry.node_binary),
|
||||
quoted.join(" "),
|
||||
MISSING_NODE_PREFIX,
|
||||
shell_escape(&entry.node_binary),
|
||||
)
|
||||
};
|
||||
let wrapped_command = wrap_command_for_shell(&command, &shell);
|
||||
let args = build_shell_args(&shell, &wrapped_command);
|
||||
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||
Ok(ShellCommand { shell, args })
|
||||
}
|
||||
|
||||
fn wrap_command_for_shell(command: &str, shell: &str) -> String {
|
||||
let shell_name = std::path::Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if shell_name.contains("bash") {
|
||||
return format!(
|
||||
"if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; {}",
|
||||
command
|
||||
);
|
||||
}
|
||||
|
||||
if shell_name.contains("zsh") {
|
||||
return format!(
|
||||
"if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; {}",
|
||||
command
|
||||
);
|
||||
}
|
||||
|
||||
command.to_string()
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
if let Ok(shell) = std::env::var("SHELL") {
|
||||
if !shell.trim().is_empty() {
|
||||
@@ -1306,8 +1354,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if shell_name.contains("zsh") || shell_name.contains("bash") {
|
||||
vec!["-i".into(), "-l".into(), "-c".into(), command.into()]
|
||||
if shell_name.contains("zsh") {
|
||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||
} else {
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ use tauri::webview::Webview;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
||||
};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
use tauri_plugin_global_shortcut::{
|
||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||
};
|
||||
@@ -41,6 +40,8 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||
const ZOOM_STEP: f64 = 0.1;
|
||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
|
||||
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
@@ -78,34 +79,6 @@ fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
|
||||
});
|
||||
}
|
||||
|
||||
async fn confirm_local_certificate_install(app: &AppHandle) -> Result<bool, String> {
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
|
||||
let mut dialog = app
|
||||
.dialog()
|
||||
.message(
|
||||
"CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
|
||||
)
|
||||
.title("Install Local Certificate")
|
||||
.kind(MessageDialogKind::Warning)
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
"Continue".into(),
|
||||
"Cancel".into(),
|
||||
));
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
dialog = dialog.parent(&window);
|
||||
}
|
||||
|
||||
dialog.show(move |accepted| {
|
||||
let _ = sender.send(accepted);
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || receiver.recv().unwrap_or(false))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
|
||||
let status = app.state::<AppState>().manager.status();
|
||||
let Some(base_url) = status.url else {
|
||||
@@ -172,8 +145,8 @@ fn wake_lock_start(
|
||||
config: Option<WakeLockConfig>,
|
||||
) -> Result<(), String> {
|
||||
let config = config.unwrap_or(WakeLockConfig {
|
||||
display: true,
|
||||
idle: false,
|
||||
display: false,
|
||||
idle: true,
|
||||
sleep: false,
|
||||
});
|
||||
|
||||
@@ -329,6 +302,7 @@ async fn open_remote_window_impl(
|
||||
let initial_url = window_url.clone();
|
||||
|
||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
||||
.initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
@@ -367,6 +341,24 @@ async fn open_remote_window_impl(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn needs_local_certificate_install() -> Result<bool, String> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
|
||||
format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}")
|
||||
})?;
|
||||
return cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
|
||||
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -379,17 +371,6 @@ async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Res
|
||||
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
|
||||
)
|
||||
})?;
|
||||
if cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
|
||||
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
|
||||
})? {
|
||||
let accepted = confirm_local_certificate_install(&app).await?;
|
||||
if !accepted {
|
||||
return Err(
|
||||
"CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
||||
return Err(format!(
|
||||
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
||||
@@ -564,6 +545,9 @@ fn main() {
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
build_menu(&app.handle())?;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
|
||||
}
|
||||
if let Some(shortcut) = fullscreen_shortcut() {
|
||||
let shortcut_manager = app.handle().global_shortcut();
|
||||
let _ = shortcut_manager.register(shortcut.clone());
|
||||
@@ -598,6 +582,7 @@ fn main() {
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop,
|
||||
needs_local_certificate_install,
|
||||
open_remote_window
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
|
||||
@@ -43,11 +43,6 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"files": {
|
||||
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop"
|
||||
}
|
||||
},
|
||||
"deb": {
|
||||
"files": {
|
||||
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
|
||||
|
||||
@@ -22,7 +22,7 @@ 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 { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||
import {
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
updateSessionModel,
|
||||
} from "./stores/sessions"
|
||||
|
||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||
import { hasWakeLockEligibleWork } from "./stores/session-status"
|
||||
import { openSettings } from "./stores/settings-screen"
|
||||
import {
|
||||
closeSidecarTab,
|
||||
@@ -137,7 +137,7 @@ const App: Component = () => {
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const shouldShow =
|
||||
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||
!isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||
})
|
||||
|
||||
@@ -204,8 +204,7 @@ const App: Component = () => {
|
||||
const shouldHoldWakeLock = createMemo(() => {
|
||||
const map = instances()
|
||||
for (const id of map.keys()) {
|
||||
const status = getInstanceSessionIndicatorStatus(id)
|
||||
if (status !== "idle") {
|
||||
if (hasWakeLockEligibleWork(id)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -444,7 +443,7 @@ const App: Component = () => {
|
||||
|
||||
// Listen for Tauri menu events
|
||||
onMount(() => {
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||
if (tauriBridge?.event) {
|
||||
let unlistenMenu: (() => void) | null = null
|
||||
|
||||
@@ -58,6 +58,16 @@ function resolveAbsolutePath(root: string, relativePath: string) {
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) {
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
}
|
||||
|
||||
type FolderRow =
|
||||
| { type: "up"; path: string }
|
||||
| { type: "folder"; entry: FileSystemEntry }
|
||||
@@ -67,6 +77,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [pathInput, setPathInput] = createSignal("")
|
||||
const [pathInputDirty, setPathInputDirty] = createSignal(false)
|
||||
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||
@@ -75,12 +87,16 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
|
||||
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||
let latestNavigationId = 0
|
||||
|
||||
function resetState() {
|
||||
setRootPath("")
|
||||
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
|
||||
setLoadingPaths(new Set<string>())
|
||||
setCurrentPathKey(null)
|
||||
setCurrentMetadata(null)
|
||||
setPathInput("")
|
||||
setPathInputDirty(false)
|
||||
metadataCache.clear()
|
||||
inFlightRequests.clear()
|
||||
setError(null)
|
||||
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const metadata = await loadDirectory()
|
||||
applyMetadata(metadata)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
await navigateTo()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -197,13 +209,22 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
}
|
||||
|
||||
async function navigateTo(path?: string) {
|
||||
const navigationId = ++latestNavigationId
|
||||
setError(null)
|
||||
try {
|
||||
const metadata = await loadDirectory(path)
|
||||
if (navigationId !== latestNavigationId) {
|
||||
return null
|
||||
}
|
||||
applyMetadata(metadata)
|
||||
return metadata
|
||||
} catch (err) {
|
||||
if (navigationId !== latestNavigationId) {
|
||||
return null
|
||||
}
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,31 +246,58 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
})
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
setPathInputDirty(false)
|
||||
void navigateTo(path)
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
const parent = currentMetadata()?.parentPath
|
||||
if (parent) {
|
||||
setPathInputDirty(false)
|
||||
void navigateTo(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const currentAbsolutePath = createMemo(() => {
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata) {
|
||||
return ""
|
||||
return getAbsolutePathFromMetadata(currentMetadata())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const absolutePath = currentAbsolutePath()
|
||||
if (!pathInputDirty()) {
|
||||
setPathInput(absolutePath)
|
||||
}
|
||||
if (metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
})
|
||||
|
||||
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
||||
const canSubmitPath = createMemo(() => pathInput().trim().length > 0)
|
||||
|
||||
async function handlePathSubmit() {
|
||||
const target = pathInput().trim()
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
const metadata = await navigateTo(target)
|
||||
if (!metadata) {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||
}
|
||||
|
||||
async function handleSelectCurrent() {
|
||||
const target = pathInput().trim()
|
||||
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||
if (!metadata) {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
const absolute = getAbsolutePathFromMetadata(metadata)
|
||||
if (absolute) {
|
||||
setPathInput(absolute)
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolutePath = entry.absolutePath
|
||||
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
|
||||
async function handleCreateFolder() {
|
||||
if (creatingFolder()) return
|
||||
const metadata = currentMetadata()
|
||||
const target = pathInput().trim()
|
||||
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||
|
||||
const name =
|
||||
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<div class="directory-browser-current">
|
||||
<div class="directory-browser-current-meta">
|
||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput()}
|
||||
onInput={(event) => {
|
||||
setPathInput(event.currentTarget.value)
|
||||
setPathInputDirty(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void handlePathSubmit()
|
||||
}
|
||||
}}
|
||||
spellcheck={false}
|
||||
class="selector-input directory-browser-current-path"
|
||||
/>
|
||||
</div>
|
||||
<div class="directory-browser-current-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
|
||||
onClick={() => void handleSelectCurrent()}
|
||||
>
|
||||
{t("directoryBrowser.selectCurrent")}
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
@@ -16,7 +16,7 @@ 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 { runtimeEnv } from "../lib/runtime-env"
|
||||
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
|
||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
@@ -58,7 +58,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
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
|
||||
|
||||
type LanguageOption = { value: Locale; label: string }
|
||||
@@ -78,6 +77,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const folders = () => recentFolders()
|
||||
const serverList = () => remoteServers()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
|
||||
|
||||
function getActiveListLength() {
|
||||
return activeTab() === "local" ? folders().length : serverList().length
|
||||
@@ -124,17 +124,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
const normalizedKey = e.key.toLowerCase()
|
||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||
const blockedKeys = [
|
||||
"ArrowDown",
|
||||
"ArrowUp",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
"Home",
|
||||
"End",
|
||||
"Enter",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
]
|
||||
const blockedKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", "Enter"]
|
||||
|
||||
if (isLoading()) {
|
||||
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
|
||||
@@ -192,21 +182,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleEnterKey()
|
||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault()
|
||||
if (listLength > 0 && focusMode() === "recent") {
|
||||
if (activeTab() === "local") {
|
||||
const folder = folders()[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
}
|
||||
} else {
|
||||
const server = serverList()[selectedIndex()]
|
||||
if (server) {
|
||||
removeRemoteServerProfile(server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +206,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
createEffect(() => {
|
||||
activeTab()
|
||||
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
|
||||
setActiveTab("local")
|
||||
return
|
||||
}
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("recent")
|
||||
})
|
||||
@@ -305,11 +284,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
}
|
||||
|
||||
function openServerDialog() {
|
||||
if (!canUseRemoteServerWindows()) return
|
||||
resetServerDialog()
|
||||
setIsServerDialogOpen(true)
|
||||
}
|
||||
|
||||
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||
if (openWindow && !canUseRemoteServerWindows()) {
|
||||
throw new Error("Remote server windows can only be opened from a local desktop window")
|
||||
}
|
||||
|
||||
const trimmedName = input.name.trim()
|
||||
const trimmedUrl = input.baseUrl.trim()
|
||||
if (!trimmedName || !trimmedUrl) {
|
||||
@@ -334,7 +318,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
if (openWindow) {
|
||||
const remoteProxySession =
|
||||
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
||||
isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
||||
? await serverApi.createRemoteProxySession({
|
||||
baseUrl: profile.baseUrl,
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
@@ -379,6 +363,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
}
|
||||
|
||||
async function handleConnectSavedServer(id: string) {
|
||||
if (!canUseRemoteServerWindows()) return
|
||||
const target = remoteServers().find((entry) => entry.id === id)
|
||||
if (!target || connectingServerId()) return
|
||||
setConnectingServerId(id)
|
||||
@@ -397,7 +382,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
if (nativeDialogsAvailable) {
|
||||
if (supportsNativeDialogsInCurrentWindow()) {
|
||||
const fallbackPath = folders()[0]?.path
|
||||
const selected = await openNativeFolderDialog({
|
||||
title: t("folderSelection.dialog.title"),
|
||||
@@ -554,15 +539,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
>
|
||||
<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={canUseRemoteServerWindows()}>
|
||||
<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>
|
||||
<Show when={props.onClose}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -636,7 +623,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header !gap-0 !p-0">
|
||||
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
||||
<div class={`grid ${canUseRemoteServerWindows() ? "grid-cols-2" : "grid-cols-1"} gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none`}>
|
||||
<button
|
||||
type="button"
|
||||
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||
@@ -671,35 +658,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
)}
|
||||
</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)",
|
||||
<Show when={canUseRemoteServerWindows()}>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "servers",
|
||||
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.tabs.servers")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("servers")}
|
||||
>
|
||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||
</p>
|
||||
</button>
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -707,23 +696,25 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
when={activeTab() === "local"}
|
||||
fallback={
|
||||
<Show
|
||||
when={remoteServers().length > 0}
|
||||
when={canUseRemoteServerWindows() && 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" />
|
||||
<Show when={canUseRemoteServerWindows()}>
|
||||
<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
|
||||
type="button"
|
||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||
onClick={openServerDialog}
|
||||
>
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||
onClick={openServerDialog}
|
||||
>
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
@@ -891,15 +882,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</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>
|
||||
<Show when={canUseRemoteServerWindows()}>
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* OpenCode settings section */}
|
||||
@@ -935,10 +928,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>{t("folderSelection.hints.select")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>{t("folderSelection.hints.remove")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { canOpenRemoteWindows } from "../lib/runtime-env"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { openSettings } from "../stores/settings-screen"
|
||||
import type { AppTabRecord } from "../stores/app-tabs"
|
||||
@@ -99,14 +100,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<Show when={canOpenRemoteWindows()}>
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,9 +171,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
void handleEnterKey()
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault()
|
||||
void handleDeleteKey()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,29 +184,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
if (index >= sessions.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleSessionDelete(sessions[index].id)
|
||||
|
||||
const updatedSessions = parentSessions()
|
||||
if (updatedSessions.length === 0) {
|
||||
setFocusMode("new-session")
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
||||
setSelectedIndex(nextIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(nextIndex)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
@@ -562,10 +536,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>{t("instanceWelcome.hints.resume")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>{t("instanceWelcome.hints.delete")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
@@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
|
||||
@@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
async function handleBrowseBinary() {
|
||||
if (props.disabled) return
|
||||
setValidationError(null)
|
||||
if (nativeDialogsAvailable) {
|
||||
if (supportsNativeDialogsInCurrentWindow()) {
|
||||
const selected = await openNativeFileDialog({
|
||||
title: t("opencodeBinarySelector.dialog.title"),
|
||||
})
|
||||
|
||||
@@ -15,25 +15,33 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||
import { canOpenRemoteWindows } from "../lib/runtime-env"
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const { t } = useI18n()
|
||||
|
||||
const sections = createMemo(() => [
|
||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||
])
|
||||
const sections = createMemo(() => {
|
||||
const items = [
|
||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||
]
|
||||
|
||||
if (canOpenRemoteWindows()) {
|
||||
items.splice(2, 0, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") })
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const renderSection = () => {
|
||||
switch (activeSettingsSection()) {
|
||||
case "notifications":
|
||||
return <NotificationsSettingsSection />
|
||||
case "remote":
|
||||
return <RemoteAccessSettingsSection />
|
||||
return canOpenRemoteWindows() ? <RemoteAccessSettingsSection /> : <AppearanceSettingsSection />
|
||||
case "speech":
|
||||
return <SpeechSettingsSection />
|
||||
case "sidecars":
|
||||
|
||||
@@ -17,6 +17,8 @@ interface LspDiagnostic {
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
export type DiagnosticsMap = Record<string, LspDiagnostic[] | undefined>
|
||||
|
||||
export interface DiagnosticEntry {
|
||||
id: string
|
||||
severity: number
|
||||
@@ -30,7 +32,7 @@ export interface DiagnosticEntry {
|
||||
column: number
|
||||
}
|
||||
|
||||
function normalizeDiagnosticPath(path: string) {
|
||||
export function normalizeDiagnosticPath(path: string) {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
@@ -53,49 +55,73 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr
|
||||
|
||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||
const input = (state.input || {}) as Record<string, unknown>
|
||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
||||
const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined
|
||||
if (!diagnosticsMap) return []
|
||||
|
||||
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find(
|
||||
(value) => typeof value === "string" && value.length > 0,
|
||||
) as string | undefined
|
||||
return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path].map((value) =>
|
||||
typeof value === "string" ? value : undefined,
|
||||
))
|
||||
}
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
if (!normalizedPreferred) return []
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): string | undefined {
|
||||
if (Object.keys(diagnostics).length === 0) return undefined
|
||||
|
||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
return normalized === normalizedPreferred
|
||||
})
|
||||
const normalizedPreferred = preferredPaths
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.map((value) => normalizeDiagnosticPath(value))
|
||||
|
||||
if (prioritizedEntries.length === 0) return []
|
||||
if (normalizedPreferred.length === 0) return undefined
|
||||
|
||||
for (const preferred of normalizedPreferred) {
|
||||
if (diagnostics[preferred]) return preferred
|
||||
}
|
||||
|
||||
const keys = Object.keys(diagnostics)
|
||||
|
||||
for (const preferred of normalizedPreferred) {
|
||||
const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred)
|
||||
if (direct) return direct
|
||||
}
|
||||
|
||||
for (const preferred of normalizedPreferred) {
|
||||
const suffixMatch = keys.find((key) => {
|
||||
const normalized = normalizeDiagnosticPath(key)
|
||||
return normalized === preferred || normalized.endsWith("/" + preferred)
|
||||
})
|
||||
if (suffixMatch) return suffixMatch
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): DiagnosticEntry[] {
|
||||
const key = resolveDiagnosticsKey(diagnostics, preferredPaths)
|
||||
if (!key) return []
|
||||
|
||||
const list = diagnostics[key]
|
||||
if (!Array.isArray(list) || list.length === 0) return []
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
if (!Array.isArray(list)) continue
|
||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
const normalizedPath = normalizeDiagnosticPath(key)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
|
||||
@@ -1,107 +1,14 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
import type { DiagnosticEntry } from "../diagnostics"
|
||||
|
||||
type LspRangePosition = {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
type LspRange = {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
type LspDiagnostic = {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics"
|
||||
|
||||
type ApplyPatchFile = {
|
||||
filePath?: string
|
||||
relativePath?: string
|
||||
type?: string
|
||||
diff?: string
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
|
||||
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
|
||||
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function resolveDiagnosticsKey(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
): string | undefined {
|
||||
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
|
||||
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
|
||||
if (absolute && diagnostics[absolute]) return absolute
|
||||
if (relative && diagnostics[relative]) return relative
|
||||
|
||||
if (absolute) {
|
||||
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
|
||||
if (direct) return direct
|
||||
}
|
||||
|
||||
if (relative) {
|
||||
const suffixMatch = Object.keys(diagnostics).find((key) => {
|
||||
const normalized = normalizePath(key)
|
||||
return normalized === relative || normalized.endsWith("/" + relative)
|
||||
})
|
||||
if (suffixMatch) return suffixMatch
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): DiagnosticEntry[] {
|
||||
const key = resolveDiagnosticsKey(diagnostics, file)
|
||||
if (!key) return []
|
||||
const list = diagnostics[key]
|
||||
if (!Array.isArray(list) || list.length === 0) return []
|
||||
|
||||
const normalizedKey = normalizePath(key)
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone, t)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
|
||||
entries.push({
|
||||
id: `${normalizedKey}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedKey,
|
||||
displayPath: getRelativePath(normalizedKey),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
patch?: string
|
||||
}
|
||||
|
||||
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
|
||||
@@ -164,7 +71,7 @@ export const applyPatchRenderer: ToolRenderer = {
|
||||
})
|
||||
const diagnosticsMap = createMemo(() => {
|
||||
const value = (payload.metadata as any).diagnostics
|
||||
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
|
||||
return value && typeof value === "object" ? (value as DiagnosticsMap) : {}
|
||||
})
|
||||
|
||||
if (files().length === 0) {
|
||||
@@ -178,9 +85,9 @@ export const applyPatchRenderer: ToolRenderer = {
|
||||
<For each={files()}>
|
||||
{(file, index) => {
|
||||
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
|
||||
const diffText = typeof file.diff === "string" ? file.diff : ""
|
||||
const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
|
||||
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
|
||||
const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath]))
|
||||
|
||||
return (
|
||||
<div class="tool-call-apply-patch-file">
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
normalizeDroppedDirectoryPaths,
|
||||
supportsDesktopFolderDrop,
|
||||
} from "../native/desktop-file-drop"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { isTauriHost } from "../runtime-env"
|
||||
|
||||
interface UseFolderDropOptions {
|
||||
enabled: Accessor<boolean>
|
||||
@@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
|
||||
const bind: FolderDropBindings = {
|
||||
onDragEnter(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
setIsActive(true)
|
||||
},
|
||||
onDragOver(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
setIsActive(true)
|
||||
},
|
||||
onDragLeave(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
|
||||
if (!isSupported || isTauriHost() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
return
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||
"folderSelection.servers.certificateInstall.title": "Install Local Certificate",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "Continue",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "Cancel",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Conectando...",
|
||||
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
||||
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "Cancelar",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||
"folderSelection.servers.certificateInstall.title": "Installer le certificat local",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "Annuler",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||
"folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "ביטול",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "接続中...",
|
||||
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
||||
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
||||
"folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "キャンセル",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Подключение...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
||||
"folderSelection.servers.certificateInstall.title": "Установить локальный сертификат",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "Продолжить",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "Отмена",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "连接中...",
|
||||
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
||||
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
||||
"folderSelection.servers.certificateInstall.title": "安装本地证书",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "继续",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "取消",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
export async function restartCli(): Promise<boolean> {
|
||||
if (!canRestartCli()) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
|
||||
if (api?.restartCli) {
|
||||
await api.restartCli()
|
||||
@@ -15,7 +19,7 @@ export async function restartCli(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
||||
await invoke("cli_restart")
|
||||
return true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { getLogger } from "../logger"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -21,7 +21,7 @@ function getFilePath(file: File): string | null {
|
||||
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
||||
return file.path
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
||||
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
||||
return electronPath
|
||||
@@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]>
|
||||
}
|
||||
|
||||
export function supportsDesktopFolderDrop(): boolean {
|
||||
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
|
||||
return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop()
|
||||
}
|
||||
|
||||
export function containsFileDrop(event: DragEvent): boolean {
|
||||
@@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<s
|
||||
if (uniquePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
return resolveElectronDirectoryPaths(uniquePaths)
|
||||
}
|
||||
return uniquePaths
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
if (!isTauriHost()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
if (!isTauriHost()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env"
|
||||
import type { NativeDialogOptions } from "./types"
|
||||
import { openElectronNativeDialog } from "./electron/functions"
|
||||
import { openTauriNativeDialog } from "./tauri/functions"
|
||||
@@ -6,20 +6,23 @@ import { openTauriNativeDialog } from "./tauri/functions"
|
||||
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
|
||||
|
||||
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
|
||||
switch (runtimeEnv.host) {
|
||||
case "electron":
|
||||
return openElectronNativeDialog
|
||||
case "tauri":
|
||||
return openTauriNativeDialog
|
||||
default:
|
||||
return null
|
||||
if (isElectronHost()) {
|
||||
return openElectronNativeDialog
|
||||
}
|
||||
if (isTauriHost()) {
|
||||
return openTauriNativeDialog
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function supportsNativeDialogs(): boolean {
|
||||
return resolveNativeHandler() !== null
|
||||
}
|
||||
|
||||
export function supportsNativeDialogsInCurrentWindow(): boolean {
|
||||
return canUseNativeDialogs()
|
||||
}
|
||||
|
||||
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||
const handler = resolveNativeHandler()
|
||||
if (!handler) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import type { RemoteServerProfile } from "../../../../server/src/api-types"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { showConfirmDialog } from "../../stores/alerts"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
|
||||
|
||||
export interface RemoteWindowOpenPayload {
|
||||
id: string
|
||||
@@ -16,6 +18,10 @@ export async function openRemoteServerWindow(
|
||||
entryUrl?: string,
|
||||
proxySessionId?: string,
|
||||
): Promise<void> {
|
||||
if (!canOpenRemoteWindows()) {
|
||||
throw new Error("Remote server windows can only be opened from a local desktop window")
|
||||
}
|
||||
|
||||
const payload: RemoteWindowOpenPayload = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
@@ -25,7 +31,7 @@ export async function openRemoteServerWindow(
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||
if (typeof api?.openRemoteWindow === "function") {
|
||||
await api.openRemoteWindow(payload)
|
||||
@@ -33,7 +39,29 @@ export async function openRemoteServerWindow(
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
const requiresLocalCertificate =
|
||||
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")
|
||||
|
||||
if (requiresLocalCertificate) {
|
||||
const needsInstall = await invoke<boolean>("needs_local_certificate_install")
|
||||
if (needsInstall) {
|
||||
const accepted = await showConfirmDialog(
|
||||
tGlobal("folderSelection.servers.certificateInstall.confirmMessage"),
|
||||
{
|
||||
title: tGlobal("folderSelection.servers.certificateInstall.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: tGlobal("folderSelection.servers.certificateInstall.confirmLabel"),
|
||||
cancelLabel: tGlobal("folderSelection.servers.certificateInstall.cancelLabel"),
|
||||
},
|
||||
)
|
||||
|
||||
if (!accepted) {
|
||||
throw new Error(tGlobal("folderSelection.servers.certificateInstall.cancelled"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await invoke("open_remote_window", { payload })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { isElectronHost, isTauriHost } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
@@ -9,61 +9,16 @@ let inFlight: Promise<boolean> | null = null
|
||||
|
||||
let applied = false
|
||||
|
||||
let webWakeLock: any = null
|
||||
|
||||
async function setWebWakeLock(enabled: boolean): Promise<boolean> {
|
||||
if (typeof navigator === "undefined") return false
|
||||
|
||||
const api = (navigator as any).wakeLock
|
||||
if (!api?.request) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
if (webWakeLock) {
|
||||
return true
|
||||
}
|
||||
webWakeLock = await api.request("screen")
|
||||
try {
|
||||
webWakeLock.addEventListener?.("release", () => {
|
||||
// If the lock is released by the UA (e.g., tab hidden), clear local state.
|
||||
webWakeLock = null
|
||||
if (desired) {
|
||||
// Re-acquire best-effort.
|
||||
queueMicrotask(() => {
|
||||
void setWakeLockDesired(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// optional
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (webWakeLock) {
|
||||
await webWakeLock.release?.()
|
||||
}
|
||||
webWakeLock = null
|
||||
return false
|
||||
} catch (error) {
|
||||
log.log("[wake-lock] web wake lock failed", error)
|
||||
webWakeLock = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnyWakeLockSupport(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const api = (window as any).electronAPI
|
||||
if (api?.setWakeLock) return true
|
||||
}
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
return typeof window.__TAURI__?.core?.invoke === "function"
|
||||
}
|
||||
return Boolean((navigator as any)?.wakeLock?.request)
|
||||
return false
|
||||
}
|
||||
|
||||
async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
|
||||
@@ -89,9 +44,7 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// Match Electron's prevent-display-sleep behavior by keeping the display
|
||||
// awake without blocking explicit system sleep requests.
|
||||
await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } })
|
||||
await invoke("wake_lock_start", { config: { display: false, idle: true, sleep: false } })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -106,19 +59,17 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
||||
async function applyWakeLock(enabled: boolean): Promise<boolean> {
|
||||
if (typeof window === "undefined") return false
|
||||
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const ok = await setElectronWakeLock(enabled)
|
||||
if (ok || !enabled) return ok
|
||||
// fallback to web API if electron preload didn't expose it
|
||||
return ok
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
const ok = await setTauriWakeLock(enabled)
|
||||
if (ok || !enabled) return ok
|
||||
// fallback to web API if tauri command isn't available
|
||||
return ok
|
||||
}
|
||||
|
||||
return await setWebWakeLock(enabled)
|
||||
return false
|
||||
}
|
||||
|
||||
export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
|
||||
|
||||
@@ -2,10 +2,12 @@ import { getLogger } from "./logger"
|
||||
|
||||
export type HostRuntime = "electron" | "tauri" | "web"
|
||||
export type PlatformKind = "desktop" | "mobile"
|
||||
export type WindowContextKind = "local" | "remote"
|
||||
|
||||
export interface RuntimeEnvironment {
|
||||
host: HostRuntime
|
||||
platform: PlatformKind
|
||||
windowContext: WindowContextKind
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -14,6 +16,7 @@ declare global {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CODENOMAD_WINDOW_CONTEXT__?: WindowContextKind
|
||||
electronAPI?: unknown
|
||||
__TAURI__?: {
|
||||
core?: TauriCoreModule
|
||||
@@ -21,11 +24,41 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
function detectWindowContext(): WindowContextKind {
|
||||
if (typeof window === "undefined") {
|
||||
return "remote"
|
||||
}
|
||||
|
||||
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "remote") {
|
||||
return "remote"
|
||||
}
|
||||
|
||||
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "local") {
|
||||
return "local"
|
||||
}
|
||||
|
||||
const win = window as Window & { electronAPI?: unknown }
|
||||
if (typeof win.electronAPI !== "undefined" || typeof win.__TAURI__ !== "undefined") {
|
||||
return "local"
|
||||
}
|
||||
|
||||
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
|
||||
return "local"
|
||||
}
|
||||
|
||||
return "remote"
|
||||
}
|
||||
|
||||
function detectHost(): HostRuntime {
|
||||
if (typeof window === "undefined") {
|
||||
return "web"
|
||||
}
|
||||
|
||||
const explicitHost = window.__CODENOMAD_RUNTIME_HOST__
|
||||
if (explicitHost) {
|
||||
return explicitHost
|
||||
}
|
||||
|
||||
const win = window as Window & { electronAPI?: unknown }
|
||||
if (typeof win.electronAPI !== "undefined") {
|
||||
return "electron"
|
||||
@@ -71,16 +104,24 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
|
||||
cachedEnv = {
|
||||
host: detectHost(),
|
||||
platform: detectPlatform(),
|
||||
windowContext: detectWindowContext(),
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`)
|
||||
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform} context=${cachedEnv.windowContext}`)
|
||||
}
|
||||
return cachedEnv
|
||||
}
|
||||
|
||||
export const runtimeEnv = detectRuntimeEnvironment()
|
||||
|
||||
export const isElectronHost = () => runtimeEnv.host === "electron"
|
||||
export const isTauriHost = () => runtimeEnv.host === "tauri"
|
||||
export const isWebHost = () => runtimeEnv.host === "web"
|
||||
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"
|
||||
export const isElectronHost = () => detectHost() === "electron"
|
||||
export const isTauriHost = () => detectHost() === "tauri"
|
||||
export const isWebHost = () => detectHost() === "web"
|
||||
export const isDesktopHost = () => isElectronHost() || isTauriHost()
|
||||
export const isMobilePlatform = () => detectPlatform() === "mobile"
|
||||
export const isLocalWindow = () => detectWindowContext() === "local"
|
||||
export const isRemoteWindow = () => detectWindowContext() === "remote"
|
||||
export const canUseNativeDialogs = () => isDesktopHost() && isLocalWindow()
|
||||
export const canOpenRemoteWindows = () => isDesktopHost() && isLocalWindow()
|
||||
export const canRestartCli = () => isDesktopHost() && isLocalWindow()
|
||||
export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow()
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from "../../stores/preferences"
|
||||
import type { Command } from "../commands"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { isWebHost } from "../runtime-env"
|
||||
|
||||
export type BehaviorSettingKind = "toggle" | "enum"
|
||||
|
||||
@@ -84,7 +84,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
||||
next,
|
||||
)
|
||||
},
|
||||
disabled: () => runtimeEnv.host === "web",
|
||||
disabled: () => isWebHost(),
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
@@ -337,13 +337,13 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[]
|
||||
),
|
||||
description: () =>
|
||||
tGlobal(
|
||||
runtimeEnv.host === "web"
|
||||
isWebHost()
|
||||
? "commands.keyboardShortcutHints.description.disabledWeb"
|
||||
: "commands.keyboardShortcutHints.description",
|
||||
),
|
||||
category: "System",
|
||||
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
||||
disabled: () => runtimeEnv.host === "web",
|
||||
disabled: () => isWebHost(),
|
||||
action: actions.toggleKeyboardShortcutHints,
|
||||
},
|
||||
{
|
||||
|
||||
20
packages/ui/src/stores/session-status.test.ts
Normal file
20
packages/ui/src/stores/session-status.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility.ts"
|
||||
|
||||
describe("shouldSessionHoldWakeLock", () => {
|
||||
it("holds wake lock only for qualifying active work", () => {
|
||||
assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: false }), true)
|
||||
assert.equal(
|
||||
shouldSessionHoldWakeLock({ status: "compacting", pendingPermission: false, pendingQuestion: false }),
|
||||
true,
|
||||
)
|
||||
assert.equal(shouldSessionHoldWakeLock({ status: "idle", pendingPermission: false, pendingQuestion: false }), false)
|
||||
})
|
||||
|
||||
it("does not hold wake lock while waiting for permission or input", () => {
|
||||
assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: true, pendingQuestion: false }), false)
|
||||
assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: true }), false)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { Session, SessionRetryState, SessionStatus } from "../types/session"
|
||||
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
|
||||
import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility"
|
||||
|
||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
export function hasWakeLockEligibleWork(instanceId: string): boolean {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (shouldSessionHoldWakeLock(session)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
|
||||
const session = getSession(instanceId, sessionId)
|
||||
if (!session) {
|
||||
|
||||
11
packages/ui/src/stores/wake-lock-eligibility.ts
Normal file
11
packages/ui/src/stores/wake-lock-eligibility.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Session } from "../types/session"
|
||||
|
||||
export function shouldSessionHoldWakeLock(
|
||||
session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion">,
|
||||
): boolean {
|
||||
if (session.pendingPermission || session.pendingQuestion) {
|
||||
return false
|
||||
}
|
||||
|
||||
return session.status === "working" || session.status === "compacting"
|
||||
}
|
||||
12
packages/ui/src/types/global.d.ts
vendored
12
packages/ui/src/types/global.d.ts
vendored
@@ -63,10 +63,12 @@ declare global {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CODENOMAD_API_BASE__?: string
|
||||
__CODENOMAD_EVENTS_URL__?: string
|
||||
electronAPI?: ElectronAPI
|
||||
__TAURI__?: TauriBridge
|
||||
codenomadLogger?: LoggerControls
|
||||
__CODENOMAD_API_BASE__?: string
|
||||
__CODENOMAD_EVENTS_URL__?: string
|
||||
__CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web"
|
||||
__CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote"
|
||||
electronAPI?: ElectronAPI
|
||||
__TAURI__?: TauriBridge
|
||||
codenomadLogger?: LoggerControls
|
||||
}
|
||||
}
|
||||
|
||||
19
tasks/current.md
Normal file
19
tasks/current.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Current Tasks
|
||||
|
||||
## Active Discussions
|
||||
|
||||
- DISCUSSION-001 — Wake lock behavior change for macOS sleep vs screen lock — summarized, routed to task 056
|
||||
|
||||
## Active
|
||||
|
||||
- 055-wake-lock-investigation.md — standard / investigation / logic — Assigned to tech_lead
|
||||
- 056-wake-lock-behavior-change.md — complex / spec / logic — Assigned to business_analyst
|
||||
- 057-implement-system-sleep-only-wake-lock.md — complex / implementation / logic — Assigned to workflow_runner
|
||||
|
||||
## Todo
|
||||
|
||||
- 023-symbol-attachments.md
|
||||
|
||||
## Blocked
|
||||
|
||||
- None.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
id: DISCUSSION-001
|
||||
title: "Wake lock behavior change for macOS sleep vs screen lock"
|
||||
status: closed
|
||||
summarized_by: business_analyst
|
||||
source: runtime-transcript
|
||||
---
|
||||
|
||||
# Discussion Summary
|
||||
|
||||
## Topic
|
||||
Change wake lock behavior so screen lock/display sleep is allowed while system sleep is still prevented during active work.
|
||||
|
||||
## Purpose
|
||||
Capture a workflow-ready summary of a requested product behavior change affecting desktop apps and web, including current behavior, desired behavior, scope, and unresolved platform feasibility.
|
||||
|
||||
## Repository Truth Relevant To This Discussion
|
||||
- Current desktop wake lock behavior is effectively configured as a display wake lock.
|
||||
- Electron currently uses `prevent-display-sleep`.
|
||||
- Tauri currently includes `display: true` in its wake-lock-related configuration.
|
||||
- This current setup keeps the screen awake and blocks normal screen lock/display sleep on macOS.
|
||||
|
||||
## Facts Established
|
||||
- The reported problem is specific to current wake lock behavior preventing screen lock on macOS.
|
||||
- The user wants wake lock to allow screen lock while still preventing the device from going to sleep.
|
||||
- The requested scope was expanded beyond macOS-only behavior.
|
||||
- The user explicitly requested coverage for all desktop apps and web.
|
||||
- Browser/web platform limitations may affect how fully the requested behavior can be implemented.
|
||||
|
||||
## Requirements Captured
|
||||
- Wake lock must allow the display to sleep or lock normally.
|
||||
- Wake lock must prevent only system sleep while work is active.
|
||||
- On macOS, the screen should be able to turn off and lock while the machine remains awake enough to continue the task.
|
||||
- The change should be researched and then applied, not just discussed.
|
||||
- Scope should include all desktop apps and web, subject to technical feasibility.
|
||||
|
||||
## Constraints
|
||||
- The change affects multiple platforms and should not be treated as a macOS-only behavior change.
|
||||
- Web support may be constrained by browser capabilities and wake lock API limitations.
|
||||
- Platform-specific implementation details may differ between Electron, Tauri, and web.
|
||||
|
||||
## Non-Goals
|
||||
- Keeping the display continuously awake.
|
||||
- Preserving the current display-wake behavior on macOS.
|
||||
- Defining a macOS-only special case unless later justified.
|
||||
|
||||
## Decisions Made
|
||||
- Preferred product direction: allow display sleep/screen lock while preventing only system sleep during active work.
|
||||
- Scope direction confirmed by the user: all desktop apps and web.
|
||||
- The discussion should move into tracked workflow work with product and technical input before implementation.
|
||||
|
||||
## Assumptions
|
||||
- “Work is active” refers to periods when the application is performing a task that currently relies on wake lock protection.
|
||||
- The intended outcome is continued task execution while the screen is locked or asleep, not continuous visual display.
|
||||
- Some platforms may require best-effort behavior rather than identical implementation mechanics.
|
||||
|
||||
## Open Questions
|
||||
- What exact user-facing definition of “work is active” should trigger wake lock behavior across products?
|
||||
- What behavior is achievable on web given browser/API support and permission constraints?
|
||||
- If a platform cannot prevent only system sleep without also affecting display sleep, what fallback behavior is acceptable?
|
||||
- Should platform-specific differences be exposed to users or documented in product behavior notes?
|
||||
|
||||
## Risks Or Concerns
|
||||
- Web may not support the requested behavior fully or consistently across browsers.
|
||||
- A platform may not offer a clean “prevent system sleep only” mode, creating inconsistent behavior across products.
|
||||
- Changing wake lock semantics could affect long-running task reliability if background execution assumptions are wrong.
|
||||
|
||||
## Referenced Files Or Areas
|
||||
- Electron wake lock implementation using `prevent-display-sleep`
|
||||
- Tauri wake lock / `keepawake` configuration currently using `display: true`
|
||||
- Cross-platform wake lock behavior for desktop apps
|
||||
- Web wake lock behavior and browser capability research areas
|
||||
|
||||
## Recommended Workflow Next Step
|
||||
- assigned_to: product_manager
|
||||
- why: Create a tracked task and SCR-ready handoff for cross-platform research and specification, then route to business analyst and technical architect for requirements and feasibility clarification before implementation.
|
||||
54
tasks/todo/055-wake-lock-investigation.md
Normal file
54
tasks/todo/055-wake-lock-investigation.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Wake Lock Investigation
|
||||
complexity: standard
|
||||
track: investigation
|
||||
slice: logic
|
||||
status: active
|
||||
assigned_to: tech_lead
|
||||
---
|
||||
|
||||
# Goal
|
||||
|
||||
Understand and explain how wake lock is held across `packages/ui`, `packages/tauri-app`, and `packages/electron-app`, including which layer initiates the request, which native/platform APIs are used, and how acquire/release lifecycle is coordinated.
|
||||
|
||||
# Request Context
|
||||
|
||||
The Product Owner asked: "Understand how we hold wake lock in packages/ui packages/tauri-app and packages/electron-app".
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- AC-1: Identify all wake-lock-related code paths in `packages/ui`, `packages/tauri-app`, and `packages/electron-app`.
|
||||
- AC-2: Explain which package owns the wake-lock decision and which package executes the platform-specific hold/release behavior.
|
||||
- AC-3: Describe the acquire and release lifecycle, including triggering events, cleanup behavior, and any fallback or unsupported-platform handling.
|
||||
- AC-4: Note any discrepancies, risks, or unclear behavior discovered during the investigation.
|
||||
|
||||
# Instructions For Assigned Agent
|
||||
|
||||
1. Read this task file first.
|
||||
2. Investigate the repository code paths relevant to wake lock in the three packages named above.
|
||||
3. Produce a concise technical report using the specialist output contract sections:
|
||||
- Summary
|
||||
- Work Performed
|
||||
- Acceptance Criteria Coverage
|
||||
- Documentation Impact
|
||||
- Open Risks
|
||||
- Recommended Next Step
|
||||
4. Include file paths and function/module names for the relevant wake-lock implementation points.
|
||||
5. Update this task file with a `# Post Implementation Task Updates` section and `## Tech Lead: Post Implementation Expectations` bullets describing the observable outputs of your investigation.
|
||||
|
||||
# Discussion Record
|
||||
|
||||
- Created by PMA to answer a direct user investigation request about wake-lock behavior across UI and native app shells.
|
||||
|
||||
# Notes
|
||||
|
||||
- This is investigation only. Do not implement changes.
|
||||
- Repository has unrelated untracked items in the working tree (`.nomadworks/`, `.playwright-cli/`, `cloudsecrets`, `tmp/`). Treat them as pre-existing and out of scope unless directly relevant.
|
||||
|
||||
# Post Implementation Task Updates
|
||||
|
||||
## Tech Lead: Post Implementation Expectations
|
||||
|
||||
- Deliver a wake-lock investigation report that traces the call flow from `packages/ui/src/App.tsx` through `packages/ui/src/lib/native/wake-lock.ts` into the Electron preload/main IPC path and the Tauri command path.
|
||||
- Identify which session states cause the UI to request wake lock and which native APIs actually hold or release the lock on Electron and Tauri.
|
||||
- Document lifecycle behavior for acquire, release, fallback handling, unsupported-platform behavior, and any cleanup gaps or discrepancies discovered during review.
|
||||
76
tasks/todo/056-wake-lock-behavior-change.md
Normal file
76
tasks/todo/056-wake-lock-behavior-change.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Wake Lock Behavior Change
|
||||
complexity: complex
|
||||
track: spec
|
||||
slice: logic
|
||||
status: active
|
||||
assigned_to: business_analyst
|
||||
scr: SCR-2026-04-21-001
|
||||
---
|
||||
|
||||
# Goal
|
||||
|
||||
Research and define the cross-platform wake-lock behavior change so implementation can safely shift from display wake to system-sleep-only behavior wherever feasible.
|
||||
|
||||
# Request Context
|
||||
|
||||
The Product Owner requested: allow screen lock/display sleep while preventing device sleep during active work, across desktop apps and web, and asked to research options and apply the change.
|
||||
|
||||
# Inputs
|
||||
|
||||
- `tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md`
|
||||
- `tasks/todo/055-wake-lock-investigation.md`
|
||||
- `docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md`
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- AC-1: Define product intent for when wake lock should engage, using explicit user-observable terms.
|
||||
- AC-2: Document platform feasibility/options for Electron, Tauri, and web, including unsupported cases.
|
||||
- AC-3: Recommend a product-safe fallback policy for any platform that cannot prevent system sleep without also preventing screen lock.
|
||||
- AC-4: Update the SCR with clarified scope and implementation-ready acceptance criteria.
|
||||
|
||||
# Instructions For Assigned Agents
|
||||
|
||||
## Business Analyst
|
||||
|
||||
1. Read this task file and all listed inputs in full.
|
||||
2. Clarify the product behavior and fallback expectations.
|
||||
3. Update the SCR with product-facing scope, terminology, and refined ACs.
|
||||
4. Return the specialist output contract sections.
|
||||
|
||||
## Tech Lead
|
||||
|
||||
1. After BA input, assess technical feasibility and implementation options for Electron, Tauri, and web.
|
||||
2. Identify the safest implementation direction and any platform gaps.
|
||||
3. Return the specialist output contract sections.
|
||||
|
||||
# Discussion Record
|
||||
|
||||
- Discussion summary captured in `tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md`.
|
||||
- User confirmed scope should include all desktop apps and web.
|
||||
- User asked to research options and apply the resulting change.
|
||||
|
||||
# Notes
|
||||
|
||||
- This task starts as spec/discovery. Implementation should not begin until product and technical feasibility are clear enough to proceed safely.
|
||||
|
||||
# Reviews
|
||||
|
||||
- Business Analyst review completed: clarified that product intent is consistent across platforms but platform execution is best-effort, defined "active work" in user-observable terms, and set the fallback rule to prefer no wake lock over display-wake behavior when system-sleep-only support is unavailable.
|
||||
- Tech Lead review completed: Electron can switch safely to `powerSaveBlocker.start("prevent-app-suspension")`; Tauri can target `keepawake` with `display: false, idle: true, sleep: false` as the closest cross-platform system-idle-sleep-only mode; web has no viable standards-based system-sleep-only API and must fall back to no wake lock. Scope is implementation-ready with explicit platform limitation notes and verification of long-running background behavior per desktop runtime.
|
||||
|
||||
# Post Implementation Task Updates
|
||||
|
||||
## Business Analyst: Post Implementation Expectations
|
||||
|
||||
- The SCR defines "active work" as only currently running in-app work that should continue without continuous foreground interaction, and excludes idle, paused, completed, cancelled, or waiting-for-input states.
|
||||
- The SCR states a consistent product rule across platforms: allow screen lock/display sleep, use system-sleep-only protection where available, and release protection promptly when qualifying work ends.
|
||||
- The SCR defines unsupported-platform fallback behavior, including the explicit rule that web or any other unsupported runtime must not use display/screen wake as a substitute and should instead fall back to no wake lock.
|
||||
- The SCR leaves final platform feasibility validation to technical review without expanding scope into implementation or new user settings.
|
||||
|
||||
## Tech Lead: Post Implementation Expectations
|
||||
|
||||
- Electron wake lock behavior changes from `prevent-display-sleep` to `prevent-app-suspension`, so active work keeps the machine awake without intentionally blocking display sleep or screen lock.
|
||||
- Tauri wake lock requests use the native `keepawake` path with `display: false, idle: true, sleep: false`, aligning behavior to prevent idle system sleep without requesting display wake or explicit-sleep inhibition.
|
||||
- Web no longer attempts `navigator.wakeLock.request("screen")` for this feature path and instead degrades to no wake lock with a documented limitation because the web platform does not expose a true system-sleep-only primitive.
|
||||
- Runtime behavior releases protection promptly when qualifying active work ends, pauses, fails, is cancelled, or the app cleans up, and desktop verification confirms long-running work continues while the display can sleep or lock.
|
||||
64
tasks/todo/057-implement-system-sleep-only-wake-lock.md
Normal file
64
tasks/todo/057-implement-system-sleep-only-wake-lock.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Implement System-Sleep-Only Wake Lock
|
||||
complexity: complex
|
||||
track: implementation
|
||||
slice: logic
|
||||
status: active
|
||||
assigned_to: workflow_runner
|
||||
scr: SCR-2026-04-21-001
|
||||
---
|
||||
|
||||
# Goal
|
||||
|
||||
Implement the approved wake-lock behavior change so qualifying active work prevents idle system sleep where supported, allows screen lock/display sleep, and degrades to no wake lock on unsupported platforms.
|
||||
|
||||
# Scope
|
||||
|
||||
- Update wake-lock decision logic in UI so only qualifying active work engages wake lock.
|
||||
- Update Electron to use system-sleep-only behavior instead of display wake.
|
||||
- Update Tauri to request system-idle-sleep prevention without display wake.
|
||||
- Remove the web screen wake-lock fallback for this feature path.
|
||||
- Add/adjust tests and documentation impacted by the behavior change.
|
||||
|
||||
# Inputs
|
||||
|
||||
- `docs/scrs/SCR-2026-04-21-001-wake-lock-system-sleep-only.md`
|
||||
- `tasks/todo/055-wake-lock-investigation.md`
|
||||
- `tasks/todo/056-wake-lock-behavior-change.md`
|
||||
- `tasks/discussions/DISCUSSION-001-wake-lock-behavior-change-for-macos-sleep-vs-screen-lock.md`
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- AC-1: Wake lock engages only for qualifying active work and does not engage for idle, paused, completed, cancelled, or waiting-for-user-input states.
|
||||
- AC-2: Electron uses a system-sleep-only mode that allows screen lock/display sleep while active work is running.
|
||||
- AC-3: Tauri requests the closest supported system-idle-sleep prevention mode without requesting display wake.
|
||||
- AC-4: Web does not use screen/display wake lock as a fallback for this feature path and instead degrades to no wake lock.
|
||||
- AC-5: Wake lock is released promptly when qualifying active work ends or the app cleans up.
|
||||
- AC-6: Required verification demonstrates the new desktop behavior and confirms fallback handling/documentation for unsupported web behavior.
|
||||
|
||||
# Implementation Guidance
|
||||
|
||||
- Align the UI definition of qualifying active work with the SCR. In particular, do not treat waiting-for-input / permission-question states as wake-lock-worthy unless technical review finds a repository truth conflict that must be escalated.
|
||||
- Electron target behavior from Tech Lead review: `powerSaveBlocker.start("prevent-app-suspension")`.
|
||||
- Tauri target behavior from Tech Lead review: use `keepawake` configuration `display: false, idle: true, sleep: false`.
|
||||
- Web fallback from BA/Tech Lead review: no wake lock for this feature path; do not use `navigator.wakeLock.request("screen")` as a substitute.
|
||||
- Update product-facing and/or code-adjacent docs as needed to record the supported-platform limitation and resulting behavior.
|
||||
- Collect clear verification evidence mapped back to AC-1 through AC-6.
|
||||
|
||||
# Specialist Expectations
|
||||
|
||||
- Workflow Runner should orchestrate implementation plus verification using the appropriate specialists.
|
||||
- Introduce specialist roles in verification feedback.
|
||||
- Do not create a final git commit unless PMA explicitly instructs you to do so.
|
||||
|
||||
# Notes
|
||||
|
||||
- Existing unrelated untracked files in the repository are out of scope unless they block verification.
|
||||
|
||||
# Post Implementation Task Updates
|
||||
|
||||
## Workflow Runner: Post Implementation Expectations
|
||||
|
||||
- UI wake-lock eligibility now follows the SCR definition of qualifying active work and excludes sessions waiting on permission or question input.
|
||||
- Electron now requests `prevent-app-suspension`, Tauri now requests `display: false, idle: true, sleep: false`, and web no longer falls back to screen wake lock for this feature path.
|
||||
- Verification covers UI eligibility logic, workspace typechecks/build checks, and the documented unsupported web fallback behavior.
|
||||
Reference in New Issue
Block a user