Miner UI 4 (b): a screenshot from the machine itself: POST /api/shot asks the window host over the SHOT line; the Mac host snapshots the web view, the Windows host captures through WebView2, both to <data root>/shots/; app-shot.ps1 plus a collect glob bring it back. 152 app tests on the box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 22:40:57 +00:00
parent 681d5c0ba1
commit 04ccd38e18
6 changed files with 84 additions and 0 deletions

View file

@ -67,10 +67,28 @@ pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
reply
}
/// Where a screenshot lands: `<data root>/shots/igneum-<name>-<unix>.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 {

View file

@ -368,6 +368,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::ClockCheck);
Ok(json!({ "ok": true }))
}
// Miner UI 4 (b): a screenshot from the machine itself. The host (app/mac, app/windows) owns the window, so the
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
// what it shows to that path under <data root>/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)"));

View file

@ -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"] {

View file

@ -16,6 +16,7 @@
#endif
#include <windows.h>
#include <shellapi.h>
#include <shlwapi.h>
#include <dbt.h>
#include <wrl.h>
#include <string>
@ -39,6 +40,13 @@ static HWND g_hwnd = nullptr;
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
static ComPtr<ICoreWebView2Controller> g_controller;
static ComPtr<ICoreWebView2> 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<IStream> 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<ICoreWebView2CapturePreviewCompletedHandler>([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();

View file

@ -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 <path>` on the host protocol (beside URL, STATE, EXIT); the Mac host answers with `WKWebView.takeSnapshot`, the Windows host with WebView2 `CapturePreview` (PNG), to `<data root>/shots/igneum-<name>-<unix>.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).

View file

@ -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 <data root>\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