From 1ee7e138affc52d7c32c4e1961efb2557eaf98a5 Mon Sep 17 00:00:00 2001 From: SF Date: Mon, 27 Jul 2026 19:07:28 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(desktop):=20integrated=20terminal=20?= =?UTF-8?q?=E2=80=94=20server=20+=20container=20shells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Terminal panel to the management window: tabbed server SSH shells and per-app container shells (docker exec), full-screen toggle, live resize. - internal/ssh: Executor.InteractiveShell — PTY-backed session bridging stdio, with an out-of-band resize control frame (WindowChange). - cmd/neo-bridge: `pty` subcommand — raw interactive mode reusing neo's SSH auth (login shell or `docker exec -it sh`). - src-tauri/pty.rs: spawns `neo-bridge pty` via std::process (posix_spawn — no fork abort on macOS) and streams raw bytes (no line buffering), so per-key echo and prompts render correctly. Commands: pty_spawn/write/resize/kill. - frontend: xterm.js TerminalPanel + tabs store + TerminalSection wired into Management. Bridge resolves auth, so tabs need only a server name (+ app). --- apps/desktop/package-lock.json | 17 ++ apps/desktop/package.json | 2 + apps/desktop/src-tauri/src/lib.rs | 7 + apps/desktop/src-tauri/src/pty.rs | 156 ++++++++++++++++++ apps/desktop/src/app/Management.tsx | 6 + .../src/features/terminal/TerminalPanel.tsx | 109 ++++++++++++ .../src/features/terminal/TerminalSection.tsx | 100 +++++++++++ apps/desktop/src/lib/terminals.ts | 74 +++++++++ apps/desktop/src/styles/global.css | 48 ++++++ cmd/neo-bridge/main.go | 6 + cmd/neo-bridge/pty.go | 78 +++++++++ internal/ssh/executor.go | 100 +++++++++++ 12 files changed, 703 insertions(+) create mode 100644 apps/desktop/src-tauri/src/pty.rs create mode 100644 apps/desktop/src/features/terminal/TerminalPanel.tsx create mode 100644 apps/desktop/src/features/terminal/TerminalSection.tsx create mode 100644 apps/desktop/src/lib/terminals.ts create mode 100644 cmd/neo-bridge/pty.go diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index c65e57d..dbde438 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -14,6 +14,8 @@ "@tauri-apps/plugin-notification": "^2.2.0", "@tauri-apps/plugin-process": "^2.2.0", "@tauri-apps/plugin-updater": "^2.3.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1917,6 +1919,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bcee806..e8d91f6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,8 @@ "@tauri-apps/plugin-notification": "^2.2.0", "@tauri-apps/plugin-process": "^2.2.0", "@tauri-apps/plugin-updater": "^2.3.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9c9034e..9d0ab78 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub mod bridge; mod commands; +mod pty; mod tray; use std::sync::Arc; @@ -56,8 +57,14 @@ pub fn run() { bridge::operation_cancel, bridge::logs_subscribe, bridge::logs_unsubscribe, + pty::pty_spawn, + pty::pty_write, + pty::pty_resize, + pty::pty_kill, ]) .setup(|app| { + pty::init(app); + // macOS: behave as a menu-bar accessory (no dock icon) until a full // management window is opened. Slice-1 approximation of the plan's // "no dock icon when only the popover is open" requirement. diff --git a/apps/desktop/src-tauri/src/pty.rs b/apps/desktop/src-tauri/src/pty.rs new file mode 100644 index 0000000..45b12e1 --- /dev/null +++ b/apps/desktop/src-tauri/src/pty.rs @@ -0,0 +1,156 @@ +//! Integrated terminals. Each session runs the bundled `neo-bridge pty …`, +//! which opens a real remote PTY over neo's own SSH auth. +//! +//! We spawn it with std::process::Command (NOT tauri-plugin-shell, NOT a PTY +//! crate): with no pre_exec hook, std uses posix_spawn on macOS, so there's no +//! fork inside this multithreaded WebKit process (which modern macOS aborts). +//! We read the child's stdout as RAW BYTES — the shell plugin line-buffers, +//! which breaks a terminal (no per-keystroke echo; prompts without a trailing +//! newline like `$ ` or `password:` never appear). +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::Mutex; + +use tauri::{Emitter, Manager}; + +struct Session { + child: Child, + stdin: ChildStdin, +} + +#[derive(Default)] +pub struct PtyState { + sessions: Mutex>, +} + +/// Locate the bundled neo-bridge next to our executable (Tauri copies the +/// externalBin there without the target-triple suffix). +fn bridge_path() -> Option { + let dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); + for name in ["neo-bridge", "neo-bridge-aarch64-apple-darwin", "neo-bridge-x86_64-apple-darwin"] { + let cand = dir.join(name); + if cand.exists() { + return Some(cand); + } + } + None +} + +#[tauri::command] +pub fn pty_spawn( + app: tauri::AppHandle, + id: String, + server: String, + container: Option, + cols: u16, + rows: u16, +) -> Result<(), String> { + if let Some(mut old) = app.state::().sessions.lock().unwrap().remove(&id) { + let _ = old.child.kill(); + } + + let bridge = bridge_path().ok_or_else(|| "neo-bridge not found".to_string())?; + let container_arg = container.filter(|c| !c.is_empty()).unwrap_or_else(|| "-".to_string()); + + let mut child = Command::new(&bridge) + .arg("pty") + .arg(&server) + .arg(&container_arg) + .arg(cols.max(1).to_string()) + .arg(rows.max(1).to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + + let stdout = child.stdout.take().ok_or("no stdout")?; + let stderr = child.stderr.take().ok_or("no stderr")?; + let stdin = child.stdin.take().ok_or("no stdin")?; + + let data_ev = format!("pty://data/{id}"); + let exit_ev = format!("pty://exit/{id}"); + + let app_out = app.clone(); + let ev_out = data_ev.clone(); + std::thread::spawn(move || pump(stdout, &app_out, &ev_out)); + + let app_err = app.clone(); + let ev_err = data_ev.clone(); + std::thread::spawn(move || pump(stderr, &app_err, &ev_err)); + + app.state::().sessions.lock().unwrap().insert(id.clone(), Session { child, stdin }); + + // Reap the child and notify the frontend when it exits. + let app_reap = app.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_millis(300)); + let state = app_reap.state::(); + let mut guard = state.sessions.lock().unwrap(); + match guard.get_mut(&id) { + Some(sess) => match sess.child.try_wait() { + Ok(Some(_)) => { + guard.remove(&id); + drop(guard); + let _ = app_reap.emit(&exit_ev, ()); + return; + } + Ok(None) => {} + Err(_) => { + guard.remove(&id); + return; + } + }, + None => return, // killed elsewhere + } + }); + + Ok(()) +} + +fn pump(mut r: R, app: &tauri::AppHandle, event: &str) { + let mut buf = [0u8; 8192]; + loop { + match r.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + let _ = app.emit(event, String::from_utf8_lossy(&buf[..n]).to_string()); + } + } + } +} + +#[tauri::command] +pub fn pty_write(app: tauri::AppHandle, id: String, data: String) -> Result<(), String> { + let state = app.state::(); + let mut sessions = state.sessions.lock().unwrap(); + if let Some(sess) = sessions.get_mut(&id) { + sess.stdin.write_all(data.as_bytes()).map_err(|e| e.to_string())?; + sess.stdin.flush().map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Resize the remote PTY via a control frame the bridge intercepts on stdin. +#[tauri::command] +pub fn pty_resize(app: tauri::AppHandle, id: String, cols: u16, rows: u16) { + let state = app.state::(); + let mut sessions = state.sessions.lock().unwrap(); + if let Some(sess) = sessions.get_mut(&id) { + let frame = format!("\x1eR{}x{}\n", cols.max(1), rows.max(1)); + let _ = sess.stdin.write_all(frame.as_bytes()); + let _ = sess.stdin.flush(); + } +} + +#[tauri::command] +pub fn pty_kill(app: tauri::AppHandle, id: String) { + if let Some(mut sess) = app.state::().sessions.lock().unwrap().remove(&id) { + let _ = sess.child.kill(); + } +} + +pub fn init(app: &tauri::App) { + app.manage(PtyState::default()); +} diff --git a/apps/desktop/src/app/Management.tsx b/apps/desktop/src/app/Management.tsx index c7ee1f3..aef9077 100644 --- a/apps/desktop/src/app/Management.tsx +++ b/apps/desktop/src/app/Management.tsx @@ -20,6 +20,7 @@ import { LogViewer } from "../features/logs/LogViewer"; import { AppActionDialog } from "../features/actions/AppActionDialog"; import { ActionHistoryList } from "../features/actions/ActionHistoryList"; import { DiagnosticBundlePanel } from "../features/diagnostics/DiagnosticBundlePanel"; +import { TerminalSection } from "../features/terminal/TerminalSection"; import { statusFor, useServerData } from "./useServerData"; import { useAppActions } from "./useAppActions"; @@ -221,6 +222,11 @@ export function Management({ api }: { api: DesktopAPI }) { initialTarget={logsTarget} /> + + s.id === data.selected)?.name} + apps={data.apps} + /> {dialog ? ( diff --git a/apps/desktop/src/features/terminal/TerminalPanel.tsx b/apps/desktop/src/features/terminal/TerminalPanel.tsx new file mode 100644 index 0000000..89a7096 --- /dev/null +++ b/apps/desktop/src/features/terminal/TerminalPanel.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef } from "react"; +import { Terminal as XTerm } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import type { TermTab } from "../../lib/terminals"; + +// One PTY session per tab, driven by the Rust `pty_*` commands (which spawn +// `neo-bridge pty …`). Output streams over per-session events; xterm and the +// remote PTY are kept the same size via `pty_resize`. +export function TerminalPanel({ tab, active }: { tab: TermTab; active: boolean }) { + const ref = useRef(null); + const syncRef = useRef<() => void>(() => {}); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const term = new XTerm({ + fontFamily: '"SF Mono", Menlo, Monaco, "Cascadia Code", monospace', + fontSize: 12, + cursorBlink: true, + allowProposedApi: true, + theme: { + background: "#00000000", + foreground: "#e6e6e6", + cursor: "#0a84ff", + selectionBackground: "rgba(10,132,255,0.35)", + }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(el); + + let cleanup: Array<() => void> = []; + let disposed = false; + + (async () => { + const sync = () => { + try { + fit.fit(); + } catch { + return; + } + if (term.cols > 0 && term.rows > 0) { + invoke("pty_resize", { id: tab.id, cols: term.cols, rows: term.rows }); + } + }; + syncRef.current = sync; + + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); + if (disposed) return; + try { + fit.fit(); + } catch { + /* ignore */ + } + + const { spec } = tab; + term.writeln( + `\x1b[90mConnecting to ${spec.server}${spec.kind === "container" ? ` → ${spec.app}` : ""}…\x1b[0m` + ); + + const unlisten = await listen(`pty://data/${tab.id}`, (e) => term.write(e.payload)); + const unexit = await listen(`pty://exit/${tab.id}`, () => + term.writeln("\r\n\x1b[90m[disconnected]\x1b[0m") + ); + const onData = term.onData((d) => invoke("pty_write", { id: tab.id, data: d })); + + try { + await invoke("pty_spawn", { + id: tab.id, + server: spec.server, + container: spec.kind === "container" ? spec.app : null, + cols: term.cols || 80, + rows: term.rows || 24, + }); + } catch (e) { + term.writeln(`\r\n\x1b[31mfailed to start: ${String(e)}\x1b[0m`); + } + + const ro = new ResizeObserver(() => sync()); + ro.observe(el); + setTimeout(sync, 120); + + cleanup = [ + () => unlisten(), + () => unexit(), + () => onData.dispose(), + () => ro.disconnect(), + () => invoke("pty_kill", { id: tab.id }), + ]; + })(); + + return () => { + disposed = true; + cleanup.forEach((fn) => fn()); + term.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab.id]); + + useEffect(() => { + if (active) requestAnimationFrame(() => syncRef.current()); + }, [active]); + + return
; +} diff --git a/apps/desktop/src/features/terminal/TerminalSection.tsx b/apps/desktop/src/features/terminal/TerminalSection.tsx new file mode 100644 index 0000000..565a19f --- /dev/null +++ b/apps/desktop/src/features/terminal/TerminalSection.tsx @@ -0,0 +1,100 @@ +import { useEffect, useReducer, useState } from "react"; +import { terminals } from "../../lib/terminals"; +import { TerminalPanel } from "./TerminalPanel"; + +interface AppRef { + id: string; + name: string; +} + +// Self-contained terminal panel for the management window: open server shells +// or container shells (over `neo-bridge pty`), switch tabs, and maximize. +// Needs only the selected server NAME and the apps on it — no edits to the rest +// of the UI or the bridge transport. +export function TerminalSection({ server, apps }: { server?: string; apps: AppRef[] }) { + const [, force] = useReducer((x) => x + 1, 0); + const [full, setFull] = useState(false); + const [appSel, setAppSel] = useState(""); + + useEffect(() => terminals.subscribe(force), []); + + const tabs = terminals.tabs; + const activeId = terminals.activeId; + + function openServer() { + if (server && !terminals.atLimit()) terminals.openServer(server); + } + function openContainer() { + if (server && appSel && !terminals.atLimit()) terminals.openContainer(server, appSel); + } + + return ( +
+
+

Terminal

+
+ + + + +
+
+ + {tabs.length > 0 && ( +
+ {tabs.map((t) => ( + + ))} +
+ )} + +
+ {tabs.length === 0 ? ( +
+ {server ? "Open a server or container shell above." : "Select a server first."} +
+ ) : ( + tabs.map((t) => ) + )} +
+
+ ); +} diff --git a/apps/desktop/src/lib/terminals.ts b/apps/desktop/src/lib/terminals.ts new file mode 100644 index 0000000..0081dd6 --- /dev/null +++ b/apps/desktop/src/lib/terminals.ts @@ -0,0 +1,74 @@ +// Manages the open terminal tabs (server shells + container shells). Each tab +// maps to one `neo-bridge pty` session keyed by id. The bridge resolves SSH +// auth from ~/.neo, so a tab only needs the server name (+ app for containers). +export interface TermSpec { + kind: "server" | "container"; + server: string; // server name + app?: string; // container app name (kind === "container") +} + +export interface TermTab { + id: string; + title: string; + spec: TermSpec; +} + +const MAX = 6; +let tabs: TermTab[] = []; +let activeId = ""; +let counter = 0; +const subs = new Set<() => void>(); +let reveal: (() => void) | null = null; + +function emit() { + subs.forEach((f) => f()); +} + +export const terminals = { + MAX, + get tabs() { + return tabs; + }, + get activeId() { + return activeId; + }, + atLimit() { + return tabs.length >= MAX; + }, + subscribe(f: () => void): () => void { + subs.add(f); + return () => { + subs.delete(f); + }; + }, + onReveal(cb: () => void) { + reveal = cb; + }, + setActive(id: string) { + activeId = id; + emit(); + }, + openServer(server: string): string | null { + if (tabs.length >= MAX) return null; + const id = `t${++counter}`; + tabs = [...tabs, { id, title: server, spec: { kind: "server", server } }]; + activeId = id; + reveal?.(); + emit(); + return id; + }, + openContainer(server: string, app: string): string | null { + if (tabs.length >= MAX) return null; + const id = `t${++counter}`; + tabs = [...tabs, { id, title: app, spec: { kind: "container", server, app } }]; + activeId = id; + reveal?.(); + emit(); + return id; + }, + close(id: string) { + tabs = tabs.filter((t) => t.id !== id); + if (activeId === id) activeId = tabs[tabs.length - 1]?.id ?? ""; + emit(); + }, +}; diff --git a/apps/desktop/src/styles/global.css b/apps/desktop/src/styles/global.css index 298f005..65c83f6 100644 --- a/apps/desktop/src/styles/global.css +++ b/apps/desktop/src/styles/global.css @@ -2091,3 +2091,51 @@ select { outline: 1px solid currentColor; } } + +/* ---------- Integrated terminal (features/terminal) ---------- */ +.terminal-panel { display: flex; flex-direction: column; } +.terminal-panel__controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.terminal-panel__appsel { + height: 28px; padding: 0 8px; font-size: 13px; + background: var(--surface); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; +} +.terminal-panel--full { + position: fixed; inset: 0; z-index: 100; margin: 0; border-radius: 0; + height: 100vh; max-height: 100vh; background: var(--surface); +} +.terminal-panel--full .term-stack { flex: 1; height: auto; } + +.term-tabs { display: flex; align-items: flex-end; gap: 3px; margin-top: 10px; overflow-x: auto; } +.term-tab { + display: flex; align-items: center; gap: 6px; + padding: 5px 9px; max-width: 180px; + background: transparent; border: none; border-radius: 7px 7px 0 0; + color: var(--text-muted, #8a97a8); font-size: 12px; cursor: pointer; + border-bottom: 2px solid transparent; +} +.term-tab:hover { background: color-mix(in srgb, var(--text) 8%, transparent); } +.term-tab.active { + background: color-mix(in srgb, var(--text) 12%, transparent); + color: var(--text); border-bottom-color: var(--accent); +} +.tab-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; background: #32d74b; } +.tab-dot.container { background: #ff9f0a; } +.tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tab-close { color: inherit; opacity: 0.5; font-size: 10px; padding: 1px 3px; border-radius: 4px; } +.tab-close:hover { opacity: 1; background: color-mix(in srgb, var(--text) 16%, transparent); } + +.term-stack { + position: relative; margin-top: 8px; + height: 340px; min-height: 200px; + border: 1px solid var(--border); border-radius: 10px; + background: #0b0e14; overflow: hidden; +} +.term { position: absolute; inset: 0; padding: 6px 8px; } +.term .xterm { width: 100%; height: 100%; } +.term .xterm-viewport { background: transparent !important; } +.term-hidden { display: none; } +.term-empty { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + color: var(--text-muted, #8a97a8); font-size: 13px; text-align: center; padding: 20px; +} diff --git a/cmd/neo-bridge/main.go b/cmd/neo-bridge/main.go index 8e8fce5..89a7965 100644 --- a/cmd/neo-bridge/main.go +++ b/cmd/neo-bridge/main.go @@ -39,6 +39,12 @@ var ( ) func main() { + // Raw interactive-terminal mode, outside the JSON protocol. Spawned by the + // desktop app as `neo-bridge pty …` for each integrated terminal. + if len(os.Args) > 1 && os.Args[1] == "pty" { + os.Exit(runInteractivePty(os.Args[2:])) + } + logger := newLogger(os.Getenv("NEO_BRIDGE_LOG_LEVEL")) slog.SetDefault(logger) diff --git a/cmd/neo-bridge/pty.go b/cmd/neo-bridge/pty.go new file mode 100644 index 0000000..7660960 --- /dev/null +++ b/cmd/neo-bridge/pty.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/vxero/neo/internal/config" + "github.com/vxero/neo/internal/ssh" +) + +// runInteractivePty is a raw stdio mode (NOT the JSON protocol): it bridges the +// process's stdin/stdout to a remote PTY over neo's own SSH auth. The desktop +// app spawns `neo-bridge pty …` for each integrated terminal. +// +// It connects to a login shell, or to `docker exec -it sh` when a +// container is given. Args: [cols] [rows]. +func runInteractivePty(args []string) int { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: neo-bridge pty [cols] [rows]") + return 2 + } + server := args[0] + container := "" + if len(args) > 1 && args[1] != "-" && args[1] != "" { + container = args[1] + } + cols := ptyArgInt(args, 2, 80) + rows := ptyArgInt(args, 3, 24) + + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if server == "" { + server = cfg.Current + } + srv, ok := cfg.Servers[server] + if !ok { + fmt.Fprintf(os.Stderr, "server not found: %s\r\n", server) + return 1 + } + + exec := ssh.New(srv.Host, srv.Port) + exec.NonInteractive = true // never prompt on stdin (it's the terminal stream) + if srv.Key != "" { + if data, e := os.ReadFile(srv.Key); e == nil { + exec.PrivateKey = data + } + } + if err := exec.Connect(); err != nil { + fmt.Fprintf(os.Stderr, "cannot connect to %s: %v\r\n", srv.Host, err) + return 1 + } + defer exec.Close() + + cmd := "" + if container != "" { + cmd = fmt.Sprintf("docker exec -it %s sh", config.AppContainer(container)) + } + if err := exec.InteractiveShell(cols, rows, cmd); err != nil { + fmt.Fprintf(os.Stderr, "\r\nsession ended: %v\r\n", err) + return 1 + } + return 0 +} + +func ptyArgInt(args []string, i, def int) int { + if i >= len(args) { + return def + } + if n, err := strconv.Atoi(strings.TrimSpace(args[i])); err == nil && n > 0 { + return n + } + return def +} diff --git a/internal/ssh/executor.go b/internal/ssh/executor.go index e673cb4..1ea8b49 100644 --- a/internal/ssh/executor.go +++ b/internal/ssh/executor.go @@ -8,6 +8,7 @@ import ( "net" "os" "path/filepath" + "strconv" "strings" "time" @@ -97,6 +98,105 @@ func (e *Executor) Close() error { return nil } +// InteractiveShell opens a PTY-backed session and bridges the local process's +// stdin/stdout/stderr to it. If command is empty it starts a login shell; +// otherwise it runs that command interactively (e.g. `docker exec -it … sh`). +// Used by `neo-bridge pty` so the desktop terminal reuses neo's working auth. +// +// Stdin carries an out-of-band resize control frame: "\x1eRx\n" +// (0x1e = RS, never produced by a keyboard). Those frames are intercepted and +// turned into WindowChange calls; everything else forwards as keystrokes. +func (e *Executor) InteractiveShell(cols, rows int, command string) error { + session, err := e.client.NewSession() + if err != nil { + return fmt.Errorf("ssh session: %w", err) + } + defer session.Close() + + if cols <= 0 { + cols = 80 + } + if rows <= 0 { + rows = 24 + } + modes := ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } + if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { + return fmt.Errorf("request pty: %w", err) + } + + session.Stdout = os.Stdout + session.Stderr = os.Stderr + stdinPipe, err := session.StdinPipe() + if err != nil { + return err + } + + go func() { + buf := make([]byte, 4096) + var ctrl []byte + inCtrl := false + for { + n, rerr := os.Stdin.Read(buf) + if n > 0 { + forward := make([]byte, 0, n) + for _, b := range buf[:n] { + switch { + case inCtrl && b == '\n': + applyResize(session, ctrl) + ctrl = ctrl[:0] + inCtrl = false + case inCtrl: + ctrl = append(ctrl, b) + case b == 0x1e: + inCtrl = true + ctrl = ctrl[:0] + default: + forward = append(forward, b) + } + } + if len(forward) > 0 { + if _, werr := stdinPipe.Write(forward); werr != nil { + return + } + } + } + if rerr != nil { + return + } + } + }() + + if command != "" { + if err := session.Start(command); err != nil { + return err + } + } else if err := session.Shell(); err != nil { + return err + } + return session.Wait() +} + +// applyResize parses a "Rx" control frame and resizes the PTY. +func applyResize(session *ssh.Session, frame []byte) { + s := string(frame) + if len(s) < 2 || s[0] != 'R' { + return + } + parts := strings.SplitN(s[1:], "x", 2) + if len(parts) != 2 { + return + } + cols, e1 := strconv.Atoi(parts[0]) + rows, e2 := strconv.Atoi(parts[1]) + if e1 == nil && e2 == nil && cols > 0 && rows > 0 { + _ = session.WindowChange(rows, cols) + } +} + // Run executes a command and returns combined stdout. func (e *Executor) Run(cmd string) (string, error) { e.debugf("run: %s", cmd) From 983baf26802a8ee5402aa5cd0b5fa7ca2bb55e78 Mon Sep 17 00:00:00 2001 From: SF Date: Mon, 27 Jul 2026 19:18:47 +0800 Subject: [PATCH 2/4] style(desktop): rustfmt pty.rs --- apps/desktop/src-tauri/src/pty.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/pty.rs b/apps/desktop/src-tauri/src/pty.rs index 45b12e1..33e7c15 100644 --- a/apps/desktop/src-tauri/src/pty.rs +++ b/apps/desktop/src-tauri/src/pty.rs @@ -28,7 +28,11 @@ pub struct PtyState { /// externalBin there without the target-triple suffix). fn bridge_path() -> Option { let dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); - for name in ["neo-bridge", "neo-bridge-aarch64-apple-darwin", "neo-bridge-x86_64-apple-darwin"] { + for name in [ + "neo-bridge", + "neo-bridge-aarch64-apple-darwin", + "neo-bridge-x86_64-apple-darwin", + ] { let cand = dir.join(name); if cand.exists() { return Some(cand); @@ -51,7 +55,9 @@ pub fn pty_spawn( } let bridge = bridge_path().ok_or_else(|| "neo-bridge not found".to_string())?; - let container_arg = container.filter(|c| !c.is_empty()).unwrap_or_else(|| "-".to_string()); + let container_arg = container + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| "-".to_string()); let mut child = Command::new(&bridge) .arg("pty") @@ -80,7 +86,11 @@ pub fn pty_spawn( let ev_err = data_ev.clone(); std::thread::spawn(move || pump(stderr, &app_err, &ev_err)); - app.state::().sessions.lock().unwrap().insert(id.clone(), Session { child, stdin }); + app.state::() + .sessions + .lock() + .unwrap() + .insert(id.clone(), Session { child, stdin }); // Reap the child and notify the frontend when it exits. let app_reap = app.clone(); @@ -126,7 +136,9 @@ pub fn pty_write(app: tauri::AppHandle, id: String, data: String) -> Result<(), let state = app.state::(); let mut sessions = state.sessions.lock().unwrap(); if let Some(sess) = sessions.get_mut(&id) { - sess.stdin.write_all(data.as_bytes()).map_err(|e| e.to_string())?; + sess.stdin + .write_all(data.as_bytes()) + .map_err(|e| e.to_string())?; sess.stdin.flush().map_err(|e| e.to_string())?; } Ok(()) From 232306c0d0458bfaef26d9c02a33483114896501 Mon Sep 17 00:00:00 2001 From: SF Date: Tue, 28 Jul 2026 00:40:03 +0800 Subject: [PATCH 3/4] fix(ci): gate macOS signing so unsigned desktop releases build The release build passed APPLE_CERTIFICATE unconditionally; with no cert secret Tauri still ran `security import` and aborted ("failed to import keychain certificate"), failing darwin-aarch64. Split into a signed step (only when the cert secret exists) and an unsigned step (macOS ad-hoc; Windows still signs via the composed thumbprint). --- .github/workflows/desktop-release.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 1cad4d5..f40388b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -188,7 +188,11 @@ jobs: # consumes APPLE_* env natively (keychain import, codesign of the app AND # the embedded sidecar, notarization); the updater key env makes it emit # signed updater artifacts. - - name: Build, package, and sign + # Signed build — only when an Apple certificate secret is present. Passing + # the APPLE_* env unconditionally makes Tauri run `security import` on an + # empty/invalid cert and abort, so the unsigned path below omits it. + - name: Build, package, and sign (Apple cert present) + if: steps.signing.outputs.apple_cert == 'true' shell: bash env: NEO_DESKTOP_VERSION: ${{ needs.verify.outputs.version }} @@ -203,6 +207,19 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: npm run tauri build -- --target ${{ matrix.triple }} --config tauri.release.json + # Unsigned build — no Apple signing env (macOS ad-hoc). Windows still signs + # via the certificateThumbprint composed into the overlay above. Updater + # artifacts are still produced when TAURI_SIGNING_PRIVATE_KEY is set. + - name: Build and package (unsigned) + if: steps.signing.outputs.apple_cert != 'true' + shell: bash + env: + NEO_DESKTOP_VERSION: ${{ needs.verify.outputs.version }} + NEO_DESKTOP_COMMIT: ${{ github.sha }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri build -- --target ${{ matrix.triple }} --config tauri.release.json + # Step 7: smoke test on the native runner. The embedded bridge must speak # protocol v1 and report exactly the tag version (acceptance: "the # embedded bridge is the expected version"). From 4cf95badf82257056218375c03dadcd1f616bd9b Mon Sep 17 00:00:00 2001 From: SF Date: Tue, 28 Jul 2026 00:51:55 +0800 Subject: [PATCH 4/4] chore(desktop): bump version to 0.1.1 for release --- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/tauri.conf.json | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e8d91f6..7afa543 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "neo-desktop", "productName": "Neo Desktop", - "version": "0.1.0", + "version": "0.1.1", "description": "Neo Desktop — menu-bar / notification-area tray app for the Neo CLI", "type": "module", "private": true, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index f98c28c..6c0c161 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Neo Desktop", - "version": "0.1.0", + "version": "0.1.1", "identifier": "dev.vxero.neo.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -13,7 +13,9 @@ "withGlobalTauri": false, "security": { "csp": "default-src 'self'; img-src 'self' data: asset: https://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost", - "capabilities": ["default"] + "capabilities": [ + "default" + ] }, "windows": [ { @@ -51,7 +53,9 @@ "category": "DeveloperTool", "shortDescription": "Neo Desktop tray app", "longDescription": "Menu-bar / notification-area companion for the Neo CLI: monitor remote servers, view status, and run safe actions.", - "externalBin": ["binaries/neo-bridge"], + "externalBin": [ + "binaries/neo-bridge" + ], "icon": [ "icons/32x32.png", "icons/128x128.png",