From 3863809ae85321866c4bc3692fe8816c38a646c6 Mon Sep 17 00:00:00 2001 From: Kelorastus <108483685+Kelorastus@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:36:14 +0200 Subject: [PATCH] new console --- src-tauri/src/commands.rs | 5 + src-tauri/src/launcher.rs | 20 ++- src-tauri/src/lib.rs | 1 + src-tauri/src/release.rs | 207 ++++++++++++++++++++++++++- src/App.svelte | 8 +- src/components/ActionPanel.svelte | 19 ++- src/components/ReleaseConsole.svelte | 166 +++++++++++++++++++++ src/lib/backend.ts | 2 + src/lib/launcher.svelte.ts | 52 +++++++ src/lib/mock.ts | 35 ++++- src/lib/types.ts | 12 ++ 11 files changed, 513 insertions(+), 14 deletions(-) create mode 100644 src/components/ReleaseConsole.svelte diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0b4c82c..4d3cb48 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -32,6 +32,11 @@ pub async fn release_run( launcher.release_run(password, with_api, with_mcp).await } +#[tauri::command] +pub fn release_console(launcher: State<'_, Launcher>, since: usize) -> CmdResult { + Ok(launcher.release_console(since)) +} + #[tauri::command] pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult { Ok(launcher.status().await) diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 41f0bd2..fd30b47 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -254,6 +254,8 @@ pub struct Launcher { stack_states: std::sync::Mutex>, sign_in_cancel: Notify, install_cancel: std::sync::Mutex>, + /// What the running release has printed, for the console panel. + release_console: crate::release::Shared, } /// What an operator picked when installing from a drive: the site store an ODIAN drive @@ -414,6 +416,7 @@ impl Launcher { stack_states: std::sync::Mutex::new(Vec::new()), sign_in_cancel: Notify::new(), install_cancel: std::sync::Mutex::new(None), + release_console: Default::default(), }) } @@ -2338,8 +2341,13 @@ impl Launcher { ) } - /// Start a release in its own console. Returns once it has started, not when it is done. + /// Start a release. Returns once it has started, not when it is done: its output arrives + /// in the console panel, which reads `release_console` as it fills. pub async fn release_run(&self, password: String, with_api: bool, with_mcp: bool) -> Result<(), String> { + // Two at once would interleave into one buffer and fight over the same build dirs. + if self.release_console.lock().is_ok_and(|c| c.is_running()) { + return Err("A release is already running: wait for it to finish.".to_string()); + } let plan = self.release_plan().await; if let Some(why) = plan.blocked { return Err(why); @@ -2373,10 +2381,20 @@ impl Launcher { with_api && plan.docker, with_mcp && plan.docker, &password, + &self.release_console, ) .map(|_| ()) } + /// Everything the release has printed since line `since`. + pub fn release_console(&self, since: usize) -> crate::release::ConsoleTail { + match self.release_console.lock() { + Ok(c) => c.tail(since), + // A poisoned lock must not take the window down with it. + Err(e) => e.into_inner().tail(since), + } + } + // ── operator-data sync ────────────────────────────────────────────────── /// Sync, unless this run is pretending to be another operator. Whatever it authored is diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f1855b..a77efc1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,6 +59,7 @@ pub fn run() { commands::get_status, commands::release_plan, commands::release_run, + commands::release_console, commands::get_market, commands::apply_market, commands::check_market, diff --git a/src-tauri/src/release.rs b/src-tauri/src/release.rs index 69df9de..5bab2f4 100644 --- a/src-tauri/src/release.rs +++ b/src-tauri/src/release.rs @@ -9,7 +9,7 @@ //! Comparing versions would not do: a version says what was cut, not from what. use serde::Serialize; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; /// What a release could do right now, and what stops it. #[derive(Clone, Debug, Serialize)] @@ -160,7 +160,101 @@ pub fn plan( ReleasePlan { docker, online, scripts, items, blocked } } -/// Cut and publish, in its own console. +/// The Release console's buffer: filled by the running release, read by the window. +/// +/// It sits behind a plain (not async) lock because the threads draining the child's pipes +/// have no async context to lock from. +#[derive(Default)] +pub struct Console { + lines: Vec, + /// How many lines fell off the front, so indices stay stable while it trims. + dropped: usize, + running: bool, + exit: Option, +} + +pub type Shared = std::sync::Arc>; + +/// A release prints a great deal (cargo, npm, docker); keep the tail, not all of it. +const MAX_LINES: usize = 4000; + +/// What the window asks for: everything it has not seen yet. +#[derive(Serialize, Clone)] +pub struct ConsoleTail { + /// Index of the first line returned. + pub from: usize, + pub lines: Vec, + pub total: usize, + pub running: bool, + pub exit: Option, +} + +impl Console { + fn start(&mut self) { + self.lines.clear(); + self.dropped = 0; + self.running = true; + self.exit = None; + } + + fn push(&mut self, line: String) { + self.lines.push(line); + if self.lines.len() > MAX_LINES { + let cut = self.lines.len() - MAX_LINES; + self.lines.drain(..cut); + self.dropped += cut; + } + } + + fn finish(&mut self, exit: Option) { + self.running = false; + self.exit = exit; + } + + pub fn is_running(&self) -> bool { + self.running + } + + /// Everything from `since` on. A `since` already trimmed away starts at the oldest line + /// still held, rather than pretending the dropped ones are still coming. + pub fn tail(&self, since: usize) -> ConsoleTail { + let total = self.dropped + self.lines.len(); + let from = since.clamp(self.dropped, total); + ConsoleTail { + from, + lines: self.lines[from - self.dropped..].to_vec(), + total, + running: self.running, + exit: self.exit, + } + } +} + +/// Drain one of the child's streams into the console, a line at a time. +fn pump(stream: impl std::io::Read, console: Shared) { + use std::io::BufRead; + let mut reader = std::io::BufReader::new(stream); + let mut raw = Vec::new(); + loop { + raw.clear(); + match reader.read_until(b'\n', &mut raw) { + Ok(0) | Err(_) => return, + Ok(_) => { + let text = String::from_utf8_lossy(&raw); + let text = text.trim_end_matches('\n').trim_end_matches('\r'); + // A terminal shows only what follows the last carriage return, so a progress line + // that kept rewriting itself does not become a thousand lines in the panel. + let shown = text.rsplit('\r').next().unwrap_or(text); + match console.lock() { + Ok(mut c) => c.push(shown.to_string()), + Err(_) => return, + } + } + } + } +} + +/// Cut and publish, into the launcher's own console panel. /// /// The three scripts are run as a person runs them, in order, and each decides for itself /// whether it has anything to do: `release_pack.ps1` stops with "nothing changed" when the @@ -178,6 +272,7 @@ pub fn run_release( with_api: bool, with_mcp: bool, sudo: &str, + console: &Shared, ) -> Result { let scripts = mcp_repo.join("scripts"); let log = work_dir.join("release.log"); @@ -229,24 +324,32 @@ pub fn run_release( // Everything inside try/finally, and everything recorded. A step that throws used to // take the window with it - the release simply vanished, with nothing to read. + // + // No `pause` here any more: this runs into the launcher's own panel, where there is no + // console to press Enter in. The exit code carries the verdict instead - swallowing a + // failure and exiting 0 would make a failed release look like a finished one. let ps = format!( "$ErrorActionPreference = 'Stop' + # The pipe gets UTF-8 or Windows' own French messages arrive as mojibake. + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false $pw = $env:ODIAN_NODE_SUDO $env:ODIAN_NODE_SUDO = $null Start-Transcript -Path '{}' -Force | Out-Null + $ok = $true try {{ {} Write-Host '' - Write-Host 'Release finished.' -ForegroundColor Green + Write-Host 'Release finished.' }} catch {{ Write-Host '' - Write-Host \"FAILED: $_\" -ForegroundColor Red + Write-Host \"FAILED: $_\" + $ok = $false }} finally {{ Stop-Transcript | Out-Null Write-Host '' Write-Host 'Log: {}' - pause }} +if (-not $ok) {{ exit 1 }} ", log.display(), body, @@ -260,15 +363,50 @@ pub fn run_release( let mut cmd = Command::new("powershell"); cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file); cmd.env("ODIAN_NODE_SUDO", sudo); + // Nothing can type into this, so stdin is closed: a script that ever asks a question + // then fails at once instead of hanging forever with no window to answer in. + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); #[cfg(windows)] { use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0000_0010); // CREATE_NEW_CONSOLE: the developer watches it run + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: the output belongs in the panel } - cmd.spawn().map_err(|e| format!("cannot start the release: {e}"))?; + let mut child = cmd.spawn().map_err(|e| format!("cannot start the release: {e}"))?; + console.lock().map_err(|_| "the release console is unusable")?.start(); + + // Both streams, kept separate: merging them with `2>&1` inside PowerShell wraps every + // stderr line from a native exe in a NativeCommandError and makes a clean run look failed. + let mut pumps = Vec::new(); + for stream in [child.stdout.take().map(Either::Out), child.stderr.take().map(Either::Err)] { + if let Some(s) = stream { + let c = console.clone(); + pumps.push(std::thread::spawn(move || match s { + Either::Out(r) => pump(r, c), + Either::Err(r) => pump(r, c), + })); + } + } + let c = console.clone(); + std::thread::spawn(move || { + let code = child.wait().ok().and_then(|s| s.code()); + // Drain what is still sitting in the pipes BEFORE saying it finished: the window stops + // asking once running goes false, and the last lines are the ones that say why. + for p in pumps { + let _ = p.join(); + } + if let Ok(mut c) = c.lock() { + c.finish(code); + } + }); Ok(file) } +/// The two pipes have different types; this lets one thread body handle either. +enum Either { + Out(std::process::ChildStdout), + Err(std::process::ChildStderr), +} + /// Where the Engine API deploy script is: beside the other release scripts, or in the /// release skill that carries it. fn api_script(scripts: &Path) -> Option { @@ -363,4 +501,59 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn the_console_hands_over_only_what_the_window_has_not_seen() { + let mut c = Console::default(); + c.start(); + for i in 0..3 { + c.push(format!("line {i}")); + } + let first = c.tail(0); + assert_eq!(first.from, 0); + assert_eq!(first.total, 3); + assert_eq!(first.lines.len(), 3); + assert!(first.running, "a started console is running"); + + // Asking again from where it got to yields nothing, not the whole buffer again. + let again = c.tail(first.total); + assert!(again.lines.is_empty()); + assert_eq!(again.total, 3); + + c.push("line 3".into()); + let more = c.tail(3); + assert_eq!(more.lines, vec!["line 3".to_string()]); + + c.finish(Some(1)); + let done = c.tail(4); + assert!(!done.running); + assert_eq!(done.exit, Some(1), "a failed release must not look finished"); + } + + #[test] + fn trimming_a_long_release_keeps_indices_honest() { + let mut c = Console::default(); + c.start(); + for i in 0..(MAX_LINES + 500) { + c.push(format!("line {i}")); + } + let t = c.tail(0); + assert_eq!(t.total, MAX_LINES + 500, "total counts every line ever printed"); + assert_eq!(t.lines.len(), MAX_LINES, "only the tail is kept"); + assert_eq!(t.from, 500, "it says where it really starts, not 0"); + assert_eq!(t.lines[0], "line 500"); + // A window that asked from beyond the end gets nothing rather than a panic. + assert!(c.tail(t.total + 99).lines.is_empty()); + } + + #[test] + fn a_progress_line_that_rewrites_itself_stays_one_line() { + let stream: &[u8] = b"first\r\nbuilding 10%\rbuilding 90%\rbuilt\nplain\n"; + let c: Shared = Default::default(); + c.lock().unwrap().start(); + pump(stream, c.clone()); + let got = c.lock().unwrap().tail(0); + // CRLF is an ending, not content; a lone CR overwrites, exactly as a terminal shows it. + assert_eq!(got.lines, vec!["first".to_string(), "built".to_string(), "plain".to_string()]); + } } diff --git a/src/App.svelte b/src/App.svelte index 1a3420d..3efc662 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -6,6 +6,7 @@ import KeyArt from "./components/KeyArt.svelte"; import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte"; import NewsPanel from "./components/NewsPanel.svelte"; + import ReleaseConsole from "./components/ReleaseConsole.svelte"; import ReleasePanel from "./components/ReleasePanel.svelte"; import SettingsDrawer from "./components/SettingsDrawer.svelte"; import SyncPanel from "./components/SyncPanel.svelte"; @@ -18,6 +19,9 @@ // Dev mode drops the patch notes: they describe the INSTALLED version, which is not what // a dev run starts. With that column gone the art has the whole window to sit in. const dev = $derived(launcher.devOn); + // A release takes that column back while it runs: its output is the one thing worth + // reading there, and it is where a developer is already looking. + const showConsole = $derived(dev && launcher.consoleOpen); onMount(() => launcher.start()); @@ -26,9 +30,11 @@ (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} /> -
+
{#if !dev}
+ {:else if showConsole} +
{/if}
diff --git a/src/components/ActionPanel.svelte b/src/components/ActionPanel.svelte index 0c77cd5..bd82a09 100644 --- a/src/components/ActionPanel.svelte +++ b/src/components/ActionPanel.svelte @@ -118,6 +118,11 @@ await launcher.loadRelease(); } + // While one runs, this button is the way BACK to its output rather than a way to start a + // second: closing the console otherwise left a running release with nowhere to be seen. + const releasing = $derived(launcher.consoleRunning); + const hasOutput = $derived(launcher.consoleLines.length > 0 && !launcher.consoleOpen); + async function browseCheckout() { const path = await launcher.pickFolder(); if (path) await launcher.setDev({ dev_root: path }); @@ -304,10 +309,16 @@
- - {#if !s?.online} + {#if releasing} + + {:else} + + {/if} + {#if hasOutput && !releasing} + + {:else if !s?.online} needs the DHP {/if}
diff --git a/src/components/ReleaseConsole.svelte b/src/components/ReleaseConsole.svelte new file mode 100644 index 0000000..420c339 --- /dev/null +++ b/src/components/ReleaseConsole.svelte @@ -0,0 +1,166 @@ + + +
+
+

Release

+ + {#if running} + running + {:else if exit === null} + idle + {:else if exit === 0} + finished + {:else} + failed ({exit}) + {/if} + + + {lines.length} + +
+ + + +
+ {#if lines.length} + {#each lines as line, i (i)} +
{line || " "}
+ {/each} + {:else} +

Waiting for the first line…

+ {/if} +
+ + {#if !running && exit !== null} +
+ {exit === 0 ? "Closing this keeps the output until the next release." : "The full transcript is in state\\release\\release.log."} +
+ {/if} +
+ + diff --git a/src/lib/backend.ts b/src/lib/backend.ts index 507e302..c39c8db 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -15,6 +15,7 @@ import type { SignInResult, StackEvent, Status, + ReleaseConsoleTail, ReleasePlan, RemoveProgress, SyncConflict, @@ -73,6 +74,7 @@ function createTauriBackend(): Backend { onInstallProgress: (handler) => subscribe("install-progress", handler), releasePlan: () => invoke("release_plan"), releaseRun: (password, withApi, withMcp) => invoke("release_run", { password, withApi, withMcp }), + releaseConsole: (since) => invoke("release_console", { since }), onTileSyncProgress: (handler) => subscribe("tile-sync-progress", handler), onRemoveProgress: (handler) => subscribe("remove-progress", handler), onStack: (handler) => subscribe("stack", handler), diff --git a/src/lib/launcher.svelte.ts b/src/lib/launcher.svelte.ts index d56d618..7ca6eff 100644 --- a/src/lib/launcher.svelte.ts +++ b/src/lib/launcher.svelte.ts @@ -418,9 +418,61 @@ class Launcher { return this.run("release", async () => { await backend.releaseRun(password, withApi, withMcp); this.releaseOpen = false; + // Straight into the console: the release is running with nowhere else to show itself. + this.consoleLines = []; + this.consoleTotal = 0; + this.consoleExit = null; + this.consoleRunning = true; + this.consoleOpen = true; + this.pollConsole(); }); } + // ---- the release console ------------------------------------------------------------- + /** Shown in the notes column in dev mode, where the patch notes sit otherwise. */ + consoleOpen = $state(false); + consoleLines = $state([]); + consoleRunning = $state(false); + consoleExit = $state(null); + /** How many lines have been taken, so each poll asks only for what is new. */ + private consoleTotal = 0; + private polling = false; + + /** Polled rather than pushed: reopening the panel then needs no replay, and a slow window + * cannot fall behind a build that prints thousands of lines. */ + private async pollConsole() { + if (this.polling) return; + this.polling = true; + try { + for (;;) { + const tail = await backend.releaseConsole(this.consoleTotal); + if (tail.from > this.consoleTotal && this.consoleLines.length) { + this.consoleLines.push(`... ${tail.from - this.consoleTotal} earlier lines dropped`); + } + if (tail.lines.length) this.consoleLines.push(...tail.lines); + this.consoleTotal = tail.total; + this.consoleRunning = tail.running; + this.consoleExit = tail.exit; + if (!tail.running) return; + await new Promise((r) => setTimeout(r, 400)); + } + } catch { + this.consoleRunning = false; + } finally { + this.polling = false; + } + } + + /** Reopen it after it was closed; a finished release keeps its output to be read. */ + showConsole() { + this.consoleOpen = true; + if (this.consoleRunning) this.pollConsole(); + } + + closeConsole() { + this.consoleOpen = false; + } + /** Change part of the settings and save, for the dev controls on the hub. */ setDev(change: Partial) { const current = this.settings; diff --git a/src/lib/mock.ts b/src/lib/mock.ts index 5240fc1..546277a 100644 --- a/src/lib/mock.ts +++ b/src/lib/mock.ts @@ -24,6 +24,7 @@ import { type MarketItem, type MockControls, type MockScenario, + type ReleaseConsoleTail, type ReleasePlan, type Settings, type SignInResult, @@ -786,7 +787,39 @@ class MockBackend implements Backend { async releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise { await this.wait(400); if (!password.trim()) throw "The node's sudo password is needed to cut a pack and to roll the API."; - this.log("info", `Release started in its own window (browser mock: nothing runs). API ${withApi}, MCP ${withMcp}`); + // Nothing runs in the browser, so play a short script into the console instead: enough + // to see the panel fill, scroll and finish. + this.consoleLines = ["=== ODIAN pack 0.74.0 ===", "", "=== Preflight ==="]; + this.consoleDone = null; + const rest = [ + "engine at 1255873...", + "Nothing that ships has changed. No pack cut.", + "pack: nothing to release", + `=== Launcher 0.1.40 === (API ${withApi}, MCP ${withMcp})`, + "npx tauri build ...", + "Release finished.", + ]; + void (async () => { + for (const line of rest) { + await this.wait(700); + this.consoleLines.push(line); + } + this.consoleDone = 0; + })(); + } + + private consoleLines: string[] = []; + private consoleDone: number | null = 0; + + async releaseConsole(since: number): Promise { + await this.wait(60); + return { + from: Math.min(since, this.consoleLines.length), + lines: this.consoleLines.slice(since), + total: this.consoleLines.length, + running: this.consoleDone === null, + exit: this.consoleDone, + }; } async syncNow(): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 2f472ff..f72d773 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -306,6 +306,17 @@ export interface ReleasePlan { blocked: string | null; } +/** A slice of the running release's output: what the window has not shown yet. */ +export interface ReleaseConsoleTail { + /** Index of the first line returned; higher than asked for when older lines were trimmed. */ + from: number; + lines: string[]; + total: number; + running: boolean; + /** Null while running; 0 means it finished, anything else means it failed. */ + exit: number | null; +} + export const DEV_TARGETS = ["client", "web", "both"] as const; export type DevTarget = (typeof DEV_TARGETS)[number]; @@ -375,6 +386,7 @@ export interface Backend { syncTiles(): Promise; releasePlan(): Promise; releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise; + releaseConsole(since: number): Promise; /** What the operator has to decide, oldest first. */ getSyncConflicts(): Promise; /** Records one decision; resolves with what is left to decide. */