app
Shotlog - Screenshot Log
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 really watching and on which surface, and ingests Adom Bridge child-window arrays via --meta.
← Commit history
Publish 0.4.1
15 files changed
+3676−4
Cargo.lockadded+1325@@ -0,0 +1,1325 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-shotlog"+version = "0.4.1"+dependencies = [+ "axum",+ "base64",+ "chrono",+ "clap",+ "clap_complete",+ "image",+ "serde",+ "serde_json",+ "tokio",+ "tower-http",+]++[[package]]+name = "android_system_properties"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"+dependencies = [+ "libc",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "atomic-waker"+version = "1.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"++[[package]]+name = "autocfg"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"++[[package]]+name = "axum"+version = "0.8.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"+dependencies = [+ "axum-core",+ "axum-macros",+ "base64",+ "bytes",+ "form_urlencoded",+ "futures-util",+ "http",+ "http-body",+ "http-body-util",+ "hyper",+ "hyper-util",+ "itoa",+ "matchit",+ "memchr",+ "mime",+ "percent-encoding",+ "pin-project-lite",+ "serde_core",+ "serde_json",+ "serde_path_to_error",+ "serde_urlencoded",+ "sha1",+ "sync_wrapper",+ "tokio",+ "tokio-tungstenite",+ "tower",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "axum-core"+version = "0.5.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"+dependencies = [+ "bytes",+ "futures-core",+ "http",+ "http-body",+ "http-body-util",+ "mime",+ "pin-project-lite",+ "sync_wrapper",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "axum-macros"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "bitflags"+version = "2.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"++[[package]]+name = "block-buffer"+version = "0.10.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"+dependencies = [+ "generic-array",+]++[[package]]+name = "bumpalo"+version = "3.20.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"++[[package]]+name = "bytemuck"+version = "1.25.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"++[[package]]+name = "byteorder-lite"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"++[[package]]+name = "bytes"+version = "1.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"++[[package]]+name = "cc"+version = "1.2.58"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chrono"+version = "0.4.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"+dependencies = [+ "iana-time-zone",+ "js-sys",+ "num-traits",+ "serde",+ "wasm-bindgen",+ "windows-link",+]++[[package]]+name = "clap"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_complete"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"+dependencies = [+ "clap",+]++[[package]]+name = "clap_derive"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "core-foundation-sys"+version = "0.8.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"++[[package]]+name = "cpufeatures"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"+dependencies = [+ "libc",+]++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "crypto-common"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"+dependencies = [+ "generic-array",+ "typenum",+]++[[package]]+name = "data-encoding"+version = "2.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"++[[package]]+name = "digest"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"+dependencies = [+ "block-buffer",+ "crypto-common",+]++[[package]]+name = "errno"+version = "0.3.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"+dependencies = [+ "libc",+ "windows-sys",+]++[[package]]+name = "fdeflate"+version = "0.3.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"+dependencies = [+ "simd-adler32",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "futures-channel"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"+dependencies = [+ "futures-core",+]++[[package]]+name = "futures-core"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-sink"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"++[[package]]+name = "futures-task"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"++[[package]]+name = "futures-util"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"+dependencies = [+ "futures-core",+ "futures-sink",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "generic-array"+version = "0.14.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"+dependencies = [+ "typenum",+ "version_check",+]++[[package]]+name = "getrandom"+version = "0.3.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"+dependencies = [+ "cfg-if",+ "libc",+ "r-efi",+ "wasip2",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "http"+version = "1.4.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"+dependencies = [+ "bytes",+ "itoa",+]++[[package]]+name = "http-body"+version = "1.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"+dependencies = [+ "bytes",+ "http",+]++[[package]]+name = "http-body-util"+version = "0.1.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"+dependencies = [+ "bytes",+ "futures-core",+ "http",+ "http-body",+ "pin-project-lite",+]++[[package]]+name = "httparse"+version = "1.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "hyper"+version = "1.8.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"+dependencies = [+ "atomic-waker",+ "bytes",+ "futures-channel",+ "futures-core",+ "http",+ "http-body",+ "httparse",+ "httpdate",+ "itoa",+ "pin-project-lite",+ "pin-utils",+ "smallvec",+ "tokio",+]++[[package]]+name = "hyper-util"+version = "0.1.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"+dependencies = [+ "bytes",+ "http",+ "http-body",+ "hyper",+ "pin-project-lite",+ "tokio",+ "tower-service",+]++[[package]]+name = "iana-time-zone"+version = "0.1.65"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"+dependencies = [+ "android_system_properties",+ "core-foundation-sys",+ "iana-time-zone-haiku",+ "js-sys",+ "log",+ "wasm-bindgen",+ "windows-core",+]++[[package]]+name = "iana-time-zone-haiku"+version = "0.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"+dependencies = [+ "cc",+]++[[package]]+name = "image"+version = "0.25.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"+dependencies = [+ "bytemuck",+ "byteorder-lite",+ "moxcms",+ "num-traits",+ "png",+ "zune-core",+ "zune-jpeg",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "js-sys"+version = "0.3.92"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"+dependencies = [+ "once_cell",+ "wasm-bindgen",+]++[[package]]+name = "libc"+version = "0.2.183"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"++[[package]]+name = "lock_api"+version = "0.4.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"+dependencies = [+ "scopeguard",+]++[[package]]+name = "log"+version = "0.4.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "matchit"+version = "0.8.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "mime"+version = "0.3.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "mio"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"+dependencies = [+ "libc",+ "wasi",+ "windows-sys",+]++[[package]]+name = "moxcms"+version = "0.8.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"+dependencies = [+ "num-traits",+ "pxfm",+]++[[package]]+name = "num-traits"+version = "0.2.19"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"+dependencies = [+ "autocfg",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "parking_lot"+version = "0.12.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"+dependencies = [+ "lock_api",+ "parking_lot_core",+]++[[package]]+name = "parking_lot_core"+version = "0.9.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"+dependencies = [+ "cfg-if",+ "libc",+ "redox_syscall",+ "smallvec",+ "windows-link",+]++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "pin-utils"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"++[[package]]+name = "png"+version = "0.18.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"+dependencies = [+ "bitflags",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide",+]++[[package]]+name = "ppv-lite86"+version = "0.2.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"+dependencies = [+ "zerocopy",+]++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "pxfm"+version = "0.1.28"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "r-efi"+version = "5.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"++[[package]]+name = "rand"+version = "0.9.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"+dependencies = [+ "rand_chacha",+ "rand_core",+]++[[package]]+name = "rand_chacha"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"+dependencies = [+ "ppv-lite86",+ "rand_core",+]++[[package]]+name = "rand_core"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"+dependencies = [+ "getrandom",+]++[[package]]+name = "redox_syscall"+version = "0.5.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"+dependencies = [+ "bitflags",+]++[[package]]+name = "rustversion"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"++[[package]]+name = "ryu"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"++[[package]]+name = "scopeguard"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "serde_path_to_error"+version = "0.1.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"+dependencies = [+ "itoa",+ "serde",+ "serde_core",+]++[[package]]+name = "serde_urlencoded"+version = "0.7.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"+dependencies = [+ "form_urlencoded",+ "itoa",+ "ryu",+ "serde",+]++[[package]]+name = "sha1"+version = "0.10.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"+dependencies = [+ "cfg-if",+ "cpufeatures",+ "digest",+]++[[package]]+name = "shlex"+version = "1.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"++[[package]]+name = "signal-hook-registry"+version = "1.4.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"+dependencies = [+ "errno",+ "libc",+]++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "smallvec"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"++[[package]]+name = "socket2"+version = "0.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"+dependencies = [+ "libc",+ "windows-sys",+]++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "sync_wrapper"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"++[[package]]+name = "thiserror"+version = "2.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"+dependencies = [+ "thiserror-impl",+]++[[package]]+name = "thiserror-impl"+version = "2.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tokio"+version = "1.50.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"+dependencies = [+ "bytes",+ "libc",+ "mio",+ "parking_lot",+ "pin-project-lite",+ "signal-hook-registry",+ "socket2",+ "tokio-macros",+ "windows-sys",+]++[[package]]+name = "tokio-macros"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tokio-tungstenite"+version = "0.28.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"+dependencies = [+ "futures-util",+ "log",+ "tokio",+ "tungstenite",+]++[[package]]+name = "tower"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"+dependencies = [+ "futures-core",+ "futures-util",+ "pin-project-lite",+ "sync_wrapper",+ "tokio",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "tower-http"+version = "0.6.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"+dependencies = [+ "bitflags",+ "bytes",+ "http",+ "pin-project-lite",+ "tower-layer",+ "tower-service",+]++[[package]]+name = "tower-layer"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"++[[package]]+name = "tower-service"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"++[[package]]+name = "tracing"+version = "0.1.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"+dependencies = [+ "log",+ "pin-project-lite",+ "tracing-core",+]++[[package]]+name = "tracing-core"+version = "0.1.36"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"+dependencies = [+ "once_cell",+]++[[package]]+name = "tungstenite"+version = "0.28.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"+dependencies = [+ "bytes",+ "data-encoding",+ "http",+ "httparse",+ "log",+ "rand",+ "sha1",+ "thiserror",+ "utf-8",+]++[[package]]+name = "typenum"+version = "1.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "utf-8"+version = "0.7.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "wasip2"+version = "1.0.2+wasi-0.2.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"+dependencies = [+ "wit-bindgen",+]++[[package]]+name = "wasm-bindgen"+version = "0.2.115"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"+dependencies = [+ "cfg-if",+ "once_cell",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.115"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.115"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.115"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "windows-core"+version = "0.62.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"+dependencies = [+ "windows-implement",+ "windows-interface",+ "windows-link",+ "windows-result",+ "windows-strings",+]++[[package]]+name = "windows-implement"+version = "0.60.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "windows-interface"+version = "0.59.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-result"+version = "0.4.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-strings"+version = "0.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "wit-bindgen"+version = "0.51.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"++[[package]]+name = "zerocopy"+version = "0.8.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"+dependencies = [+ "zerocopy-derive",+]++[[package]]+name = "zerocopy-derive"+version = "0.8.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"++[[package]]+name = "zune-core"+version = "0.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"++[[package]]+name = "zune-jpeg"+version = "0.5.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"+dependencies = [+ "zune-core",+]
Cargo.tomladded+22@@ -0,0 +1,22 @@+[package]+name = "adom-shotlog"+version = "0.4.1"+edition = "2021"+description = "Screenshot log viewer and CLI for Adom"++[dependencies]+axum = { version = "0.8", features = ["macros", "ws"] }+tokio = { version = "1", features = ["full"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+clap = { version = "4", features = ["derive"] }+clap_complete = "4"+image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }+base64 = "0.22"+chrono = { version = "0.4", features = ["serde"] }+tower-http = { version = "0.6", features = ["cors"] }++[profile.release]+opt-level = "z"+lto = true+strip = true
VERSIONadded+1@@ -0,0 +1 @@+0.4.1
bin/adom-shotlog⋯ 1 unchanged line ⋯
build.shadded+17@@ -0,0 +1,17 @@+#!/bin/bash+set -e+cd "$(dirname "$0")"++VERSION=$(cat VERSION | tr -d '[:space:]')+echo "[adom-shotlog] Building v${VERSION}..."++source "$HOME/.cargo/env" 2>/dev/null+cargo build --release 2>&1 | tail -1+sudo cp target/release/adom-shotlog /usr/local/bin/adom-shotlog+# Back-compat `shotlog` alias+sudo ln -sf /usr/local/bin/adom-shotlog /usr/local/bin/shotlog++echo "[adom-shotlog] Running install..."+adom-shotlog install++echo "[adom-shotlog] v${VERSION} built and installed (alias: shotlog)."
package.json+7−2@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.4.0",+ "version": "0.4.1", "type": "app", "title": "adom-shotlog", "description": "See the shots your AI never showed you. Your AI screenshots your app while it tests but never surfaces them (it thinks in text) \u2014 adom-shotlog streams every shot it gathered into a live gallery you open in a webview, pup, your browser, or your phone. Now ingests Adom Desktop owned-popup arrays + coordMap via --meta.",@@ -17,7 +17,12 @@ "install.sh", "uninstall.sh", "bin/**",- "screenshots/**"+ "screenshots/**",+ "src/**",+ "Cargo.toml",+ "Cargo.lock",+ "build.sh",+ "VERSION" ], "install": { "binary_name": "adom-shotlog"
page.json+7−2@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.4.0",+ "version": "0.4.1", "type": "app", "title": "adom-shotlog", "description": "See the shots your AI never showed you. Your AI screenshots your app while it tests but never surfaces them (it thinks in text) — adom-shotlog streams every shot it gathered into a live gallery you open in a webview, pup, your browser, or your phone. Now ingests Adom Desktop owned-popup arrays + coordMap via --meta.",@@ -17,7 +17,12 @@ "install.sh", "uninstall.sh", "bin/**",- "screenshots/**"+ "screenshots/**",+ "src/**",+ "Cargo.toml",+ "Cargo.lock",+ "build.sh",+ "VERSION" ], "install": { "binary_name": "adom-shotlog"
src/describe.rsadded+49@@ -0,0 +1,49 @@+use std::sync::Arc;+use crate::store::Store;++/// Spawn an async task to describe an image via claude CLI and update the entry.+pub fn describe_async(+ store: Arc<Store>,+ channel: String,+ entry_id: u64,+ image_path: String,+) {+ tokio::spawn(async move {+ match call_claude(&image_path).await {+ Ok(desc) => {+ store.update_description(&channel, entry_id, desc).await;+ }+ Err(e) => {+ eprintln!("[shotlog] Claude describe failed: {e}");+ }+ }+ });+}++async fn call_claude(image_path: &str) -> Result<String, String> {+ let prompt = "Describe this screenshot in one sentence. Be specific about what's shown. No preamble.";+ let cmd_str = format!("claude -p \"{}\" --model haiku --image {}", prompt, image_path);+ eprintln!("[shotlog] Running: {cmd_str}");++ let output = tokio::process::Command::new("claude")+ .args([+ "-p", prompt,+ "--model", "haiku",+ "--image", image_path,+ ])+ .output()+ .await+ .map_err(|e| format!("Failed to run claude CLI: {e}"))?;++ if !output.status.success() {+ let stderr = String::from_utf8_lossy(&output.stderr);+ return Err(format!("claude CLI failed: {stderr}"));+ }++ let desc = String::from_utf8_lossy(&output.stdout).trim().to_string();+ if desc.is_empty() {+ Err("Empty response from claude CLI".into())+ } else {+ Ok(desc)+ }+}
src/html/icon.svgadded+1@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e6edf3"><path d="M1 5h2v14H1V5zm4 0h2v14H5V5zm17 1v12a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h3.17L15 2h3l1.83 2H20a2 2 0 0 1 2 2zm-2.5 6.5A4.5 4.5 0 1 0 15 17a4.5 4.5 0 0 0 4.5-4.5zM17.5 12.5A2.5 2.5 0 1 1 15 10a2.5 2.5 0 0 1 2.5 2.5z"/></svg>
src/html/index.htmladded+473@@ -0,0 +1,473 @@+<!doctype html>+<html lang="en" style="background:#0d1117">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">+<meta name="apple-mobile-web-app-capable" content="yes">+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<title>adom-shotlog</title>+<style>+@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight: 700; font-display: swap; }+@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'); font-weight: 600; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }++:root {+ --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;+ --border: #30363d; --border-muted: #21262d;+ --text: #e6edf3; --text2: #8b949e; --text3: #484f58;+ --accent: #00b8b0; --accent-bright: #00e6dc; --purple: #8C6BF7; --danger: #f85149;+}+*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }+html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); font: 13px/1.4 'Satoshi', -apple-system, system-ui, sans-serif; }+body { display: flex; flex-direction: column; height: 100dvh; }+body.paste-active { outline: 2px solid var(--accent); outline-offset: -2px; }++/* Global tooltip — body-appended fixed (human-ui-patterns §1d) */+#global-tooltip {+ position: fixed; top: 0; left: 0; transform: translate(0,0);+ background: rgba(22,27,34,.97); backdrop-filter: blur(12px);+ border: 1px solid rgba(0,184,176,.25); border-radius: 7px; padding: 6px 10px;+ font: 12px/1.45 'Satoshi', sans-serif; color: var(--text); max-width: 300px;+ white-space: pre-wrap; box-shadow: 0 8px 28px rgba(0,0,0,.55);+ z-index: 99999; pointer-events: none; opacity: 0; display: none; transition: opacity .18s;+}+#global-tooltip.visible { display: block; } #global-tooltip.shown { opacity: 1; }++/* Header (adom-app-header, 44px) */+header {+ display: flex; align-items: center; gap: 10px; padding: 0 12px; height: 44px;+ flex-shrink: 0; background: var(--surface); border-bottom: 1px solid var(--border-muted);+ position: relative;+}+.brand { display: flex; align-items: center; gap: 7px; color: var(--accent);+ font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600;+ cursor: default; flex-shrink: 0; outline: none; }+.brand:hover .app-name { text-decoration: underline; text-underline-offset: 3px; }+.brand svg.logo { width: 15px; height: 15px; flex-shrink: 0; color: var(--text); } /* monochrome per brand */+.brand .sep { color: var(--text3); font-weight: 400; margin: 0 1px; }+.brand .app-subtitle { color: var(--text2); font-weight: 500; font-size: 12px; }+.vsep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }+.subject { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }+.subject-name { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600;+ color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.subject-meta { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px;+ white-space: nowrap; flex-shrink: 0; }+@media (max-width: 480px) { .subject-meta { display: none; } .brand .sep, .brand .app-subtitle { display: none; } }+.actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }+.hdr-btn { background: transparent; color: var(--text2); border: 1px solid var(--border);+ padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;+ font-family: 'Satoshi', sans-serif; flex-shrink: 0; transition: background .15s, color .15s; }+.hdr-btn:hover { background: var(--border); color: var(--text); }+.hdr-btn.danger { border-color: #f8514955; color: var(--danger); }+.hdr-btn.danger:hover { background: #f8514922; }+.ws-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; cursor: help; order: -1;+ background: var(--text3); transition: background .4s, box-shadow .4s; }+.ws-led.live { background: #2ea043; box-shadow: 0 0 7px rgba(46,160,67,.7); }++/* App-info popover */+.hover-popover { position: absolute; top: calc(100% + 6px); left: 12px;+ background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;+ box-shadow: 0 8px 24px rgba(0,0,0,.5); padding: 11px 13px; min-width: 280px; max-width: 400px;+ font: 12px 'Satoshi', sans-serif; color: var(--text); z-index: 99999;+ opacity: 0; transform: translateY(-2px); pointer-events: none; transition: opacity 120ms, transform 120ms; }+.hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }+.hover-popover .pop-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 700; margin-bottom: 8px; }+.hover-popover .pop-row { display: flex; gap: 10px; padding: 2px 0; align-items: baseline; }+.hover-popover .pop-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 9px; width: 56px; flex-shrink: 0; }+.hover-popover .pop-row .v { color: var(--text); font-size: 11px; flex: 1; }+.hover-popover .pop-actions { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border-muted); }+.hover-popover .pop-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; }++/* Gallery */+.feed { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; -webkit-overflow-scrolling: touch; }+.empty { text-align: center; color: var(--text3); padding: 48px 20px; font-size: 13px; line-height: 1.6; }+.empty code { color: var(--accent); font-family: 'JetBrains Mono', monospace; }++.shot-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;+ overflow: hidden; transition: border-color .15s; cursor: zoom-in;+ display: flex; flex-direction: column; flex-shrink: 0; } /* never compress in the flex-column feed */+.shot-card:hover, .shot-card:active { border-color: rgba(0,184,176,.5); }+.shot-card-desc { padding: 10px 13px 5px; font-family: 'Satoshi', sans-serif; font-size: 13px;+ font-weight: 500; color: var(--text); line-height: 1.4; max-height: 76px; overflow: hidden; flex-shrink: 0; }+.shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; }+.shot-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 3px 13px 7px;+ font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }+.shot-card-meta .ts { color: var(--text2); }+.shot-card-meta .sep { color: var(--border); }+.shot-card-meta .dim-main { color: var(--text2); cursor: help; }+.shot-card-meta .dim-orig { cursor: help; }+.shot-card-meta .dim-missing { color: var(--danger); font-weight: 600; cursor: help; }+.shot-card-meta .src { font-style: italic; }+.shot-card-img { display: block; width: 100%; height: 220px; flex-shrink: 0; background: #111;+ object-fit: contain; object-position: center top; transition: opacity .15s; }+.shot-card:active .shot-card-img { opacity: .85; }+/* Popup-array badge + nerd info chip on a card */+.shot-card-meta .badge-pop { background: rgba(0,184,176,.16); color: var(--accent); border: 1px solid rgba(0,184,176,.4);+ border-radius: 999px; padding: 0 7px; font-weight: 600; cursor: help; }+.shot-card-meta .badge-nerd { color: var(--text3); cursor: help; border: 1px solid var(--border);+ border-radius: 4px; padding: 0 5px; }++/* Zoom modal — full card */+.modal { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: none;+ align-items: flex-start; justify-content: center; z-index: 100; overflow-y: auto;+ padding: 16px; padding-top: max(16px, env(safe-area-inset-top)); }+.modal.open { display: flex; }+.modal-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;+ overflow: hidden; width: 100%; max-width: 820px; flex-shrink: 0; }+.modal-card .m-desc { padding: 14px 16px 6px; font: 500 15px/1.4 'Satoshi', sans-serif; color: var(--text); }+.modal-card .m-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 4px 16px 10px;+ font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text3); }+.modal-card .m-meta .dim-main { color: var(--text2); } .modal-card .m-meta .dim-missing { color: var(--danger); font-weight: 600; }+.modal-card .m-meta .sep { color: var(--border); }+.modal-card img { display: block; width: 100%; object-fit: contain; background: #000; }+/* Popup array inside the modal */+.modal-card .m-popups { padding: 10px 16px 14px; }+.modal-card .m-popups .pop-h { font: 600 12px 'Familjen Grotesk', sans-serif; color: var(--text2);+ margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }+.modal-card .m-popups .pop-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }+.modal-card .m-popups .pop-cell { background: #000; border: 1px solid var(--border); border-radius: 8px;+ overflow: hidden; cursor: zoom-in; }+.modal-card .m-popups .pop-cell img { width: 100%; height: 110px; object-fit: contain; background: #111; }+.modal-card .m-popups .pop-cap { padding: 4px 6px; font-family: 'JetBrains Mono', monospace; font-size: 9px;+ color: var(--text3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+/* Nerd metadata panel — readable + copyable */+.modal-card .nerd { margin: 0 16px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--elevated); }+.modal-card .nerd summary { cursor: pointer; padding: 8px 12px; font: 600 11px 'JetBrains Mono', monospace;+ color: var(--text2); list-style: none; display: flex; align-items: center; gap: 8px; }+.modal-card .nerd summary::-webkit-details-marker { display: none; }+.modal-card .nerd summary .copy { margin-left: auto; font-size: 10px; color: var(--accent); border: 1px solid rgba(0,184,176,.4);+ border-radius: 4px; padding: 1px 7px; }+.modal-card .nerd .body { padding: 0 12px 10px; }+.modal-card .nerd .row { display: flex; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px;+ padding: 2px 0; border-top: 1px solid rgba(255,255,255,.04); }+.modal-card .nerd .row .k { color: var(--text3); min-width: 130px; } .modal-card .nerd .row .v { color: var(--text); user-select: all; word-break: break-all; }+.modal-card .nerd pre { margin: 8px 0 0; padding: 8px; background: #0c0f12; border-radius: 6px; max-height: 220px;+ overflow: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); user-select: all; white-space: pre-wrap; word-break: break-all; }++/* Toast */+.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);+ background: var(--elevated); color: var(--text); border: 1px solid var(--border);+ padding: 7px 13px; border-radius: 999px; font-size: 12px; opacity: 0; transition: opacity .18s;+ pointer-events: none; z-index: 200; white-space: nowrap; }+.toast.show { opacity: 1; }++/* Paste progress overlay */+.paste-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); display: none;+ align-items: center; justify-content: center; z-index: 150; }+.paste-overlay.active { display: flex; }+.paste-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;+ padding: 20px 24px; min-width: 260px; max-width: 420px; }+.paste-box .title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; margin-bottom: 8px; }+.paste-box .detail { font-size: 12px; color: var(--text2); margin: 3px 0; }+.paste-box .val { color: var(--text); } .paste-box .filepath-result { font-family: 'JetBrains Mono', monospace;+ font-size: 10px; color: var(--text3); margin-top: 8px; word-break: break-all; }+</style>+</head>+<body tabindex="0">++<div id="global-tooltip"></div>++<header>+ <div class="brand" id="brandZone" data-tooltip-target="app-info" tabindex="0"+ data-tooltip="adom-shotlog — screenshot log Hover for app info">+ <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">+ <path d="M1 5h2v14H1V5zm4 0h2v14H5V5zm17 1v12a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h3.17L15 2h3l1.83 2H20a2 2 0 0 1 2 2zm-2.5 6.5A4.5 4.5 0 1 0 15 17a4.5 4.5 0 0 0 4.5-4.5zM17.5 12.5A2.5 2.5 0 1 1 15 10a2.5 2.5 0 0 1 2.5 2.5z"/>+ </svg>+ <span class="app-name">adom-shotlog</span>+ <span class="sep">·</span>+ <span class="app-subtitle">screenshot log</span>+ </div>+ <div class="vsep"></div>+ <div class="subject">+ <span class="subject-name" id="channelName"+ data-tooltip="The channel — a screenshot log for one project or task. AI agents inject shots here; you watch them arrive live.">…</span>+ <span class="subject-meta" id="shotCount"></span>+ </div>+ <div class="actions">+ <button class="hdr-btn" id="toTopBtn" data-tooltip="Scroll to newest (top)">↑</button>+ <button class="hdr-btn danger" id="clearBtn" data-tooltip="Delete ALL screenshots in this channel (cannot be undone)">clear</button>+ <span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live. While green the server knows you're watching and won't force-open a duplicate tab on new shots."></span>+ </div>+ <div class="hover-popover" id="app-info" role="dialog">+ <div class="pop-title">adom-shotlog</div>+ <div class="pop-row"><span class="k">what</span><span class="v">Chronological screenshot log. AI agents inject shots as they work; you view them here, newest first, with descriptions and source resolution.</span></div>+ <div class="pop-row"><span class="k">inject</span><span class="v">adom-shotlog inject -c CH -d "why" FILE — auto-resizes >1500px and records the original size</span></div>+ <div class="pop-row"><span class="k">paste</span><span class="v">Ctrl+V anywhere to paste a screenshot from the clipboard</span></div>+ <div class="pop-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+ <div class="pop-actions"><a class="pop-link" href="https://wiki.adom.inc/adom/adom-shotlog" target="_blank" rel="noopener">Wiki ↗</a></div>+ </div>+</header>++<div class="feed" id="feed"><div class="empty">loading…</div></div>++<div class="toast" id="toast"></div>+<div class="modal" id="modal"><div class="modal-card" id="modalCard">+ <div class="m-desc" id="mDesc"></div><div class="m-meta" id="mMeta"></div><img id="mImg" alt="">+ <div class="m-popups" id="mPopups" style="display:none"></div>+ <div id="mNerd"></div>+</div></div>+<div class="paste-overlay" id="pasteOverlay"><div class="paste-box" id="pasteMsg"></div></div>++<script>+(function() {+ const $ = id => document.getElementById(id);+ const feed = $('feed'), toast = $('toast'), wsLed = $('wsLed');+ const modal = $('modal'), mImg = $('mImg'), mDesc = $('mDesc'), mMeta = $('mMeta');+ const mPopups = $('mPopups'), mNerd = $('mNerd');++ // Channel from URL path /log/<channel>/ (matches Rust route + base href)+ const m = location.pathname.match(/\/log\/([^/]+)\/?/);+ const channel = m ? decodeURIComponent(m[1]) : 'default';+ $('channelName').textContent = channel;+ document.title = 'shotlog · ' + channel;++ let entries = []; // full list (ascending by id)+ let shotCount = 0;++ // ── Tooltip system (body-appended, 600ms, cursor-quadrant) ───────────────+ (function() {+ const tip = $('global-tooltip'); let cx=0, cy=0, timer=null;+ document.addEventListener('mousemove', e => { cx=e.clientX; cy=e.clientY; }, true);+ function show(el) {+ const t = el.getAttribute('data-tooltip'); if (!t) return;+ tip.textContent = t; tip.classList.add('visible');+ const tw=tip.offsetWidth, th=tip.offsetHeight, vw=innerWidth, vh=innerHeight, O=14;+ const c=[{x:cx+O,y:cy+O},{x:cx-tw-O,y:cy+O},{x:cx+O,y:cy-th-O},{x:cx-tw-O,y:cy-th-O}]+ .find(p=>p.x>=4&&p.y>=4&&p.x+tw<=vw-4&&p.y+th<=vh-4)||{x:cx+O,y:cy+O};+ tip.style.transform=`translate(${Math.max(4,c.x)}px,${Math.max(4,c.y)}px)`;+ requestAnimationFrame(()=>tip.classList.add('shown'));+ }+ function hide(){ if(timer){clearTimeout(timer);timer=null;} tip.classList.remove('shown');+ setTimeout(()=>{ if(!tip.classList.contains('shown')) tip.classList.remove('visible'); },180); }+ document.addEventListener('mouseover', e => { const el=e.target.closest('[data-tooltip]');+ if(el){ if(timer)clearTimeout(timer); timer=setTimeout(()=>show(el),600); } else hide(); }, true);+ document.addEventListener('mouseout', e => { if(!e.relatedTarget?.closest('[data-tooltip]')) hide(); }, true);+ })();++ // ── App-info popover ─────────────────────────────────────────────────────+ (function() {+ const trigger = $('brandZone'), target = $('app-info'); let st=null, ht=null;+ const show=()=>{clearTimeout(ht); if(target.classList.contains('show'))return; clearTimeout(st); st=setTimeout(()=>target.classList.add('show'),600);};+ const hide=()=>{clearTimeout(st); clearTimeout(ht); ht=setTimeout(()=>target.classList.remove('show'),220);};+ trigger.addEventListener('mouseenter',show); trigger.addEventListener('mouseleave',hide);+ target.addEventListener('mouseenter',show); target.addEventListener('mouseleave',hide);+ trigger.addEventListener('click',e=>{clearTimeout(st);target.classList.toggle('show');e.stopPropagation();});+ document.addEventListener('click',e=>{ if(!target.contains(e.target)&&!trigger.contains(e.target)) target.classList.remove('show'); });+ })();++ // ── Helpers ──────────────────────────────────────────────────────────────+ function showToast(msg){ toast.textContent=msg; toast.classList.add('show'); setTimeout(()=>toast.classList.remove('show'),1800); }+ function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }+ function fmtTime(ts){ const d=new Date(ts), h=d.getHours(), mm=String(d.getMinutes()).padStart(2,'0');+ return (h%12||12)+':'+mm+(h<12?'am':'pm'); }+ function fmtDate(ts){ const d=new Date(ts), mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()];+ return mo+' '+d.getDate()+', '+fmtTime(ts); }+ function imgUrl(e){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(e.filename); }+ function popUrl(fn){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(fn); }+ // Owned popups can be stored as a bare array (current) or wrapped — normalize to an array.+ function popList(e){ const p=e&&e.popups; return Array.isArray(p)?p:(p&&Array.isArray(p.popups)?p.popups:[]); }+ // Build a flat readable [key,value] view of the capture metadata for the nerd panel.+ function metaRows(e){+ const rows=[]; const m=e.meta||{}; const cm=m.coordMap||{};+ const push=(k,v)=>{ if(v!==undefined&&v!==null&&v!=='') rows.push([k,typeof v==='object'?JSON.stringify(v):String(v)]); };+ push('shotId', m.shotId);+ push('image size', (e.w&&e.h)?e.w+'×'+e.h:undefined);+ if(e.orig_w&&e.orig_h) push('orig capture', e.orig_w+'×'+e.orig_h);+ push('screenRect', cm.screenRect);+ push('dpiScale', cm.dpiScale!==undefined?cm.dpiScale:m.dpiScale);+ push('imageToScreen', cm.imageToScreen);+ push('screen resolution', cm.screenResolution||cm.screen||m.screenResolution);+ const np=m.ownedPopupCount!==undefined?m.ownedPopupCount:popList(e).length;+ push('owned popups', np||undefined);+ return rows;+ }++ function dimNodes(e, into) {+ if (!e.w || !e.h) return;+ const dm=document.createElement('span'); dm.className='dim-main'; dm.textContent=e.w+'×'+e.h;+ dm.setAttribute('data-tooltip','Stored size (after any resize) — what the AI sees when it reads the shot.');+ into.appendChild(dm);+ // Show the original only when it genuinely differs from the stored size.+ // (New injects always establish the original; equal/absent → show nothing.)+ if (e.orig_w && e.orig_h && (e.orig_w !== e.w || e.orig_h !== e.h)) {+ const sep=document.createElement('span'); sep.className='sep'; sep.textContent='·'; into.appendChild(sep);+ const o=document.createElement('span'); o.className='dim-orig'; o.textContent='orig '+e.orig_w+'×'+e.orig_h;+ o.setAttribute('data-tooltip','Original capture resolution before resize — confirms what was actually captured.');+ into.appendChild(o);+ }+ }++ function card(e) {+ const c=document.createElement('div'); c.className='shot-card'; c.dataset.id=e.id;+ c.addEventListener('click',()=>openModal(e));+ const desc=document.createElement('div');+ if (e.description) { desc.className='shot-card-desc'; desc.textContent=e.description; }+ else { desc.className='shot-card-desc no-desc'; desc.textContent='⚠ No description — inject with -d "what and why"'; }+ c.appendChild(desc);+ const meta=document.createElement('div'); meta.className='shot-card-meta';+ const ts=document.createElement('span'); ts.className='ts'; ts.textContent=fmtDate(e.timestamp);+ ts.setAttribute('data-tooltip', new Date(e.timestamp).toLocaleString()); meta.appendChild(ts);+ if (e.w&&e.h){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); dimNodes(e,meta); }+ if (e.kb){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+ const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); }+ if (e.source && e.source!=='cli'){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+ const sr=document.createElement('span'); sr.className='src'; sr.textContent=e.source; meta.appendChild(sr); }+ const pops=popList(e);+ if (pops.length){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+ const b=document.createElement('span'); b.className='badge-pop'; b.textContent='+'+pops.length+' popup'+(pops.length>1?'s':'');+ b.setAttribute('data-tooltip','This shot captured '+pops.length+' owned popup'+(pops.length>1?'s':'')+' (dropdowns / dialogs). Open the shot to view each one.');+ meta.appendChild(b); }+ if (e.meta){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+ const n=document.createElement('span'); n.className='badge-nerd'; n.textContent='ⓘ meta';+ n.setAttribute('data-tooltip', metaRows(e).map(([k,v])=>k+': '+v).join(' ')||'capture metadata');+ meta.appendChild(n); }+ c.appendChild(meta);+ const img=document.createElement('img'); img.className='shot-card-img'; img.src=imgUrl(e); img.alt=e.description||e.filename;+ c.appendChild(img);+ return c;+ }++ function openModal(e) {+ mImg.src=imgUrl(e); mImg.alt=e.description||e.filename;+ mDesc.textContent=e.description||''; mDesc.style.display=e.description?'':'none';+ mMeta.innerHTML='';+ const add=(cls,txt,tip)=>{ const s=document.createElement('span'); if(cls)s.className=cls; s.textContent=txt; if(tip)s.setAttribute('data-tooltip',tip); mMeta.appendChild(s); };+ const sep=()=>add('sep','·');+ add('ts',fmtDate(e.timestamp));+ if(e.w&&e.h){ sep(); add('dim-main',e.w+'×'+e.h);+ if(e.orig_w&&e.orig_h&&(e.orig_w!==e.w||e.orig_h!==e.h)){ sep(); add('','orig '+e.orig_w+'×'+e.orig_h); } }+ if(e.kb){ sep(); add('',e.kb+' KB'); }+ if(e.source&&e.source!=='cli'){ sep(); add('',e.source); }++ // Owned-popup thumbnails — each opens full-size in its own modal entry.+ const pops=popList(e);+ mPopups.innerHTML='';+ if(pops.length){+ mPopups.style.display='';+ const h=document.createElement('div'); h.className='pop-h';+ h.textContent='Owned popups · '+pops.length+' captured';+ mPopups.appendChild(h);+ const grid=document.createElement('div'); grid.className='pop-grid';+ pops.forEach(p=>{+ const cell=document.createElement('div'); cell.className='pop-cell';+ const im=document.createElement('img'); im.src=popUrl(p.filename); im.alt=p.title||p.kind||'popup';+ im.loading='lazy';+ const cap=document.createElement('div'); cap.className='pop-cap';+ cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?' '+p.w+'×'+p.h:'');+ cell.appendChild(im); cell.appendChild(cap);+ cell.addEventListener('click', ev=>{ ev.stopPropagation();+ openModal({filename:p.filename, description:p.title||p.kind||'popup', w:p.w, h:p.h, kb:p.kb,+ timestamp:e.timestamp, source:'popup', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); });+ grid.appendChild(cell);+ });+ mPopups.appendChild(grid);+ } else { mPopups.style.display='none'; }++ // Nerd metadata panel — readable rows + copyable raw JSON.+ mNerd.innerHTML='';+ const rows=metaRows(e);+ if(e.meta || rows.length){+ const raw=JSON.stringify({meta:e.meta||null, popups:pops.length?pops:undefined}, null, 2);+ const d=document.createElement('details'); d.className='nerd';+ const sum=document.createElement('summary'); sum.textContent='⌗ capture metadata';+ const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';+ cp.addEventListener('click', ev=>{ ev.preventDefault(); ev.stopPropagation();+ navigator.clipboard?.writeText(raw).then(()=>{ cp.textContent='copied ✓'; setTimeout(()=>cp.textContent='copy JSON',1200); }); });+ sum.appendChild(cp); d.appendChild(sum);+ const body=document.createElement('div'); body.className='body';+ rows.forEach(([k,v])=>{ const r=document.createElement('div'); r.className='row';+ const kk=document.createElement('span'); kk.className='k'; kk.textContent=k;+ const vv=document.createElement('span'); vv.className='v'; vv.textContent=v;+ r.appendChild(kk); r.appendChild(vv); body.appendChild(r); });+ const pre=document.createElement('pre'); pre.textContent=raw; body.appendChild(pre);+ d.appendChild(body); mNerd.appendChild(d);+ }++ modal.classList.add('open'); modal.scrollTop=0;+ }+ modal.addEventListener('click', e => { if(e.target===modal) modal.classList.remove('open'); });++ function renderAll() {+ feed.innerHTML='';+ if (!entries.length) {+ feed.innerHTML='<div class="empty">No screenshots yet in <b>'+esc(channel)+'</b>.<br><br>'++ 'Inject one:<br><code>shotlog inject -c '+esc(channel)+' -d "what and why" FILE</code></div>';+ shotCount=0; updateCount(); return;+ }+ shotCount=entries.length;+ for (let i=entries.length-1;i>=0;i--) feed.appendChild(card(entries[i])); // newest first+ updateCount();+ }+ function updateCount(){ $('shotCount').textContent = shotCount? shotCount+(shotCount===1?' shot':' shots'):''; }++ // ── Load ───────────────────────────────────────────────────────────────+ fetch('api/shots?channel='+encodeURIComponent(channel)).then(r=>r.json()).then(data=>{+ entries = Array.isArray(data)?data:[]; renderAll(); feed.scrollTop=0;+ }).catch(e=>{ feed.innerHTML='<div class="empty">load failed: '+esc(e.message)+'</div>'; });++ // ── WebSocket live updates ({type:'entry', entry}) ───────────────────────+ function connectWs() {+ const proto = location.protocol==='https:'?'wss:':'ws:';+ const baseHref = document.querySelector('base')?.href.replace(location.origin,'') || '/';+ const ws = new WebSocket(`${proto}//${location.host}${baseHref}ws/${encodeURIComponent(channel)}`);+ ws.onopen = () => { wsLed.classList.add('live'); wsLed.setAttribute('data-tooltip','WebSocket live — the server knows you are watching.'); };+ ws.onmessage = ev => {+ try {+ const msg = JSON.parse(ev.data);+ if (msg.type==='entry' && msg.entry) {+ const e = msg.entry, existing = entries.find(x=>x.id===e.id);+ const atTop = feed.scrollTop < 60;+ if (existing) { Object.assign(existing,e); const old=feed.querySelector(`[data-id="${e.id}"]`); if(old) old.replaceWith(card(e)); }+ else { entries.push(e); const empty=feed.querySelector('.empty'); if(empty) feed.innerHTML='';+ feed.insertBefore(card(e), feed.firstChild); shotCount++; updateCount(); if(atTop) feed.scrollTop=0; }+ }+ } catch {}+ };+ ws.onclose = () => { wsLed.classList.remove('live');+ wsLed.setAttribute('data-tooltip','WebSocket disconnected — reconnecting…'); setTimeout(connectWs,3000); };+ ws.onerror = () => { try{ws.close();}catch{} };+ }+ connectWs();++ // ── Wiring ───────────────────────────────────────────────────────────────+ $('toTopBtn').addEventListener('click',()=>{ feed.scrollTop=0; });+ $('clearBtn').addEventListener('click', async ()=>{+ if(!confirm(`Delete ALL screenshots in "${channel}"? This cannot be undone.`)) return;+ entries=[]; renderAll();+ await fetch('api/clear-all?channel='+encodeURIComponent(channel),{method:'DELETE'}).catch(()=>{});+ showToast('cleared');+ });++ // ── Clipboard paste (Ctrl+V anywhere) ────────────────────────────────────+ const pasteOverlay=$('pasteOverlay'), pasteMsg=$('pasteMsg');+ document.body.addEventListener('click',()=>document.body.focus());+ document.body.classList.add('paste-active');+ document.addEventListener('paste', async e => {+ const items=e.clipboardData?.items; if(!items) return;+ for (const it of items) if (it.type.startsWith('image/')) {+ e.preventDefault(); const blob=it.getAsFile(); if(!blob) continue;+ pasteMsg.innerHTML='<div class="title">Processing paste…</div><div class="detail">'+Math.round(blob.size/1024)+' KB</div>';+ pasteOverlay.classList.add('active');+ try {+ const dataUrl = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(blob); });+ const res = await fetch('api/paste',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({channel,dataUrl})});+ const r = await res.json();+ if (r.ok) {+ let oi = (r.original_w&&r.original_h)?`<div class="detail">Original: <span class="val">${r.original_w}×${r.original_h}</span></div>`:'';+ pasteMsg.innerHTML=`<div class="title">Saved to log</div><div class="detail">File: <span class="val">${esc(r.name)}</span></div>`++ `<div class="detail">Size: <span class="val">${r.w}×${r.h}</span> · <span class="val">${r.kb} KB</span></div>`+oi;+ setTimeout(()=>pasteOverlay.classList.remove('active'),2500);+ } else { pasteMsg.innerHTML='<div class="title" style="color:#f85149">Paste failed</div>'; setTimeout(()=>pasteOverlay.classList.remove('active'),2000); }+ } catch(err){ pasteMsg.innerHTML='<div class="title" style="color:#f85149">Error: '+esc(err.message)+'</div>'; setTimeout(()=>pasteOverlay.classList.remove('active'),2000); }+ break;+ }+ });++ // Refresh relative timestamps periodically (cards use absolute, but keep ts tooltips fresh) — no-op for now+})();+</script>+</body>+</html>
src/image.rsadded+120@@ -0,0 +1,120 @@+use base64::Engine;+use image::imageops::FilterType;+use image::ImageFormat;+use std::io::Cursor;++/// Read width and height from a PNG file's IHDR chunk (bytes 16-23).+/// Returns (0, 0) if the buffer is too short or not a valid PNG.+pub fn read_png_dimensions(bytes: &[u8]) -> (u32, u32) {+ if bytes.len() < 24 {+ return (0, 0);+ }+ // PNG signature check+ if &bytes[0..4] != b"\x89PNG" {+ return (0, 0);+ }+ let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);+ let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);+ (w, h)+}++/// Resize a PNG image if its width exceeds `max_width`.+/// Resize so neither dimension exceeds `max_edge`, preserving aspect ratio.+/// Returns the (possibly resized) PNG bytes and final (w, h). Caps BOTH width+/// and height — a tall narrow screenshot is constrained by height, not just width.+pub fn resize_png(bytes: Vec<u8>, max_edge: u32) -> Result<(Vec<u8>, u32, u32), String> {+ let (orig_w, orig_h) = read_png_dimensions(&bytes);++ if orig_w == 0 || (orig_w <= max_edge && orig_h <= max_edge) {+ return Ok((bytes, orig_w, orig_h));+ }++ let img = image::load_from_memory_with_format(&bytes, ImageFormat::Png)+ .map_err(|e| format!("Failed to decode PNG: {e}"))?;++ // Scale by whichever edge is over the limit (the larger ratio wins).+ let scale = (max_edge as f64 / orig_w as f64).min(max_edge as f64 / orig_h as f64);+ let new_w = (orig_w as f64 * scale).round().max(1.0) as u32;+ let new_h = (orig_h as f64 * scale).round().max(1.0) as u32;++ let resized = img.resize_exact(new_w, new_h, FilterType::Lanczos3);++ let mut buf = Cursor::new(Vec::new());+ resized+ .write_to(&mut buf, ImageFormat::Png)+ .map_err(|e| format!("Failed to encode resized PNG: {e}"))?;++ Ok((buf.into_inner(), new_w, new_h))+}++/// PNG metadata from the IHDR chunk.+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]+pub struct PngMeta {+ pub bit_depth: u8,+ pub color_type: String,+ pub channels: u8,+ pub raw_bytes: u64,+ pub file_bytes: u64,+ pub compression_ratio: f64,+ pub compression_pct: u8,+}++/// Read extended PNG metadata from IHDR chunk.+pub fn read_png_meta(bytes: &[u8]) -> Option<PngMeta> {+ if bytes.len() < 26 || &bytes[0..4] != b"\x89PNG" {+ return None;+ }+ let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);+ let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);+ let bit_depth = bytes[24];+ let color_type_byte = bytes[25];++ let (color_type, channels) = match color_type_byte {+ 0 => ("Grayscale", 1u8),+ 2 => ("RGB", 3),+ 3 => ("Indexed", 1),+ 4 => ("Grayscale+Alpha", 2),+ 6 => ("RGBA", 4),+ _ => ("Unknown", 1),+ };++ let bytes_per_pixel = (channels as u64) * (bit_depth as u64) / 8;+ let raw = (w as u64) * (h as u64) * bytes_per_pixel.max(1);+ let file = bytes.len() as u64;+ let ratio = if file > 0 { raw as f64 / file as f64 } else { 1.0 };+ let pct = if raw > 0 { ((1.0 - (file as f64 / raw as f64)) * 100.0).max(0.0) as u8 } else { 0 };++ Some(PngMeta {+ bit_depth,+ color_type: color_type.to_string(),+ channels,+ raw_bytes: raw,+ file_bytes: file,+ compression_ratio: (ratio * 10.0).round() / 10.0,+ compression_pct: pct,+ })+}++/// Parse a data URL like `data:image/png;base64,iVBOR...` into (mime_type, raw_bytes).+pub fn parse_data_url(data_url: &str) -> Result<(String, Vec<u8>), String> {+ let rest = data_url+ .strip_prefix("data:")+ .ok_or("Not a data URL")?;++ let (meta, b64) = rest+ .split_once(",")+ .ok_or("No comma in data URL")?;++ let mime = meta+ .split(';')+ .next()+ .unwrap_or("application/octet-stream")+ .to_string();++ let bytes = base64::engine::general_purpose::STANDARD+ .decode(b64)+ .map_err(|e| format!("Base64 decode error: {e}"))?;++ Ok((mime, bytes))+}+
src/main.rsadded+585@@ -0,0 +1,585 @@+mod describe;+mod image;+mod open;+mod server;+mod store;++use clap::{CommandFactory, Parser, Subcommand};+use std::path::PathBuf;+use std::sync::Arc;++fn ok(msg: &str) { println!("OK: {msg}"); }+fn err(msg: &str) { eprintln!("ERROR: {msg}"); }+fn hint(msg: &str) { eprintln!("Hint: {msg}"); }++const SKILL_MD: &str = include_str!("../SKILL.md");++#[derive(Parser)]+#[command(name = "adom-shotlog", version, about = "Screenshot log viewer and CLI")]+struct Cli {+ #[command(subcommand)]+ command: Commands,+}++#[derive(Subcommand)]+enum Commands {+ /// Start the shotlog HTTP server+ Serve {+ /// Port to listen on+ #[arg(short, long, default_value_t = 8820)]+ port: u16,+ /// Data directory for screenshot storage+ #[arg(short, long, default_value = "./screenshots/shotlog")]+ data_dir: PathBuf,+ },+ /// Inject a screenshot into the log (posts to running server).+ ///+ /// shotlog must always know the ORIGINAL capture resolution. Pass a full-res+ /// image (shotlog resizes >1500px itself and records the original), or for a+ /// pre-shrunk image declare the original via --orig-file / --orig-w+--orig-h,+ /// or assert it was never resized with --native. An inject that establishes+ /// none of these is REJECTED — so the viewer never has to show "orig missing".+ Inject {+ /// Channel name+ #[arg(short, long)]+ channel: String,+ /// Description of why this screenshot was taken (becomes the filename)+ #[arg(short, long)]+ desc: Option<String>,+ /// Source identifier (e.g., av_capture, pup_screenshot)+ #[arg(short, long, default_value = "cli")]+ source: String,+ /// Port of the running shotlog server+ #[arg(short, long, default_value_t = 8820)]+ port: u16,+ /// Path to the ORIGINAL (pre-shrink) image — shotlog reads its+ /// dimensions and records them. Use this when FILE is already resized.+ #[arg(long)]+ orig_file: Option<String>,+ /// Original width before your resize (alternative to --orig-file)+ #[arg(long)]+ orig_w: Option<u32>,+ /// Original height before your resize (alternative to --orig-file)+ #[arg(long)]+ orig_h: Option<u32>,+ /// Assert FILE is at original capture resolution (never resized). Use+ /// only when true — it records the original as the image's own size.+ #[arg(long)]+ native: bool,+ /// Path to an Adom Desktop screenshot-response JSON (from+ /// desktop_screenshot_window / _screen). shotlog reads coordMap + DPI ++ /// origWidth/Height + the screenshots[] OWNED POPUPS (by localSafePath)+ /// and stores them all on this entry: the popups become viewable+ /// thumbnails and the coordMap/screen metadata a hover panel.+ #[arg(long)]+ meta: Option<String>,+ /// PNG file path (use - for stdin)+ file: String,+ },+ /// Open a shotlog webview tab in the Adom workspace+ Open {+ /// Channel name+ #[arg(short, long)]+ channel: String,+ /// Display name for the tab+ #[arg(short, long)]+ name: Option<String>,+ /// MDI icon for the tab+ #[arg(short, long)]+ icon: Option<String>,+ /// Port of the running shotlog server+ #[arg(short, long, default_value_t = 8820)]+ port: u16,+ },+ /// Resize (if needed) and inject a screenshot (like inject but applies 1400px max-width)+ Paste {+ /// Channel name+ #[arg(short, long)]+ channel: String,+ /// Description (becomes the filename)+ #[arg(short, long)]+ desc: Option<String>,+ /// Source identifier+ #[arg(short, long, default_value = "cli")]+ source: String,+ /// Port of the running shotlog server+ #[arg(short, long, default_value_t = 8820)]+ port: u16,+ /// PNG file path (use - for stdin)+ file: String,+ },+ /// Resize an image to max 1400px wide with maximum PNG compression (for AI submission)+ Resize {+ /// Input PNG file+ file: String,+ /// Output file (default: overwrites input)+ #[arg(short, long)]+ output: Option<String>,+ /// Max width in pixels+ #[arg(short, long, default_value_t = 1400)]+ width: u32,+ },+ /// Check if the shotlog server is running+ Health {+ /// Port of the running shotlog server+ #[arg(short, long, default_value_t = 8820)]+ port: u16,+ },+ /// Install everything: skill, bash completions+ Install,+ /// Generate shell completions (bash, zsh, fish)+ Completions {+ /// Shell type+ shell: clap_complete::Shell,+ },+}++#[tokio::main]+async fn main() {+ let cli = Cli::parse();++ match cli.command {+ Commands::Serve { port, data_dir } => {+ run_server(port, data_dir).await;+ }+ Commands::Inject {+ channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, file,+ } => {+ let desc = match desc {+ Some(d) if d.len() > 5 => d,+ Some(d) => {+ err(&format!("Description too short: \"{d}\""));+ hint("Write a specific description of what this screenshot shows and why you took it.");+ hint("Example: --desc \"PCB board layout after routing power traces\"");+ hint("The description becomes the filename, so make it meaningful.");+ std::process::exit(1);+ }+ None => {+ err("Missing --desc flag. Every screenshot needs a description.");+ hint("The description becomes the filename and helps the user understand what they're looking at.");+ hint("Example: shotlog inject -c my-project -d \"Homepage after nav redesign\" -s pup_screenshot file.png");+ std::process::exit(1);+ }+ };++ // --meta: ingest an Adom Desktop screenshot-response JSON. Pulls the+ // coordMap/DPI/screen metadata, the original W×H (so the shot is never+ // "orig missing"), and the owned-popup array (reads each popup PNG by+ // its localSafePath and attaches it). All additive.+ let (cap_meta, popups, meta_ow, meta_oh) = match &meta {+ None => (None, None, None, None),+ Some(mp) => {+ let raw = std::fs::read_to_string(mp).unwrap_or_else(|e| {+ err(&format!("Failed to read --meta {mp}: {e}")); std::process::exit(1);+ });+ let ad: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| {+ err(&format!("--meta {mp} is not valid JSON: {e}")); std::process::exit(1);+ });+ let cm = serde_json::json!({+ "shotId": ad.get("shotId"), "coordMap": ad.get("coordMap"),+ "ownedPopupCount": ad.get("ownedPopupCount"),+ "width": ad.get("width"), "height": ad.get("height"),+ "origWidth": ad.get("origWidth"), "origHeight": ad.get("origHeight"),+ "note": ad.get("_popups_note"),+ });+ // Owned popups → read each PNG by localSafePath, base64 it.+ let mut pops = Vec::new();+ if let Some(arr) = ad.get("screenshots").and_then(|v| v.as_array()) {+ for s in arr {+ let path = s.get("localSafePath").or_else(|| s.get("localFullPath")).and_then(|v| v.as_str());+ let Some(path) = path else { continue };+ match std::fs::read(path) {+ Ok(b) => {+ let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &b);+ pops.push(serde_json::json!({+ "image": format!("data:image/png;base64,{b64}"),+ "hwnd": s.get("hwnd"), "title": s.get("title"),+ "kind": s.get("kind"), "shotId": s.get("shotId"),+ "coordMap": s.get("coordMap"),+ }));+ }+ Err(e) => err(&format!("WARN: popup {path} unreadable ({e}) — skipped")),+ }+ }+ }+ let ow = ad.get("origWidth").and_then(|v| v.as_u64()).map(|n| n as u32);+ let oh = ad.get("origHeight").and_then(|v| v.as_u64()).map(|n| n as u32);+ (Some(cm), if pops.is_empty() { None } else { Some(pops) }, ow, oh)+ }+ };+ // --meta's origWidth/Height count as the declared original (so it+ // satisfies the orig-resolution requirement below).+ let (orig_w, orig_h) = (orig_w.or(meta_ow), orig_h.or(meta_oh));++ // Resolve the original dimensions. Two ways to supply them for a+ // pre-shrunk image: --orig-file (measure it) or --orig-w/--orig-h.+ let (orig_w, orig_h) = match (orig_file, orig_w, orig_h) {+ (Some(of), _, _) => {+ let bytes = std::fs::read(&of).unwrap_or_else(|e| {+ err(&format!("Failed to read --orig-file {of}: {e}"));+ std::process::exit(1);+ });+ let (w, h) = image::read_png_dimensions(&bytes);+ if w == 0 {+ err(&format!("--orig-file {of} is not a readable PNG."));+ std::process::exit(1);+ }+ (Some(w), Some(h))+ }+ (None, Some(w), Some(h)) => (Some(w), Some(h)),+ (None, None, None) => (None, None),+ (None, _, _) => {+ err("Give BOTH --orig-w and --orig-h, or use --orig-file, or --native.");+ hint("These record the original resolution of a pre-shrunk screenshot.");+ std::process::exit(1);+ }+ };++ // Enforce: shotlog must always know the original resolution, so the+ // viewer never shows "orig missing".+ // - explicit --orig-* already resolved above → trust it+ // - stdin: can't peek the file → trust caller+ // - otherwise read FILE's dims:+ // --native → record orig = the image's own size+ // >1500px (any edge) → leave None; the server resizes + records orig+ // ≤1500px, nothing → REJECT (forces the AI to declare the original)+ let (orig_w, orig_h) = if orig_w.is_some() || file == "-" {+ (orig_w, orig_h)+ } else {+ let bytes = std::fs::read(&file).unwrap_or_else(|e| {+ err(&format!("Failed to read {file}: {e}"));+ std::process::exit(1);+ });+ let (fw, fh) = image::read_png_dimensions(&bytes);+ if native {+ if fw == 0 { (None, None) } else { (Some(fw), Some(fh)) }+ } else if fw != 0 && fw <= 1500 && fh <= 1500 {+ err(&format!("Original resolution required — refusing to inject {fw}x{fh} blind."));+ hint("Resizing before inject is fine and expected — oversized images crash Claude's");+ hint("vision analysis, so most screenshot verbs return a shrunk copy. The original");+ hint("size just has to travel WITH the shot. Provide it, ONE of:");+ hint(" --orig-w <W> --orig-h <H> (the original dimensions)");+ hint(" --orig-file <the original image> (shotlog measures it — keep the original around)");+ hint(" --native (only if this image was NOT resized)");+ hint("If your screenshot tool doesn't surface the original W×H yet, fix it to report");+ hint("the pre-resize dimensions (and keep the full-res file available for --orig-file).");+ std::process::exit(1);+ } else {+ (None, None) // >1500 → server records the original on resize+ }+ };++ run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups);+ }+ Commands::Open {+ channel, name, icon, port,+ } => {+ open::open_webview(&channel, name.as_deref(), icon.as_deref(), port);+ }+ Commands::Paste {+ channel, desc, source, port, file,+ } => {+ let desc = match desc {+ Some(d) if d.len() > 5 => d,+ Some(d) => {+ err(&format!("Description too short: \"{d}\""));+ hint("Write a specific description. Example: --desc \"Schematic after adding decoupling caps\"");+ std::process::exit(1);+ }+ None => {+ err("Missing --desc flag. Every screenshot needs a description.");+ hint("Example: shotlog paste -c my-project -d \"Full page screenshot resized for AI\" file.png");+ std::process::exit(1);+ }+ };+ run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None);+ }+ Commands::Resize { file, output, width } => {+ run_resize(&file, output.as_deref(), width);+ }+ Commands::Health { port } => {+ run_health(port);+ }+ Commands::Install => {+ run_install();+ }+ Commands::Completions { shell } => {+ let mut cmd = Cli::command();+ clap_complete::generate(shell, &mut cmd, "adom-shotlog", &mut std::io::stdout());+ }+ }+}++async fn run_server(port: u16, data_dir: PathBuf) {+ let store = store::Store::new(data_dir);+ store.recover().await;++ let channel_count = store.channel_names().await.len();+ ok(&format!("Recovered {channel_count} channel(s) from disk"));++ let state = Arc::new(server::AppState { store, port });+ let app = server::create_router(state);++ let addr = format!("0.0.0.0:{port}");+ ok(&format!("Listening on {addr}"));++ let listener = tokio::net::TcpListener::bind(&addr)+ .await+ .expect("Failed to bind port");+ axum::serve(listener, app)+ .await+ .expect("Server error");+}++#[allow(clippy::too_many_arguments)]+fn run_inject(+ channel: &str,+ desc: &str,+ source: &str,+ port: u16,+ file: &str,+ resize: bool,+ orig_w: Option<u32>,+ orig_h: Option<u32>,+ cap_meta: Option<serde_json::Value>,+ popups: Option<Vec<serde_json::Value>>,+) {+ let bytes = if file == "-" {+ use std::io::Read;+ let mut buf = Vec::new();+ std::io::stdin()+ .read_to_end(&mut buf)+ .expect("Failed to read stdin");+ buf+ } else {+ std::fs::read(file).unwrap_or_else(|e| {+ err(&format!("Failed to read {file}: {e}"));+ std::process::exit(1);+ })+ };++ let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);+ let data_url = format!("data:image/png;base64,{b64}");++ let body = if resize {+ serde_json::json!({+ "channel": channel,+ "dataUrl": data_url,+ "description": desc,+ })+ } else {+ let mut b = serde_json::json!({+ "channel": channel,+ "image": data_url,+ "description": desc,+ "source": source,+ });+ if let (Some(ow), Some(oh)) = (orig_w, orig_h) {+ b["orig_w"] = serde_json::json!(ow);+ b["orig_h"] = serde_json::json!(oh);+ }+ if let Some(m) = cap_meta { b["meta"] = m; }+ if let Some(p) = popups { b["popups"] = serde_json::json!(p); }+ b+ };++ let endpoint = if resize { "/api/paste" } else { "/api/shots" };+ match http_post(port, endpoint, &body) {+ Ok(val) => {+ if val.get("ok").and_then(|v| v.as_bool()) == Some(true) {+ let w = val["w"].as_u64().unwrap_or(0);+ let h = val["h"].as_u64().unwrap_or(0);+ let ow = val["original_w"].as_u64();+ let oh = val["original_h"].as_u64();+ let dim_str = match (ow, oh) {+ (Some(ow), Some(oh)) => format!("{w}x{h}, orig {ow}x{oh}"),+ _ => format!("{w}x{h}"),+ };+ ok(&format!(+ "Injected {} ({dim_str}, {} KB) into channel {channel}\n Saved to: {}",+ val["name"].as_str().unwrap_or("?"),+ val["kb"],+ val["path"].as_str().unwrap_or("?"),+ ));+ // shotlog resized a full-res image itself and recorded the original.+ if let (Some(ow), Some(oh)) = (ow, oh) {+ if orig_w.is_none() {+ hint(&format!("Auto-resized from {ow}x{oh} → {w}x{h}; original size recorded."));+ }+ }+ } else {+ err(&format!("Server error: {}", serde_json::to_string(&val).unwrap_or_default()));+ }+ }+ Err(e) => {+ err(&e);+ hint("Is `shotlog serve` running? Start with: shotlog serve &");+ }+ }+}++fn run_resize(file: &str, output: Option<&str>, max_width: u32) {+ let bytes = std::fs::read(file).unwrap_or_else(|e| {+ err(&format!("Failed to read {file}: {e}"));+ std::process::exit(1);+ });++ let (orig_w, orig_h) = image::read_png_dimensions(&bytes);+ let orig_kb = bytes.len() / 1024;++ match image::resize_png(bytes, max_width) {+ Ok((resized, new_w, new_h)) => {+ let new_kb = resized.len() / 1024;+ let out_path = output.unwrap_or(file);+ std::fs::write(out_path, &resized).unwrap_or_else(|e| {+ err(&format!("Failed to write {out_path}: {e}"));+ std::process::exit(1);+ });++ if orig_w != new_w {+ ok(&format!(+ "Resized {file}: {orig_w}x{orig_h} ({orig_kb} KB) -> {new_w}x{new_h} ({new_kb} KB) saved to {out_path}"+ ));+ } else {+ ok(&format!(+ "Already within {max_width}px: {file} {orig_w}x{orig_h} ({new_kb} KB)"+ ));+ }+ }+ Err(e) => {+ err(&format!("Resize failed: {e}"));+ std::process::exit(1);+ }+ }+}++fn run_health(port: u16) {+ match http_get(port, "/health") {+ Ok(val) => {+ let channels = val["channels"].as_array().map(|a| a.len()).unwrap_or(0);+ ok(&format!(+ "Shotlog server running on port {port} with {channels} channel(s)"+ ));+ }+ Err(e) => {+ err(&format!("Cannot connect to shotlog on port {port}"));+ hint("Start with: shotlog serve &");+ eprintln!("{e}");+ std::process::exit(1);+ }+ }+}++fn run_install() {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());++ // 1. Install skill+ let skill_dir = format!("{home}/.claude/skills/adom-shotlog");+ let skill_file = format!("{skill_dir}/SKILL.md");+ std::fs::create_dir_all(&skill_dir).ok();+ match std::fs::write(&skill_file, SKILL_MD) {+ Ok(_) => ok(&format!("Skill installed to {skill_file}")),+ Err(e) => err(&format!("Failed to write skill: {e}")),+ }++ // 2. Install bash completions+ let comp_dir = format!("{home}/.local/share/bash-completion/completions");+ std::fs::create_dir_all(&comp_dir).ok();+ let comp_file = format!("{comp_dir}/adom-shotlog");+ let mut cmd = Cli::command();+ let mut buf = Vec::new();+ clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, "adom-shotlog", &mut buf);+ match std::fs::write(&comp_file, &buf) {+ Ok(_) => ok(&format!("Bash completions installed to {comp_file}")),+ Err(e) => err(&format!("Failed to write completions: {e}")),+ }++ // 3. Add to .bashrc if not there+ let bashrc = format!("{home}/.bashrc");+ let bashrc_content = std::fs::read_to_string(&bashrc).unwrap_or_default();+ let marker = "# adom-shotlog completions";+ if !bashrc_content.contains(marker) {+ let line = format!("\n{marker}\ncommand -v adom-shotlog >/dev/null && eval \"$(adom-shotlog completions bash 2>/dev/null)\"\n");+ match std::fs::OpenOptions::new().append(true).open(&bashrc) {+ Ok(mut f) => {+ use std::io::Write;+ f.write_all(line.as_bytes()).ok();+ ok("Bash completions added to .bashrc");+ }+ Err(e) => err(&format!("Failed to update .bashrc: {e}")),+ }+ }++ // 4. Back-compat `shotlog` alias → symlink next to the adom-shotlog binary.+ if let Ok(exe) = std::env::current_exe() {+ if let Some(dir) = exe.parent() {+ let alias = dir.join("shotlog");+ if !alias.exists() {+ #[cfg(unix)]+ match std::os::unix::fs::symlink(&exe, &alias) {+ Ok(_) => ok(&format!("`shotlog` alias linked to {}", alias.display())),+ Err(e) => err(&format!("Could not create `shotlog` alias ({e}) — run: sudo ln -s {} {}", exe.display(), alias.display())),+ }+ }+ }+ }++ println!("\nInstall complete.");+}++// ── HTTP client (raw TCP) ───────────────────────────────────────++fn http_post(port: u16, endpoint: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {+ let body_str = body.to_string();+ let request = format!(+ "POST {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_str}",+ body_str.len(),+ );+ send_request(port, &request)+}++fn http_get(port: u16, endpoint: &str) -> Result<serde_json::Value, String> {+ let request = format!(+ "GET {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"+ );+ send_request(port, &request)+}++fn send_request(port: u16, request: &str) -> Result<serde_json::Value, String> {+ use std::io::{Read, Write};++ let mut stream = std::net::TcpStream::connect(format!("127.0.0.1:{port}"))+ .map_err(|e| format!("Connection refused: {e}"))?;++ let timeout = std::time::Duration::from_secs(10);+ stream.set_read_timeout(Some(timeout)).ok();+ stream.set_write_timeout(Some(timeout)).ok();++ stream.write_all(request.as_bytes()).map_err(|e| e.to_string())?;+ let mut response = String::new();+ stream.read_to_string(&mut response).unwrap_or(0);++ if response.is_empty() {+ return Ok(serde_json::json!({ "ok": true }));+ }++ let body_start = match response.find("\r\n\r\n") {+ Some(pos) => pos,+ None => return Ok(serde_json::json!({ "ok": true })),+ };+ let raw_body = &response[body_start + 4..];++ // Handle chunked transfer encoding+ let body = if let Some(json_start) = raw_body.find('{') {+ let json_end = raw_body.rfind('}').unwrap_or(raw_body.len() - 1);+ &raw_body[json_start..=json_end]+ } else {+ raw_body.trim()+ };++ if body.is_empty() {+ return Ok(serde_json::json!({ "ok": true }));+ }++ serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {e}"))+}
src/open.rsadded+109@@ -0,0 +1,109 @@+use std::process::Command;++/// Open a webview tab in the Adom workspace for a shotlog channel.+pub fn open_webview(channel: &str, display_name: Option<&str>, icon: Option<&str>, port: u16) {+ let default_name = format!("Shots: {channel}");+ let name = display_name.unwrap_or(&default_name);+ let icon = icon.unwrap_or("mdi:camera-burst");++ // Build the proxy URL from VSCODE_PROXY_URI+ 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}")+ }+ };++ let url = format!("{proxy_uri}log/{channel}/");++ // Get the workspace layout to find a leaf node+ let layout_output = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "get"])+ .output();++ let leaf_id = match layout_output {+ Ok(output) => {+ let stdout = String::from_utf8_lossy(&output.stdout);+ find_leaf_id(&stdout)+ }+ Err(e) => {+ eprintln!("Failed to get workspace layout: {e}");+ None+ }+ };++ let Some(leaf_id) = leaf_id else {+ eprintln!("Could not find a leaf pane in workspace layout.");+ eprintln!("URL: {url}");+ return;+ };++ let initial_state = serde_json::json!({ "url": url }).to_string();++ let result = Command::new("adom-cli")+ .args([+ "hydrogen",+ "workspace",+ "add-tab",+ "--panel-id",+ &leaf_id,+ "--panel-type",+ "adom/a1b2c3d4-0031-4000-a000-000000000031",+ "--display-name",+ name,+ "--display-icon",+ icon,+ "--initial-state",+ &initial_state,+ ])+ .output();++ match result {+ Ok(output) => {+ if output.status.success() {+ println!("Opened shotlog viewer: {name}");+ println!("Channel: {channel}");+ println!("URL: {url}");+ } else {+ let stderr = String::from_utf8_lossy(&output.stderr);+ eprintln!("adom-cli add-tab failed: {stderr}");+ }+ }+ Err(e) => eprintln!("Failed to run adom-cli: {e}"),+ }+}++/// Walk the workspace JSON to find the first leaf node ID.+fn find_leaf_id(json_str: &str) -> Option<String> {+ let val: serde_json::Value = serde_json::from_str(json_str).ok()?;+ // The layout may be wrapped in a "root" key+ if let Some(root) = val.get("root") {+ find_leaf_recursive(root)+ } else {+ find_leaf_recursive(&val)+ }+}++fn find_leaf_recursive(val: &serde_json::Value) -> Option<String> {+ if let Some(node_type) = val.get("type").and_then(|t| t.as_str()) {+ if node_type == "leaf" {+ return val.get("id").and_then(|id| id.as_str()).map(|s| s.to_string());+ }+ if node_type == "split" {+ // Try second child first (usually the right/bottom pane where viewers go)+ if let Some(second) = val.get("second") {+ if let Some(id) = find_leaf_recursive(second) {+ return Some(id);+ }+ }+ if let Some(first) = val.get("first") {+ if let Some(id) = find_leaf_recursive(first) {+ return Some(id);+ }+ }+ }+ }+ None+}
src/server.rsadded+598@@ -0,0 +1,598 @@+use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};+use axum::extract::{DefaultBodyLimit, Path, Query, State};+use axum::http::{header, HeaderValue, Method, StatusCode};+use axum::response::{Html, IntoResponse, Redirect, Response};+use axum::routing::{delete, get, post};+use axum::{Json, Router};+use serde::{Deserialize, Serialize};+use std::sync::Arc;+use tower_http::cors::CorsLayer;++use crate::describe;+use crate::image::{parse_data_url, read_png_dimensions, resize_png};+use crate::store::Store;++const INDEX_HTML: &str = include_str!("html/index.html");+const ICON_SVG: &str = include_str!("html/icon.svg");++/// Turn a description into a safe filename: lowercase, hyphens, max 60 chars+fn slugify_filename(desc: &str) -> String {+ let slug: String = desc+ .to_lowercase()+ .chars()+ .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { ' ' })+ .collect::<String>()+ .split_whitespace()+ .collect::<Vec<_>>()+ .join("-");+ let slug = if slug.len() > 60 { &slug[..60] } else { &slug };+ let slug = slug.trim_end_matches('-');+ if slug.is_empty() {+ let ts = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();+ format!("shot-{ts}.png")+ } else {+ format!("{slug}.png")+ }+}++/// Ensure a filename is unique in a directory by appending -2, -3, etc.+fn unique_filename(dir: &std::path::Path, filename: &str) -> String {+ if !dir.join(filename).exists() {+ return filename.to_string();+ }+ let stem = filename.trim_end_matches(".png");+ for i in 2..100 {+ let candidate = format!("{stem}-{i}.png");+ if !dir.join(&candidate).exists() {+ return candidate;+ }+ }+ // Fallback: add timestamp+ let ts = chrono::Utc::now().format("%H%M%S").to_string();+ format!("{stem}-{ts}.png")+}++pub struct AppState {+ pub store: Arc<Store>,+ pub port: u16,+}++pub fn create_router(state: Arc<AppState>) -> Router {+ let cors = CorsLayer::new()+ .allow_origin(tower_http::cors::Any)+ .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])+ .allow_headers([header::CONTENT_TYPE]);++ Router::new()+ .route("/", get(root_redirect))+ .route("/log/{channel}", get(log_redirect))+ .route("/log/{channel}/", get(serve_viewer))+ .route("/favicon.svg", get(serve_favicon))+ .route("/log/{channel}/favicon.svg", get(serve_favicon))+ .route("/api/shots", get(get_shots))+ .route("/api/shots", post(post_shots))+ .route("/api/viewer-status", get(viewer_status))+ .route("/prompt", get(serve_prompt))+ .route("/api/paste", post(post_paste))+ .route("/api/open", post(open_in_vscode))+ .route("/api/reveal", post(reveal_in_vscode))+ .route("/api/clear-log", delete(clear_log))+ .route("/api/clear-all", delete(clear_all))+ .route("/api/delete", delete(delete_entry))+ .route("/ws/{channel}", get(ws_handler))+ .route("/shots/{channel}/{filename}", get(serve_image))+ .route("/health", get(health))+ .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50MB+ .layer(cors)+ .with_state(state)+}++async fn root_redirect() -> Redirect {+ Redirect::temporary("/log/default/")+}++async fn log_redirect(Path(channel): Path<String>) -> Redirect {+ Redirect::permanent(&format!("/log/{channel}/"))+}++async fn serve_favicon() -> Response {+ let mut res = ICON_SVG.to_string().into_response();+ res.headers_mut().insert(+ header::CONTENT_TYPE,+ HeaderValue::from_static("image/svg+xml"),+ );+ res.headers_mut().insert(+ header::CACHE_CONTROL,+ HeaderValue::from_static("public, max-age=86400"),+ );+ res+}++async fn serve_viewer() -> Html<String> {+ let html = INDEX_HTML.replacen(+ "<head>",+ "<head><script>document.write('<base href=\"'+((location.pathname.match(/^\\/proxy\\/\\d+\\//)||['/'])[0])+'\">')</script>",+ 1,+ );+ Html(html)+}++#[derive(Deserialize)]+struct ChannelQuery {+ channel: Option<String>,+}++async fn get_shots(+ State(state): State<Arc<AppState>>,+ Query(q): Query<ChannelQuery>,+) -> Json<Vec<crate::store::Entry>> {+ let channel = q.channel.unwrap_or_else(|| "default".into());+ let entries = state.store.get_entries(&channel).await;+ Json(entries)+}++async fn viewer_status(+ State(state): State<Arc<AppState>>,+ Query(q): Query<ChannelQuery>,+) -> Json<serde_json::Value> {+ let channel = q.channel.unwrap_or_else(|| "default".into());+ let viewers = state.store.viewer_count(&channel).await;+ Json(serde_json::json!({+ "channel": channel,+ "viewers": viewers,+ "has_viewers": viewers > 0,+ }))+}++/// Paste-prompt text served at /prompt — guidance for AI agents on how to inject.+async fn serve_prompt() -> impl IntoResponse {+ let body = "\+Use 'shotlog inject' for all screenshot logging.++shotlog ALWAYS records the original capture resolution. Resizing before inject is+fine and expected — oversized images crash Claude's vision analysis, so most+screenshot verbs return a shrunk copy. The original size just has to travel with+the shot.++If you inject a PRE-SHRUNK image (<=1500px), declare the original — shotlog+REJECTS the inject otherwise. One of:+ --orig-w <W> --orig-h <H> (the original dimensions)+ --orig-file <the original full-res image> (shotlog measures it)+ --native (only if it was NOT resized)++ shotlog inject -c CHANNEL -d \"what and why\" -s pup_screenshot --orig-w 1920 --orig-h 1080 SHRUNK.png++Building a screenshot tool? Have it (1) report the ORIGINAL width/height, and+(2) keep the full-res file available, so callers can pass --orig-w/--orig-h (or+--orig-file). A full-res image also works — shotlog resizes >1500px itself and+records the original.++The description becomes the filename -- say what you are looking at and why.+";+ ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body)+}++/// Force-open the viewer for a channel when a shot lands with nobody watching.+/// In-process replacement for an external fs-watcher: called from the inject+/// path. Debounced per-channel so a burst of injects can't spawn a storm.+fn force_open_viewer(channel: &str, port: u16) {+ use std::sync::Mutex;+ use std::collections::HashMap;+ use std::time::Instant;+ static LAST: Mutex<Option<HashMap<String, Instant>>> = Mutex::new(None);++ let proxy_tmpl = match std::env::var("VSCODE_PROXY_URI") {+ Ok(v) => v,+ Err(_) => return, // not in an Adom container; nothing to open+ };++ {+ let mut guard = LAST.lock().unwrap();+ let map = guard.get_or_insert_with(HashMap::new);+ if let Some(t) = map.get(channel) {+ if t.elapsed().as_secs() < 60 {+ return; // cooldown — opened recently+ }+ }+ map.insert(channel.to_string(), Instant::now());+ }++ let base = proxy_tmpl.replace("{{port}}", &port.to_string());+ let base = base.trim_end_matches('/').to_string();+ let url = format!("{base}/log/{channel}/");+ let tab = format!("shotlog:{channel}");+ let icon = format!("data:image/svg+xml;base64,{}", base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ICON_SVG));++ // Best-effort; don't block the inject response on it.+ std::thread::spawn(move || {+ let _ = std::process::Command::new("adom-cli")+ .args([+ "hydrogen", "webview", "open-or-refresh",+ "--name", &tab,+ "--url", &url,+ "--display-icon", &icon,+ ])+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .status();+ });+}++#[derive(Deserialize)]+struct InjectRequest {+ channel: String,+ image: String,+ description: String,+ source: Option<String>,+ filename: Option<String>,+ /// Caller-reported original dimensions (when the caller resized before sending).+ orig_w: Option<u32>,+ orig_h: Option<u32>,+ /// Free-form capture metadata (Adom Desktop coordMap / dpiScale / screenRect /+ /// screen resolution, shotId, ownedPopupCount, …). Stored verbatim on the entry.+ meta: Option<serde_json::Value>,+ /// Owned-popup screenshots. Each object carries an `image` data-URL plus any+ /// metadata (hwnd, title, kind, coordMap, shotId). The `image` is saved as its+ /// own PNG and replaced with `filename` in the stored array.+ popups: Option<Vec<serde_json::Value>>,+}++/// Images larger than this on any edge are auto-resized (Claude crashes >1500px).+const MAX_EDGE: u32 = 1500;+/// Target edge for the auto-resize.+const RESIZE_TO: u32 = 1400;++#[derive(Serialize)]+struct InjectResponse {+ ok: bool,+ id: u64,+ name: String,+ path: String,+ w: u32,+ h: u32,+ kb: u64,+ bytes: u64,+ #[serde(skip_serializing_if = "Option::is_none")]+ original_w: Option<u32>,+ #[serde(skip_serializing_if = "Option::is_none")]+ original_h: Option<u32>,+}++async fn post_shots(+ State(state): State<Arc<AppState>>,+ Json(req): Json<InjectRequest>,+) -> Result<Json<InjectResponse>, (StatusCode, String)> {+ let (_mime, raw_bytes) =+ parse_data_url(&req.image).map_err(|e| (StatusCode::BAD_REQUEST, e))?;++ let (incoming_w, incoming_h) = read_png_dimensions(&raw_bytes);++ // Auto-resize anything over the limit, recording the TRUE original. This is+ // how orig dims get captured at the source: shotlog owns the resize, so it+ // always knows what the image was before shrinking. No caller pre-resize needed.+ let (bytes, w, h, orig_w, orig_h) = if incoming_w > MAX_EDGE || incoming_h > MAX_EDGE {+ let (resized, nw, nh) =+ resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;+ (resized, nw, nh, Some(incoming_w), Some(incoming_h))+ } else {+ // Image already within limits. If the caller pre-resized externally and+ // reported the original via orig_w/orig_h, honor it; otherwise none.+ (raw_bytes, incoming_w, incoming_h, req.orig_w, req.orig_h)+ };++ let raw_name = req.filename.unwrap_or_else(|| slugify_filename(&req.description));+ let channel_dir = state.store.base_dir().join(&req.channel);+ let filename = unique_filename(&channel_dir, &raw_name);++ let source = req.source.unwrap_or_else(|| "ai".into());++ // Owned popups: save each popup's image as its own PNG next to the primary,+ // strip the data-URL from the stored object, and record its filename + dims.+ let popups_stored = if let Some(popups) = req.popups {+ let stem = filename.trim_end_matches(".png");+ let mut out = Vec::new();+ std::fs::create_dir_all(&channel_dir).ok();+ for (i, mut p) in popups.into_iter().enumerate() {+ let Some(img) = p.get("image").and_then(|v| v.as_str()).map(|s| s.to_string()) else { continue };+ let Ok((_m, pbytes)) = parse_data_url(&img) else { continue };+ let (pw, ph) = read_png_dimensions(&pbytes);+ let pname = format!("{stem}__popup{}.png", i + 1);+ std::fs::write(channel_dir.join(&pname), &pbytes).ok();+ if let Some(obj) = p.as_object_mut() {+ obj.remove("image");+ obj.insert("filename".into(), serde_json::json!(pname));+ obj.insert("w".into(), serde_json::json!(pw));+ obj.insert("h".into(), serde_json::json!(ph));+ obj.insert("kb".into(), serde_json::json!(pbytes.len() / 1024));+ }+ out.push(p);+ }+ if out.is_empty() { None } else { Some(serde_json::Value::Array(out)) }+ } else { None };++ let entry = state+ .store+ .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored)+ .await;++ // If nobody is watching this channel, force-open a viewer so the user+ // actually sees the shot instead of it landing invisibly in the log.+ if state.store.viewer_count(&req.channel).await == 0 {+ force_open_viewer(&req.channel, state.port);+ }++ Ok(Json(InjectResponse {+ ok: true,+ id: entry.id,+ name: entry.filename,+ path: entry.path,+ w: entry.w,+ h: entry.h,+ kb: entry.kb,+ bytes: entry.bytes,+ original_w: entry.orig_w,+ original_h: entry.orig_h,+ }))+}++#[derive(Deserialize)]+struct PasteRequest {+ channel: String,+ #[serde(rename = "dataUrl")]+ data_url: String,+ description: Option<String>,+}++async fn post_paste(+ State(state): State<Arc<AppState>>,+ Json(req): Json<PasteRequest>,+) -> Result<Json<InjectResponse>, (StatusCode, String)> {+ let (_mime, raw_bytes) =+ parse_data_url(&req.data_url).map_err(|e| (StatusCode::BAD_REQUEST, e))?;++ let (orig_w, orig_h) = read_png_dimensions(&raw_bytes);+ let orig_bytes = raw_bytes.len() as u64;++ let (bytes, w, h) =+ resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;++ let was_resized = w != orig_w || h != orig_h;++ let description = req.description.unwrap_or_else(|| {+ if was_resized {+ format!(+ "Clipboard paste (resized from {}x{}, {:.0} KB to {}x{}, {:.0} KB)",+ orig_w, orig_h, orig_bytes as f64 / 1024.0,+ w, h, bytes.len() as f64 / 1024.0,+ )+ } else {+ format!("Clipboard paste ({}x{}, {:.0} KB)", w, h, bytes.len() as f64 / 1024.0)+ }+ });++ let raw_name = slugify_filename(&description);+ let channel_dir = state.store.base_dir().join(&req.channel);+ let filename = unique_filename(&channel_dir, &raw_name);++ let entry = state+ .store+ .add_entry(+ &req.channel, bytes, filename, description, "clipboard".into(), w, h,+ if was_resized { Some(orig_w) } else { None },+ if was_resized { Some(orig_h) } else { None },+ None, None,+ )+ .await;+++ Ok(Json(InjectResponse {+ ok: true,+ id: entry.id,+ name: entry.filename,+ path: entry.path,+ w: entry.w,+ h: entry.h,+ kb: entry.kb,+ bytes: entry.bytes,+ original_w: if was_resized { Some(orig_w) } else { None },+ original_h: if was_resized { Some(orig_h) } else { None },+ }))+}++#[derive(Deserialize)]+struct OpenRequest {+ path: String,+}++async fn open_in_vscode(+ Json(req): Json<OpenRequest>,+) -> Json<serde_json::Value> {+ if req.path.contains("..") {+ return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));+ }++ // Detect if path is a directory (reveal) or file (open)+ let is_dir = std::path::Path::new(&req.path).is_dir();+ let cmd = if is_dir { "reveal" } else { "open" };++ let result = std::process::Command::new("adom-vscode")+ .args([cmd, &req.path])+ .output()+ .or_else(|_| {+ std::process::Command::new(+ "/usr/lib/code-server/lib/vscode/bin/remote-cli/code-linux.sh",+ )+ .args(["--reuse-window", &req.path])+ .output()+ });++ match result {+ Ok(output) if output.status.success() => {+ Json(serde_json::json!({ "ok": true }))+ }+ Ok(output) => {+ let err = String::from_utf8_lossy(&output.stderr);+ Json(serde_json::json!({ "ok": false, "error": err.to_string() }))+ }+ Err(e) => {+ Json(serde_json::json!({ "ok": false, "error": e.to_string() }))+ }+ }+}++async fn reveal_in_vscode(+ Json(req): Json<OpenRequest>,+) -> Json<serde_json::Value> {+ if req.path.contains("..") {+ return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));+ }++ // Ensure directory exists before revealing+ std::fs::create_dir_all(&req.path).ok();++ let result = std::process::Command::new("adom-vscode")+ .args(["reveal", &req.path])+ .output();++ match result {+ Ok(output) if output.status.success() => {+ Json(serde_json::json!({ "ok": true }))+ }+ Ok(output) => {+ let err = String::from_utf8_lossy(&output.stderr);+ Json(serde_json::json!({ "ok": false, "error": err.to_string() }))+ }+ Err(e) => {+ Json(serde_json::json!({ "ok": false, "error": e.to_string() }))+ }+ }+}++async fn clear_log(+ State(state): State<Arc<AppState>>,+ Query(q): Query<ChannelQuery>,+) -> Json<serde_json::Value> {+ let channel = q.channel.unwrap_or_else(|| "default".into());+ let count = state.store.clear_log(&channel).await;+ Json(serde_json::json!({ "ok": true, "cleared": count }))+}++async fn clear_all(+ State(state): State<Arc<AppState>>,+ Query(q): Query<ChannelQuery>,+) -> Json<serde_json::Value> {+ let channel = q.channel.unwrap_or_else(|| "default".into());+ let count = state.store.clear_all(&channel).await;+ Json(serde_json::json!({ "ok": true, "cleared": count, "files_deleted": true }))+}++#[derive(Deserialize)]+struct DeleteQuery {+ channel: Option<String>,+ filename: Option<String>,+}++async fn delete_entry(+ State(state): State<Arc<AppState>>,+ Query(q): Query<DeleteQuery>,+) -> Json<serde_json::Value> {+ let channel = q.channel.unwrap_or_else(|| "default".into());+ let filename = match q.filename {+ Some(f) => f,+ None => return Json(serde_json::json!({ "ok": false, "error": "missing filename" })),+ };++ // Delete the PNG and JSON sidecar from disk+ let channel_dir = state.store.base_dir().join(&channel);+ let png_path = channel_dir.join(&filename);+ let json_path = png_path.with_extension("json");+ std::fs::remove_file(&png_path).ok();+ std::fs::remove_file(&json_path).ok();++ // Remove from in-memory entries+ state.store.delete_entry(&channel, &filename).await;++ Json(serde_json::json!({ "ok": true, "deleted": filename }))+}++// WebSocket: client connects to /ws/<channel>, receives new entries as JSON text frames+async fn ws_handler(+ ws: WebSocketUpgrade,+ Path(channel): Path<String>,+ State(state): State<Arc<AppState>>,+) -> impl IntoResponse {+ ws.on_upgrade(move |socket| ws_connection(socket, channel, state))+}++async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppState>) {+ let mut rx = state.store.subscribe(&channel).await;++ loop {+ tokio::select! {+ result = rx.recv() => {+ match result {+ Ok(entry) => {+ let json = serde_json::json!({+ "type": "entry",+ "entry": entry,+ });+ if socket.send(Message::Text(json.to_string().into())).await.is_err() {+ break; // client disconnected+ }+ }+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,+ Err(_) => break,+ }+ }+ msg = socket.recv() => {+ match msg {+ Some(Ok(Message::Close(_))) | None => break,+ _ => {} // ignore client messages+ }+ }+ }+ }+}++async fn serve_image(+ State(state): State<Arc<AppState>>,+ Path((channel, filename)): Path<(String, String)>,+) -> Response {+ if channel.contains("..") || filename.contains("..") {+ return (StatusCode::BAD_REQUEST, "Invalid path").into_response();+ }++ let file_path = state.store.base_dir().join(&channel).join(&filename);+ match tokio::fs::read(&file_path).await {+ Ok(bytes) => {+ let mut res = bytes.into_response();+ res.headers_mut().insert(+ header::CONTENT_TYPE,+ HeaderValue::from_static("image/png"),+ );+ res.headers_mut().insert(+ header::CACHE_CONTROL,+ HeaderValue::from_static("no-cache"),+ );+ res+ }+ Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(),+ }+}++#[derive(Serialize)]+struct HealthResponse {+ ok: bool,+ port: u16,+ data_dir: String,+ channels: Vec<String>,+}++async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {+ Json(HealthResponse {+ ok: true,+ port: state.port,+ data_dir: state.store.base_dir().to_string_lossy().to_string(),+ channels: state.store.channel_names().await,+ })+}
src/store.rsadded+362@@ -0,0 +1,362 @@+use chrono::{DateTime, Utc};+use serde::{Deserialize, Serialize};+use std::collections::HashMap;+use std::fs;+use std::path::{Path, PathBuf};+use std::sync::Arc;+use tokio::sync::{broadcast, RwLock};++use crate::image::{read_png_dimensions, read_png_meta, PngMeta};++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Entry {+ pub id: u64,+ pub timestamp: DateTime<Utc>,+ pub filename: String,+ pub description: String,+ pub w: u32,+ pub h: u32,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub orig_w: Option<u32>,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub orig_h: Option<u32>,+ pub bytes: u64,+ pub kb: u64,+ pub source: String,+ pub path: String,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub png_meta: Option<PngMeta>,+ /// Free-form capture metadata (e.g. Adom Desktop coordMap, dpiScale,+ /// screenRect, screen resolution). Additive — absent for plain injects.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub meta: Option<serde_json::Value>,+ /// Owned-popup screenshots attached to this shot (autofill dropdowns,+ /// confirm bubbles, native dialogs). Each carries its own filename ++ /// coordMap. Additive — absent for plain single-image injects.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub popups: Option<serde_json::Value>,+}++struct Channel {+ entries: Vec<Entry>,+ next_id: u64,+ tx: broadcast::Sender<Entry>,+}++const MAX_ENTRIES: usize = 200;++pub struct Store {+ channels: RwLock<HashMap<String, Channel>>,+ base_dir: PathBuf,+}++impl Store {+ pub fn new(base_dir: PathBuf) -> Arc<Self> {+ fs::create_dir_all(&base_dir).ok();+ // Canonicalize so all paths are absolute+ let base_dir = fs::canonicalize(&base_dir).unwrap_or(base_dir);+ let store = Arc::new(Self {+ channels: RwLock::new(HashMap::new()),+ base_dir,+ });+ store+ }++ /// Recover existing screenshots from disk on startup.+ pub async fn recover(&self) {+ let entries_dir = &self.base_dir;+ let Ok(read_dir) = fs::read_dir(entries_dir) else {+ return;+ };++ for dir_entry in read_dir.flatten() {+ if !dir_entry.path().is_dir() {+ continue;+ }+ let channel_name = dir_entry.file_name().to_string_lossy().to_string();+ let channel_dir = dir_entry.path();++ let mut files: Vec<(PathBuf, std::fs::Metadata)> = Vec::new();+ if let Ok(inner) = fs::read_dir(&channel_dir) {+ for f in inner.flatten() {+ let p = f.path();+ if p.extension().and_then(|e| e.to_str()) == Some("png") {+ // Skip owned-popup attachment PNGs ({stem}__popupN.png) — they+ // belong to a primary shot's sidecar, not a standalone entry.+ if p.file_name().and_then(|n| n.to_str()).map(|n| n.contains("__popup")).unwrap_or(false) {+ continue;+ }+ if let Ok(meta) = fs::metadata(&p) {+ files.push((p, meta));+ }+ }+ }+ }++ // Sort by modification time ascending+ files.sort_by_key(|(_, m)| m.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH));++ let (tx, _) = broadcast::channel(64);+ let mut entries = Vec::new();+ let mut next_id = 1u64;++ for (path, meta) in &files {+ let bytes_raw = fs::read(path).unwrap_or_default();+ let (w, h) = read_png_dimensions(&bytes_raw);+ let file_bytes = meta.len();+ let filename = path+ .file_name()+ .unwrap_or_default()+ .to_string_lossy()+ .to_string();++ let modified: DateTime<Utc> = meta+ .modified()+ .unwrap_or(std::time::SystemTime::UNIX_EPOCH)+ .into();++ // Read sidecar metadata if it exists+ let meta_path = path.with_extension("json");+ let (description, source, timestamp, orig_w, orig_h, cap_meta, popups) = if let Ok(json_str) = fs::read_to_string(&meta_path) {+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_str) {+ let desc = v["description"].as_str().unwrap_or("").to_string();+ let src = v["source"].as_str().unwrap_or("recovered").to_string();+ let ts = v["timestamp"].as_str()+ .and_then(|s| s.parse::<DateTime<Utc>>().ok())+ .unwrap_or(modified);+ let ow = v["orig_w"].as_u64().map(|n| n as u32);+ let oh = v["orig_h"].as_u64().map(|n| n as u32);+ let cm = if v.get("meta").map(|m| !m.is_null()).unwrap_or(false) { Some(v["meta"].clone()) } else { None };+ let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None };+ (desc, src, ts, ow, oh, cm, pp)+ } else {+ (String::new(), "recovered".into(), modified, None, None, None, None)+ }+ } else {+ (String::new(), "recovered".into(), modified, None, None, None, None)+ };++ let png_meta = read_png_meta(&bytes_raw);+ entries.push(Entry {+ id: next_id,+ timestamp,+ filename: filename.clone(),+ description,+ w,+ h,+ orig_w,+ orig_h,+ bytes: file_bytes,+ kb: file_bytes / 1024,+ source,+ path: path.to_string_lossy().to_string(),+ png_meta,+ meta: cap_meta,+ popups,+ });+ next_id += 1;+ }++ // Keep only the last MAX_ENTRIES+ if entries.len() > MAX_ENTRIES {+ entries = entries.split_off(entries.len() - MAX_ENTRIES);+ }++ let mut channels = self.channels.write().await;+ channels.insert(+ channel_name,+ Channel {+ entries,+ next_id,+ tx,+ },+ );+ }+ }++ /// Add a new screenshot entry. Saves to disk and broadcasts via SSE.+ #[allow(clippy::too_many_arguments)]+ pub async fn add_entry(+ &self,+ channel_name: &str,+ image_bytes: Vec<u8>,+ filename: String,+ description: String,+ source: String,+ w: u32,+ h: u32,+ orig_w: Option<u32>,+ orig_h: Option<u32>,+ meta: Option<serde_json::Value>,+ popups: Option<serde_json::Value>,+ ) -> Entry {+ let channel_dir = self.base_dir.join(channel_name);+ fs::create_dir_all(&channel_dir).ok();++ let file_path = channel_dir.join(&filename);+ fs::write(&file_path, &image_bytes).ok();++ let file_bytes = image_bytes.len() as u64;+ let png_meta = read_png_meta(&image_bytes);++ // Save metadata sidecar JSON+ let meta_path = file_path.with_extension("json");+ let mut meta_json = serde_json::json!({+ "description": description,+ "source": source,+ "w": w, "h": h,+ "bytes": file_bytes,+ "timestamp": Utc::now().to_rfc3339(),+ });+ if let (Some(ow), Some(oh)) = (orig_w, orig_h) {+ meta_json["orig_w"] = serde_json::json!(ow);+ meta_json["orig_h"] = serde_json::json!(oh);+ }+ if let Some(m) = &meta { meta_json["meta"] = m.clone(); }+ if let Some(p) = &popups { meta_json["popups"] = p.clone(); }+ fs::write(&meta_path, meta_json.to_string()).ok();++ let mut channels = self.channels.write().await;+ let ch = channels+ .entry(channel_name.to_string())+ .or_insert_with(|| {+ let (tx, _) = broadcast::channel(64);+ Channel {+ entries: Vec::new(),+ next_id: 1,+ tx,+ }+ });++ let entry = Entry {+ id: ch.next_id,+ timestamp: Utc::now(),+ filename,+ description,+ w,+ h,+ orig_w,+ orig_h,+ bytes: file_bytes,+ kb: file_bytes / 1024,+ source,+ path: file_path.to_string_lossy().to_string(),+ png_meta,+ meta,+ popups,+ };++ ch.next_id += 1;+ ch.entries.push(entry.clone());+ if ch.entries.len() > MAX_ENTRIES {+ ch.entries.remove(0);+ }++ // Broadcast to SSE listeners (ignore errors — no receivers is fine)+ let _ = ch.tx.send(entry.clone());++ entry+ }++ /// Get all entries for a channel.+ pub async fn get_entries(&self, channel_name: &str) -> Vec<Entry> {+ let channels = self.channels.read().await;+ channels+ .get(channel_name)+ .map(|ch| ch.entries.clone())+ .unwrap_or_default()+ }++ /// Number of live viewers (WebSocket subscribers) for a channel.+ pub async fn viewer_count(&self, channel_name: &str) -> usize {+ let channels = self.channels.read().await;+ channels.get(channel_name).map(|ch| ch.tx.receiver_count()).unwrap_or(0)+ }++ /// Get a broadcast receiver for a channel's SSE stream.+ pub async fn subscribe(&self, channel_name: &str) -> broadcast::Receiver<Entry> {+ let mut channels = self.channels.write().await;+ let ch = channels+ .entry(channel_name.to_string())+ .or_insert_with(|| {+ let (tx, _) = broadcast::channel(64);+ Channel {+ entries: Vec::new(),+ next_id: 1,+ tx,+ }+ });+ ch.tx.subscribe()+ }++ /// Update the description of an existing entry and broadcast the update.+ pub async fn update_description(&self, channel_name: &str, entry_id: u64, description: String) {+ let mut channels = self.channels.write().await;+ if let Some(ch) = channels.get_mut(channel_name) {+ if let Some(entry) = ch.entries.iter_mut().find(|e| e.id == entry_id) {+ entry.description = description.clone();++ // Update the sidecar JSON on disk+ let meta_path = std::path::Path::new(&entry.path).with_extension("json");+ if let Ok(json_str) = fs::read_to_string(&meta_path) {+ if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&json_str) {+ v["description"] = serde_json::json!(description);+ v["described_by"] = serde_json::json!("claude -p \"...\" --model haiku --image <path>");+ v["described_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());+ fs::write(&meta_path, v.to_string()).ok();+ }+ }++ let _ = ch.tx.send(entry.clone());+ }+ }+ }++ /// Delete a single entry by filename.+ pub async fn delete_entry(&self, channel_name: &str, filename: &str) {+ let mut channels = self.channels.write().await;+ if let Some(ch) = channels.get_mut(channel_name) {+ ch.entries.retain(|e| e.filename != filename);+ }+ }++ /// Clear in-memory entries for a channel (log only, files stay on disk).+ pub async fn clear_log(&self, channel_name: &str) -> usize {+ let mut channels = self.channels.write().await;+ if let Some(ch) = channels.get_mut(channel_name) {+ let count = ch.entries.len();+ ch.entries.clear();+ count+ } else {+ 0+ }+ }++ /// Clear entries AND delete all files for a channel.+ pub async fn clear_all(&self, channel_name: &str) -> usize {+ let count = self.clear_log(channel_name).await;+ let channel_dir = self.base_dir.join(channel_name);+ if channel_dir.is_dir() {+ if let Ok(entries) = fs::read_dir(&channel_dir) {+ for entry in entries.flatten() {+ let p = entry.path();+ if p.extension().and_then(|e| e.to_str()) == Some("png") {+ fs::remove_file(&p).ok();+ }+ }+ }+ }+ count+ }++ /// List all channel names.+ pub async fn channel_names(&self) -> Vec<String> {+ let channels = self.channels.read().await;+ channels.keys().cloned().collect()+ }++ /// Get the base directory path.+ pub fn base_dir(&self) -> &Path {+ &self.base_dir+ }+}