app
Adom Shotlog
Public Made by Adomby adom
See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
use std::process::Command;
const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";
/// Open a webview tab in the Adom workspace for a shotlog channel.
///
/// `skinny` puts it in a slim right-side panel (the preferred shotlog layout):
/// if the workspace already has a right pane, the tab is added there and that
/// split is resized so the shotlog panel is ~`ratio` of the width; if there's
/// only one pane, it's split to create the right panel. Multiple `open --skinny`
/// calls stack their tabs in the same panel.
pub fn open_webview(
channel: &str,
display_name: Option<&str>,
icon: Option<&str>,
port: u16,
skinny: bool,
ratio: f64,
) {
let default_name = "Shotlog".to_string();
let name = display_name.unwrap_or(&default_name);
let icon = icon.unwrap_or("mdi:camera-burst");
let proxy_uri = match std::env::var("VSCODE_PROXY_URI") {
Ok(uri) => uri.replace("{{port}}", &port.to_string()),
Err(_) => {
eprintln!("VSCODE_PROXY_URI not set — cannot determine proxy URL.");
eprintln!("Falling back to http://127.0.0.1:{port}");
format!("http://127.0.0.1:{port}")
}
};
// The SINGLE-WINDOW app: /log/ hosts every channel as in-app tabs;
// ?channel deep-links the initially active tab.
let url = format!("{proxy_uri}log/?surface=webview&channel={channel}");
let initial_state = serde_json::json!({ "url": url }).to_string();
// Fetch the layout once.
let layout_json = Command::new("adom-cli")
.args(["hydrogen", "workspace", "get"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let layout: serde_json::Value = serde_json::from_str(&layout_json).unwrap_or(serde_json::Value::Null);
let root = layout.get("root").unwrap_or(&layout);
// No split yet + skinny → split the single pane to create the right panel.
if skinny {
if let Some(leaf_id) = single_leaf_id(root) {
// first child keeps (1 - ratio); the new right pane is `ratio` wide.
let first_ratio = (1.0 - ratio).clamp(0.1, 0.9);
let out = Command::new("adom-cli")
.args([
"hydrogen", "workspace", "split",
"--panel-id", &leaf_id,
"--direction", "horizontal",
"--position", "after",
"--ratio", &format!("{first_ratio}"),
"--panel-type", WEBVIEW_PANEL_TYPE,
"--display-name", name,
"--display-icon", icon,
"--initial-state", &initial_state,
])
.output();
report(out, name, channel, &url);
return;
}
}
// Otherwise: add the tab to the preferred (right/second) leaf.
let Some(leaf_id) = find_leaf_id(root) else {
eprintln!("Could not find a leaf pane in workspace layout.");
eprintln!("URL: {url}");
return;
};
// Idempotent create-or-refresh: with --panel-id it CREATES the tab if the
// name doesn't exist, else refreshes it. (Without --panel-id it can only
// refresh — the bug that made auto-open silently fail.)
let out = Command::new("adom-cli")
.args([
"hydrogen", "webview", "open-or-refresh",
"--name", name,
"--url", &url,
"--panel-id", &leaf_id,
"--display-icon", icon,
])
.output();
report(out, name, channel, &url);
// Skinny + an existing split → resize so the shotlog pane is ~`ratio` wide.
if skinny {
if let Some((split_id, side)) = find_enclosing_split(root, &leaf_id) {
// ratio is the FIRST child's fraction. If shotlog is the second
// (right) child, first gets (1 - ratio); if it's the first, it gets ratio.
let first_ratio = if side == "second" { 1.0 - ratio } else { ratio };
let first_ratio = first_ratio.clamp(0.1, 0.9);
let _ = Command::new("adom-cli")
.args(["hydrogen", "workspace", "resize", "--split-id", &split_id, "--ratio", &format!("{first_ratio}")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
}
fn report(out: std::io::Result<std::process::Output>, name: &str, channel: &str, url: &str) {
match out {
Ok(output) if output.status.success() => {
println!("Opened shotlog viewer: {name}");
println!("Channel: {channel}");
println!("URL: {url}");
surface_tab(name);
}
Ok(output) => eprintln!("adom-cli failed: {}", String::from_utf8_lossy(&output.stderr)),
Err(e) => eprintln!("Failed to run adom-cli: {e}"),
}
}
/// After opening/refreshing a webview tab, make sure the user actually LANDS on
/// it: bring it to the front in its own pane (workspace active-tab - never a
/// tab move) and paint the orange update highlight. Without this, a pup->webview
/// switch left the new tab buried behind whatever was active in the panel.
fn surface_tab(name: &str) {
let _ = Command::new("adom-cli")
.args(["hydrogen", "workspace", "active-tab", "--name", name])
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status();
let _ = Command::new("adom-cli")
.args(["hydrogen", "alert", "set", "--name", name, "-d", "6000"])
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status();
}
/// The id of the single leaf, iff the layout is exactly one leaf (no split).
fn single_leaf_id(root: &serde_json::Value) -> Option<String> {
if root.get("type").and_then(|t| t.as_str()) == Some("leaf") {
return root.get("id").and_then(|id| id.as_str()).map(String::from);
}
None
}
/// First leaf id, preferring the second (right/bottom) child of a split.
fn find_leaf_id(root: &serde_json::Value) -> Option<String> {
find_leaf_recursive(root)
}
fn find_leaf_recursive(val: &serde_json::Value) -> Option<String> {
match val.get("type").and_then(|t| t.as_str()) {
Some("leaf") => val.get("id").and_then(|id| id.as_str()).map(String::from),
Some("split") => val
.get("second").and_then(find_leaf_recursive)
.or_else(|| val.get("first").and_then(find_leaf_recursive)),
_ => None,
}
}
/// Find the split directly containing the given leaf, and whether the leaf is its
/// "first" or "second" child.
fn find_enclosing_split(val: &serde_json::Value, leaf_id: &str) -> Option<(String, String)> {
if val.get("type").and_then(|t| t.as_str()) != Some("split") {
return None;
}
for side in ["first", "second"] {
if let Some(child) = val.get(side) {
if child.get("type").and_then(|t| t.as_str()) == Some("leaf")
&& child.get("id").and_then(|i| i.as_str()) == Some(leaf_id)
{
let sid = val.get("id").and_then(|i| i.as_str())?.to_string();
return Some((sid, side.to_string()));
}
if let Some(found) = find_enclosing_split(child, leaf_id) {
return Some(found);
}
}
}
None
}
/// Open (or add a tab to) the ONE shared shotlog pup window for a channel.
/// The full-browser canvas: real zoom, full-size images. The URL carries
/// `?surface=pup&session=shotlog`, so the broker flashes this window's taskbar
/// orange automatically on every update. `swap` also closes the channel's
/// skinny webview tab so pup becomes the channel's primary canvas.
pub fn open_pup(channel: &str, port: u16, swap: bool) {
let proxy_uri = match std::env::var("VSCODE_PROXY_URI") {
Ok(uri) => uri.replace("{{port}}", &port.to_string()),
Err(_) => {
eprintln!("ERROR: VSCODE_PROXY_URI not set - cannot build the proxy URL for pup.");
std::process::exit(1);
}
};
let url = format!("{proxy_uri}log/?surface=pup&session=shotlog&channel={channel}");
let ad = |args: &[&str]| -> (bool, String) {
let mut cmd = Command::new("adom-desktop");
if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
cmd.args(["--target", &t]);
}
match cmd.args(args).output() {
Ok(o) => {
let out = format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
(o.status.success() && !out.contains("\"error\""), out)
}
Err(e) => (false, e.to_string()),
}
};
// One shotlog pup window for ALL channels, one tab PER channel (idempotent).
// SPEED: each `ad` call is a cloud->laptop round-trip (~1s), so branch off
// the ONE list_tabs result instead of try-and-fallback chains:
// session missing -> open_window (2 trips total)
// tab exists -> switch_tab (2 trips)
// session, no tab -> open_tab (2 trips)
let _ = &channel;
let needle = "/log/".to_string();
// The bridge's tab REGISTRY can outlive the actual Chrome window (a closed
// window leaves GHOST tab records; verbs then fail "browser is not
// connected"). So: try the cheap path, but ANY failure of switch/open_tab
// falls through to browser_open_window, which revives a dead session.
let open_window = |ad: &dyn Fn(&[&str]) -> (bool, String)| -> bool {
let win_json = format!("{{\"sessionId\":\"shotlog\",\"profile\":\"shotlog\",\"url\":\"{url}\"}}");
let (mut ok, mut out) = ad(&["browser_open_window", &win_json]);
// A COLD BRIDGE is not a failure: AD auto-starts Puppeteer+Chrome on
// demand (10-15s, sometimes more after a reboot). The response says
// status:"bridge_starting"/pending:true - WAIT and retry instead of
// surrendering (this misread stranded humans on the webview fallback
// for a whole day while pup was one warm-up away).
let mut waited = 0u32;
while !ok && waited < 9
&& (out.contains("bridge_starting") || out.contains("\"pending\":true") || out.contains("\"pending\": true"))
{
eprintln!("Hint: pup bridge is cold - Adom Desktop is auto-starting Chrome, waiting 10s (attempt {}/9)...", waited + 1);
std::thread::sleep(std::time::Duration::from_secs(10));
waited += 1;
let r = ad(&["browser_open_window", &win_json]);
ok = r.0;
out = r.1;
}
ok
};
let (listed, list_out) = ad(&["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]);
let mut opened = false;
let mut via_window = false;
if !listed || list_out.contains("session_not_found") {
opened = open_window(&ad);
via_window = true;
} else {
let existing = serde_json::from_str::<serde_json::Value>(&list_out).ok()
.and_then(|v| v.get("tabs").and_then(|t| t.as_array()).and_then(|tabs| {
tabs.iter().find(|t| t.get("url").and_then(|u| u.as_str()).map(|u| u.contains(&needle)).unwrap_or(false))
.and_then(|t| t.get("tabId").or_else(|| t.get("id")).and_then(|x| x.as_str()).map(String::from))
}));
if let Some(tid) = existing {
let sw = format!("{{\"sessionId\":\"shotlog\",\"tabId\":\"{tid}\"}}");
opened = ad(&["browser_switch_tab", &sw]).0;
} else {
let tab_json = format!("{{\"sessionId\":\"shotlog\",\"url\":\"{url}\"}}");
opened = ad(&["browser_open_tab", &tab_json]).0;
}
if !opened {
opened = open_window(&ad);
via_window = true;
}
}
// TRUST BUT VERIFY: the bridge's registry can outlive the real Chrome
// window and fake tab-verb success (switch_tab "ok" into the void). A pup
// viewer's WebSocket CONNECTING to the broker is the only real proof -
// poll briefly; if nobody connects, force a fresh window.
if opened && !via_window {
let mut connected = false;
for _ in 0..4 {
std::thread::sleep(std::time::Duration::from_secs(2));
if broker_has_pup_viewer(port) {
connected = true;
break;
}
}
if !connected {
eprintln!("Hint: pup tab verbs succeeded but no viewer connected (ghost session) - opening a fresh window.");
opened = open_window(&ad);
}
}
if !opened {
eprintln!("ERROR: could not open the pup window (is adom-desktop connected?).");
eprintln!("Hint: check `adom-desktop targets`; with multiple desktops set SHOTLOG_AD_TARGET=<name>.");
std::process::exit(1);
}
println!("OK: pup canvas open for '{channel}' (tab in the shared 'shotlog' pup window).");
println!("URL: {url}");
eprintln!("Hint: the broker will flash this window's taskbar ORANGE automatically on every update (the URL registered session=shotlog). Do NOT foreground or refresh it yourself.");
eprintln!("Hint: pup is the full-browser canvas: zooming a card now uses nearly the whole screen, so text is readable. The skinny webview is better for ambient watching; switch back any time with `shotlog open -c {channel} --skinny`.");
if swap {
let removed = Command::new("adom-cli")
.args(["hydrogen", "workspace", "remove-tab", "--name", &format!("Shots: {channel}")])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if removed {
println!("OK: closed the skinny webview tab for '{channel}' - pup is now this channel's canvas.");
} else {
eprintln!("Hint: no webview tab found to close for '{channel}' (already closed, or named differently).");
}
} else {
eprintln!("Hint: the webview tab (if open) stays connected and will still pull forward on updates. Pass --swap to close it and make pup the ONLY canvas for this channel.");
}
}
/// Does the broker currently see ANY pup viewer connected (the real proof a
/// pup page is alive)? Minimal HTTP GET, no external deps.
pub fn broker_has_pup_viewer(port: u16) -> bool {
use std::io::{Read, Write};
let Ok(mut st) = std::net::TcpStream::connect(("127.0.0.1", port)) else { return false };
let _ = st.set_read_timeout(Some(std::time::Duration::from_secs(3)));
let req = format!("GET /api/viewer-status?channel=_all HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n");
if st.write_all(req.as_bytes()).is_err() { return false; }
let mut buf = String::new();
let _ = st.read_to_string(&mut buf);
buf.contains("\"pup\"")
}
/// Close this channel's tab in the shared shotlog pup window (if present).
/// Used when swapping a channel back to the webview canvas.
pub fn close_pup_tab(channel: &str) -> bool {
let ad = |args: &[&str]| -> (bool, String) {
let mut cmd = Command::new("adom-desktop");
if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
cmd.args(["--target", &t]);
}
match cmd.args(args).output() {
Ok(o) => (o.status.success(),
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))),
Err(e) => (false, e.to_string()),
}
};
let _ = &channel;
let needle = "/log/".to_string();
let (ok, out) = ad(&["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]);
if !ok { return false; }
let Ok(v) = serde_json::from_str::<serde_json::Value>(&out) else { return false };
let Some(tab) = v.get("tabs").and_then(|t| t.as_array()).and_then(|tabs| {
tabs.iter().find(|t| t.get("url").and_then(|u| u.as_str()).map(|u| u.contains(&needle)).unwrap_or(false))
}) else { return false };
let Some(tid) = tab.get("tabId").or_else(|| tab.get("id")).and_then(|x| x.as_str()) else { return false };
ad(&["browser_close_tab", &format!("{{\"sessionId\":\"shotlog\",\"tabId\":\"{tid}\"}}")]).0
}
/// Switch the shared shotlog pup window's ACTIVE tab to this channel's tab
/// (no create, no window state change). The pup mirror of the webview
/// pull-to-front: a new shot should be what the user sees when they look.
pub fn switch_pup_tab(channel: &str) -> bool {
let ad = |args: &[&str]| -> (bool, String) {
let mut cmd = Command::new("adom-desktop");
if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
cmd.args(["--target", &t]);
}
match cmd.args(args).output() {
Ok(o) => (o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("\"error\""),
String::from_utf8_lossy(&o.stdout).to_string()),
Err(e) => (false, e.to_string()),
}
};
let needle = format!("/log/{channel}/");
let (ok, out) = ad(&["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]);
if !ok { return false; }
let Ok(v) = serde_json::from_str::<serde_json::Value>(&out) else { return false };
let tabs = v.get("tabs").and_then(|t| t.as_array());
let Some(tabs) = tabs else { return false };
let Some(tab) = tabs.iter().find(|t| t.get("url").and_then(|u| u.as_str()).map(|u| u.contains(&needle)).unwrap_or(false)) else { return false };
// already front?
let tid = tab.get("tabId").or_else(|| tab.get("id")).and_then(|x| x.as_str()).unwrap_or("");
if v.get("activeTabId").and_then(|a| a.as_str()) == Some(tid) { return true; }
ad(&["browser_switch_tab", &format!("{{\"sessionId\":\"shotlog\",\"tabId\":\"{tid}\"}}")]).0
}