Compare commits

..

19 Commits

Author SHA1 Message Date
Shantur Rathore
105714778b fix(ci): pin npm for publish workflow 2026-04-21 10:06:29 +01:00
Shantur Rathore
c9eea8c003 fix(tauri): require standalone server in desktop bundles 2026-04-21 08:33:53 +01:00
Shantur Rathore
25512e8dc1 fix(ci): install xdg-utils for Linux Tauri bundling 2026-04-21 07:55:32 +01:00
Shantur Rathore
f56d63d166 fix(tauri): strip native config addons from bundles 2026-04-21 07:51:53 +01:00
Shantur Rathore
8173030b1a fix(ci): log Linux Tauri bundle diagnostics 2026-04-21 07:35:22 +01:00
Shantur Rathore
73a97e64ba fix(tauri): let CI control platform CLI binaries 2026-04-20 23:35:13 +01:00
Shantur Rathore
a5f38ee625 fix(tauri): align packaged CLI with Linux bundler 2026-04-20 23:25:39 +01:00
Shantur Rathore
ca880451e7 fix(tauri): prune Bun from Linux app bundle 2026-04-20 23:14:16 +01:00
Shantur Rathore
4af8cc08b9 fix(ci): restore dev Linux Tauri bundling env 2026-04-20 23:04:32 +01:00
Shantur Rathore
b60d86116a fix(tauri): fall back to Node server on Linux 2026-04-20 22:54:21 +01:00
Shantur Rathore
76f14e2189 fix(ci): pin Linux Tauri CLI to known-good version 2026-04-20 21:12:56 +01:00
Shantur Rathore
9ecd5131a6 fix(ci): stabilize Linux Tauri AppImage bundling 2026-04-20 21:00:19 +01:00
Shantur Rathore
95f47ebbe4 fix(tauri): avoid AppImage linuxdeploy desktop alias conflict 2026-04-20 20:35:26 +01:00
Shantur Rathore
6c50564df6 fix(ci): align Tauri CLI with packaged desktop builds 2026-04-20 14:45:32 +01:00
Shantur Rathore
166edd2e30 fix(ci): align Node and Tauri versions for desktop builds 2026-04-20 14:30:29 +01:00
Shantur Rathore
79dbbd4cb4 fix(server): preserve streamed proxy bodies and strip hop headers 2026-04-20 14:13:05 +01:00
Shantur Rathore
1c2ec1558e fix(build): use bundled Bun for standalone server builds 2026-04-20 13:16:23 +01:00
Shantur Rathore
3b08bc3262 fix(desktop): align standalone server startup and proxy workspace instances 2026-04-20 12:58:56 +01:00
Shantur Rathore
016c7bda4a fix(tauri): use in-app certificate install confirmation 2026-04-20 08:49:50 +01:00
33 changed files with 1838 additions and 532 deletions

View File

@@ -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 \

View File

@@ -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,8 +60,15 @@ 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 "$tool_dir/node_modules/npm/bin" >> "$GITHUB_PATH"
"$tool_dir/node_modules/npm/bin/npm-cli.js" --version
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces

View File

@@ -14,7 +14,7 @@ permissions:
contents: read contents: read
env: env:
NODE_VERSION: 20 NODE_VERSION: 22
jobs: jobs:
release-ui: release-ui:

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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[] {
@@ -291,7 +293,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 +312,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) {
@@ -620,7 +618,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 +636,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)) {

View File

@@ -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] }
} }
@@ -593,9 +606,8 @@ export class CliProcessManager extends EventEmitter {
const devEntry = this.resolveDevEntry() const devEntry = this.resolveDevEntry()
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

View File

@@ -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,

View File

@@ -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()
} }

View File

@@ -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"
} }
} }

View File

@@ -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",

View 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)
}

View File

@@ -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}`)

View File

@@ -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

View File

@@ -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)) {

View 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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
} }

View File

@@ -14,6 +14,6 @@
"build": "tauri build" "build": "tauri build"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.10.1"
} }
} }

View File

@@ -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()

View File

@@ -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"

View File

@@ -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()]
} }

View File

@@ -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,
}; };
@@ -78,34 +77,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 {
@@ -367,6 +338,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 +368,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}"
@@ -598,6 +576,7 @@ fn main() {
cli_restart, cli_restart,
wake_lock_start, wake_lock_start,
wake_lock_stop, wake_lock_stop,
needs_local_certificate_install,
open_remote_window open_remote_window
]) ])
.on_menu_event(|app_handle, event| { .on_menu_event(|app_handle, event| {

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
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 { showConfirmDialog } from "../../stores/alerts"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env" import { runtimeEnv } from "../runtime-env"
export interface RemoteWindowOpenPayload { export interface RemoteWindowOpenPayload {
@@ -34,6 +36,28 @@ export async function openRemoteServerWindow(
} }
if (runtimeEnv.host === "tauri") { if (runtimeEnv.host === "tauri") {
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
} }