Eingebautes Terminal (xterm.js + portable-pty) und Start/Beenden-Button

- Terminal-Fenster als eigener Prozess (--terminal <projekt>) mit eigener PTY,
  startet claude-sync im Projektordner
- Projektliste: ein Button pro Zeile — grün Starten (open_terminal) bzw.
  rot Beenden (stop_project, SIGTERM auf exakte PID / Ghostty-Quit)
- is_running erfasst Ghostty oder laufenden Terminal-Prozess
- Status-Kreis ohne Prozess unsichtbar
This commit is contained in:
marcus.hinz
2026-07-04 10:00:12 +02:00
parent 70e4898259
commit f7473797a9
13 changed files with 724 additions and 22 deletions
+91 -1
View File
@@ -82,6 +82,7 @@ dependencies = [
"base64 0.22.1",
"getrandom 0.2.17",
"log",
"portable-pty",
"serde",
"serde_json",
"sha2",
@@ -224,7 +225,7 @@ checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
]
[[package]]
@@ -441,6 +442,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -788,6 +795,12 @@ dependencies = [
"tendril",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -920,6 +933,17 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1775,6 +1799,12 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -1961,6 +1991,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2372,6 +2414,27 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg 0.10.1",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3023,6 +3086,17 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "serial2"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
dependencies = [
"cfg-if",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -3065,6 +3139,22 @@ dependencies = [
"digest",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "2.0.1"
+1
View File
@@ -28,3 +28,4 @@ ureq = { version = "2", features = ["json"] }
base64 = "0.22"
sha2 = "0.10"
getrandom = "0.2"
portable-pty = "0.9"
+3 -1
View File
@@ -3,10 +3,12 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
"main",
"term-*"
],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"autostart:default"
]
}
+104 -4
View File
@@ -1,3 +1,5 @@
mod terminal;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -75,6 +77,10 @@ fn bundle_id(project: &str) -> String {
}
fn is_running(project: &str) -> bool {
ghostty_running(project) || !terminal_pids(project).is_empty()
}
fn ghostty_running(project: &str) -> bool {
let out = Command::new("lsappinfo")
.args(["find", &format!("bundleid={}", bundle_id(project))])
.output();
@@ -84,6 +90,20 @@ fn is_running(project: &str) -> bool {
}
}
/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
fn terminal_pids(project: &str) -> Vec<u32> {
let out = Command::new("pgrep")
.args(["-f", "--", &format!("--terminal {project}$")])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|l| l.trim().parse().ok())
.collect(),
Err(_) => Vec::new(),
}
}
fn check_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
return Err(format!("ungültiger Name: {name}"));
@@ -402,6 +422,37 @@ fn start_project(project: String) -> Result<(), String> {
open_project(&project)
}
/// Beendet den laufenden Prozess eines Projekts: das eingebaute Terminal per
/// SIGTERM auf die exakte PID (nie über die Bundle-ID — die würde alle
/// Instanzen inkl. der Haupt-App treffen), sonst die Ghostty-App per Quit.
#[tauri::command]
fn stop_project(project: String) -> Result<(), String> {
let pids = terminal_pids(&project);
if !pids.is_empty() {
for pid in pids {
let out = Command::new("kill")
.arg(pid.to_string())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
}
return Ok(());
}
if ghostty_running(&project) {
let out = Command::new("osascript")
.args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))])
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
return Ok(());
}
Err(format!("{project} läuft nicht"))
}
/// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch
/// nachfragen), wartet auf das Prozessende und startet sie neu.
#[tauri::command]
@@ -624,6 +675,22 @@ fn pct_decode(s: &str) -> String {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut args = std::env::args().skip(1);
let builder = match args.next().as_deref() {
// `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
// damit jedes Terminal ein eigenes Dock-Icon bekommt.
Some("--terminal") => {
terminal_builder(args.next().expect("--terminal braucht einen Projektnamen"))
}
_ => main_builder(),
};
builder
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
fn main_builder() -> tauri::Builder<tauri::Wry> {
use tauri::Manager;
tauri::Builder::default()
@@ -644,6 +711,14 @@ pub fn run() {
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
.title("AI Control")
.inner_size(800.0, 600.0)
.visible(false)
.build()?;
let open = tauri::menu::MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?;
let quit = tauri::menu::MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?;
@@ -663,7 +738,7 @@ pub fn run() {
Ok(())
})
// Fenster schließen versteckt nur; Beenden geht übers Tray-Menü.
// Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü.
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
@@ -681,13 +756,38 @@ pub fn run() {
assign_pool,
unassign_pool,
start_project,
stop_project,
restart_project,
oauth_login,
oauth_refresh,
set_apikey
set_apikey,
terminal::open_terminal
])
}
/// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt
/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal.
fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
tauri::Builder::default()
.manage(terminal::Terminals::default())
.setup(move |app| {
terminal::build_window(app.handle(), &project)?;
Ok(())
})
// Fenster zu → PTY-Session abräumen; danach endet der Prozess.
.on_window_event(|window, event| {
if let tauri::WindowEvent::Destroyed = event {
terminal::close(window);
}
})
.invoke_handler(tauri::generate_handler![
terminal::term_start,
terminal::term_log,
terminal::term_write,
terminal::term_resize,
// Pool-Chip im Terminal-Header braucht die Projektliste auch hier.
list_projects
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// ---------- Tests ----------
+159
View File
@@ -0,0 +1,159 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
/// Eine laufende PTY-Session, gekoppelt an ein Terminal-Fenster (Key = Fenster-Label).
pub struct Session {
writer: Box<dyn Write + Send>,
master: Box<dyn MasterPty + Send>,
killer: Box<dyn ChildKiller + Send + Sync>,
}
#[derive(Default)]
pub struct Terminals(pub Mutex<HashMap<String, Session>>);
/// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als
/// Terminal-Prozess (`--terminal <projekt>`), damit jedes Terminal ein
/// eigenes Dock-Icon bekommt.
#[tauri::command]
pub fn open_terminal(project: String) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
let dir = PathBuf::from(home).join("claude-projects").join(&project);
if !dir.is_dir() {
return Err(format!("Projektordner fehlt: {}", dir.display()));
}
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
std::process::Command::new(exe)
.args(["--terminal", &project])
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
}
/// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst,
/// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine
/// Ausgaben verloren, bevor der Event-Listener steht.
pub fn build_window(app: &AppHandle, project: &str) -> tauri::Result<()> {
let builder = WebviewWindowBuilder::new(
app,
format!("term-{project}"),
WebviewUrl::App(format!("terminal.html?project={project}").into()),
)
.title(format!("{project} — Session"))
.inner_size(980.0, 640.0)
// Theme-Hintergrund (Catppuccin Mocha), sonst blitzt beim Öffnen Weiß auf.
.background_color(tauri::window::Color(0x1e, 0x1e, 0x2e, 0xff));
// Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region.
#[cfg(target_os = "macos")]
let builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
builder.build()?;
Ok(())
}
/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst — identisch zum Ghostty-Starter.
#[tauri::command]
pub fn term_start(
window: tauri::WebviewWindow,
terminals: State<Terminals>,
project: String,
rows: u16,
cols: u16,
) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
let cwd = PathBuf::from(home).join("claude-projects").join(&project);
let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())?;
let mut cmd = CommandBuilder::new("/bin/zsh");
cmd.args(["-ic", "claude-sync"]);
cmd.cwd(&cwd);
cmd.env("TERM", "xterm-256color");
let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
drop(pty.slave);
let killer = child.clone_killer();
let writer = pty.master.take_writer().map_err(|e| e.to_string())?;
let mut reader = pty.master.try_clone_reader().map_err(|e| e.to_string())?;
let label = window.label().to_string();
let app = window.app_handle().clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let _ = app.emit_to(&label, "pty-output", STANDARD.encode(&buf[..n]));
}
}
}
let _ = child.wait();
let _ = app.emit_to(&label, "pty-exit", ());
});
terminals.0.lock().unwrap().insert(
window.label().to_string(),
Session { writer, master: pty.master, killer },
);
Ok(())
}
/// Debug: JS-Fehler aus dem Terminal-Fenster ins Dev-Log.
#[tauri::command]
pub fn term_log(window: tauri::WebviewWindow, msg: String) {
eprintln!("[{}] {msg}", window.label());
}
/// Tastatureingaben des Fensters in die PTY.
#[tauri::command]
pub fn term_write(
window: tauri::WebviewWindow,
terminals: State<Terminals>,
data: String,
) -> Result<(), String> {
let mut map = terminals.0.lock().unwrap();
let s = map
.get_mut(window.label())
.ok_or("keine Session für dieses Fenster")?;
s.writer.write_all(data.as_bytes()).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn term_resize(
window: tauri::WebviewWindow,
terminals: State<Terminals>,
rows: u16,
cols: u16,
) -> Result<(), String> {
let map = terminals.0.lock().unwrap();
let s = map
.get(window.label())
.ok_or("keine Session für dieses Fenster")?;
s.master
.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())
}
/// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen.
/// Das Droppen des Masters schließt die PTY — claude bekommt HUP wie beim
/// Fenster-X in Ghostty.
pub fn close(window: &tauri::Window) {
let terminals = window.state::<Terminals>();
let session = terminals.0.lock().unwrap().remove(window.label());
if let Some(mut s) = session {
let _ = s.killer.kill();
}
}
+1 -10
View File
@@ -10,16 +10,7 @@
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "AI Control",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false,
"visible": false
}
],
"windows": [],
"security": {
"csp": null
}