diff --git a/app/igneum-app/src/live.rs b/app/igneum-app/src/live.rs index 6e87443f5..af0ec3782 100644 --- a/app/igneum-app/src/live.rs +++ b/app/igneum-app/src/live.rs @@ -67,10 +67,28 @@ pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet) -> Value { reply } +/// Where a screenshot lands: `/shots/igneum--.png`, the name reduced to [a-z0-9-] (at most +/// 24 characters, "shot" when empty) so a caller cannot steer the host outside the folder. +pub fn shot_path(data_root: &std::path::Path, name: &str, unix: u64) -> std::path::PathBuf { + let safe: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').map(|c| c.to_ascii_lowercase()).take(24).collect(); + let safe = if safe.is_empty() { "shot".to_string() } else { safe }; + data_root.join("shots").join(format!("igneum-{safe}-{unix}.png")) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_shot_lands_under_the_data_root_with_a_safe_name() { + let root = std::path::Path::new("/tmp/igneum-data"); + assert_eq!(shot_path(root, "overview", 1791327000), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-overview-1791327000.png")); + assert_eq!(shot_path(root, "../../etc/passwd", 1), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-etcpasswd-1.png")); + assert_eq!(shot_path(root, "", 2), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-shot-2.png")); + assert_eq!(shot_path(root, "Cards Light!", 3), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-cardslight-3.png")); + assert!(shot_path(root, &"x".repeat(80), 4).file_name().unwrap().to_string_lossy().len() < 48); + } + // a fixture in the observer's shape (site/api/live.mjs): three miners, this machine is 8fafda27, one of its blocks on // the selected chain and proven, one excluded; a locked and a pending checkpoint fn fixture() -> Value { diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 9db8176c7..b661f9791 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -368,6 +368,17 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result", beside URL, STATE and EXIT) and the host writes a PNG of + // what it shows to that path under /shots/, where a collect job's glob "shots/*.png" picks it up. + // Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file. + "/api/shot" => { + let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let path = crate::live::shot_path(&crate::platform::data_root(), name, crate::platform::unix_now()); + if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } + println!("SHOT {}", path.display()); + Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" })) + } "/api/quit" => { // the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)")); diff --git a/app/mac/IgneumMiner.swift b/app/mac/IgneumMiner.swift index fbd48cf58..747253618 100644 --- a/app/mac/IgneumMiner.swift +++ b/app/mac/IgneumMiner.swift @@ -209,6 +209,9 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele } } else if line.hasPrefix("STATE ") { applyState(String(line.dropFirst(6))) + } else if line.hasPrefix("SHOT ") { + // Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path + shotTo(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)) } else if line.hasPrefix("FATAL ") { fail(String(line.dropFirst(6))) } else if line == "EXIT" { @@ -322,6 +325,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler() } + // ---- a screenshot on request (the SHOT line), the app keeps running ---- + func shotTo(_ path: String) { + let conf = WKSnapshotConfiguration() + conf.rect = web.bounds + web.takeSnapshot(with: conf) { image, error in + guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else { + FileHandle.standardError.write("shot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!) + return + } + do { try png.write(to: URL(fileURLWithPath: path)) } catch { FileHandle.standardError.write("shot: could not write \(path): \(error)\n".data(using: .utf8)!) } + } + } + // ---- snapshot ---- func snapshot(to path: String) { if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] { diff --git a/app/windows/host.cpp b/app/windows/host.cpp index 88328f388..b1a8c2738 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -16,6 +16,7 @@ #endif #include #include +#include #include #include #include @@ -39,6 +40,13 @@ static HWND g_hwnd = nullptr; static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr; static ComPtr g_controller; static ComPtr g_webview; +// a PNG of the web view at `path` (the SHOT line); the stream is released by the completion handler +static void capturePreview(const std::wstring& path) { + if (!g_webview) return; + ComPtr stream; + if (FAILED(SHCreateStreamOnFileEx(path.c_str(), STGM_CREATE | STGM_WRITE | STGM_SHARE_EXCLUSIVE, FILE_ATTRIBUTE_NORMAL, TRUE, nullptr, &stream))) return; + g_webview->CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, stream.Get(), Callback([stream](HRESULT) -> HRESULT { return S_OK; }).Get()); +} static std::wstring g_url, g_status = L"starting the engine"; static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false; static NOTIFYICONDATAW g_nid = {}; @@ -308,6 +316,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { applyState(line->substr(6)); } else if (line->rfind("ELEVATE ", 0) == 0) { runElevated(widen(line->substr(8))); + } else if (line->rfind("SHOT ", 0) == 0) { + // Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path + capturePreview(widen(line->substr(5))); } else if (line->rfind("FATAL ", 0) == 0) { g_status = widen(line->substr(6)); repaintStatus(); diff --git a/docs/plans/miner-ui-4.md b/docs/plans/miner-ui-4.md index a9b09125e..e443a81b3 100644 --- a/docs/plans/miner-ui-4.md +++ b/docs/plans/miner-ui-4.md @@ -81,3 +81,14 @@ at 872f1c9) under a new number; the engine fields and the pinning below ship as At the 0.3.17 merge `src/live.rs` is ember-tune's (the fuller one, node first, site second); this branch's 0.3.15 file is superseded and its `source` word is "site" too, so nothing in the UI changes between the two. + +## 8. N4 (the frozen tip), the capture route, and PC 2's light render (6 October 2026, 23:0x to 23:4x UK) + +| What | Where | +|---|---| +| N4, the app half: `sync_decision_v2` (the old rule AND the watch line's `tip_age_s` at or under 120 s AND peers >= 1; the cause word frozen / no peers / syncing / behind on `node.sync_cause`; the state word "behind" or "no peers", never "synced" on a frozen tip); `is_stall_exit` (code 45 or a "STALLED '" tail line; the first restarts the miner, the second since the node started restarts the node and re-dials its peers); `node.tip_age_s`, `node.stall_exits`. The known-failed case is the first test. UI: "Node behind · 1 peer · 133,000 blocks · last block 53 min ago" with the cause line, the pill "Node behind", the big button's "waiting: the node is behind the chain". The node lane's miner half is ca3-v4-0316. | `src/engine.rs`, `src/state.rs`, `ui/app.js`, `view.test.mjs` | +| (b) a screenshot from the machine itself: `POST /api/shot {name}` writes `SHOT ` on the host protocol (beside URL, STATE, EXIT); the Mac host answers with `WKWebView.takeSnapshot`, the Windows host with WebView2 `CapturePreview` (PNG), to `/shots/igneum--.png` (`live::shot_path`, the name reduced to [a-z0-9-]); `relay/playbooks/app-shot.ps1` asks and waits, a collect job's glob `shots/*.png` brings the file back. Read-only. The Swift parses (`swiftc -parse`); the C++ builds on the GitHub runner at the cut (`BUILD-APP.bat` already links shlwapi.lib). | `src/server.rs`, `src/live.rs`, `app/mac/IgneumMiner.swift`, `app/windows/host.cpp`, `relay/playbooks/app-shot.ps1` | +| The light-theme showcase capture: Edge headless on PC 2 wrote nothing in three takes (154.0.4258.53, exit 0, no stderr, in the app's elevated session); PC 2's `api/state` and `api/live` were fetched by `relay/playbooks/app-state-grab.ps1` into the data root and collected, and the ui-mock replayed them (`IGNEUM_MOCK_STATE`, `IGNEUM_MOCK_LIVE`) through the real 0.3.16 UI on the Mac: `docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-light-1440.png`, `pc2-cards-light-1440.png`, `pc2-overview-dark-1440.png`, captioned "rendered from PC 2's live state at 23:33 UK" (DESKTOP-KMCV30N, RTX 5090 at 103 MH/s and 317 W, node synced, 5 peers). Handed to the site lane. | `tools/ui-mock/server.mjs`, `relay/playbooks/app-state-grab.ps1` | +| The fleet watts count mining and tuning cards only (PC 2's idle integrated card drew 48 W and was summed); the Ember off-state line no longer promises a start. | `ui/app.js` | + +Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, the four live tests among them). diff --git a/relay/playbooks/app-shot.ps1 b/relay/playbooks/app-shot.ps1 new file mode 100644 index 000000000..2eae51492 --- /dev/null +++ b/relay/playbooks/app-shot.ps1 @@ -0,0 +1,17 @@ +# Igneum playbook: a screenshot of the installed app's window from the machine itself (Miner UI 4 (b), 0.3.17): +# POST api/shot asks the engine, the window host writes a PNG of what it shows into \shots\, and a collect +# job's glob "shots/*.png" brings it back. Read-only: never pauses, resumes or quits anything (the rule of 5 Oct 2026). +# Needs an app at 0.3.17 or later (the route and the host's SHOT line); an older app answers 400 and this exits 3. +# Queue: packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --script relay/playbooks/app-shot.ps1 --title "app shot" +# then packaging/ota/publish-jobs.sh add --kind collect --target 1ccfe586 --glob "shots/*.png" +$ErrorActionPreference = 'Continue' +$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url' +if (-not (Test-Path $urlFile)) { Write-Host "ERROR: $urlFile missing (is the app running?)"; exit 2 } +$url = (Get-Content $urlFile -Raw).Trim() +$name = if ($env:SHOT_NAME) { $env:SHOT_NAME } else { 'window' } +try { $r = Invoke-RestMethod -Method Post -Uri ($url + 'api/shot') -ContentType 'application/json' -Body (@{ name = $name } | ConvertTo-Json) -TimeoutSec 15 } +catch { Write-Host "RESULT shot=$name failed: $($_.Exception.Message) (the app needs 0.3.17 or later)"; exit 3 } +$path = $r.path +$tries = 0; while (-not (Test-Path $path) -and $tries -lt 30) { Start-Sleep -Milliseconds 500; $tries++ } +if (Test-Path $path) { Write-Host "RESULT shot=$name path=$path bytes=$((Get-Item $path).Length)"; exit 0 } +Write-Host "RESULT shot=$name missing after 15 s (path $path); the window host did not write it"; exit 4