Compare commits
8 Commits
v0.14.0-de
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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).
|
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -372,7 +372,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
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
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -456,7 +456,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
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
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -542,7 +542,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
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
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -614,6 +614,7 @@ jobs:
|
|||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
xdg-utils \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libglib2.0-dev \
|
libglib2.0-dev \
|
||||||
libwebkit2gtk-4.1-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
@@ -642,6 +643,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
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
|
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
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
@@ -741,6 +743,7 @@ jobs:
|
|||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
xdg-utils \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
g++-aarch64-linux-gnu \
|
g++-aarch64-linux-gnu \
|
||||||
libgtk-3-dev:arm64 \
|
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:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
PUBLISH_NPM_VERSION: 11.5.1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -59,17 +60,24 @@ jobs:
|
|||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
- name: Ensure npm >=11.5.1
|
- name: Prepare pinned npm CLI
|
||||||
run: npm install -g npm@latest
|
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
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: node "$PINNED_NPM_CLI" ci --workspaces
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- 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)
|
- 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
|
- name: Set publish metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -83,7 +91,7 @@ jobs:
|
|||||||
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Bump package version for publish
|
- 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
|
- name: Set server package name for publish
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -107,4 +115,4 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Using NPM_TOKEN authentication"
|
echo "Using NPM_TOKEN authentication"
|
||||||
fi
|
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
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-ui:
|
release-ui:
|
||||||
|
|||||||
2
.github/workflows/reusable-release.yml
vendored
2
.github/workflows/reusable-release.yml
vendored
@@ -39,7 +39,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-release:
|
prepare-release:
|
||||||
|
|||||||
1216
package-lock.json
generated
1216
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,8 @@ function loadLoadingScreen(window: BrowserWindow) {
|
|||||||
loader.catch((error) => {
|
loader.catch((error) => {
|
||||||
console.error("[cli] failed to load loading screen:", error)
|
console.error("[cli] failed to load loading screen:", error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return loader
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||||
@@ -277,6 +279,7 @@ function createWindow() {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
spellcheck: !isMac,
|
spellcheck: !isMac,
|
||||||
|
additionalArguments: ["--codenomad-window-context=local"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -291,7 +294,7 @@ function createWindow() {
|
|||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
clearWindowAllowedOrigin(window)
|
clearWindowAllowedOrigin(window)
|
||||||
loadLoadingScreen(window)
|
const loadingReady = loadLoadingScreen(window)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
window.webContents.openDevTools({ mode: "detach" })
|
window.webContents.openDevTools({ mode: "detach" })
|
||||||
@@ -310,11 +313,7 @@ function createWindow() {
|
|||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (pendingCliUrl) {
|
return loadingReady
|
||||||
const url = pendingCliUrl
|
|
||||||
pendingCliUrl = null
|
|
||||||
startCliPreload(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoadingScreen(force = false) {
|
function showLoadingScreen(force = false) {
|
||||||
@@ -440,6 +439,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
spellcheck: !isMac,
|
spellcheck: !isMac,
|
||||||
|
additionalArguments: ["--codenomad-window-context=remote"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -620,7 +620,8 @@ app.whenReady().then(() => {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
startCli()
|
const loadingReady = createWindow()
|
||||||
|
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
@@ -637,8 +638,11 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createWindow()
|
void loadingReady.finally(() => {
|
||||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
setTimeout(() => {
|
||||||
|
void startCli()
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||||
if (isInsecureOriginAllowed(url)) {
|
if (isInsecureOriginAllowed(url)) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface StartOptions {
|
|||||||
|
|
||||||
interface CliEntryResolution {
|
interface CliEntryResolution {
|
||||||
entry: string
|
entry: string
|
||||||
runner: "node" | "tsx"
|
runner: "node" | "tsx" | "standalone"
|
||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,15 +148,15 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
|
|
||||||
let child: ManagedChild
|
let child: ManagedChild
|
||||||
|
|
||||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
|
||||||
const runtimePath = this.resolveShellNodeCommand()
|
|
||||||
const entryPath = this.resolveBundledProdEntry()
|
|
||||||
const supervisorPath = this.resolveCliSupervisorPath()
|
const supervisorPath = this.resolveCliSupervisorPath()
|
||||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
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({
|
const supervisorPayload = JSON.stringify({
|
||||||
command: shellCommand.command,
|
command: shellCommand.command,
|
||||||
args: shellCommand.args,
|
args: shellCommand.args,
|
||||||
@@ -164,28 +164,33 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.info(
|
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] utility supervisor: ${supervisorPath}`)
|
||||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||||
|
|
||||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||||
env: shellEnv,
|
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
serviceName: "CodeNomad CLI Supervisor",
|
serviceName: "CodeNomad CLI Supervisor",
|
||||||
})
|
})
|
||||||
this.childLaunchMode = "utility"
|
this.childLaunchMode = "utility"
|
||||||
} else {
|
} else {
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
|
||||||
console.info(
|
console.info(
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
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()
|
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)
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
@@ -563,6 +568,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||||
|
if (cliEntry.runner === "standalone") {
|
||||||
|
return this.buildExecutableCommand(cliEntry.entry, args)
|
||||||
|
}
|
||||||
|
|
||||||
const parts = [JSON.stringify(process.execPath)]
|
const parts = [JSON.stringify(process.execPath)]
|
||||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||||
@@ -577,6 +586,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
|
if (cliEntry.runner === "standalone") {
|
||||||
|
return { command: cliEntry.entry, args }
|
||||||
|
}
|
||||||
|
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
}
|
}
|
||||||
@@ -594,8 +607,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
const distEntry = this.resolveProdEntry()
|
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
|
||||||
return { entry: distEntry, runner: "node" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTsx(): string | null {
|
private resolveTsx(): string | null {
|
||||||
@@ -635,20 +647,25 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProdEntry(): string {
|
private resolveStandaloneProdEntry(): string {
|
||||||
try {
|
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
|
||||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
const candidates = [
|
||||||
if (existsSync(entry)) {
|
path.join(process.resourcesPath, "server", "dist", executableName),
|
||||||
return entry
|
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 {
|
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
|
||||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveCliSupervisorPath(): string {
|
private resolveCliSupervisorPath(): string {
|
||||||
@@ -666,26 +683,6 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
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 {
|
private describeUtilityProcessError(error: unknown): string {
|
||||||
if (error instanceof Error && error.message) {
|
if (error instanceof Error && error.message) {
|
||||||
return error.message
|
return error.message
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
function resolveWindowContext() {
|
||||||
|
const prefix = "--codenomad-window-context="
|
||||||
|
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
|
||||||
|
const context = arg ? arg.slice(prefix.length) : "local"
|
||||||
|
return context === "remote" ? "remote" : "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeHost(windowContext) {
|
||||||
|
return "electron"
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowContext = resolveWindowContext()
|
||||||
|
|
||||||
|
const localElectronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||||
@@ -26,4 +39,15 @@ const electronAPI = {
|
|||||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
const remoteElectronAPI = {
|
||||||
|
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
|
||||||
|
setWakeLock: localElectronAPI.setWakeLock,
|
||||||
|
showNotification: localElectronAPI.showNotification,
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld(
|
||||||
|
"electronAPI",
|
||||||
|
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
|
||||||
|
)
|
||||||
|
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
|
||||||
|
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { spawn } from "child_process"
|
import { spawn } from "child_process"
|
||||||
import { existsSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import path, { join } from "path"
|
import path, { join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
@@ -14,6 +14,46 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
|||||||
const nodeModulesPath = join(appDir, "node_modules")
|
const nodeModulesPath = join(appDir, "node_modules")
|
||||||
const workspaceNodeModulesPath = join(workspaceRoot, "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 = {
|
const platforms = {
|
||||||
mac: {
|
mac: {
|
||||||
args: ["--mac", "--x64", "--arm64"],
|
args: ["--mac", "--x64", "--arm64"],
|
||||||
@@ -105,6 +145,8 @@ async function build(platform) {
|
|||||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ensureEsbuildPlatformBinary()
|
||||||
|
|
||||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const npmNodeExecPath = process.env.npm_node_execpath
|
|||||||
|
|
||||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "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) {
|
function log(message) {
|
||||||
console.log(`[prepare-resources] ${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() {
|
function ensureServerDependencies() {
|
||||||
if (fs.existsSync(serverDepsMarker)) {
|
if (fs.existsSync(serverDepsMarker)) {
|
||||||
return
|
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() {
|
function copyServerArtifacts() {
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
@@ -121,7 +195,9 @@ function stripNodeModuleBins() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
|
ensureStandaloneServerBuild()
|
||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
|
ensureEsbuildPlatformBinary()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.3.7"
|
"@opencode-ai/plugin": "1.14.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"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": "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",
|
"build:ui": "npm run build --prefix ../ui",
|
||||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||||
@@ -25,16 +26,16 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^12.6.2",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^9.1.1",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^5.8.5",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"openai": "^6.27.0",
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^8.1.0",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/yauzl": "^2.10.0",
|
"@types/yauzl": "^2.10.0",
|
||||||
|
"bun": "^1.3.13",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"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
|
#!/usr/bin/env node
|
||||||
import { spawnSync } from "child_process"
|
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 path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
@@ -14,6 +14,67 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
|
|||||||
const npmExecPath = process.env.npm_execpath
|
const npmExecPath = process.env.npm_execpath
|
||||||
const npmNodeExecPath = process.env.npm_node_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)) {
|
if (!existsSync(sourceDir)) {
|
||||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
@@ -58,4 +119,14 @@ rmSync(targetDir, { recursive: true, force: true })
|
|||||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||||
cpSync(sourceDir, 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}`)
|
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||||
|
|||||||
@@ -29,13 +29,14 @@ import { SideCarManager } from "./sidecars/manager"
|
|||||||
import { ClientConnectionManager } from "./clients/connection-manager"
|
import { ClientConnectionManager } from "./clients/connection-manager"
|
||||||
import { PluginChannelManager } from "./plugins/channel"
|
import { PluginChannelManager } from "./plugins/channel"
|
||||||
import { VoiceModeManager } from "./plugins/voice-mode"
|
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||||
|
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
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 __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
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 {
|
interface CliOptions {
|
||||||
host: string
|
host: string
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import path from "path"
|
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
|
import { resolveOpencodeTemplateDir } from "./runtime-paths"
|
||||||
|
|
||||||
const log = createLogger({ component: "opencode-config" })
|
const log = createLogger({ component: "opencode-config" })
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const templateDir = resolveOpencodeTemplateDir(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 isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
|
||||||
const templateDir = isDevBuild
|
|
||||||
? devTemplateDir
|
|
||||||
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
|
|
||||||
|
|
||||||
export function getOpencodeConfigDir(): string {
|
export function getOpencodeConfigDir(): string {
|
||||||
if (!existsSync(templateDir)) {
|
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 fs from "fs"
|
||||||
import { connect as connectTcp, type Socket } from "net"
|
import { connect as connectTcp, type Socket } from "net"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
import { pipeline } from "stream/promises"
|
||||||
import { connect as connectTls, type TLSSocket } from "tls"
|
import { connect as connectTls, type TLSSocket } from "tls"
|
||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
@@ -626,57 +628,57 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.from(targetUrl, {
|
const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
|
||||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
|
||||||
if (instanceAuthHeader) {
|
|
||||||
headers.authorization = instanceAuthHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
if (logger.isLevelEnabled("trace")) {
|
||||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
logger.trace(
|
||||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
{
|
||||||
|
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).
|
const init: any = {
|
||||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
redirect: "manual",
|
||||||
|
}
|
||||||
|
|
||||||
if (logger.isLevelEnabled("trace")) {
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
const outgoing: Record<string, unknown> = {}
|
const body = toProxyRequestBody(request.body)
|
||||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
if (body !== undefined) {
|
||||||
outgoing[key] = value
|
init.body = body
|
||||||
}
|
init.duplex = "half"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redact sensitive headers.
|
try {
|
||||||
for (const key of Object.keys(outgoing)) {
|
const response = await fetch(targetUrl, init)
|
||||||
const lower = key.toLowerCase()
|
reply.code(response.status)
|
||||||
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
applyInstanceProxyResponseHeaders(reply, response)
|
||||||
outgoing[key] = "<redacted>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.trace(
|
if (!response.body || request.method === "HEAD") {
|
||||||
{
|
reply.send()
|
||||||
workspaceId,
|
return
|
||||||
method: request.method,
|
}
|
||||||
targetUrl,
|
|
||||||
worktreeSlug,
|
|
||||||
directory,
|
|
||||||
contentType: request.headers["content-type"],
|
|
||||||
body: bodyToJson(request.body),
|
|
||||||
headers: outgoing,
|
|
||||||
},
|
|
||||||
"Proxy -> OpenCode request",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
reply.hijack()
|
||||||
},
|
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
|
||||||
onError: (proxyReply, { error }) => {
|
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
|
||||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
} catch (error) {
|
||||||
if (!proxyReply.sent) {
|
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||||
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
|
if (!reply.sent) {
|
||||||
}
|
reply.code(502).send({ error: "Workspace instance proxy failed" })
|
||||||
},
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||||
@@ -867,12 +869,90 @@ function isApiRequest(rawUrl: string | null | undefined) {
|
|||||||
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
||||||
const result: Record<string, string> = {}
|
const result: Record<string, string> = {}
|
||||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
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
|
result[key] = Array.isArray(value) ? value.join(",") : value
|
||||||
}
|
}
|
||||||
return result
|
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: {
|
async function proxySideCarRequest(args: {
|
||||||
request: FastifyRequest
|
request: FastifyRequest
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from "fs"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { AuthManager } from "../../auth/manager"
|
import type { AuthManager } from "../../auth/manager"
|
||||||
import { isLoopbackAddress } from "../../auth/http-auth"
|
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||||
|
import { resolveAuthTemplatePath } from "../../runtime-paths"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
@@ -21,21 +22,21 @@ const PasswordSchema = z.object({
|
|||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
})
|
})
|
||||||
|
|
||||||
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
|
||||||
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
|
||||||
|
|
||||||
let cachedLoginTemplate: string | null = null
|
let cachedLoginTemplate: string | null = null
|
||||||
let cachedTokenTemplate: 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
|
if (cache) return cache
|
||||||
const content = fs.readFileSync(url, "utf-8")
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLoginHtml(defaultUsername: string): string {
|
function getLoginHtml(defaultUsername: string): string {
|
||||||
if (!cachedLoginTemplate) {
|
if (!cachedLoginTemplate) {
|
||||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapedUsername = escapeHtml(defaultUsername)
|
const escapedUsername = escapeHtml(defaultUsername)
|
||||||
@@ -44,7 +45,7 @@ function getLoginHtml(defaultUsername: string): string {
|
|||||||
|
|
||||||
function getTokenHtml(): string {
|
function getTokenHtml(): string {
|
||||||
if (!cachedTokenTemplate) {
|
if (!cachedTokenTemplate) {
|
||||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedTokenTemplate
|
return cachedTokenTemplate
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
import { probeBinaryVersion } from "../../workspaces/spawn"
|
||||||
import type { SettingsService } from "../../settings/service"
|
import type { SettingsService } from "../../settings/service"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||||
|
|||||||
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
|
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 {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
settings: SettingsService
|
settings: SettingsService
|
||||||
@@ -266,6 +330,12 @@ export class WorkspaceManager {
|
|||||||
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
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
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,100 +4,10 @@ import path from "path"
|
|||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
|
||||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
|
||||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
|
||||||
|
|
||||||
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
|
||||||
|
|
||||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
return { command: binaryPath, args, options: {} as const }
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = path.extname(binaryPath).toLowerCase()
|
|
||||||
|
|
||||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
|
||||||
const comspec = process.env.ComSpec || "cmd.exe"
|
|
||||||
// cmd.exe requires the full command as a single string.
|
|
||||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
|
||||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: comspec,
|
|
||||||
args: ["/d", "/s", "/c", commandLine],
|
|
||||||
options: { windowsVerbatimArguments: true } as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
|
||||||
// powershell.exe ships with Windows. (pwsh may not.)
|
|
||||||
return {
|
|
||||||
command: "powershell.exe",
|
|
||||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
|
||||||
options: {} as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { command: binaryPath, args, options: {} as const }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function probeBinaryVersion(binaryPath: string): {
|
|
||||||
valid: boolean
|
|
||||||
version?: string
|
|
||||||
reported?: string
|
|
||||||
error?: string
|
|
||||||
} {
|
|
||||||
if (!binaryPath) {
|
|
||||||
return { valid: false, error: "Missing binary path" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(spec.command, spec.args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
windowsVerbatimArguments: Boolean(
|
|
||||||
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { valid: false, error: result.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const stderr = result.stderr?.trim()
|
|
||||||
const stdout = result.stdout?.trim()
|
|
||||||
const combined = stderr || stdout
|
|
||||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
|
||||||
return { valid: false, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdoutLines = String(result.stdout ?? "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
const stderrLines = String(result.stderr ?? "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
|
|
||||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
|
||||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
|
||||||
if (!reported) {
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionMatch = reported.match(VERSION_REGEX)
|
|
||||||
const version = versionMatch?.[1]
|
|
||||||
return { valid: true, version, reported }
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||||
|
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
|
||||||
|
|
||||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||||
const redacted: Record<string, string | undefined> = {}
|
const redacted: Record<string, string | undefined> = {}
|
||||||
@@ -130,6 +40,10 @@ export interface ProcessExitInfo {
|
|||||||
interface ManagedProcess {
|
interface ManagedProcess {
|
||||||
child: ChildProcess
|
child: ChildProcess
|
||||||
requestedStop: boolean
|
requestedStop: boolean
|
||||||
|
wsl?: {
|
||||||
|
distro: string
|
||||||
|
linuxPid: number | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkspaceRuntime {
|
export class WorkspaceRuntime {
|
||||||
@@ -167,7 +81,13 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
const propagatedEnvKeys = Object.keys(options.environment ?? {})
|
||||||
|
const spec = buildSpawnSpec(options.binaryPath, args, {
|
||||||
|
cwd: options.folder,
|
||||||
|
env,
|
||||||
|
propagateEnvKeys: propagatedEnvKeys,
|
||||||
|
wslPidMarker: WSL_PID_MARKER,
|
||||||
|
})
|
||||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
@@ -197,14 +117,18 @@ export class WorkspaceRuntime {
|
|||||||
)
|
)
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spec.command, spec.args, {
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: spec.cwd,
|
||||||
env,
|
env: spec.env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached,
|
detached,
|
||||||
...spec.options,
|
...spec.options,
|
||||||
})
|
})
|
||||||
|
|
||||||
const managed: ManagedProcess = { child, requestedStop: false }
|
const managed: ManagedProcess = {
|
||||||
|
child,
|
||||||
|
requestedStop: false,
|
||||||
|
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
|
||||||
|
}
|
||||||
this.processes.set(options.workspaceId, managed)
|
this.processes.set(options.workspaceId, managed)
|
||||||
|
|
||||||
let stdoutBuffer = ""
|
let stdoutBuffer = ""
|
||||||
@@ -284,6 +208,15 @@ export class WorkspaceRuntime {
|
|||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
if (!trimmed) continue
|
if (!trimmed) continue
|
||||||
|
|
||||||
|
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
|
||||||
|
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
|
||||||
|
if (Number.isFinite(linuxPid) && linuxPid > 0) {
|
||||||
|
managed.wsl.linuxPid = linuxPid
|
||||||
|
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
recentStdout.push(trimmed)
|
recentStdout.push(trimmed)
|
||||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||||
recentStdout.shift()
|
recentStdout.shift()
|
||||||
@@ -398,11 +331,44 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trySignalWslProcess = (signal: NodeJS.Signals) => {
|
||||||
|
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
|
||||||
|
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
|
||||||
|
const exitCode = result.status
|
||||||
|
if (exitCode === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||||
|
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||||
|
const combined = `${stdout}\n${stderr}`
|
||||||
|
if (combined.includes("no such process") || combined.includes("not found")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
|
||||||
|
"WSL kill failed",
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
// Best-effort: terminate the whole process tree rooted at pid.
|
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
|
||||||
// Use /F only for escalation.
|
if (!trySignalWslProcess(signal)) {
|
||||||
tryTaskkill(signal === "SIGKILL")
|
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
|
||||||
|
tryTaskkill(signal === "SIGKILL")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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"
|
"build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.10.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const serverDevInstallCommand =
|
|||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"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 serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||||
|
const serverStandaloneBuildCommand = "npm run build:standalone --workspace @neuralnomads/codenomad"
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...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() {
|
function ensureUiBuild() {
|
||||||
const loadingHtml = path.join(uiDist, "loading.html")
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
if (fs.existsSync(loadingHtml)) {
|
if (fs.existsSync(loadingHtml)) {
|
||||||
@@ -117,15 +127,19 @@ function ensureServerDevDependencies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureServerDependencies() {
|
function ensureServerDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
console.log("[prebuild] pruning server to production dependencies...")
|
||||||
return
|
execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[prebuild] ensuring server production dependencies...")
|
|
||||||
execSync(serverInstallCommand, {
|
|
||||||
cwd: serverRoot,
|
cwd: serverRoot,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!fs.existsSync(braceExpansionPath)) {
|
||||||
|
console.log("[prebuild] restoring missing server production dependencies...")
|
||||||
|
execSync(serverInstallCommand, {
|
||||||
|
cwd: serverRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureUiDevDependencies() {
|
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() {
|
function copyServerArtifacts() {
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
@@ -256,8 +311,10 @@ function copyUiLoadingAssets() {
|
|||||||
ensureUiDevDependencies()
|
ensureUiDevDependencies()
|
||||||
await ensureMonacoAssets()
|
await ensureMonacoAssets()
|
||||||
ensureRollupPlatformBinary()
|
ensureRollupPlatformBinary()
|
||||||
ensureServerDependencies()
|
ensureEsbuildPlatformBinary()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
|
ensureStandaloneServerBuild()
|
||||||
|
ensureServerDependencies()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
syncServerUiBundle()
|
syncServerUiBundle()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ edition = "2021"
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.2", features = [] }
|
tauri-build = { version = "2.5.6", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
tauri = { version = "2.10.1", features = [ "devtools"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|||||||
@@ -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 SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
@@ -624,16 +628,19 @@ impl CliProcessManager {
|
|||||||
log_line("development mode: will prefer tsx + source if present");
|
log_line("development mode: will prefer tsx + source if present");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cwd = workspace_root();
|
let cwd = launch_cwd();
|
||||||
if let Some(ref c) = cwd {
|
if let Some(ref c) = cwd {
|
||||||
log_line(&format!("using cwd={}", c.display()));
|
log_line(&format!("using cwd={}", c.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let use_user_shell = supports_user_shell();
|
let use_user_shell = supports_user_shell();
|
||||||
|
|
||||||
if !use_user_shell && which::which(&resolution.node_binary).is_err() {
|
if resolution.runner == Runner::Tsx
|
||||||
|
&& !use_user_shell
|
||||||
|
&& which::which(&resolution.node_binary).is_err()
|
||||||
|
{
|
||||||
return Err(anyhow::anyhow!(
|
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
|
resolution.node_binary
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -642,9 +649,17 @@ impl CliProcessManager {
|
|||||||
log_line("spawning via user shell");
|
log_line("spawning via user shell");
|
||||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
} else {
|
} 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 {
|
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),
|
args: resolution.runner_args(&args),
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -654,11 +669,13 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||||
let mut c = Command::new(&cmd.shell);
|
let mut c = Command::new(&cmd.shell);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
|
||||||
.env_remove("npm_config_prefix")
|
.env_remove("npm_config_prefix")
|
||||||
.env_remove("NPM_CONFIG_PREFIX")
|
.env_remove("NPM_CONFIG_PREFIX")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
if resolution.runner != Runner::Standalone {
|
||||||
|
c.env("ELECTRON_RUN_AS_NODE", "1");
|
||||||
|
}
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
@@ -671,9 +688,11 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
||||||
let mut c = Command::new(&cmd.program);
|
let mut c = Command::new(&cmd.program);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
if resolution.runner != Runner::Standalone {
|
||||||
|
c.env("ELECTRON_RUN_AS_NODE", "1");
|
||||||
|
}
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
@@ -924,7 +943,7 @@ impl CliProcessManager {
|
|||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
if locked.error.is_none() {
|
if locked.error.is_none() {
|
||||||
locked.error = Some(format!(
|
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()
|
node_binary.trim()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1047,7 +1066,7 @@ struct CliEntry {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Runner {
|
enum Runner {
|
||||||
Node,
|
Standalone,
|
||||||
Tsx,
|
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 {
|
return Ok(Self {
|
||||||
entry,
|
entry,
|
||||||
runner: Runner::Node,
|
runner: Runner::Standalone,
|
||||||
runner_path: None,
|
runner_path: None,
|
||||||
node_binary,
|
node_binary: String::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow::anyhow!(
|
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> {
|
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||||
|
if self.runner == Runner::Standalone {
|
||||||
|
return cli_args.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
let mut args = VecDeque::new();
|
let mut args = VecDeque::new();
|
||||||
if self.runner == Runner::Tsx {
|
if self.runner == Runner::Tsx {
|
||||||
if let Some(path) = &self.runner_path {
|
if let Some(path) = &self.runner_path {
|
||||||
@@ -1204,45 +1227,37 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
first_existing(candidates)
|
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 base = workspace_root();
|
||||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
let mut candidates = vec![base
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
.as_ref()
|
||||||
base.as_ref()
|
.map(|p| p.join("packages/server/dist").join(executable_name))];
|
||||||
.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")),
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
candidates.push(Some(
|
||||||
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
dir.join("resources/server/dist").join(executable_name),
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
));
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
|
||||||
|
|
||||||
let resources = dir.join("../Resources");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
candidates.push(Some(resources.join("server/dist").join(executable_name)));
|
||||||
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(
|
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")];
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||||
for root in linux_resource_roots {
|
for root in linux_resource_roots {
|
||||||
candidates.push(Some(root.join("server/dist/bin.js")));
|
candidates.push(Some(root.join("server/dist").join(executable_name)));
|
||||||
candidates.push(Some(root.join("server/dist/index.js")));
|
candidates.push(Some(
|
||||||
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
root.join("resources/server/dist").join(executable_name),
|
||||||
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")));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,22 +1271,55 @@ fn build_shell_command_string(
|
|||||||
) -> anyhow::Result<ShellCommand> {
|
) -> anyhow::Result<ShellCommand> {
|
||||||
let shell = default_shell();
|
let shell = default_shell();
|
||||||
let mut quoted: Vec<String> = Vec::new();
|
let mut quoted: Vec<String> = Vec::new();
|
||||||
quoted.push(shell_escape(&entry.node_binary));
|
let command = if entry.runner == Runner::Standalone {
|
||||||
for arg in entry.runner_args(cli_args) {
|
quoted.push(shell_escape(&entry.entry));
|
||||||
quoted.push(shell_escape(&arg));
|
for arg in 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",
|
format!("exec {}", quoted.join(" "))
|
||||||
shell_escape(&entry.node_binary),
|
} else {
|
||||||
quoted.join(" "),
|
quoted.push(shell_escape(&entry.node_binary));
|
||||||
MISSING_NODE_PREFIX,
|
for arg in entry.runner_args(cli_args) {
|
||||||
shell_escape(&entry.node_binary),
|
quoted.push(shell_escape(&arg));
|
||||||
);
|
}
|
||||||
let args = build_shell_args(&shell, &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 wrapped_command = wrap_command_for_shell(&command, &shell);
|
||||||
|
let args = build_shell_args(&shell, &wrapped_command);
|
||||||
log_line(&format!("user shell command: {} {:?}", shell, args));
|
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||||
Ok(ShellCommand { shell, args })
|
Ok(ShellCommand { shell, args })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fn default_shell() -> String {
|
||||||
if let Ok(shell) = std::env::var("SHELL") {
|
if let Ok(shell) = std::env::var("SHELL") {
|
||||||
if !shell.trim().is_empty() {
|
if !shell.trim().is_empty() {
|
||||||
@@ -1306,8 +1354,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
if shell_name.contains("zsh") || shell_name.contains("bash") {
|
if shell_name.contains("zsh") {
|
||||||
vec!["-i".into(), "-l".into(), "-c".into(), command.into()]
|
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||||
} else {
|
} else {
|
||||||
vec!["-l".into(), "-c".into(), command.into()]
|
vec!["-l".into(), "-c".into(), command.into()]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ use tauri::webview::Webview;
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
||||||
};
|
};
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
|
||||||
use tauri_plugin_global_shortcut::{
|
use tauri_plugin_global_shortcut::{
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||||
};
|
};
|
||||||
@@ -41,6 +40,8 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
|||||||
const ZOOM_STEP: f64 = 0.1;
|
const ZOOM_STEP: f64 = 0.1;
|
||||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||||
|
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
|
||||||
|
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||||
@@ -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> {
|
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
|
||||||
let status = app.state::<AppState>().manager.status();
|
let status = app.state::<AppState>().manager.status();
|
||||||
let Some(base_url) = status.url else {
|
let Some(base_url) = status.url else {
|
||||||
@@ -329,6 +302,7 @@ async fn open_remote_window_impl(
|
|||||||
let initial_url = window_url.clone();
|
let initial_url = window_url.clone();
|
||||||
|
|
||||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
||||||
|
.initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
|
||||||
.title(title)
|
.title(title)
|
||||||
.inner_size(1400.0, 900.0)
|
.inner_size(1400.0, 900.0)
|
||||||
.min_inner_size(800.0, 600.0)
|
.min_inner_size(800.0, 600.0)
|
||||||
@@ -367,6 +341,24 @@ async fn open_remote_window_impl(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn needs_local_certificate_install() -> Result<bool, String> {
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
|
||||||
|
format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}")
|
||||||
|
})?;
|
||||||
|
return cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
|
||||||
|
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[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}"
|
"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) {
|
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
"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| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
set_windows_app_user_model_id();
|
||||||
build_menu(&app.handle())?;
|
build_menu(&app.handle())?;
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
|
||||||
|
}
|
||||||
if let Some(shortcut) = fullscreen_shortcut() {
|
if let Some(shortcut) = fullscreen_shortcut() {
|
||||||
let shortcut_manager = app.handle().global_shortcut();
|
let shortcut_manager = app.handle().global_shortcut();
|
||||||
let _ = shortcut_manager.register(shortcut.clone());
|
let _ = shortcut_manager.register(shortcut.clone());
|
||||||
@@ -598,6 +582,7 @@ fn main() {
|
|||||||
cli_restart,
|
cli_restart,
|
||||||
wake_lock_start,
|
wake_lock_start,
|
||||||
wake_lock_stop,
|
wake_lock_stop,
|
||||||
|
needs_local_certificate_install,
|
||||||
open_remote_window
|
open_remote_window
|
||||||
])
|
])
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
|
|||||||
@@ -43,11 +43,6 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"linux": {
|
"linux": {
|
||||||
"appimage": {
|
|
||||||
"files": {
|
|
||||||
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deb": {
|
"deb": {
|
||||||
"files": {
|
"files": {
|
||||||
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
|
"/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 { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
||||||
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
|
||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
@@ -137,7 +137,7 @@ const App: Component = () => {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
!isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||||
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
// Listen for Tauri menu events
|
// Listen for Tauri menu events
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||||
if (tauriBridge?.event) {
|
if (tauriBridge?.event) {
|
||||||
let unlistenMenu: (() => void) | null = null
|
let unlistenMenu: (() => void) | null = null
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ function resolveAbsolutePath(root: string, relativePath: string) {
|
|||||||
return `${trimmedRoot}${normalized}`
|
return `${trimmedRoot}${normalized}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) {
|
||||||
|
if (!metadata || metadata.pathKind === "drives") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if (metadata.pathKind === "relative") {
|
||||||
|
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||||
|
}
|
||||||
|
return metadata.displayPath
|
||||||
|
}
|
||||||
|
|
||||||
type FolderRow =
|
type FolderRow =
|
||||||
| { type: "up"; path: string }
|
| { type: "up"; path: string }
|
||||||
| { type: "folder"; entry: FileSystemEntry }
|
| { type: "folder"; entry: FileSystemEntry }
|
||||||
@@ -67,6 +77,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [pathInput, setPathInput] = createSignal("")
|
||||||
|
const [pathInputDirty, setPathInputDirty] = createSignal(false)
|
||||||
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||||
@@ -75,12 +87,16 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
|
|
||||||
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||||
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
|
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||||
|
let latestNavigationId = 0
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
|
setRootPath("")
|
||||||
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
|
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
|
||||||
setLoadingPaths(new Set<string>())
|
setLoadingPaths(new Set<string>())
|
||||||
setCurrentPathKey(null)
|
setCurrentPathKey(null)
|
||||||
setCurrentMetadata(null)
|
setCurrentMetadata(null)
|
||||||
|
setPathInput("")
|
||||||
|
setPathInputDirty(false)
|
||||||
metadataCache.clear()
|
metadataCache.clear()
|
||||||
inFlightRequests.clear()
|
inFlightRequests.clear()
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
async function initialize() {
|
async function initialize() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const metadata = await loadDirectory()
|
await navigateTo()
|
||||||
applyMetadata(metadata)
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
|
||||||
setError(message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -197,13 +209,22 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function navigateTo(path?: string) {
|
async function navigateTo(path?: string) {
|
||||||
|
const navigationId = ++latestNavigationId
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const metadata = await loadDirectory(path)
|
const metadata = await loadDirectory(path)
|
||||||
|
if (navigationId !== latestNavigationId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
applyMetadata(metadata)
|
applyMetadata(metadata)
|
||||||
|
return metadata
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (navigationId !== latestNavigationId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||||
setError(message)
|
setError(message)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,31 +246,58 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleNavigateTo(path: string) {
|
function handleNavigateTo(path: string) {
|
||||||
|
setPathInputDirty(false)
|
||||||
void navigateTo(path)
|
void navigateTo(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNavigateUp() {
|
function handleNavigateUp() {
|
||||||
const parent = currentMetadata()?.parentPath
|
const parent = currentMetadata()?.parentPath
|
||||||
if (parent) {
|
if (parent) {
|
||||||
|
setPathInputDirty(false)
|
||||||
void navigateTo(parent)
|
void navigateTo(parent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentAbsolutePath = createMemo(() => {
|
const currentAbsolutePath = createMemo(() => {
|
||||||
const metadata = currentMetadata()
|
return getAbsolutePathFromMetadata(currentMetadata())
|
||||||
if (!metadata) {
|
})
|
||||||
return ""
|
|
||||||
|
createEffect(() => {
|
||||||
|
const absolutePath = currentAbsolutePath()
|
||||||
|
if (!pathInputDirty()) {
|
||||||
|
setPathInput(absolutePath)
|
||||||
}
|
}
|
||||||
if (metadata.pathKind === "drives") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if (metadata.pathKind === "relative") {
|
|
||||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
|
||||||
}
|
|
||||||
return metadata.displayPath
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
||||||
|
const canSubmitPath = createMemo(() => pathInput().trim().length > 0)
|
||||||
|
|
||||||
|
async function handlePathSubmit() {
|
||||||
|
const target = pathInput().trim()
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const metadata = await navigateTo(target)
|
||||||
|
if (!metadata) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPathInputDirty(false)
|
||||||
|
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectCurrent() {
|
||||||
|
const target = pathInput().trim()
|
||||||
|
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||||
|
if (!metadata) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPathInputDirty(false)
|
||||||
|
const absolute = getAbsolutePathFromMetadata(metadata)
|
||||||
|
if (absolute) {
|
||||||
|
setPathInput(absolute)
|
||||||
|
props.onSelect(absolute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleEntrySelect(entry: FileSystemEntry) {
|
function handleEntrySelect(entry: FileSystemEntry) {
|
||||||
const absolutePath = entry.absolutePath
|
const absolutePath = entry.absolutePath
|
||||||
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
|
|
||||||
async function handleCreateFolder() {
|
async function handleCreateFolder() {
|
||||||
if (creatingFolder()) return
|
if (creatingFolder()) return
|
||||||
const metadata = currentMetadata()
|
const target = pathInput().trim()
|
||||||
|
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||||
if (!metadata || metadata.pathKind === "drives") {
|
if (!metadata || metadata.pathKind === "drives") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setPathInputDirty(false)
|
||||||
|
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||||
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<div class="directory-browser-current">
|
<div class="directory-browser-current">
|
||||||
<div class="directory-browser-current-meta">
|
<div class="directory-browser-current-meta">
|
||||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pathInput()}
|
||||||
|
onInput={(event) => {
|
||||||
|
setPathInput(event.currentTarget.value)
|
||||||
|
setPathInputDirty(true)
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void handlePathSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
spellcheck={false}
|
||||||
|
class="selector-input directory-browser-current-path"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="directory-browser-current-actions">
|
<div class="directory-browser-current-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||||
disabled={!canSelectCurrent() || creatingFolder()}
|
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
|
||||||
onClick={() => {
|
onClick={() => void handleSelectCurrent()}
|
||||||
const absolute = currentAbsolutePath()
|
|
||||||
if (absolute) {
|
|
||||||
props.onSelect(absolute)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("directoryBrowser.selectCurrent")}
|
{t("directoryBrowser.selectCurrent")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S
|
|||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||||
import VersionPill from "./version-pill"
|
import VersionPill from "./version-pill"
|
||||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
@@ -16,7 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
import { openExternalUrl } from "../lib/external-url"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { runtimeEnv } from "../lib/runtime-env"
|
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
|
||||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
@@ -58,7 +58,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||||
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||||
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
type LanguageOption = { value: Locale; label: string }
|
type LanguageOption = { value: Locale; label: string }
|
||||||
@@ -78,6 +77,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
const serverList = () => remoteServers()
|
const serverList = () => remoteServers()
|
||||||
const isLoading = () => Boolean(props.isLoading)
|
const isLoading = () => Boolean(props.isLoading)
|
||||||
|
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
|
||||||
|
|
||||||
function getActiveListLength() {
|
function getActiveListLength() {
|
||||||
return activeTab() === "local" ? folders().length : serverList().length
|
return activeTab() === "local" ? folders().length : serverList().length
|
||||||
@@ -124,17 +124,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
const normalizedKey = e.key.toLowerCase()
|
const normalizedKey = e.key.toLowerCase()
|
||||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||||
const blockedKeys = [
|
const blockedKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", "Enter"]
|
||||||
"ArrowDown",
|
|
||||||
"ArrowUp",
|
|
||||||
"PageDown",
|
|
||||||
"PageUp",
|
|
||||||
"Home",
|
|
||||||
"End",
|
|
||||||
"Enter",
|
|
||||||
"Backspace",
|
|
||||||
"Delete",
|
|
||||||
]
|
|
||||||
|
|
||||||
if (isLoading()) {
|
if (isLoading()) {
|
||||||
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
|
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
|
||||||
@@ -192,21 +182,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleEnterKey()
|
handleEnterKey()
|
||||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
|
||||||
e.preventDefault()
|
|
||||||
if (listLength > 0 && focusMode() === "recent") {
|
|
||||||
if (activeTab() === "local") {
|
|
||||||
const folder = folders()[selectedIndex()]
|
|
||||||
if (folder) {
|
|
||||||
handleRemove(folder.path)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const server = serverList()[selectedIndex()]
|
|
||||||
if (server) {
|
|
||||||
removeRemoteServerProfile(server.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +206,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
activeTab()
|
activeTab()
|
||||||
|
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
|
||||||
|
setActiveTab("local")
|
||||||
|
return
|
||||||
|
}
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
})
|
})
|
||||||
@@ -305,11 +284,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openServerDialog() {
|
function openServerDialog() {
|
||||||
|
if (!canUseRemoteServerWindows()) return
|
||||||
resetServerDialog()
|
resetServerDialog()
|
||||||
setIsServerDialogOpen(true)
|
setIsServerDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||||
|
if (openWindow && !canUseRemoteServerWindows()) {
|
||||||
|
throw new Error("Remote server windows can only be opened from a local desktop window")
|
||||||
|
}
|
||||||
|
|
||||||
const trimmedName = input.name.trim()
|
const trimmedName = input.name.trim()
|
||||||
const trimmedUrl = input.baseUrl.trim()
|
const trimmedUrl = input.baseUrl.trim()
|
||||||
if (!trimmedName || !trimmedUrl) {
|
if (!trimmedName || !trimmedUrl) {
|
||||||
@@ -334,7 +318,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
if (openWindow) {
|
if (openWindow) {
|
||||||
const remoteProxySession =
|
const remoteProxySession =
|
||||||
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
||||||
? await serverApi.createRemoteProxySession({
|
? await serverApi.createRemoteProxySession({
|
||||||
baseUrl: profile.baseUrl,
|
baseUrl: profile.baseUrl,
|
||||||
skipTlsVerify: profile.skipTlsVerify,
|
skipTlsVerify: profile.skipTlsVerify,
|
||||||
@@ -379,6 +363,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleConnectSavedServer(id: string) {
|
async function handleConnectSavedServer(id: string) {
|
||||||
|
if (!canUseRemoteServerWindows()) return
|
||||||
const target = remoteServers().find((entry) => entry.id === id)
|
const target = remoteServers().find((entry) => entry.id === id)
|
||||||
if (!target || connectingServerId()) return
|
if (!target || connectingServerId()) return
|
||||||
setConnectingServerId(id)
|
setConnectingServerId(id)
|
||||||
@@ -397,7 +382,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
if (nativeDialogsAvailable) {
|
if (supportsNativeDialogsInCurrentWindow()) {
|
||||||
const fallbackPath = folders()[0]?.path
|
const fallbackPath = folders()[0]?.path
|
||||||
const selected = await openNativeFolderDialog({
|
const selected = await openNativeFolderDialog({
|
||||||
title: t("folderSelection.dialog.title"),
|
title: t("folderSelection.dialog.title"),
|
||||||
@@ -554,15 +539,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Settings class="w-4 h-4" />
|
<Settings class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Show when={canUseRemoteServerWindows()}>
|
||||||
type="button"
|
<button
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
type="button"
|
||||||
onClick={() => openSettings("remote")}
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
onClick={() => openSettings("remote")}
|
||||||
title={t("instanceTabs.remote.title")}
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
>
|
title={t("instanceTabs.remote.title")}
|
||||||
<MonitorUp class="w-4 h-4" />
|
>
|
||||||
</button>
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<Show when={props.onClose}>
|
<Show when={props.onClose}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -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="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||||
<div class="panel flex flex-col flex-1 min-h-0">
|
<div class="panel flex flex-col flex-1 min-h-0">
|
||||||
<div class="panel-header !gap-0 !p-0">
|
<div class="panel-header !gap-0 !p-0">
|
||||||
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
<div class={`grid ${canUseRemoteServerWindows() ? "grid-cols-2" : "grid-cols-1"} gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="border-r border-base px-4 py-3 text-left transition-colors"
|
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||||
@@ -671,35 +658,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Show when={canUseRemoteServerWindows()}>
|
||||||
type="button"
|
<button
|
||||||
class="px-4 py-3 text-left transition-colors"
|
type="button"
|
||||||
classList={{
|
class="px-4 py-3 text-left transition-colors"
|
||||||
"text-primary": activeTab() === "servers",
|
classList={{
|
||||||
"text-muted hover:text-secondary": activeTab() !== "servers",
|
"text-primary": activeTab() === "servers",
|
||||||
}}
|
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||||
style={{
|
|
||||||
"background-color": "var(--surface-secondary)",
|
|
||||||
}}
|
|
||||||
onClick={() => setActiveTab("servers")}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="panel-title text-base"
|
|
||||||
style={{
|
|
||||||
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
{t("folderSelection.tabs.servers")}
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="panel-subtitle mt-1"
|
|
||||||
style={{
|
style={{
|
||||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
"background-color": "var(--surface-secondary)",
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setActiveTab("servers")}
|
||||||
>
|
>
|
||||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
<div
|
||||||
</p>
|
class="panel-title text-base"
|
||||||
</button>
|
style={{
|
||||||
|
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("folderSelection.tabs.servers")}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="panel-subtitle mt-1"
|
||||||
|
style={{
|
||||||
|
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -707,23 +696,25 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
when={activeTab() === "local"}
|
when={activeTab() === "local"}
|
||||||
fallback={
|
fallback={
|
||||||
<Show
|
<Show
|
||||||
when={remoteServers().length > 0}
|
when={canUseRemoteServerWindows() && remoteServers().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="panel-empty-state flex-1">
|
<Show when={canUseRemoteServerWindows()}>
|
||||||
<div class="panel-empty-state-icon">
|
<div class="panel-empty-state flex-1">
|
||||||
<Globe class="w-12 h-12 mx-auto" />
|
<div class="panel-empty-state-icon">
|
||||||
|
<Globe class="w-12 h-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||||
|
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||||
|
onClick={openServerDialog}
|
||||||
|
>
|
||||||
|
<Globe class="w-4 h-4" />
|
||||||
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
</Show>
|
||||||
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
|
||||||
onClick={openServerDialog}
|
|
||||||
>
|
|
||||||
<Globe class="w-4 h-4" />
|
|
||||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -891,15 +882,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<Show when={canUseRemoteServerWindows()}>
|
||||||
onClick={openServerDialog}
|
<button
|
||||||
class="button-primary w-full flex items-center justify-center text-sm"
|
onClick={openServerDialog}
|
||||||
>
|
class="button-primary w-full flex items-center justify-center text-sm"
|
||||||
<div class="flex items-center gap-2">
|
>
|
||||||
<Globe class="w-4 h-4" />
|
<div class="flex items-center gap-2">
|
||||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
<Globe class="w-4 h-4" />
|
||||||
</div>
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
</button>
|
</div>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* OpenCode settings section */}
|
||||||
@@ -935,10 +928,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>{t("folderSelection.hints.select")}</span>
|
<span>{t("folderSelection.hints.select")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<kbd class="kbd">Del</kbd>
|
|
||||||
<span>{t("folderSelection.hints.remove")}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
|||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
|
import { canOpenRemoteWindows } from "../lib/runtime-env"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { openSettings } from "../stores/settings-screen"
|
import { openSettings } from "../stores/settings-screen"
|
||||||
import type { AppTabRecord } from "../stores/app-tabs"
|
import type { AppTabRecord } from "../stores/app-tabs"
|
||||||
@@ -99,14 +100,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<Show when={canOpenRemoteWindows()}>
|
||||||
class="new-tab-button tab-remote-button"
|
<button
|
||||||
onClick={() => openSettings("remote")}
|
class="new-tab-button tab-remote-button"
|
||||||
title={t("instanceTabs.remote.title")}
|
onClick={() => openSettings("remote")}
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
title={t("instanceTabs.remote.title")}
|
||||||
>
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
<MonitorUp class="w-4 h-4" />
|
>
|
||||||
</button>
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -171,9 +171,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void handleEnterKey()
|
void handleEnterKey()
|
||||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
|
||||||
e.preventDefault()
|
|
||||||
void handleDeleteKey()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,29 +184,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteKey() {
|
|
||||||
const sessions = parentSessions()
|
|
||||||
const index = selectedIndex()
|
|
||||||
|
|
||||||
if (index >= sessions.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleSessionDelete(sessions[index].id)
|
|
||||||
|
|
||||||
const updatedSessions = parentSessions()
|
|
||||||
if (updatedSessions.length === 0) {
|
|
||||||
setFocusMode("new-session")
|
|
||||||
setSelectedIndex(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
|
||||||
setSelectedIndex(nextIndex)
|
|
||||||
setFocusMode("sessions")
|
|
||||||
scrollToIndex(nextIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
|
||||||
@@ -562,10 +536,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>{t("instanceWelcome.hints.resume")}</span>
|
<span>{t("instanceWelcome.hints.resume")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<kbd class="kbd">Del</kbd>
|
|
||||||
<span>{t("instanceWelcome.hints.delete")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
|
|||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
@@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
|
||||||
|
|
||||||
const binaries = () => opencodeBinaries()
|
const binaries = () => opencodeBinaries()
|
||||||
|
|
||||||
@@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
async function handleBrowseBinary() {
|
async function handleBrowseBinary() {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
if (nativeDialogsAvailable) {
|
if (supportsNativeDialogsInCurrentWindow()) {
|
||||||
const selected = await openNativeFileDialog({
|
const selected = await openNativeFileDialog({
|
||||||
title: t("opencodeBinarySelector.dialog.title"),
|
title: t("opencodeBinarySelector.dialog.title"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,25 +15,33 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
|||||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||||
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||||
|
import { canOpenRemoteWindows } from "../lib/runtime-env"
|
||||||
|
|
||||||
export const SettingsScreen: Component = () => {
|
export const SettingsScreen: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const sections = createMemo(() => [
|
const sections = createMemo(() => {
|
||||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
const items = [
|
||||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||||
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||||
])
|
]
|
||||||
|
|
||||||
|
if (canOpenRemoteWindows()) {
|
||||||
|
items.splice(2, 0, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") })
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
switch (activeSettingsSection()) {
|
switch (activeSettingsSection()) {
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return <NotificationsSettingsSection />
|
return <NotificationsSettingsSection />
|
||||||
case "remote":
|
case "remote":
|
||||||
return <RemoteAccessSettingsSection />
|
return canOpenRemoteWindows() ? <RemoteAccessSettingsSection /> : <AppearanceSettingsSection />
|
||||||
case "speech":
|
case "speech":
|
||||||
return <SpeechSettingsSection />
|
return <SpeechSettingsSection />
|
||||||
case "sidecars":
|
case "sidecars":
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface LspDiagnostic {
|
|||||||
range?: LspRange
|
range?: LspRange
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DiagnosticsMap = Record<string, LspDiagnostic[] | undefined>
|
||||||
|
|
||||||
export interface DiagnosticEntry {
|
export interface DiagnosticEntry {
|
||||||
id: string
|
id: string
|
||||||
severity: number
|
severity: number
|
||||||
@@ -30,7 +32,7 @@ export interface DiagnosticEntry {
|
|||||||
column: number
|
column: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDiagnosticPath(path: string) {
|
export function normalizeDiagnosticPath(path: string) {
|
||||||
return path.replace(/\\/g, "/")
|
return path.replace(/\\/g, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,49 +55,71 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr
|
|||||||
|
|
||||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||||
const input = (state.input || {}) as Record<string, unknown>
|
const input = (state.input || {}) as Record<string, unknown>
|
||||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined
|
||||||
if (!diagnosticsMap) return []
|
if (!diagnosticsMap) return []
|
||||||
|
|
||||||
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find(
|
return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path])
|
||||||
(value) => typeof value === "string" && value.length > 0,
|
}
|
||||||
) as string | undefined
|
|
||||||
|
|
||||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): string | undefined {
|
||||||
if (!normalizedPreferred) return []
|
if (Object.keys(diagnostics).length === 0) return undefined
|
||||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
|
||||||
if (candidateEntries.length === 0) return []
|
|
||||||
|
|
||||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
const normalizedPreferred = preferredPaths
|
||||||
const normalized = normalizeDiagnosticPath(path)
|
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||||
return normalized === normalizedPreferred
|
.map((value) => normalizeDiagnosticPath(value))
|
||||||
})
|
|
||||||
|
|
||||||
if (prioritizedEntries.length === 0) return []
|
if (normalizedPreferred.length === 0) return undefined
|
||||||
|
|
||||||
|
for (const preferred of normalizedPreferred) {
|
||||||
|
if (diagnostics[preferred]) return preferred
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(diagnostics)
|
||||||
|
|
||||||
|
for (const preferred of normalizedPreferred) {
|
||||||
|
const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred)
|
||||||
|
if (direct) return direct
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preferred of normalizedPreferred) {
|
||||||
|
const suffixMatch = keys.find((key) => {
|
||||||
|
const normalized = normalizeDiagnosticPath(key)
|
||||||
|
return normalized === preferred || normalized.endsWith("/" + preferred)
|
||||||
|
})
|
||||||
|
if (suffixMatch) return suffixMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): DiagnosticEntry[] {
|
||||||
|
const key = resolveDiagnosticsKey(diagnostics, preferredPaths)
|
||||||
|
if (!key) return []
|
||||||
|
|
||||||
|
const list = diagnostics[key]
|
||||||
|
if (!Array.isArray(list) || list.length === 0) return []
|
||||||
|
|
||||||
const entries: DiagnosticEntry[] = []
|
const entries: DiagnosticEntry[] = []
|
||||||
for (const [pathKey, list] of prioritizedEntries) {
|
const normalizedPath = normalizeDiagnosticPath(key)
|
||||||
if (!Array.isArray(list)) continue
|
for (let index = 0; index < list.length; index++) {
|
||||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
const diagnostic = list[index]
|
||||||
for (let index = 0; index < list.length; index++) {
|
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||||
const diagnostic = list[index]
|
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
const severityMeta = getSeverityMeta(tone)
|
||||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||||
const severityMeta = getSeverityMeta(tone)
|
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
entries.push({
|
||||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||||
entries.push({
|
severity: severityMeta.rank,
|
||||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
tone,
|
||||||
severity: severityMeta.rank,
|
label: severityMeta.label,
|
||||||
tone,
|
icon: severityMeta.icon,
|
||||||
label: severityMeta.label,
|
message: diagnostic.message,
|
||||||
icon: severityMeta.icon,
|
filePath: normalizedPath,
|
||||||
message: diagnostic.message,
|
displayPath: getRelativePath(normalizedPath),
|
||||||
filePath: normalizedPath,
|
line,
|
||||||
displayPath: getRelativePath(normalizedPath),
|
column,
|
||||||
line,
|
})
|
||||||
column,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries.sort((a, b) => a.severity - b.severity)
|
return entries.sort((a, b) => a.severity - b.severity)
|
||||||
|
|||||||
@@ -1,107 +1,14 @@
|
|||||||
import { For, Show, createMemo } from "solid-js"
|
import { For, Show, createMemo } from "solid-js"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||||
import type { DiagnosticEntry } from "../diagnostics"
|
import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics"
|
||||||
|
|
||||||
type LspRangePosition = {
|
|
||||||
line?: number
|
|
||||||
character?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type LspRange = {
|
|
||||||
start?: LspRangePosition
|
|
||||||
}
|
|
||||||
|
|
||||||
type LspDiagnostic = {
|
|
||||||
message?: string
|
|
||||||
severity?: number
|
|
||||||
range?: LspRange
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApplyPatchFile = {
|
type ApplyPatchFile = {
|
||||||
filePath?: string
|
filePath?: string
|
||||||
relativePath?: string
|
relativePath?: string
|
||||||
type?: string
|
type?: string
|
||||||
diff?: string
|
diff?: string
|
||||||
}
|
patch?: string
|
||||||
|
|
||||||
function normalizePath(value: string): string {
|
|
||||||
return value.replace(/\\/g, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
|
||||||
if (severity === 1) return "error"
|
|
||||||
if (severity === 2) return "warning"
|
|
||||||
return "info"
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
|
|
||||||
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
|
|
||||||
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
|
|
||||||
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDiagnosticsKey(
|
|
||||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
|
||||||
file: ApplyPatchFile,
|
|
||||||
): string | undefined {
|
|
||||||
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
|
|
||||||
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
|
|
||||||
if (absolute && diagnostics[absolute]) return absolute
|
|
||||||
if (relative && diagnostics[relative]) return relative
|
|
||||||
|
|
||||||
if (absolute) {
|
|
||||||
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
|
|
||||||
if (direct) return direct
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relative) {
|
|
||||||
const suffixMatch = Object.keys(diagnostics).find((key) => {
|
|
||||||
const normalized = normalizePath(key)
|
|
||||||
return normalized === relative || normalized.endsWith("/" + relative)
|
|
||||||
})
|
|
||||||
if (suffixMatch) return suffixMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDiagnostics(
|
|
||||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
|
||||||
file: ApplyPatchFile,
|
|
||||||
t: (key: string, params?: Record<string, unknown>) => string,
|
|
||||||
): DiagnosticEntry[] {
|
|
||||||
const key = resolveDiagnosticsKey(diagnostics, file)
|
|
||||||
if (!key) return []
|
|
||||||
const list = diagnostics[key]
|
|
||||||
if (!Array.isArray(list) || list.length === 0) return []
|
|
||||||
|
|
||||||
const normalizedKey = normalizePath(key)
|
|
||||||
const entries: DiagnosticEntry[] = []
|
|
||||||
for (let index = 0; index < list.length; index++) {
|
|
||||||
const diagnostic = list[index]
|
|
||||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
|
||||||
|
|
||||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
|
||||||
const severityMeta = getSeverityMeta(tone, t)
|
|
||||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
|
||||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
id: `${normalizedKey}-${index}-${diagnostic.message}`,
|
|
||||||
severity: severityMeta.rank,
|
|
||||||
tone,
|
|
||||||
label: severityMeta.label,
|
|
||||||
icon: severityMeta.icon,
|
|
||||||
message: diagnostic.message,
|
|
||||||
filePath: normalizedKey,
|
|
||||||
displayPath: getRelativePath(normalizedKey),
|
|
||||||
line,
|
|
||||||
column,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries.sort((a, b) => a.severity - b.severity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
|
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
|
||||||
@@ -164,7 +71,7 @@ export const applyPatchRenderer: ToolRenderer = {
|
|||||||
})
|
})
|
||||||
const diagnosticsMap = createMemo(() => {
|
const diagnosticsMap = createMemo(() => {
|
||||||
const value = (payload.metadata as any).diagnostics
|
const value = (payload.metadata as any).diagnostics
|
||||||
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
|
return value && typeof value === "object" ? (value as DiagnosticsMap) : {}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (files().length === 0) {
|
if (files().length === 0) {
|
||||||
@@ -178,9 +85,9 @@ export const applyPatchRenderer: ToolRenderer = {
|
|||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file, index) => {
|
{(file, index) => {
|
||||||
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
|
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
|
||||||
const diffText = typeof file.diff === "string" ? file.diff : ""
|
const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
|
||||||
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
||||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
|
const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-apply-patch-file">
|
<div class="tool-call-apply-patch-file">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
normalizeDroppedDirectoryPaths,
|
normalizeDroppedDirectoryPaths,
|
||||||
supportsDesktopFolderDrop,
|
supportsDesktopFolderDrop,
|
||||||
} from "../native/desktop-file-drop"
|
} from "../native/desktop-file-drop"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { isTauriHost } from "../runtime-env"
|
||||||
|
|
||||||
interface UseFolderDropOptions {
|
interface UseFolderDropOptions {
|
||||||
enabled: Accessor<boolean>
|
enabled: Accessor<boolean>
|
||||||
@@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
|||||||
|
|
||||||
const bind: FolderDropBindings = {
|
const bind: FolderDropBindings = {
|
||||||
onDragEnter(event) {
|
onDragEnter(event) {
|
||||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
|||||||
setIsActive(true)
|
setIsActive(true)
|
||||||
},
|
},
|
||||||
onDragOver(event) {
|
onDragOver(event) {
|
||||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
|||||||
setIsActive(true)
|
setIsActive(true)
|
||||||
},
|
},
|
||||||
onDragLeave(event) {
|
onDragLeave(event) {
|
||||||
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
|
if (!isSupported || isTauriHost() || !containsFileDrop(event)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
reset()
|
reset()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Connecting...",
|
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "Install Local Certificate",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "Continue",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "Cancel",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Conectando...",
|
"folderSelection.servers.dialog.connecting": "Conectando...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "Cancelar",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Connexion...",
|
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "Installer le certificat local",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "Annuler",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "ביטול",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "接続中...",
|
"folderSelection.servers.dialog.connecting": "接続中...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
||||||
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "キャンセル",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Подключение...",
|
"folderSelection.servers.dialog.connecting": "Подключение...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "Установить локальный сертификат",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "Продолжить",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "Отмена",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "连接中...",
|
"folderSelection.servers.dialog.connecting": "连接中...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
||||||
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "安装本地证书",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "继续",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "取消",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
export async function restartCli(): Promise<boolean> {
|
export async function restartCli(): Promise<boolean> {
|
||||||
|
if (!canRestartCli()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
|
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
|
||||||
if (api?.restartCli) {
|
if (api?.restartCli) {
|
||||||
await api.restartCli()
|
await api.restartCli()
|
||||||
@@ -15,7 +19,7 @@ export async function restartCli(): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
||||||
await invoke("cli_restart")
|
await invoke("cli_restart")
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { listen } from "@tauri-apps/api/event"
|
import { listen } from "@tauri-apps/api/event"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ function getFilePath(file: File): string | null {
|
|||||||
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
||||||
return file.path
|
return file.path
|
||||||
}
|
}
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
||||||
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
||||||
return electronPath
|
return electronPath
|
||||||
@@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function supportsDesktopFolderDrop(): boolean {
|
export function supportsDesktopFolderDrop(): boolean {
|
||||||
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
|
return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function containsFileDrop(event: DragEvent): boolean {
|
export function containsFileDrop(event: DragEvent): boolean {
|
||||||
@@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<s
|
|||||||
if (uniquePaths.length === 0) {
|
if (uniquePaths.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
return resolveElectronDirectoryPaths(uniquePaths)
|
return resolveElectronDirectoryPaths(uniquePaths)
|
||||||
}
|
}
|
||||||
return uniquePaths
|
return uniquePaths
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
||||||
if (runtimeEnv.host !== "tauri") {
|
if (!isTauriHost()) {
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
||||||
if (runtimeEnv.host !== "tauri") {
|
if (!isTauriHost()) {
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { runtimeEnv } from "../runtime-env"
|
import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env"
|
||||||
import type { NativeDialogOptions } from "./types"
|
import type { NativeDialogOptions } from "./types"
|
||||||
import { openElectronNativeDialog } from "./electron/functions"
|
import { openElectronNativeDialog } from "./electron/functions"
|
||||||
import { openTauriNativeDialog } from "./tauri/functions"
|
import { openTauriNativeDialog } from "./tauri/functions"
|
||||||
@@ -6,20 +6,23 @@ import { openTauriNativeDialog } from "./tauri/functions"
|
|||||||
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
|
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
|
||||||
|
|
||||||
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
|
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
|
||||||
switch (runtimeEnv.host) {
|
if (isElectronHost()) {
|
||||||
case "electron":
|
return openElectronNativeDialog
|
||||||
return openElectronNativeDialog
|
|
||||||
case "tauri":
|
|
||||||
return openTauriNativeDialog
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
if (isTauriHost()) {
|
||||||
|
return openTauriNativeDialog
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsNativeDialogs(): boolean {
|
export function supportsNativeDialogs(): boolean {
|
||||||
return resolveNativeHandler() !== null
|
return resolveNativeHandler() !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function supportsNativeDialogsInCurrentWindow(): boolean {
|
||||||
|
return canUseNativeDialogs()
|
||||||
|
}
|
||||||
|
|
||||||
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||||
const handler = resolveNativeHandler()
|
const handler = resolveNativeHandler()
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import type { RemoteServerProfile } from "../../../../server/src/api-types"
|
import type { RemoteServerProfile } from "../../../../server/src/api-types"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { showConfirmDialog } from "../../stores/alerts"
|
||||||
|
import { tGlobal } from "../i18n"
|
||||||
|
import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
|
||||||
|
|
||||||
export interface RemoteWindowOpenPayload {
|
export interface RemoteWindowOpenPayload {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,6 +18,10 @@ export async function openRemoteServerWindow(
|
|||||||
entryUrl?: string,
|
entryUrl?: string,
|
||||||
proxySessionId?: string,
|
proxySessionId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (!canOpenRemoteWindows()) {
|
||||||
|
throw new Error("Remote server windows can only be opened from a local desktop window")
|
||||||
|
}
|
||||||
|
|
||||||
const payload: RemoteWindowOpenPayload = {
|
const payload: RemoteWindowOpenPayload = {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
@@ -25,7 +31,7 @@ export async function openRemoteServerWindow(
|
|||||||
skipTlsVerify: profile.skipTlsVerify,
|
skipTlsVerify: profile.skipTlsVerify,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||||
if (typeof api?.openRemoteWindow === "function") {
|
if (typeof api?.openRemoteWindow === "function") {
|
||||||
await api.openRemoteWindow(payload)
|
await api.openRemoteWindow(payload)
|
||||||
@@ -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 })
|
await invoke("open_remote_window", { payload })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { isElectronHost, isTauriHost } from "../runtime-env"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
@@ -56,11 +56,11 @@ async function setWebWakeLock(enabled: boolean): Promise<boolean> {
|
|||||||
|
|
||||||
function hasAnyWakeLockSupport(): boolean {
|
function hasAnyWakeLockSupport(): boolean {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return false
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const api = (window as any).electronAPI
|
const api = (window as any).electronAPI
|
||||||
if (api?.setWakeLock) return true
|
if (api?.setWakeLock) return true
|
||||||
}
|
}
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
return typeof window.__TAURI__?.core?.invoke === "function"
|
return typeof window.__TAURI__?.core?.invoke === "function"
|
||||||
}
|
}
|
||||||
return Boolean((navigator as any)?.wakeLock?.request)
|
return Boolean((navigator as any)?.wakeLock?.request)
|
||||||
@@ -106,13 +106,13 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
|||||||
async function applyWakeLock(enabled: boolean): Promise<boolean> {
|
async function applyWakeLock(enabled: boolean): Promise<boolean> {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return false
|
||||||
|
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const ok = await setElectronWakeLock(enabled)
|
const ok = await setElectronWakeLock(enabled)
|
||||||
if (ok || !enabled) return ok
|
if (ok || !enabled) return ok
|
||||||
// fallback to web API if electron preload didn't expose it
|
// fallback to web API if electron preload didn't expose it
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
const ok = await setTauriWakeLock(enabled)
|
const ok = await setTauriWakeLock(enabled)
|
||||||
if (ok || !enabled) return ok
|
if (ok || !enabled) return ok
|
||||||
// fallback to web API if tauri command isn't available
|
// fallback to web API if tauri command isn't available
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { getLogger } from "./logger"
|
|||||||
|
|
||||||
export type HostRuntime = "electron" | "tauri" | "web"
|
export type HostRuntime = "electron" | "tauri" | "web"
|
||||||
export type PlatformKind = "desktop" | "mobile"
|
export type PlatformKind = "desktop" | "mobile"
|
||||||
|
export type WindowContextKind = "local" | "remote"
|
||||||
|
|
||||||
export interface RuntimeEnvironment {
|
export interface RuntimeEnvironment {
|
||||||
host: HostRuntime
|
host: HostRuntime
|
||||||
platform: PlatformKind
|
platform: PlatformKind
|
||||||
|
windowContext: WindowContextKind
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -14,6 +16,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
__CODENOMAD_WINDOW_CONTEXT__?: WindowContextKind
|
||||||
electronAPI?: unknown
|
electronAPI?: unknown
|
||||||
__TAURI__?: {
|
__TAURI__?: {
|
||||||
core?: TauriCoreModule
|
core?: TauriCoreModule
|
||||||
@@ -21,11 +24,41 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectWindowContext(): WindowContextKind {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "remote") {
|
||||||
|
return "remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "local") {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = window as Window & { electronAPI?: unknown }
|
||||||
|
if (typeof win.electronAPI !== "undefined" || typeof win.__TAURI__ !== "undefined") {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "remote"
|
||||||
|
}
|
||||||
|
|
||||||
function detectHost(): HostRuntime {
|
function detectHost(): HostRuntime {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return "web"
|
return "web"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const explicitHost = window.__CODENOMAD_RUNTIME_HOST__
|
||||||
|
if (explicitHost) {
|
||||||
|
return explicitHost
|
||||||
|
}
|
||||||
|
|
||||||
const win = window as Window & { electronAPI?: unknown }
|
const win = window as Window & { electronAPI?: unknown }
|
||||||
if (typeof win.electronAPI !== "undefined") {
|
if (typeof win.electronAPI !== "undefined") {
|
||||||
return "electron"
|
return "electron"
|
||||||
@@ -71,16 +104,24 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
|
|||||||
cachedEnv = {
|
cachedEnv = {
|
||||||
host: detectHost(),
|
host: detectHost(),
|
||||||
platform: detectPlatform(),
|
platform: detectPlatform(),
|
||||||
|
windowContext: detectWindowContext(),
|
||||||
}
|
}
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`)
|
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform} context=${cachedEnv.windowContext}`)
|
||||||
}
|
}
|
||||||
return cachedEnv
|
return cachedEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtimeEnv = detectRuntimeEnvironment()
|
export const runtimeEnv = detectRuntimeEnvironment()
|
||||||
|
|
||||||
export const isElectronHost = () => runtimeEnv.host === "electron"
|
export const isElectronHost = () => detectHost() === "electron"
|
||||||
export const isTauriHost = () => runtimeEnv.host === "tauri"
|
export const isTauriHost = () => detectHost() === "tauri"
|
||||||
export const isWebHost = () => runtimeEnv.host === "web"
|
export const isWebHost = () => detectHost() === "web"
|
||||||
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"
|
export const isDesktopHost = () => isElectronHost() || isTauriHost()
|
||||||
|
export const isMobilePlatform = () => detectPlatform() === "mobile"
|
||||||
|
export const isLocalWindow = () => detectWindowContext() === "local"
|
||||||
|
export const isRemoteWindow = () => detectWindowContext() === "remote"
|
||||||
|
export const canUseNativeDialogs = () => isDesktopHost() && isLocalWindow()
|
||||||
|
export const canOpenRemoteWindows = () => isDesktopHost() && isLocalWindow()
|
||||||
|
export const canRestartCli = () => isDesktopHost() && isLocalWindow()
|
||||||
|
export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
} from "../../stores/preferences"
|
} from "../../stores/preferences"
|
||||||
import type { Command } from "../commands"
|
import type { Command } from "../commands"
|
||||||
import { tGlobal } from "../i18n"
|
import { tGlobal } from "../i18n"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { isWebHost } from "../runtime-env"
|
||||||
|
|
||||||
export type BehaviorSettingKind = "toggle" | "enum"
|
export type BehaviorSettingKind = "toggle" | "enum"
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
|||||||
next,
|
next,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
disabled: () => runtimeEnv.host === "web",
|
disabled: () => isWebHost(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "toggle",
|
kind: "toggle",
|
||||||
@@ -337,13 +337,13 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[]
|
|||||||
),
|
),
|
||||||
description: () =>
|
description: () =>
|
||||||
tGlobal(
|
tGlobal(
|
||||||
runtimeEnv.host === "web"
|
isWebHost()
|
||||||
? "commands.keyboardShortcutHints.description.disabledWeb"
|
? "commands.keyboardShortcutHints.description.disabledWeb"
|
||||||
: "commands.keyboardShortcutHints.description",
|
: "commands.keyboardShortcutHints.description",
|
||||||
),
|
),
|
||||||
category: "System",
|
category: "System",
|
||||||
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
||||||
disabled: () => runtimeEnv.host === "web",
|
disabled: () => isWebHost(),
|
||||||
action: actions.toggleKeyboardShortcutHints,
|
action: actions.toggleKeyboardShortcutHints,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
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 {
|
interface Window {
|
||||||
__CODENOMAD_API_BASE__?: string
|
__CODENOMAD_API_BASE__?: string
|
||||||
__CODENOMAD_EVENTS_URL__?: string
|
__CODENOMAD_EVENTS_URL__?: string
|
||||||
electronAPI?: ElectronAPI
|
__CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web"
|
||||||
__TAURI__?: TauriBridge
|
__CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote"
|
||||||
codenomadLogger?: LoggerControls
|
electronAPI?: ElectronAPI
|
||||||
|
__TAURI__?: TauriBridge
|
||||||
|
codenomadLogger?: LoggerControls
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user