Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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").
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
},
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

pub mod bridge;
mod commands;
mod pty;
mod tray;

use std::sync::Arc;
Expand Down Expand Up @@ -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.
Expand Down
168 changes: 168 additions & 0 deletions apps/desktop/src-tauri/src/pty.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//! 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<HashMap<String, Session>>,
}

/// Locate the bundled neo-bridge next to our executable (Tauri copies the
/// externalBin there without the target-triple suffix).
fn bridge_path() -> Option<std::path::PathBuf> {
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<String>,
cols: u16,
rows: u16,
) -> Result<(), String> {
if let Some(mut old) = app.state::<PtyState>().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::<PtyState>()
.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::<PtyState>();
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<R: Read>(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::<PtyState>();
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::<PtyState>();
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::<PtyState>().sessions.lock().unwrap().remove(&id) {
let _ = sess.child.kill();
}
}

pub fn init(app: &tauri::App) {
app.manage(PtyState::default());
}
10 changes: 7 additions & 3 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": [
{
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/app/Management.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -221,6 +222,11 @@ export function Management({ api }: { api: DesktopAPI }) {
initialTarget={logsTarget}
/>
</section>

<TerminalSection
server={data.servers.find((s) => s.id === data.selected)?.name}
apps={data.apps}
/>
</div>

{dialog ? (
Expand Down
Loading
Loading