From eb5f011161a8b535a7c29a7586603f7a42142ced Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 11 Mar 2026 14:01:07 +0700 Subject: [PATCH] Add screen orientation polyfill, refactor provider launch config, and update desktop runtime manager - Add .claude schedule tasks to .gitignore - Add screen-orientation polyfill for Expo web - Refactor agent provider launch config into shared utility - Update desktop runtime manager with improved binary management - Update desktop release workflow --- .github/workflows/desktop-release.yml | 2 +- .gitignore | 2 + package-lock.json | 10 ++ packages/app/index.ts | 11 ++ packages/app/package.json | 1 + .../app/src/polyfills/screen-orientation.ts | 28 ++++ packages/desktop/src-tauri/Cargo.lock | 2 +- packages/desktop/src-tauri/src/lib.rs | 34 ++++- .../desktop/src-tauri/src/runtime_manager.rs | 130 ++++++++++++++++-- .../server/agent/provider-launch-config.ts | 60 ++++++-- .../server/agent/providers/claude-agent.ts | 13 +- .../agent/providers/codex-app-server-agent.ts | 11 +- .../server/agent/providers/opencode-agent.ts | 13 +- 13 files changed, 264 insertions(+), 53 deletions(-) create mode 100644 packages/app/src/polyfills/screen-orientation.ts diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index c77c2190e..fa6d61592 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -521,7 +521,7 @@ jobs: releaseBody: See the assets to download and install this version. releaseDraft: ${{ env.RELEASE_DRAFT }} prerelease: false - args: --bundles nsis,msi + args: --bundles nsis - name: Build Windows app (smoke only) if: env.IS_SMOKE_TAG == 'true' diff --git a/.gitignore b/.gitignore index ebb97acae..e37d4f1e6 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ valknut-report.json/ **/.paseo-provider-history/ .claude/settings.local.json **/.claude/settings.local.json +.claude/scheduled_tasks.lock +.claude/worktrees/ .plans/ packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt diff --git a/package-lock.json b/package-lock.json index 6cc5aa3d1..03d3be557 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9138,6 +9138,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-log": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz", + "integrity": "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -27589,6 +27598,7 @@ "@tanstack/react-query": "^5.90.11", "@tanstack/react-virtual": "^3.13.21", "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-log": "^2.8.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-webgl": "^0.19.0", diff --git a/packages/app/index.ts b/packages/app/index.ts index 3a3dc1101..8ff193c14 100644 --- a/packages/app/index.ts +++ b/packages/app/index.ts @@ -2,6 +2,17 @@ import { polyfillCrypto } from "./src/polyfills/crypto"; polyfillCrypto(); +// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API +import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation"; +polyfillScreenOrientation(); + +// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log +if ((globalThis as { __TAURI__?: unknown }).__TAURI__) { + import("@tauri-apps/plugin-log").then(({ attachConsole }) => { + attachConsole(); + }); +} + // Configure Unistyles before Expo Router pulls in any components using StyleSheet. import "./src/styles/unistyles"; import "expo-router/entry"; diff --git a/packages/app/package.json b/packages/app/package.json index cdb49a173..888ee9b9a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -53,6 +53,7 @@ "@tanstack/react-query": "^5.90.11", "@tanstack/react-virtual": "^3.13.21", "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-log": "^2.8.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-webgl": "^0.19.0", diff --git a/packages/app/src/polyfills/screen-orientation.ts b/packages/app/src/polyfills/screen-orientation.ts new file mode 100644 index 000000000..c623c74ed --- /dev/null +++ b/packages/app/src/polyfills/screen-orientation.ts @@ -0,0 +1,28 @@ +export function polyfillScreenOrientation() { + if ( + typeof window === "undefined" || + typeof screen === "undefined" || + screen.orientation + ) { + return; + } + + Object.defineProperty(screen, "orientation", { + value: { + get type() { + return window.innerWidth > window.innerHeight + ? "landscape-primary" + : "portrait-primary"; + }, + get angle() { + return 0; + }, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return true; + }, + }, + configurable: true, + }); +} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 313d77c3c..697ac2c8a 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2629,7 +2629,7 @@ dependencies = [ [[package]] name = "paseo" -version = "0.1.24" +version = "0.1.25" dependencies = [ "base64 0.22.1", "dirs", diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index f3b782900..92f20b19a 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -158,6 +158,7 @@ fn maybe_run_managed_headless_command(app: &AppHandle) -> Result { let Some(command) = parse_managed_headless_command()? else { return Ok(false); }; + log::info!("[headless] running managed headless command: {:?}", command); if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); } @@ -625,17 +626,36 @@ pub fn run() { garbage_collect_attachment_files ]) .setup(|app| { + log::info!( + "[app] Paseo Desktop v{} starting", + app.package_info().version + ); + if maybe_run_managed_headless_command(app.handle())? { return Ok(()); } - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .level_for("tao", log::LevelFilter::Warn) + .level_for("wry", log::LevelFilter::Warn) + .max_file_size(5_000_000) + .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) + .clear_targets() + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::LogDir { + file_name: Some("app.log".into()), + }, + )) + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Stdout, + )) + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Webview, + )) + .build(), + )?; // Start from Tauri's default menu so macOS standard shortcuts (Cmd+A/C/V/etc) // keep working. Then inject our zoom controls into a View menu. diff --git a/packages/desktop/src-tauri/src/runtime_manager.rs b/packages/desktop/src-tauri/src/runtime_manager.rs index e51544354..6920d6f6b 100644 --- a/packages/desktop/src-tauri/src/runtime_manager.rs +++ b/packages/desktop/src-tauri/src/runtime_manager.rs @@ -563,13 +563,20 @@ fn bundled_runtime_root(app: &AppHandle) -> Result { if let Ok(resource_dir) = app.path().resource_dir() { let candidate = resource_dir.join("managed-runtime"); if candidate.exists() { + log::info!("[runtime] found bundled runtime at {}", candidate.display()); return Ok(candidate); } + log::info!( + "[runtime] no bundled runtime at {}, checking dev path", + candidate.display() + ); } let dev = dev_resource_root().join("managed-runtime"); if dev.exists() { + log::info!("[runtime] using dev runtime at {}", dev.display()); return Ok(dev); } + log::error!("[runtime] no managed runtime found"); Err("Managed runtime resources are not bundled with this desktop build.".to_string()) } @@ -715,17 +722,25 @@ fn cli_command( let node = runtime_root.join(&manifest.node_relative_path); let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path); if !node.exists() { + log::error!("[cli] bundled Node missing at {}", node.display()); return Err(format!( "Bundled Node runtime is missing at {}", node.display() )); } if !cli.exists() { + log::error!("[cli] bundled CLI missing at {}", cli.display()); return Err(format!( "Bundled CLI entrypoint is missing at {}", cli.display() )); } + log::info!( + "[cli] node={} cli={} args={:?}", + node.display(), + cli.display(), + args + ); let mut command = Command::new(node); command.arg(cli); command.args(args); @@ -893,10 +908,22 @@ fn write_state_file(path: &Path, value: &ManagedStateFile) -> Result<(), String> } fn ensure_runtime_ready_internal(app: &AppHandle) -> Result { + log::info!("[runtime] ensuring runtime is ready"); let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?; let runtime_root = bundled_root.join(&pointer.relative_root); let paths = resolve_paths(app)?; + log::info!( + "[runtime] runtime_root={} managed_home={} transport={}", + runtime_root.display(), + paths.managed_home.display(), + paths.transport_path.display() + ); let manifest = load_runtime_manifest(&runtime_root)?; + log::info!( + "[runtime] manifest: id={} version={}", + manifest.runtime_id, + manifest.runtime_version + ); if let Some(parent) = paths.transport_path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?; @@ -905,6 +932,11 @@ fn ensure_runtime_ready_internal(app: &AppHandle) -> Result Result { + log::info!("[cli] running: {:?}", args); let output = cli_command(runtime_root, manifest, args, paths)? .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() - .map_err(|error| format!("Failed to run bundled CLI: {error}"))?; + .map_err(|error| { + log::error!("[cli] failed to spawn: {error}"); + format!("Failed to run bundled CLI: {error}") + })?; if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!( + "[cli] exit={} stderr={}", + output.status.code().unwrap_or(-1), + stderr.trim() + ); return Err(format!( "Bundled CLI failed (exit {}): {}", output.status.code().unwrap_or(-1), - String::from_utf8_lossy(&output.stderr).trim() + stderr.trim() )); } let stdout = String::from_utf8_lossy(&output.stdout); + log::info!("[cli] success, parsing JSON output"); serde_json::from_str(stdout.trim()).map_err(|error| { + log::error!("[cli] JSON parse error: {error}; stdout={}", stdout.trim()); format!( "Failed to parse bundled CLI JSON output: {error}; stdout={}", stdout.trim() @@ -1101,6 +1145,7 @@ fn remove_cli_shim_via_macos_prompt(shim_path: &Path) -> Result<(), String> { } fn install_cli_shim_internal(app: &AppHandle) -> Result { + log::info!("[cli-shim] installing CLI shim"); let status = ensure_runtime_ready_internal(app)?; let paths = resolve_paths(app)?; let runtime_root = PathBuf::from(&status.runtime_root); @@ -1116,6 +1161,7 @@ fn install_cli_shim_internal(app: &AppHandle) -> Result { #[cfg(windows)] let install_result: Result<(), String> = Err("AUTOMATIC_INSTALL_UNAVAILABLE".to_string()); + log::info!("[cli-shim] target path: {}", shim_path.display()); match install_result { Ok(()) => { let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile { @@ -1209,10 +1255,15 @@ fn uninstall_cli_shim_internal(app: &AppHandle) -> Result } fn start_managed_daemon_internal(app: &AppHandle) -> Result { + log::info!("[daemon] starting managed daemon"); let status = ensure_runtime_ready_internal(app)?; let paths = resolve_paths(app)?; let existing_status = managed_daemon_status_internal(app)?; if existing_status.daemon_running { + log::info!( + "[daemon] already running (pid={:?})", + existing_status.daemon_pid + ); return Ok(existing_status); } if let Some(parent) = paths.transport_path.parent() { @@ -1223,6 +1274,12 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result Result Result { + log::info!("[daemon] stopping managed daemon"); let status = ensure_runtime_ready_internal(app)?; let paths = resolve_paths(app)?; let runtime_root = PathBuf::from(&status.runtime_root); @@ -1276,24 +1350,40 @@ fn stop_managed_daemon_internal(app: &AppHandle) -> Result Result { + log::info!("[daemon] restarting managed daemon"); let status = ensure_runtime_ready_internal(app)?; let paths = resolve_paths(app)?; let runtime_root = PathBuf::from(&status.runtime_root); let manifest = load_runtime_manifest(&runtime_root)?; let state = read_state_file(&paths.state_file_path); let target = managed_transport_target(&paths, state.as_ref())?; + log::info!( + "[daemon] restart: listen={} (type={})", + target.transport_path, + target.transport_type + ); let output = cli_command( &runtime_root, &manifest, @@ -1311,14 +1401,24 @@ fn restart_managed_daemon_internal(app: &AppHandle) -> Result Result { + log::info!( + "[tcp] updating settings: enabled={} host={} port={}", + settings.enabled, + settings.host, + settings.port + ); if settings.enabled { parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?; } @@ -1596,6 +1702,12 @@ pub async fn open_local_daemon_transport( transport_path: String, ) -> Result { let session_id = transport_state.alloc_session_id(); + log::info!( + "[transport] opening session {} type={} path={}", + session_id, + transport_type, + transport_path + ); let _ = app; match transport_type.as_str() { "pipe" => { diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index ff1495424..8bc010a7d 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -1,5 +1,6 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { platform } from "node:os"; import { z } from "zod"; import type { AgentProvider } from "./agent-sdk-types.js"; @@ -88,24 +89,63 @@ export function applyProviderEnv( }; } -export function isCommandAvailable(command: string): boolean { - const normalized = command.trim(); - if (!normalized) { - return false; +/** + * Resolve an executable name to its absolute path the way the user's shell would. + * + * On Unix we first try `$SHELL -lic "which "` so that rc-file PATH + * additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the + * user opened a terminal and typed the command. If that fails (e.g. the login + * shell itself errors) we fall back to a plain `which`. + * + * On Windows the system PATH is always available, so `where.exe` is sufficient. + */ +export function findExecutable(name: string): string | null { + const trimmed = name.trim(); + if (!trimmed) { + return null; } - if (normalized.includes("/") || normalized.includes("\\")) { - return existsSync(normalized); + if (trimmed.includes("/") || trimmed.includes("\\")) { + return existsSync(trimmed) ? trimmed : null; + } + + if (platform() === "win32") { + try { + const out = execSync(`where.exe ${trimmed}`, { encoding: "utf8" }).trim(); + const firstLine = out.split(/\r?\n/)[0]?.trim(); + return firstLine || null; + } catch { + return null; + } + } + + // Unix: try the user's login shell so rc-file PATH entries are visible. + const shell = process.env["SHELL"]; + if (shell) { + try { + const out = execSync(`${shell} -lic "which ${trimmed}"`, { + encoding: "utf8", + timeout: 5000, + }).trim(); + if (out) { + return out; + } + } catch { + // Login shell failed (broken rc, etc.) — fall through to plain which. + } } try { - const output = execFileSync("which", [normalized], { encoding: "utf8" }).trim(); - return output.length > 0; + return execFileSync("which", [trimmed], { encoding: "utf8" }).trim() || null; } catch { - return false; + return null; } } +export function isCommandAvailable(command: string): boolean { + return findExecutable(command) !== null; +} + export function isProviderCommandAvailable( commandConfig: ProviderCommand | undefined, resolveDefaultCommand: () => string diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 9a226813b..b5bf3d503 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -69,6 +69,7 @@ import type { } from "../agent-sdk-types.js"; import { applyProviderEnv, + findExecutable, isProviderCommandAvailable, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; @@ -363,16 +364,8 @@ type ClaudeAgentSessionOptions = { logger: Logger; }; -function whichClaude(): string | null { - try { - return execSync("which claude", { encoding: "utf8" }).trim() || null; - } catch { - return null; - } -} - function resolveClaudeBinary(): string { - const claudePath = whichClaude(); + const claudePath = findExecutable("claude"); if (claudePath) { return claudePath; } @@ -1414,7 +1407,7 @@ export class ClaudeAgentClient implements AgentClient { this.defaults = options.defaults; this.logger = options.logger.child({ module: "agent", provider: "claude" }); this.runtimeSettings = options.runtimeSettings; - this.claudePath = whichClaude(); + this.claudePath = findExecutable("claude"); if (this.claudePath) { try { const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 56219bee5..76589507a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -40,6 +40,7 @@ import { } from "./codex/tool-call-mapper.js"; import { applyProviderEnv, + findExecutable, isProviderCommandAvailable, resolveProviderCommandPrefix, type ProviderRuntimeSettings, @@ -150,13 +151,9 @@ function mergeCodexConfiguredDefaults( } function resolveCodexBinary(): string { - try { - const codexPath = execSync("which codex", { encoding: "utf8" }).trim(); - if (codexPath) { - return codexPath; - } - } catch { - // Fall through to error + const codexPath = findExecutable("codex"); + if (codexPath) { + return codexPath; } throw new Error( "Codex CLI not found. Please install codex globally: npm install -g @openai/codex" diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 320269ebc..0d362c6f0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,4 +1,4 @@ -import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"; import net from "node:net"; import type { Logger } from "pino"; @@ -29,6 +29,7 @@ import type { } from "../agent-sdk-types.js"; import { applyProviderEnv, + findExecutable, isProviderCommandAvailable, resolveProviderCommandPrefix, type ProviderRuntimeSettings, @@ -150,13 +151,9 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch ); function resolveOpenCodeBinary(): string { - try { - const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim(); - if (opencodePath) { - return opencodePath; - } - } catch { - // fall through + const opencodePath = findExecutable("opencode"); + if (opencodePath) { + return opencodePath; } throw new Error( "OpenCode CLI not found. Please install opencode globally so Paseo can launch the provider."