Found in a wider bug audit of the streaming/process-management code.
- Viewer ctrl-c/SIGINT was ignored mid-stream: viewer::run raced the
cancel token only against listener.accept(), not the bridge itself, so
once the local player connected nothing checked it. CLI needed a second
ctrl-c to quit and a GUI "Disconnect" only took effect via the child's 2s
SIGKILL backstop (and the host saw the viewer ~2s longer). Now races the
bridge against cancel, mirroring the host's handle_peer. (viewer/mod.rs)
- Wayland portal pipewire fd leaked on a capture-setup error: wayland::start
into_raw_fd'd the fd and relied on pipeline::spawn's after_spawn hook to
close it, but setup_audio/gst-spawn can ?-return before the hook runs,
leaking the fd per failed attempt. Now the OwnedFd is moved into the hook,
so it's closed whether the hook runs or (on early error) the unused closure
is dropped. (host/wayland.rs)
- Detached players (mpv/vlc) zombied under the long-lived GUI: spawn_detached
dropped the std Child, which has no orphan reaping, so each closed player
left a <defunct> entry until the GUI exited. Now a detached thread wait()s
it; the setsid'd player still survives a parent exit (init reaps it then).
A double-fork was avoided deliberately — fork(2) + non-trivial work in this
multithreaded process is unsound. (common/process.rs)
47 gui / 8 headless tests pass, clippy + fmt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found in a bug audit of the just-merged friends-list feature. No crashes
or security holes, but five real state/correctness bugs:
- Host child dying on its own left the share campaign running, so it kept
pushing a now-dead ticket to friends (retrying offline ones forever) and
leaked share_status/met/share_code. The unexpected-exit path now captures
the stderr error, then routes through the full stop_host() teardown
(notably stop_share). (gui/mod.rs pump_host_events)
- on_friend_request downgraded an already-Accepted friend back to
PendingIncoming when they re-sent a request (e.g. after losing their
store). It now stays Accepted and re-confirms. (friends.rs)
- on_friend_accept advanced *any* known peer to Accepted, including a
PendingIncoming one — a peer could mark itself accepted without the local
user's consent. Now only a PendingOutgoing request we sent is honoured.
(friends.rs)
- A ShareCode redelivered by an ACK-loss retry fired a duplicate desktop
notification. push_notice now reports whether the code is new/changed and
only then toasts. (gui/mod.rs)
- An inbound control message could be delayed up to IO_TIMEOUT on a degraded
link because handle() awaited the sender's close before forwarding it.
Forward to the UI first, then await close so the ACK still flushes.
(control.rs)
Adds two friends-store transition tests (accept ignores a pending-incoming
peer; request doesn't downgrade an accepted friend). 47 gui / 8 headless
tests pass, clippy + fmt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
run_share tried offline peers one at a time, so a single unreachable
friend's ~10s control-plane connect timeout serialised the whole round
(N offline peers → up to N×10s per round). Spawn each round's sends into
a JoinSet and collect as they finish: a round now takes ~one timeout
regardless of how many friends are offline. Delivery receipts are still
emitted one-per-peer as each ACK lands; the code is shared across tasks
via an Arc instead of re-cloning the payload per peer per round.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host's "auto-share my code with" picker was session-scoped — an
in-memory exclusion set that reset to share-with-all on every launch.
Move the preference onto the friend itself: a `share: bool` on `Friend`
in friends.toml, `#[serde(default = true)]` so new friends are included
and an older file without the field loads as share-with-all. The picker
now toggles the stored flag and persists immediately (like the other
settings), and `selected_share_targets` filters on it. This drops the
parallel `share_excluded` state and is self-cleaning: removing a friend
takes their preference with them, no stale ids linger.
`upsert`'s update path leaves `share` untouched, so a name/presence
refresh can't reset the user's choice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The payoff phase: on Start-Hosting, auto-push the wrapped share code to the
selected accepted friends, and surface codes friends push us in a bell.
Sending (host side):
- A share-target picker on the host form lists accepted friends as checkboxes;
selection is stored as the *exclusion* set so the default ships to everyone
and a friend added mid-session is included automatically.
- When the child reports its ticket, the wrapped code is pushed to the selected
friends, gated by FriendStore::is_accepted.
- Delivery is online-now + retry-while-hosting: the presence service runs an
abortable share campaign that retries offline friends every 5s until they're
reached or hosting stops. The control-plane ACK is the delivered/failed
signal; each success emits a ShareDelivered receipt.
- The running host screen shows a live "delivered ✓ / offline, retrying" row
per targeted friend.
Receiving (viewer side):
- The previously-stubbed ShareCode handler now honours codes from accepted
friends only, records a notice (deduped per friend), and fires a desktop
notification.
- A top-right bell with a white-on-red badge counts pending notices; its panel
lets you Watch a code (opens the viewer with it prefilled) or dismiss it.
Presence service refactor: the fire-and-forget Outbound becomes a Command enum
(Send / StartShare / StopShare) and the UI now drains a PresenceEvent enum
(Message / ShareDelivered) over one unified async→sync bridge.
Tests: +2 for the share-target selection rule (43 gui pass). clippy + fmt clean
on both feature sets; smoke-launch shows the control endpoint online, no panic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build the friends feature on top of the phase-2 control plane: you can
now befriend someone you've connected with and manage a contacts list.
- common/friends.rs: a persisted FriendStore in its own friends.toml
(kept out of config.toml so a headless --reconfigure can't clobber it,
same as identity.key). Friends are keyed by stable control EndpointId;
state is PendingOutgoing / PendingIncoming / Accepted. The handshake
transitions (on_friend_request → mutual-match detection, on_friend_
accept) are pure and unit-tested.
- gui/code.rs: the bootstrap. The GUI host wraps its share code as
`pixelpassF1:<control-id>.<ticket>` so a viewer learns the host's
stable id; unwrap is lenient, so a bare/CLI ticket still works (no
friend offer). The video/streaming path is untouched.
- presence service gains an outbound path (unbounded channel → per-msg
send tasks) and exposes our control id for wrapping codes.
- gui wiring: on connect, the viewer announces itself to the host with a
Hello (carrying our display name); the host replies once, so both ends
learn each other and an "Add friend" offer appears on the running
host/view screens. Incoming requests/accepts/declines fold into the
store with desktop notifications. New Friends screen (accept/decline/
remove, edit your display name, see your id) reachable from the menu,
which shows a pending-request count. New [gui] display_name setting,
seeded from $USER.
Verified: friends store + handshake transitions covered by unit tests
(7); code wrap/unwrap round-trips (4); the control loopback still passes;
the live GUI starts clean with the presence endpoint online. fmt +
clippy clean on both features; 41 gui + 8 headless tests pass. The full
two-party UX (connect → mutual add → persisted) wants a cross-machine
manual check, as usual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stand up the friends control plane: a persistent-identity iroh endpoint
that's online for the whole GUI session, separate from the ephemeral
video sessions, ready to carry friend requests and pushed share-codes.
Identity split by plane (common/endpoint.rs): the video plane (host/
viewer) goes back to ephemeral per-session keypairs, while the new
bind_control() binds with the machine's persistent identity. They must
differ — the GUI's control endpoint and a host's video endpoint can be
live at once, and iroh routes by EndpointId, so a shared id would make
relay delivery ambiguous. Bonus: a screen-share now leaks no stable id.
common/control.rs — the protocol: a ControlMsg enum (Hello / Friend
Request / FriendAccept / FriendDecline / ShareCode) with one-message-
per-connection framing (EOF-delimited JSON) and a one-byte ACK the
receiver returns only after a successful parse, so send() gets a real
delivered/failed signal (the basis for the later code-push queue). The
sender id is taken from the connection's verified remote key, never the
payload. send() takes impl Into<EndpointAddr> so production dials a bare
EndpointId (discovery resolves it) while tests use a full addr.
gui/presence.rs — the service: a dedicated thread + current-thread tokio
runtime (mirroring the tray) binds the control endpoint and runs the
accept loop, bridging inbound messages to a std mpsc the UI drains each
tick and pinging the Waker so they land even while hidden to the tray.
The whole friends stack (identity, control, CONTROL_ALPN, bind_control)
is gated behind the `gui` feature — a headless CLI host runs no presence
service — keeping the headless build lean and warning-free.
Verified: loopback test delivers a FriendRequest across two real iroh
endpoints with the correct authenticated sender id; the live GUI binds
its control endpoint on launch under the persistent identity. fmt +
clippy clean on both feature sets; headless and gui test suites pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A newer rustfmt wraps over-long match arms and call expressions that the
version main was last formatted with left on one line. Pure formatting,
no semantic change — split out so the friends-list feature commits stay
focused on real changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iroh otherwise mints a fresh keypair every run, so a peer's EndpointId
changed on each launch. The friends system (in progress) identifies
people by that id — the public key already embedded in every share
code — so it has to stay stable across launches and across roles.
Add common::identity: load-or-create an ed25519 secret key stored as
hex in a 0600 ~/.config/pixelpass/identity.key, separate from
config.toml so a config reset or hand-edit can't clobber it. A
malformed file is a hard error rather than a silent regenerate, since
quietly minting a new identity would orphan every existing friend.
endpoint::bind() now feeds this key to the builder, so host and viewer
share one stable EndpointId. Verified end-to-end: two launches against
the same config dir emit byte-identical tickets; a fresh dir differs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Window-focused shortcuts via a handle_keys() dispatch: H/V/S on the menu,
Space/Enter to start hosting and C to copy the code on the Host screen, F1
to open the new Shortcuts screen, and Esc to back out (existing). Letter/Space
actions only fire when no widget holds focus, so they don't clash with typing
or egui's Space/Enter widget activation; popups keep their own key handling.
New Screen::Shortcuts lists every binding behind a menu button. Also enforce
that leaving Settings closes the theme editor, so a draft preview can't leak.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressing Esc on the Host/View screens stops the session and returns to the
menu (mirroring the '← Menu' button); on Settings it returns to the menu, or
closes the theme editor back to the picker if one is open. Suppressed while a
popup (colour picker, dropdown) is open so Esc just dismisses the popup there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The editor was one flat 13-row colour list. Split it into Surfaces / Text &
accent / Buttons / Status colours, each a bold subheading over its own grid,
via a new color_section helper. Pure layout — no behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two theme fields had no visible effect:
- window_bg mapped only to egui's window_fill, but the app draws on the bare
background layer with no panel, so that's never painted — the real backdrop
was a hardcoded GL clear colour. Paint a themed background rect (window_bg)
behind everything in draw() instead.
- weak_text was dead: egui's weak_text_color() derives from the text colour
unless Visuals::weak_text_color is set, which it wasn't. Set it.
Audited the rest (panel/input bg, text, accent, button, hover, and the five
status colours) — those already resolve to the right egui fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Settings screen grew past a short window once the Appearance section
landed, forcing a manual resize to reach the Save button. Wrap the body in a
vertical ScrollArea (header stays pinned), mirroring the Host screen. Also
add a '↺ Defaults' button to the right of Save in the theme editor that
resets the draft to the original Default Dark palette (previews live).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Load the saved theme at startup and apply it to egui's visuals (cloning the
global style so the font scaling is preserved); the egui context persists
across the hide/show window cycle, so it sticks. Route the previously
hardcoded status colours (streaming/waiting/success/warning/error) through
the active theme so a theme re-skins the whole app, not just the chrome (the
QR code stays black-on-white so it remains scannable). Settings gains an
Appearance section: a picker that switches themes live and persists the
choice, and an editor with a colour button per palette field, a live
preview, and Save (writes a .toml). The picker refreshes from disk when
Settings opens, so dropped-in files appear without a restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New gui::theme module: a Theme is a curated semantic palette (backgrounds,
text, accent, button, and the status colours) that serialises to TOML with
#rrggbb hex colours and builds an egui::Visuals. Missing fields fall back to
the built-in Default Dark via #[serde(default)], so partial/hand-trimmed
files still load. Three built-ins ship (Default Dark, Catppuccin Mocha,
Catppuccin Latte); user themes live as *.toml in ~/.config/pixelpass/themes/
and a user file overrides a built-in of the same name. Adds a `theme` field
to the GUI config (default "Default Dark"). Zero new deps (toml + a few
lines of hex parsing). 6 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Relay section covering the flag and env-var forms, precedence,
the GUI-child forwarding, and the same-relay-on-both-ends requirement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gui::run() dropped the parsed --relay value, so a relay chosen on the
GUI command line (pixelpass --gui --relay URL) never reached the
headless host/viewer children -- only the PIXELPASS_RELAY env-var form
propagated (via inheritance). Thread the flag into the app and append
--relay <url> to both child arg vectors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
detect_distro only matched arch/cachyos/manjaro/endeavouros, so on
Artix (Friend 2's box) and Garuda the missing-dependency hints fell
through to the generic message instead of a pacman command. Both are
pacman-based with identical package names, so add them to every
Arch-family match arm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build-appimage.sh now reads the binary from CARGO_TARGET_DIR when set, so a
broad-compat build inside an old-glibc distrobox can use an isolated target
dir without clobbering the host's. README documents the Ubuntu 24.04
distrobox recipe and why older bases don't work (the pipewire crate needs
PW >= ~1.0 headers; and a PipeWire/portal app can't run on ancient distros
anyway). Resulting baseline: glibc 2.39 (the only 2.39 symbols are weak
pidfd refs from Rust std; everything else is <= 2.35).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build-appimage.sh produces pixelpass-<version>-x86_64.AppImage via
linuxdeploy. PixelPass links almost nothing (only libpipewire, which is
excludelisted) and shells out to gst-launch-1.0/pactl/a player on the host
PATH, while its GUI graphics libs are dlopen'd and also excludelisted — so
the AppImage bundles just the binary, AppRun, desktop entry, and icon
(~13 MB, zero bundled libs). The no-sandbox model lets the bundled binary
spawn the host's tools, which is why AppImage fits this orchestrator better
than Flatpak.
AppRun opens --gui when launched with no args and no controlling terminal
(file manager / .desktop), and passes through otherwise so the CLI,
interactive menu, and viewer all work. README documents the host-deps
contract + the glibc-baseline and VAAPI caveats.
Verified: builds to 13 MB; --version/--help work; the GUI launches cleanly
on a live Wayland session via both --gui and the no-tty AppRun path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both host and viewer hardcoded presets::N0, pinning every session to the
bundled relays (which on iroh rc.0 are the canary-grade defaults). Add a
shared common::endpoint::bind() that keeps N0's DNS discovery + crypto but
swaps in a RelayMode::Custom single-relay map when --relay (or the
PIXELPASS_RELAY env var, so GUI children inherit it) is set.
Lets users point at a self-hosted relay or staging today; the production
relays (*.relay.iroh.network) speak a newer protocol that rc.0 rejects
("invalid iroh-relay version header"), so they only become usable — and
the default — after an iroh GA bump. Verified: override connects cleanly
through staging; bad URLs are rejected before any network work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommended_max_viewers() promises "at least 1", but a NaN safe_mbps cast
to 0 and an infinite one to u32::MAX. Guard non-finite / non-positive
inputs up front. Add unit tests covering the normal path, the floor, and
the NaN/Inf/negative degenerate cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
install_ctrl_c() used `if ctrl_c().await.is_ok()`, so if the handler
failed to install, ctrl-c silently stopped working with no diagnostic.
Match on the Result and log a warning (then bail the task — the second
arm would only fail the same way).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
shutdown() and Drop had byte-identical bodies that had to be kept in
sync. Extract a private cleanup(&mut self); shutdown() consumes self and
calls it, Drop calls it as the backstop. Every step is a take(), so the
second run is a no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `registered` flag was one-way: set true once the tray registered, but
never cleared if the StatusNotifierWatcher later disappeared (panel restart,
tray plugin disabled, tray app killed). After that, a Wayland close-to-tray
would destroy the window into a tray that no longer exists, with no recovery
short of SIGTERM.
Implement ksni's `watcher_online`/`watcher_offline` callbacks to keep the
shared flag in sync. On offline, also force the window back (idempotent
`show()`) so a window that was already hidden when the watcher died isn't
stranded, and return true to keep the service alive for re-registration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
open_bi/bind/local_addr/accept all `?`-propagated straight out of run(),
skipping the endpoint.close().await at the end and leaking the iroh
Endpoint on any post-connect error. Wrap the post-connect body in one
block whose result is captured, then close unconditionally — matching
the explicit-close idiom of the connect-phase select arms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously a single EMFILE / EINTR on listener.accept() returned from
run_accept_loop entirely, killing the host's HTTP viewer fanout for the
rest of the session. Most accept errors are transient — log and loop.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a `show_qr` preference (default on) to GuiSettings, with a
checkbox in Settings and a corresponding gate on the host-screen render.
Persists to config.toml alongside the existing close-to-tray setting.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The QR panel pushed the Stop hosting button below the fold at the old
520x480 default. Wraps host_running/host_form in a vertical ScrollArea
(header stays pinned) and bumps the initial height to 640 so the common
case fits without scrolling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Encodes the relay-only ticket as a QR with a 4-module quiet zone so a
phone (or a second laptop with a webcam) can pick the room up without
typing 140+ characters. Built lazily on the first draw after a Ticket
event, NEAREST-filtered, 200x200 logical; cleared on session start and
stop.
Pulls `qrcode` 0.14 with `default-features = false` so the heavy `image`
crate tree is skipped — we render modules straight to an
`egui::ColorImage` ourselves.
Reapplies the idea from Gemini's stale `feat/gemini-branch-qrcode`
(`7f07583`) against the post-hand-rolled-loop GUI; the original commit
no longer cherry-picks because gui/mod.rs was rewritten for the
true-Wayland-window-hide work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Required by the OFL for redistribution. Installs alongside the
existing MIT and Apache-2.0 license files in the Arch package.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace eframe::run_native with a winit ApplicationHandler + glutin +
egui_glow loop so "keep running in the tray" can genuinely hide the
window. winit's set_visible(false) is a deliberate no-op on Wayland
(xdg-shell has no unmap-but-keep-alive request), so the only way to hide
a toplevel is to destroy its surface: hide-to-tray now drops the Window +
GL surface (parking the GL context as not-current) and a tray click
recreates them and makes the context current again. The GL context,
glutin display/config, egui_glow painter (uploaded textures), and
egui-winit state (clipboard) all persist across the cycle — only the OS
window and its surface churn.
Wakeups route through winit's EventLoopProxy (the new Waker, and the
tray) instead of egui's repaint callback, so a child event or tray click
wakes the loop even while the window is dropped and no frame is running —
keeping viewer join/leave notifications and the tray tooltip live while
hidden. Removes the old Wayland minimize-to-tray fallback (window stayed
in the taskbar); hide is now uniform on Wayland and X11.
Deps: winit/glutin/glutin-winit/egui_glow promoted to direct (gui-gated,
optional) — all already transitive via eframe, so no new crates. winit's
default features minus wayland-csd-adwaita, so sctk-adwaita/tiny-skia/
ttf-parser aren't pulled for a CSD fallback titlebar (KWin draws
server-side decorations, and eframe never had CSD either).
Verified end-to-end on KWin Wayland: launch->render; close->window AND
taskbar entry gone (true hide, process stays alive); tray activate->
window + GL surface recreated and renders; tray quit->clean exit; stderr
clean throughout. cargo test --features gui: 15 pass; clippy clean;
headless dependency tree unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a StatusNotifierItem tray (ksni — pure-Rust over the zbus stack
notify-rust already pulls; only new crate is the pastey macro helper).
The icon reflects host/viewer status via its tooltip and offers
Show / Quit; it runs on its own thread, channel-wired to the egui app.
Add a Settings screen with a persisted toggle 'keep running in the tray
when I close the window' (config.toml [gui] close_to_tray), defaulting
OFF so the close button quits as users expect. When ON, closing hides
to the tray on X11 / minimizes on Wayland (which has no protocol to hide
a toplevel) and keeps any live stream running. If no tray is present the
close behaves normally, so the window can never be stranded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Set the Wayland app_id to `pixelpass` so the compositor matches the
installed pixelpass.desktop and uses its Icon= in the titlebar/taskbar,
replacing the generic fallback. Also embed a 256px PNG (rendered from
assets/pixelpass.svg) and set it via with_icon for X11 _NET_WM_ICON, and
add StartupWMClass=pixelpass to the desktop entry for robust window↔entry
matching across desktop environments. No new deps — eframe already pulls
the image crate, and icon_data::from_png_bytes decodes the embed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add LICENSE-MIT and LICENSE-APACHE (the dual license already declared in
Cargo.toml, previously absent) and install both into the package. Retarget
the PKGBUILD git source to main now that the packaging branch has merged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Builds pixelpass 0.1.0 with --features gui from the local repo and
installs the binary, .desktop launcher, scalable icon, and README.
Runtime deps mapped from src/common/deps.rs (GStreamer pipeline +
pactl); viewers and alternate encoders are optdepends.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Scalable SVG app icon (pixel-stream motif, indigo->violet ground) plus
a freedesktop .desktop launcher for the --gui front-end, groundwork for
the first Arch package.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`cargo clippy --fix`: drop needless borrows in interactive.rs, remove an
unneeded `return`, and derive `Default` for `HostState` / the config struct
instead of hand-writing it. No behaviour change.
README: the GUI host screen now lists connected viewers with a Kick button
and notifies on join/leave — update the description, which still mentioned
only a "live viewer count".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closing the window while a host/viewer child was running took two clicks:
the first only dropped the stream, the second actually closed the window.
eframe drops the app synchronously while it destroys the window, which ran
`ChildProc`'s teardown — SIGINT plus a up-to-2s grace-period wait — on the
event-loop thread. That wait froze the window mid-close, so the first click
looked like it only killed the stream and the window lingered until a second
close event. (The teardown runs from `Drop`, not from an `on_exit` /
`close_requested` hook, so it fires on every backend and close path; those
hooks don't fire at all under some winit backends.)
Make the teardown non-blocking: hold the child in an `Option`, and on drop
SIGINT it synchronously (so the host still runs its ctrl-c teardown even if
we exit immediately after) then reap it on a detached thread instead of
waiting inline. The app drops instantly, so the window closes on the first
click; the kicked-off SIGINT still tears the stream down cleanly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The host screen pops a desktop notification on each viewer join/leave,
so you know someone connected while the window is in the background.
Fired on a detached thread (the D-Bus call never touches the egui
frame) and gated on the same viewer-list transitions, so stopping the
host — which drops the child and stops pumping events — doesn't spray a
notification per remaining viewer.
notify-rust's default features give the pure-Rust zbus backend, so this
adds no system libdbus dependency and no GTK event loop (gui feature
only; the headless build is untouched).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Track viewers by endpoint id instead of a bare count. The JSON event
stream gains viewer_joined / viewer_left (each carrying the id),
replacing viewer_count; active/max still ride along so the count
display is unchanged.
The host screen now renders one row per connected viewer with a Kick
button. Clicking it sends `kick <id>` to the headless child over a new
stdin command channel, which the host turns into a per-viewer
CancellationToken cancel; the existing teardown path then emits the
leave, so a kick and a self-disconnect look identical downstream.
The stdin channel only runs under --output json (the GUI shell-out) and
on a detached OS thread, so a read parked on stdin can't hold up the
host's Ctrl+C shutdown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Entering View now grabs keyboard focus on the code field (once, via a
one-shot flag so it doesn't steal focus every frame), so the user can paste
or type the share code immediately without clicking into it first.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the pasted code doesn't decode, Connect is greyed out; hovering it now
shows "Paste a valid share code first." so the disabled state is
self-explanatory, complementing the amber "doesn't look like a share code"
line under the field.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Paste button + code field were wrapped in `with_layout(right_to_left)`,
which grabs the parent's entire remaining height and vertically centers the
row in it — gutting the View screen (field dropped to the middle, button
pinned far right). Use a plain `ui.horizontal` row with the button first and
the field filling the rest via INFINITY width. Same one-click-paste behavior,
correct single-row layout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quick viewer-flow polish:
- a "📋 Paste" button pinned to the right of the code field — the read-side
mirror of the host's Copy button;
- Enter in the code field connects (same decode gate as the button);
- the View screen prefills the field from the clipboard on open when it holds
a decodable ticket and the field is empty, so the freshly-shared code is
usually already there (live decode still shows the id to verify);
- the menu shows the binary version under the heading.
Tightens the common "host clicks Copy → viewer clicks Paste → Connect" loop;
the prefill only ever drops in a *valid* ticket, so it can't reintroduce the
stale/garbage paste it guards against.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The viewer screen now parses the pasted code client-side with the same
`EndpointTicket::from_str` the headless viewer uses, and surfaces what it
finds:
- live preview under the paste box: green "→ endpoint <id>…" for a valid
ticket, amber "doesn't look like a share code" otherwise;
- Connect is gated on a ticket that actually decodes (was: any non-empty
text), so a garbage paste can't burn the 15s connect timeout;
- the connecting line reads "● Connecting to <id>…" instead of a bare
"Connecting…";
- the host screen shows its own "endpoint <id>…" with the same truncation,
so the two ends are eyeball-comparable.
This closes the loop on the stale-ticket trap: a dead/wrong code is now
obvious the moment it's pasted, not 15s later. 5 unit tests cover the
decode (real round-trip ticket) and short-id truncation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>