iris: screenshots for agents — manifest, cargo-building sidecar, session wrapper, affordance and skill
Designed 2026-08-20 in conversation; PLUGIN.md is the decision record. Nothing here has run yet: no live install, no build, no capture. The wrapper's two launch branches and the cookie-seeding are written against documented behaviour, not observed behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
# Iris — the decision record
|
||||||
|
|
||||||
|
Written 2026-08-20, the day the plugin was designed in conversation and first laid down. The decisions
|
||||||
|
below are the conversation's residue; where one reverses later, record it here rather than deleting it.
|
||||||
|
|
||||||
|
## Not a fork
|
||||||
|
|
||||||
|
Upstream (`iris-screenshot` on crates.io, github.com/brijr/iris) is used as-is. The obvious reason to
|
||||||
|
fork was authentication — iris has no `--cookie`/`--header` flags — and the decision was that flags are
|
||||||
|
the wrong mechanism anyway: credentials on command lines end up in transcripts and process lists. The
|
||||||
|
session lives in a browser profile the *user* signs into through the desktop plugin, and the capture
|
||||||
|
borrows a copy. The human authenticates; the agent only ever captures.
|
||||||
|
|
||||||
|
## Build with cargo, in the sidecar
|
||||||
|
|
||||||
|
The owner's call: cargo is a declared dependency, installed before iris, and iris compiles from source
|
||||||
|
on the machine. No prebuilt releases to host, no artefacts to cross-compile, and the plugin has no
|
||||||
|
binary of its own to distribute.
|
||||||
|
|
||||||
|
The platform's installer deliberately has no build step (`osDependencies` installs packages and fetches
|
||||||
|
release binaries, nothing more), so the build lives in the one process the platform starts for a plugin:
|
||||||
|
the sidecar. First start compiles; later starts probe and skip. `--locked` honours upstream's lockfile —
|
||||||
|
the platform's frozen-lockfile principle, one language over.
|
||||||
|
|
||||||
|
Known consequence, accepted: **distro cargo is usually too old.** Upstream declares `rust-version =
|
||||||
|
1.88`; Ubuntu's apt cargo is ~1.75. The manifest still declares distro packages (right on Arch/Fedora,
|
||||||
|
and the honest default), and the sidecar checks the version *before* building and prints the rustup
|
||||||
|
one-liner when it refuses. Auto-running rustup's curl-pipe-sh was considered and rejected — that is a
|
||||||
|
decision the owner makes at a terminal, not one a sidecar makes silently.
|
||||||
|
|
||||||
|
## No browser in `osDependencies`
|
||||||
|
|
||||||
|
Three reasons. Ubuntu's apt `chromium` is a snap stub, so a truthful package map does not exist across
|
||||||
|
distros. The user prefers Brave, which is Chromium underneath — same CDP, same profile format — so the
|
||||||
|
right declaration is "any of a family", which `osDependencies` cannot express. And the browser this
|
||||||
|
plugin *wants* is the desktop plugin's, because a browser nobody can see is a browser nobody can sign
|
||||||
|
in to. `bin/iris-browser` resolves brave → chromium → chrome at run time and fails with a sentence when
|
||||||
|
none exists.
|
||||||
|
|
||||||
|
## The profile contract
|
||||||
|
|
||||||
|
`~/.config/officer/browser-profile` is the persistent, per-user profile. The desktop plugin's browser
|
||||||
|
should launch with `--user-data-dir` pointing there and `--password-store=basic` (portable cookie
|
||||||
|
encryption — keyring-backed cookies do not survive copying). Captures never open that profile directly:
|
||||||
|
the wrapper copies `Local State`, `Default/Cookies` and `Default/Login Data` into a throwaway profile,
|
||||||
|
which avoids Chrome's profile lock entirely and makes the persistent profile read-only to captures.
|
||||||
|
|
||||||
|
This contract predates the desktop plugin. When that plugin lands, this path is what it must honour.
|
||||||
|
|
||||||
|
## Open items, in order of bite
|
||||||
|
|
||||||
|
1. **Nothing has run.** No live install, no capture, no build. The whole verification table is owed.
|
||||||
|
2. **How chromiumoxide launches** decides which wrapper branch runs. If it passes its own
|
||||||
|
`--user-data-dir`, the wrapper seeds cookies into that directory; if not, the wrapper appends its
|
||||||
|
own ephemeral one. Both are written; neither is proven. Also unproven: that a seeded cookie copy is
|
||||||
|
accepted (Chrome ties cookie encryption to `Local State`, which is copied for exactly that reason).
|
||||||
|
3. **Members.** The wrapper isolates per-user by `$HOME`, but the binary lands in the *owner's*
|
||||||
|
`~/.local/bin`, and a confined member's agent cannot read it there. If members should capture,
|
||||||
|
the binary needs a shared home. Deferred until the desktop plugin defines the member story.
|
||||||
|
4. **Cookie copy under a live browser.** Copying a SQLite file mid-write can catch a torn state; in
|
||||||
|
practice the cookie DB is WAL and short-lived reads are fine, but "in practice" is not verified.
|
||||||
|
5. **`permissions: []`** is honest today (nothing platform-gated to protect) but means the owner cannot
|
||||||
|
dock-gate or role-gate the plugin's existence. Revisit if an api/ or web/ arrives.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Iris
|
||||||
|
|
||||||
|
Website screenshots for your agents. Install this and every agent you talk to can *see* rendered web
|
||||||
|
pages — any site's, and your own Officer's — instead of inferring them from source code.
|
||||||
|
|
||||||
|
Built around [iris](https://github.com/brijr/iris), a Rust CLI that renders pages in a real
|
||||||
|
Chrome-family browser (Brave, Chromium and Chrome all work) and captures them as images: full pages,
|
||||||
|
single elements, phone and tablet viewports, light and dark.
|
||||||
|
|
||||||
|
## What installing gets you
|
||||||
|
|
||||||
|
- The `iris` CLI, compiled from source on first start and put on the standard path.
|
||||||
|
- `iris-browser`, a launcher that lets captures reuse the web sessions you are signed into.
|
||||||
|
- An affordance and a skill, so agents know the tool exists and how to drive it — you never have to
|
||||||
|
explain it to them.
|
||||||
|
|
||||||
|
There is no screen and no dock tile: the consumer is the agent's shell, not you.
|
||||||
|
|
||||||
|
## Authenticated captures
|
||||||
|
|
||||||
|
Sessions are yours to grant, by signing in — never by handing credentials to an agent. The desktop
|
||||||
|
plugin's browser keeps a persistent profile at `~/.config/officer/browser-profile`; sign in to a site
|
||||||
|
there once, and captures ride a read-only copy of those cookies from then on. Each account's sessions
|
||||||
|
live in that account's own home, so nobody captures with anyone else's logins.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Rust 1.88 or newer.** The install brings in your distribution's `cargo`; if that is older than 1.88
|
||||||
|
(Ubuntu's currently is), the sidecar refuses with the exact rustup one-liner to run — check
|
||||||
|
`pm2 logs officer-iris`.
|
||||||
|
- **A Chrome-family browser.** Not installed by this plugin: the desktop plugin is the intended source,
|
||||||
|
since its browser is also where you sign in. Any Brave/Chromium/Chrome already on the machine works
|
||||||
|
for unauthenticated captures.
|
||||||
|
|
||||||
|
## State of this plugin, plainly
|
||||||
|
|
||||||
|
- The build-and-supervise sidecar, the wrapper, the affordance and the skill are written but **not yet
|
||||||
|
verified on a live install** — this plugin has not been through the install table on a real machine.
|
||||||
|
- The wrapper's session-seeding (copying cookies into the capture profile) is a design that needs live
|
||||||
|
verification against how iris launches its browser; if iris supplies its own profile directory the
|
||||||
|
wrapper seeds that instead of overriding it, but neither branch has run yet.
|
||||||
|
- The desktop plugin this integrates with is not published yet; until it is, the persistent profile is
|
||||||
|
whatever you put at `~/.config/officer/browser-profile` yourself.
|
||||||
|
- No screen, no API, no database — deliberate for now, not missing.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: Iris
|
||||||
|
description: Use when you need to SEE a rendered web page — a screenshot of any URL, including this platform's own UI.
|
||||||
|
version: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
`iris` screenshots live websites: `iris <url> -o /tmp/page.png`, then Read the file to look at it. It
|
||||||
|
renders with a real browser, so JavaScript apps come out as pixels, not empty HTML.
|
||||||
|
|
||||||
|
For a page behind a login, add `--chrome ~/.local/bin/iris-browser` — a wrapper that reuses the sessions
|
||||||
|
the user signed into through the desktop's browser. Never ask the user for credentials: if a site needs a
|
||||||
|
login, ask them to sign in once via the desktop app, then capture.
|
||||||
|
|
||||||
|
If `iris` is not on PATH the sidecar is still building it — `pm2 logs officer-iris` says where it is at.
|
||||||
|
The full flag reference is the `iris-screenshots` skill.
|
||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# iris-browser — the Chrome-family launcher iris is pointed at (`iris --chrome ~/.local/bin/iris-browser`).
|
||||||
|
#
|
||||||
|
# Two jobs, both invisible to iris:
|
||||||
|
#
|
||||||
|
# 1. Pick the browser. Brave first — it is Chromium underneath, so CDP and the profile format are the
|
||||||
|
# same — then Chromium, then Chrome. Iris only knows it asked for "a chrome".
|
||||||
|
#
|
||||||
|
# 2. Carry the user's sessions into the capture, read-only. The persistent profile is the one the
|
||||||
|
# desktop's browser signs into (~/.config/officer/browser-profile); captures ride COPIES of its
|
||||||
|
# cookies in a throwaway profile, so a capture can never corrupt or lock the profile that holds the
|
||||||
|
# sessions, and the desktop browser being open never collides with a capture.
|
||||||
|
#
|
||||||
|
# Per-user isolation is $HOME: whoever runs iris gets their own profile, their own sessions, and nothing
|
||||||
|
# of anyone else's. That is the kernel's answer, not this script's.
|
||||||
|
#
|
||||||
|
# The launcher (chromiumoxide) may pass its own --user-data-dir. If it does, we seed the sessions INTO
|
||||||
|
# that directory and change nothing else — overriding it could break how the launcher finds the DevTools
|
||||||
|
# socket. If it does not, we provide an ephemeral directory ourselves and clean it up.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PROFILE="${OFFICER_BROWSER_PROFILE:-$HOME/.config/officer/browser-profile}"
|
||||||
|
|
||||||
|
BROWSER=""
|
||||||
|
for candidate in brave-browser brave chromium chromium-browser google-chrome google-chrome-stable; do
|
||||||
|
if BROWSER="$(command -v "$candidate" 2>/dev/null)" && [ -n "$BROWSER" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$BROWSER" ]; then
|
||||||
|
echo "iris-browser: no Chrome-family browser found (looked for brave, chromium, chrome)" >&2
|
||||||
|
echo "iris-browser: install the desktop plugin, or any of the above" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cookies plus the key that decrypts them. Login Data rides along so "remembered" form logins survive.
|
||||||
|
# Everything else in a profile is cache and preference noise a capture does not need.
|
||||||
|
seed_sessions() {
|
||||||
|
dest="$1"
|
||||||
|
[ -d "$PROFILE/Default" ] || return 0
|
||||||
|
mkdir -p "$dest/Default"
|
||||||
|
cp "$PROFILE/Local State" "$dest/Local State" 2>/dev/null || true
|
||||||
|
cp "$PROFILE/Default/Cookies" "$dest/Default/Cookies" 2>/dev/null || true
|
||||||
|
cp "$PROFILE/Default/Login Data" "$dest/Default/Login Data" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Did the launcher already choose a profile directory?
|
||||||
|
CALLER_DIR=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--user-data-dir=*) CALLER_DIR="${arg#--user-data-dir=}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$CALLER_DIR" ]; then
|
||||||
|
seed_sessions "$CALLER_DIR"
|
||||||
|
exec "$BROWSER" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
EPHEMERAL="$(mktemp -d "${TMPDIR:-/tmp}/iris-profile.XXXXXX")"
|
||||||
|
trap 'rm -rf "$EPHEMERAL"' EXIT INT TERM
|
||||||
|
seed_sessions "$EPHEMERAL"
|
||||||
|
|
||||||
|
# --password-store=basic keeps cookie encryption portable between the profile and the copy — without it a
|
||||||
|
# keyring-backed browser writes cookies the copy cannot decrypt. Appended AFTER the launcher's args, and
|
||||||
|
# so is our --user-data-dir, because for a repeated switch Chrome honours the last occurrence.
|
||||||
|
"$BROWSER" "$@" --user-data-dir="$EPHEMERAL" --password-store=basic
|
||||||
|
exit $?
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iris — screenshots of live websites, so agents can SEE rendered pages instead of inferring them.
|
||||||
|
*
|
||||||
|
* ── What this plugin actually ships ──
|
||||||
|
*
|
||||||
|
* A CLI (`iris`, github.com/brijr/iris) and the knowledge of how to use it. There is no api/, no web/, no
|
||||||
|
* db/: the consumer is an agent's shell, not a screen. The plugin's value is delivered through three
|
||||||
|
* channels the platform already carries across an install:
|
||||||
|
*
|
||||||
|
* sidecar/ builds the binary once with cargo, installs the browser wrapper, then supervises
|
||||||
|
* affordances/ the short always-loaded note that tells every agent the tool exists
|
||||||
|
* skills/ the full flag reference, loaded on demand
|
||||||
|
*
|
||||||
|
* ── Why the sidecar builds instead of `release` fetching ──
|
||||||
|
*
|
||||||
|
* Upstream is a Rust crate. The install pipeline can install packages and fetch release binaries, but it
|
||||||
|
* deliberately has no build step — so the one-time `cargo install iris-screenshot` belongs to this
|
||||||
|
* plugin's own process, which PM2 starts at the end of the install. First start compiles (minutes);
|
||||||
|
* every later start finds the binary and skips. `pm2 logs officer-iris` is where the build narrates.
|
||||||
|
*
|
||||||
|
* ── Why no browser dependency is declared ──
|
||||||
|
*
|
||||||
|
* Iris renders through a Chrome-family browser, and Brave, Chromium and Chrome are all fine — the wrapper
|
||||||
|
* (`bin/iris-browser`) resolves whichever is present. Declaring one here would be wrong twice over: the
|
||||||
|
* package story differs per distro (Ubuntu's apt chromium is a snap stub), and the browser this plugin
|
||||||
|
* WANTS is the desktop plugin's, because that is where a person signs in to the sites they ask about.
|
||||||
|
* The contract with the desktop plugin is the persistent profile at ~/.config/officer/browser-profile —
|
||||||
|
* see README.md.
|
||||||
|
*/
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '0.1.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Iris',
|
||||||
|
summary: 'Website screenshots for agents — eyes on any rendered page, your own Officer included',
|
||||||
|
icon: 'Camera',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
|
||||||
|
// Empty, and honestly so: the surface is a binary agents run in their own shell, which the platform's
|
||||||
|
// permission gate cannot stand in front of. Whose sessions a capture rides is decided by whose Linux
|
||||||
|
// user runs it — the wrapper reads $HOME, so the kernel answers, not a grant. An api/ or web/ arriving
|
||||||
|
// later brings its key with it.
|
||||||
|
permissions: [],
|
||||||
|
|
||||||
|
osDependencies: [
|
||||||
|
{
|
||||||
|
binary: 'cargo',
|
||||||
|
reason:
|
||||||
|
'Builds the iris screenshot binary from source, once, on first start. Nothing works without the build. ' +
|
||||||
|
'Note: iris needs Rust 1.88+; if your distribution ships older, the sidecar log says exactly what to do.',
|
||||||
|
packages: { apt: 'cargo', dnf: 'cargo', pacman: 'rust', brew: 'rust' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'cc',
|
||||||
|
reason: 'Links the iris binary during that one-time build.',
|
||||||
|
// No brew entry: macOS has cc once the Xcode command-line tools exist, which Homebrew itself requires.
|
||||||
|
packages: { apt: 'build-essential', pacman: 'base-devel', dnf: 'gcc' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { chmodSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// The iris sidecar: a builder wearing a supervisor's clothes.
|
||||||
|
//
|
||||||
|
// The install pipeline installs packages and fetches release binaries, but deliberately runs no build
|
||||||
|
// step — so compiling a Rust crate is this plugin's own job, and the sidecar is the only process the
|
||||||
|
// platform starts for a plugin. First start pays for the build (minutes — watch `pm2 logs officer-iris`);
|
||||||
|
// every later start finds the binary and settles into a heartbeat.
|
||||||
|
//
|
||||||
|
// Everything lands where the platform already looks: `~/.local/bin` is first on the probe path in
|
||||||
|
// os-deps.ts, so `iris` and `iris-browser` are found by whoever asks, without anyone's PATH changing.
|
||||||
|
|
||||||
|
const name = 'officer-iris';
|
||||||
|
const CRATE = 'iris-screenshot';
|
||||||
|
// Upstream's declared rust-version. Distro cargo lags real Rust by a year or more, so this is checked
|
||||||
|
// BEFORE building — a clear refusal with instructions beats cargo's own MSRV error buried in build spam.
|
||||||
|
const MSRV: readonly [number, number] = [1, 88];
|
||||||
|
|
||||||
|
const home = homedir();
|
||||||
|
const localBin = join(home, '.local', 'bin');
|
||||||
|
|
||||||
|
// `Bun.which` alone reads the PATH this process started with, which under PM2 is minimal. Look where
|
||||||
|
// things actually are: our install target first, rustup's home second, then whatever PATH says.
|
||||||
|
const searchPath = (): string => `${localBin}:${join(home, '.cargo', 'bin')}:${process.env.PATH ?? ''}`;
|
||||||
|
const which = (binary: string): string | null => Bun.which(binary, { PATH: searchPath() });
|
||||||
|
|
||||||
|
const log = (message: string): void => console.log(`[${name}] ${message}`);
|
||||||
|
|
||||||
|
/** The wrapper ships in this repository; agents need it on a path they can name. Overwrite = upgrade. */
|
||||||
|
function installWrapper(): void {
|
||||||
|
const source = join(import.meta.dir, '..', 'bin', 'iris-browser');
|
||||||
|
const target = join(localBin, 'iris-browser');
|
||||||
|
mkdirSync(localBin, { recursive: true });
|
||||||
|
copyFileSync(source, target);
|
||||||
|
chmodSync(target, 0o755);
|
||||||
|
log(`iris-browser installed to ${target}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `cargo 1.75.0 (…)` → [1, 75], or null when it will not even say. */
|
||||||
|
function cargoVersion(cargo: string): [number, number] | null {
|
||||||
|
try {
|
||||||
|
const out = Bun.spawnSync([cargo, '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
const match = new TextDecoder().decode(out.stdout).match(/cargo (\d+)\.(\d+)/);
|
||||||
|
return match ? [Number(match[1]), Number(match[2])] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meetsMsrv = ([major, minor]: [number, number]): boolean =>
|
||||||
|
major > MSRV[0] || (major === MSRV[0] && minor >= MSRV[1]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build iris into ~/.local/bin. Streams cargo's output straight to PM2's log, because a silent
|
||||||
|
* multi-minute compile is indistinguishable from a hang.
|
||||||
|
*
|
||||||
|
* `--locked` builds against upstream's committed lockfile rather than re-resolving dependencies at
|
||||||
|
* install time — the platform's frozen-lockfile rule, honoured one language over.
|
||||||
|
*/
|
||||||
|
async function build(cargo: string): Promise<boolean> {
|
||||||
|
log(`building ${CRATE} — first start only, takes a few minutes`);
|
||||||
|
const proc = Bun.spawn([cargo, 'install', CRATE, '--locked', '--root', join(home, '.local')], {
|
||||||
|
stdout: 'inherit',
|
||||||
|
stderr: 'inherit',
|
||||||
|
});
|
||||||
|
const code = await proc.exited;
|
||||||
|
if (code !== 0) {
|
||||||
|
log(`build failed (cargo exited ${code}) — read the output above; restart this process to retry`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the binary is usable. Says why not, with the fix, when it is not. */
|
||||||
|
async function ensureIris(): Promise<boolean> {
|
||||||
|
const present = which('iris');
|
||||||
|
if (present) {
|
||||||
|
log(`iris ready at ${present}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cargo = which('cargo');
|
||||||
|
if (!cargo) {
|
||||||
|
log('cargo is not on PATH — the install should have provided it; install rust and restart this process');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = cargoVersion(cargo);
|
||||||
|
if (!version || !meetsMsrv(version)) {
|
||||||
|
log(`${cargo} is ${version ? `cargo ${version.join('.')}` : 'unidentifiable'}, and ${CRATE} needs ${MSRV.join('.')}+`);
|
||||||
|
log('your distribution\'s rust is too old. Install rustup as the user officer runs as:');
|
||||||
|
log(" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal");
|
||||||
|
log('then restart this process: pm2 restart officer-iris');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await build(cargo))) return false;
|
||||||
|
|
||||||
|
const built = which('iris');
|
||||||
|
if (!built) {
|
||||||
|
log('cargo reported success but iris is not in ~/.local/bin — read the build output above');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log(`iris built and ready at ${built}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`started (pid ${process.pid})`);
|
||||||
|
installWrapper();
|
||||||
|
let ready = await ensureIris();
|
||||||
|
|
||||||
|
// The heartbeat re-probes rather than repeating a remembered answer: a binary deleted out from under us
|
||||||
|
// shows up here, and a rustup installed after a refusal turns the next restart into a build — which the
|
||||||
|
// log has already told the owner. Building inside the interval is deliberately avoided: a periodic
|
||||||
|
// compile that nobody asked for is not a heartbeat.
|
||||||
|
const beat = setInterval(() => {
|
||||||
|
const present = which('iris');
|
||||||
|
if (present && !ready) {
|
||||||
|
ready = true;
|
||||||
|
log(`iris appeared at ${present}`);
|
||||||
|
} else if (!present && ready) {
|
||||||
|
ready = false;
|
||||||
|
log('iris is GONE from PATH — restart this process to rebuild');
|
||||||
|
} else {
|
||||||
|
log(`alive — iris ${present ? 'ready' : 'missing'}`);
|
||||||
|
}
|
||||||
|
}, 300_000);
|
||||||
|
|
||||||
|
const shutdown = (signal: string): void => {
|
||||||
|
log(`${signal} — exiting`);
|
||||||
|
clearInterval(beat);
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: Iris screenshots
|
||||||
|
description: Use when capturing website screenshots with the iris CLI — flags, authenticated captures, viewports, element capture, batching, and what to do when a capture comes out wrong.
|
||||||
|
version: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
`iris` (github.com/brijr/iris) renders a URL in a real Chrome-family browser over the DevTools protocol
|
||||||
|
and writes an image. It waits for fonts, images and animations before capturing, so the output is what a
|
||||||
|
person would see, not a half-loaded page.
|
||||||
|
|
||||||
|
## The basic loop
|
||||||
|
|
||||||
|
```
|
||||||
|
iris https://example.com -o /tmp/shot.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Then **Read the file** — that is the whole point. Write captures somewhere disposable (`/tmp`) unless the
|
||||||
|
user asks to keep them.
|
||||||
|
|
||||||
|
## Authenticated captures
|
||||||
|
|
||||||
|
```
|
||||||
|
iris https://site.example/private -o /tmp/private.png --chrome ~/.local/bin/iris-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
`iris-browser` picks whichever Chrome-family browser is installed (Brave first, then Chromium, then
|
||||||
|
Chrome) and seeds the capture with a copy of the cookies from `~/.config/officer/browser-profile` — the
|
||||||
|
profile the desktop plugin's browser signs into. The copy is read-only with respect to that profile:
|
||||||
|
captures cannot corrupt or lock it.
|
||||||
|
|
||||||
|
Three rules:
|
||||||
|
|
||||||
|
- **Never ask the user for credentials.** If a capture shows a login page, ask them to sign in to that
|
||||||
|
site once through the desktop app, then capture again.
|
||||||
|
- **Whose sessions ride along is decided by `$HOME`** — each Linux user has their own profile, so you can
|
||||||
|
only ever capture with the sessions of the account you run as.
|
||||||
|
- **Officer's own UI is behind its login too**, so capturing it needs the same wrapper and a signed-in
|
||||||
|
desktop session, exactly like any other site.
|
||||||
|
|
||||||
|
## Flags that matter
|
||||||
|
|
||||||
|
| Flag | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `-o, --out <path>` | output file (or directory for batches) |
|
||||||
|
| `-s, --size <preset>` | `desktop`, `iphone`, `ipad`, or `WxH` |
|
||||||
|
| `--full` | full page height, not just the viewport |
|
||||||
|
| `--selector <css>` | capture the first matching element only |
|
||||||
|
| `--padding <px>` | breathing room around a selected element |
|
||||||
|
| `--dark` | emulate `prefers-color-scheme: dark` |
|
||||||
|
| `--format <fmt>` | `png`, `jpg`, `webp` |
|
||||||
|
| `--wait <ms>` | extra settle time before capture |
|
||||||
|
| `--wait-for <css>` | wait until a selector exists |
|
||||||
|
| `--scale <n>` | device pixel ratio |
|
||||||
|
| `--timeout <secs>` | per-page limit |
|
||||||
|
| `--jobs <n>` | concurrency for multiple URLs |
|
||||||
|
| `--chrome <path>` | which browser binary — for sessions, always `~/.local/bin/iris-browser` |
|
||||||
|
| `--json` | structured result on stdout |
|
||||||
|
|
||||||
|
Verify a UI change in both themes by capturing twice, once with `--dark`. Verify responsive work with
|
||||||
|
`-s iphone` beside the desktop shot.
|
||||||
|
|
||||||
|
## When it goes wrong
|
||||||
|
|
||||||
|
- **`iris: command not found`** — the sidecar builds it on first start; `pm2 logs officer-iris` shows
|
||||||
|
progress or says exactly what is missing (usually Rust older than 1.88, with the fix printed).
|
||||||
|
- **Capture shows a login page** — the site has no session in the profile. Ask the user to sign in via
|
||||||
|
the desktop app; do not work around it.
|
||||||
|
- **`iris-browser: no Chrome-family browser found`** — the machine has no browser; the desktop plugin
|
||||||
|
provides one.
|
||||||
|
- **Blank or half-rendered output** — add `--wait-for` on a selector that only exists when the page is
|
||||||
|
really ready, or raise `--wait`.
|
||||||
Reference in New Issue
Block a user