Author SHA1 Message Date
molluskandClaude Opus 4.7 511927569b feat(gui): hand-rolled winit loop for true window-hide on Wayland
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>
2026-05-26 15:41:38 -04:00
molluskandClaude Opus 4.7 b260d57dc4 feat(gui): system tray with opt-in close-to-tray setting
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>
2026-05-26 05:54:06 -04:00
molluskandClaude Opus 4.7 ad70ce5ea9 fix(gui): give the window a real icon instead of the Wayland fallback
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>
2026-05-26 05:24:25 -04:00
molluskandClaude Opus 4.7 6c275faf28 feat(packaging): add MIT/Apache-2.0 license files
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>
2026-05-26 04:55:18 -04:00
molluskandClaude Opus 4.7 2edd7f0fa8 chore(packaging): gitignore makepkg build artifacts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 04:47:53 -04:00
molluskandClaude Opus 4.7 f4a4dd37c9 feat(packaging): add Arch PKGBUILD (local versioned build)
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>
2026-05-26 04:43:27 -04:00
molluskandClaude Opus 4.7 56d0d6c2e2 feat(packaging): add app icon and desktop entry
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>
2026-05-26 04:38:49 -04:00
molluskandClaude Opus 4.7 675f25f266 chore: clear clippy warnings and refresh the GUI README
`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>
2026-05-25 16:42:35 -04:00
molluskandClaude Opus 4.7 e54d625f2a fix(gui): close the window on the first click while hosting
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>
2026-05-25 16:34:23 -04:00
molluskandClaude Opus 4.7 f926dbea4e feat(gui): desktop notification when a viewer joins or leaves
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>
2026-05-25 15:31:48 -04:00
molluskandClaude Opus 4.7 24e0d0e799 feat(gui): list connected viewers and let the host kick them
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>
2026-05-25 15:27:49 -04:00
17 changed files with 1869 additions and 167 deletions
Generated
+331 -43
View File
@@ -242,7 +242,7 @@ dependencies = [
"serde_repr",
"tokio",
"url",
"zbus",
"zbus 4.4.0",
]
[[package]]
@@ -269,6 +269,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -711,7 +725,7 @@ dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -1487,6 +1501,7 @@ checksum = "6caa4eca47cc2358e2c5ae60843a94118e338f87099c6af4170e6e968e8d77cb"
dependencies = [
"bytemuck",
"egui",
"egui-winit",
"glow",
"log",
"memoffset",
@@ -1893,8 +1908,8 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
"windows-link 0.2.1",
"windows-result 0.4.1",
]
[[package]]
@@ -1914,7 +1929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.4",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -2385,7 +2400,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
"windows-core 0.62.2",
]
[[package]]
@@ -2582,7 +2597,7 @@ dependencies = [
"socket2",
"widestring",
"windows-registry",
"windows-result",
"windows-result 0.4.1",
"windows-sys 0.61.2",
]
@@ -2816,7 +2831,7 @@ dependencies = [
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -2888,6 +2903,19 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "ksni"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7ca513d0be42df5edb485af9f44a12b2cb85af773d91c27dc796d1c58b78edc"
dependencies = [
"futures-util",
"pastey",
"serde",
"tokio",
"zbus 5.15.0",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -2925,7 +2953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -3053,6 +3081,18 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
dependencies = [
"cc",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"time",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -3363,8 +3403,8 @@ dependencies = [
"tokio-util",
"tracing",
"web-sys",
"windows",
"windows-result",
"windows 0.62.2",
"windows-result 0.4.1",
"wmi",
]
@@ -3480,6 +3520,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "notify-rust"
version = "4.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus 5.15.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3972,7 +4026,7 @@ dependencies = [
"libc",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -3981,6 +4035,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
@@ -4107,9 +4167,14 @@ dependencies = [
"dialoguer",
"directories",
"eframe",
"egui_glow",
"glutin",
"glutin-winit",
"iroh",
"iroh-tickets",
"ksni",
"nix 0.30.1",
"notify-rust",
"pipewire",
"serde",
"serde_json",
@@ -4121,6 +4186,7 @@ dependencies = [
"tracing-subscriber",
"ureq",
"uuid",
"winit",
"x11rb",
]
@@ -4154,7 +4220,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64",
"indexmap",
"quick-xml",
"quick-xml 0.39.4",
"serde",
"time",
]
@@ -4360,6 +4426,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -5263,6 +5338,18 @@ version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5793,6 +5880,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
]
@@ -6141,7 +6229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.39.4",
"quote",
]
@@ -6367,16 +6455,38 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.61.2",
]
[[package]]
@@ -6385,7 +6495,20 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
@@ -6396,9 +6519,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading 0.1.0",
]
[[package]]
@@ -6407,9 +6541,9 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -6434,20 +6568,36 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
@@ -6456,9 +6606,18 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
@@ -6467,7 +6626,16 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
@@ -6476,7 +6644,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -6512,7 +6680,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -6546,13 +6714,31 @@ dependencies = [
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
@@ -6828,8 +7014,8 @@ dependencies = [
"log",
"serde",
"thiserror 2.0.18",
"windows",
"windows-core",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
@@ -6990,9 +7176,45 @@ dependencies = [
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix 1.1.4",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow",
"zbus_macros 5.15.0",
"zbus_names 4.3.2",
"zvariant 5.11.0",
]
[[package]]
@@ -7005,7 +7227,22 @@ dependencies = [
"proc-macro2",
"quote",
"syn",
"zvariant_utils",
"zvariant_utils 2.1.0",
]
[[package]]
name = "zbus_macros"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
"zbus_names 4.3.2",
"zvariant 5.11.0",
"zvariant_utils 3.3.1",
]
[[package]]
@@ -7016,7 +7253,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant",
"zvariant 4.2.0",
]
[[package]]
name = "zbus_names"
version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
"winnow",
"zvariant 5.11.0",
]
[[package]]
@@ -7145,7 +7393,21 @@ dependencies = [
"serde",
"static_assertions",
"url",
"zvariant_derive",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow",
"zvariant_derive 5.11.0",
"zvariant_utils 3.3.1",
]
[[package]]
@@ -7158,7 +7420,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn",
"zvariant_utils",
"zvariant_utils 2.1.0",
]
[[package]]
name = "zvariant_derive"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
"zvariant_utils 3.3.1",
]
[[package]]
@@ -7171,3 +7446,16 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zvariant_utils"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn",
"winnow",
]
+22 -1
View File
@@ -34,6 +34,27 @@ ureq = { version = "3", default-features = false, features = ["rustls"] }
toml = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
eframe = { version = "0.34.2", default-features = false, features = ["glow", "default_fonts", "wayland", "x11"], optional = true }
# Desktop notifications on viewer join/leave. Default features give the
# pure-Rust zbus backend (no system libdbus, no image crate).
notify-rust = { version = "4", optional = true }
# System-tray icon (StatusNotifierItem over D-Bus). Pure-Rust, riding the same
# zbus stack notify-rust already pulls — no GTK, no libappindicator/C libdbus.
ksni = { version = "0.3", optional = true }
# Hand-rolled windowing stack for the GUI (replaces eframe::run_native) so we
# can drop the OS window on "hide to tray" — the only way to truly hide a
# toplevel on Wayland — and recreate it on Show. All of these are already pulled
# in transitively by eframe; making them direct adds no new crates to vet.
# eframe is kept for its egui re-export + icon_data PNG decoder. egui_glow needs
# its (non-default) `winit` feature for the `EguiGlow` integration type; eframe
# pulls egui_glow but without that feature, so we enable it here.
egui_glow = { version = "0.34.2", default-features = false, features = ["winit", "wayland", "x11"], optional = true }
# winit's default set minus `wayland-csd-adwaita`: KWin (and most desktop
# compositors) draw server-side decorations, and eframe never enabled CSD
# either, so dropping it keeps the dependency tree identical to before (no
# sctk-adwaita / tiny-skia / ttf-parser pulled in just for a fallback titlebar).
winit = { version = "0.30", default-features = false, features = ["rwh_06", "x11", "wayland", "wayland-dlopen"], optional = true }
glutin = { version = "0.32", optional = true }
glutin-winit = { version = "0.5", optional = true }
[profile.release]
lto = "thin"
@@ -43,4 +64,4 @@ strip = "symbols"
[features]
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
# build never pulls the GUI toolkit tree.
gui = ["dep:eframe"]
gui = ["dep:eframe", "dep:notify-rust", "dep:ksni", "dep:egui_glow", "dep:winit", "dep:glutin", "dep:glutin-winit"]
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 mollusk
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 mollusk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+9 -2
View File
@@ -83,8 +83,15 @@ pixelpass --gui
```
Host: pick quality / max-viewers / options, click **Start hosting**, and the
share code appears with a copy button alongside a live viewer count. View:
paste a code, pick mpv or VLC, click **Connect** and the player launches.
share code appears with a copy button. Connected viewers are listed with a
**Kick** button each, and a desktop notification fires as they join or leave.
View: paste a code, pick mpv or VLC, click **Connect** and the player launches.
A system-tray icon shows current status. **Settings → "Keep running in the
tray when I close the window"** (off by default) makes the close button hide
the window — truly, by dropping it — while any active stream keeps running in
the child; reopen it from the tray. (Plain close still quits when the option is
off, or when no system tray is present.)
The window is a thin driver — it runs the same headless `pixelpass` as a
child process and reads its event stream, so the GUI is purely additive and
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=pixelpass
GenericName=Screen Sharing
Comment=P2P screen sharing over iroh — no port forwarding, no signup
Exec=pixelpass --gui
Icon=pixelpass
StartupWMClass=pixelpass
Terminal=false
Categories=Network;RemoteAccess;
Keywords=screen;share;sharing;remote;p2p;iroh;cast;
StartupNotify=true
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<title>pixelpass</title>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4338ca"/>
<stop offset="1" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
<!-- pixel stream: squares fading cyan -> white, "passed" toward the arrow -->
<rect x="50.25" y="181.29" width="16" height="16" rx="3.2" fill="#2dd5ef"/>
<rect x="67" y="124.44" width="20" height="20" rx="4" fill="#62dff2"/>
<rect x="96.25" y="82.09" width="24" height="24" rx="4.8" fill="#98e8f6"/>
<rect x="134.04" y="55.77" width="28" height="28" rx="5.6" fill="#c9f1f9"/>
<path d="M 225.5 57.5 L 183.1 85.8 L 176.9 42.2 Z" fill="#f8fafc"/>
</svg>

After

Width:  |  Height:  |  Size: 875 B

+6
View File
@@ -0,0 +1,6 @@
# makepkg build artifacts
src/
pkg/
/pixelpass/
*.pkg.tar.*
*.log
+63
View File
@@ -0,0 +1,63 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Local versioned package, built from the local git repo on `main`.
# For a tagged release, switch the source fragment to `#tag=v0.1.0`.
pkgname=pixelpass
pkgver=0.1.0
pkgrel=1
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
arch=('x86_64')
url='file:///home/mollusk/git/butter/pixelpass'
license=('MIT' 'Apache-2.0')
depends=(
'gstreamer' # gst-launch-1.0 / gst-inspect-1.0
'gst-plugins-base' # videoscale (quality-preset downscale)
'gst-plugins-good' # ximagesrc (X11 capture) + pulsesrc
'gst-plugins-bad' # h264parse, mpegtsmux, aacparse
'gst-libav' # avenc_aac (audio encode)
'gst-plugin-va' # vah264enc (default hardware H.264 encoder)
'libpulse' # pactl (audio routing / device control)
'hicolor-icon-theme' # owns the scalable icon dir
'libglvnd' # libGL for the egui (glow) GUI
'libxkbcommon' # GUI keyboard handling (winit)
'wayland' # GUI Wayland backend libs
)
optdepends=(
'mpv: recommended stream viewer (the GUI launches mpv)'
'vlc: alternative stream viewer'
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
'gst-plugin-pipewire: screen capture on Wayland sessions'
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)'
)
makedepends=('cargo' 'git')
options=('!lto')
_branch='main'
source=("$pkgname::git+file:///home/mollusk/git/butter/pixelpass#branch=$_branch")
sha256sums=('SKIP')
prepare() {
cd "$srcdir/$pkgname"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "$srcdir/$pkgname"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
# --features gui so the .desktop launcher (pixelpass --gui) works.
cargo build --frozen --release --features gui
}
package() {
cd "$srcdir/$pkgname"
install -Dm0755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
install -Dm0644 assets/pixelpass.desktop \
"$pkgdir/usr/share/applications/$pkgname.desktop"
install -Dm0644 assets/pixelpass.svg \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/$pkgname.svg"
install -Dm0644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/$pkgname/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/$pkgname/LICENSE-APACHE"
}
+16 -8
View File
@@ -1,8 +1,7 @@
//! Persistent user-level config at `~/.config/pixelpass/config.toml`.
//!
//! Right now this only tracks the bandwidth pre-flight result. Future
//! preferences (default player, default bitrate, etc.) can hang off the
//! same file under their own `[section]`.
//! It tracks the bandwidth pre-flight result and the GUI's preferences.
//! Further settings can hang off the same file under their own `[section]`.
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
@@ -16,6 +15,18 @@ use std::path::PathBuf;
pub struct Config {
#[serde(default)]
pub bandwidth: BandwidthEntry,
#[serde(default)]
pub gui: GuiSettings,
}
/// Preferences for the `pixelpass --gui` front-end.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GuiSettings {
/// When true, the window's close button hides the app to the system tray
/// (keeping any live stream running) instead of quitting. Defaults to
/// false — closing quits, which is what people expect.
#[serde(default)]
pub close_to_tray: bool,
}
/// Result of the first-run upstream measurement.
@@ -37,18 +48,15 @@ pub struct BandwidthEntry {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum BandwidthStatus {
#[default]
Unmeasured,
Measured,
Skipped,
Failed,
}
impl Default for BandwidthStatus {
fn default() -> Self {
Self::Unmeasured
}
}
fn default_status() -> BandwidthStatus {
BandwidthStatus::Unmeasured
+11 -3
View File
@@ -22,7 +22,11 @@ pub fn set_json(enabled: bool) {
JSON_ENABLED.store(enabled, Ordering::Relaxed);
}
fn json_enabled() -> bool {
/// Whether the JSON event stream is on — i.e. we're being driven by a
/// machine front-end (the `--gui` shell-out) rather than a human terminal.
/// Gates features that only make sense under that front-end, like the
/// stdin command channel the host reads `kick` requests from.
pub fn json_enabled() -> bool {
JSON_ENABLED.load(Ordering::Relaxed)
}
@@ -42,8 +46,12 @@ pub enum Event<'a> {
max_viewers: u32,
max_viewers_source: &'a str,
},
/// Active viewer count changed.
ViewerCount { active: u32, max: u32 },
/// A viewer joined. `id` is the viewer's endpoint id; `active` is the new
/// total after the join.
ViewerJoined { id: &'a str, active: u32, max: u32 },
/// A viewer left — disconnected on their own or kicked by the host. `id`
/// is the viewer's endpoint id; `active` is the new total after.
ViewerLeft { id: &'a str, active: u32, max: u32 },
/// Capture pipeline lifecycle (spawned on first viewer, torn down on last).
Capture { state: CaptureState },
/// A viewer was turned away (host full, or capture spawn failed).
+82 -36
View File
@@ -6,17 +6,18 @@
//! egui app drains each frame. stderr is captured into a small ring so a
//! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use eframe::egui;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use serde::Deserialize;
use super::Waker;
/// One parsed event from the child's stdout. Owned mirror of
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so
/// the wire format and the parser can evolve independently.
@@ -35,7 +36,13 @@ pub enum ChildEvent {
max_viewers: u32,
max_viewers_source: String,
},
ViewerCount {
ViewerJoined {
id: String,
active: u32,
max: u32,
},
ViewerLeft {
id: String,
active: u32,
max: u32,
},
@@ -60,23 +67,35 @@ pub enum CaptureState {
const STDERR_TAIL_MAX: usize = 60;
pub struct ChildProc {
child: Child,
/// `Some` while the child is owned here; `Drop` takes it to hand off to a
/// detached reaper thread (see the `Drop` impl).
child: Option<Child>,
pub rx: Receiver<ChildEvent>,
stderr_tail: Arc<Mutex<Vec<String>>>,
/// Write end of the child's stdin, for the line-based command channel
/// (see [`ChildProc::send_command`]). `None` once it's been closed.
stdin: Option<ChildStdin>,
}
impl ChildProc {
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. `ctx`
/// is repainted whenever an event arrives so the UI updates live.
pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result<Self> {
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. The
/// `waker` is pinged whenever an event arrives so the UI thread wakes to
/// drain it — this wakes the winit event loop directly (via an
/// `EventLoopProxy`), so it works even when the window is hidden to the tray
/// and no frames are running (egui's own repaint callback would not fire
/// repeatedly in that idle state — see [`super::Waker`]).
pub fn spawn(args: &[String], waker: Waker) -> std::io::Result<Self> {
let exe = std::env::current_exe()?;
let mut child = Command::new(exe)
.args(args)
.stdin(Stdio::null())
// Piped so we can send line commands (e.g. `kick <id>`); the host
// only reads it when driven this way (`--output json`).
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdin = child.stdin.take();
let (tx, rx) = std::sync::mpsc::channel();
let stdout = child.stdout.take().expect("stdout piped");
std::thread::spawn(move || {
@@ -90,9 +109,14 @@ impl ChildProc {
if tx.send(ev).is_err() {
break; // app gone
}
ctx.request_repaint();
waker.wake();
}
}
// stdout closed → the child has exited (player closed, connection
// ended, or a failed launch). Wake once more so the UI reaps it and
// clears the "running" view, even if no final event was emitted and
// the window is hidden to the tray.
waker.wake();
});
let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new()));
@@ -111,46 +135,64 @@ impl ChildProc {
});
Ok(Self {
child,
child: Some(child),
rx,
stderr_tail,
stdin,
})
}
/// Send one newline-terminated command to the child over its stdin (the
/// host parses these as `kick <endpoint-id>`). Best-effort: a closed pipe
/// (child already gone) just drops the command.
pub fn send_command(&mut self, cmd: &str) {
let Some(stdin) = self.stdin.as_mut() else {
return;
};
if let Err(e) = writeln!(stdin, "{cmd}") {
tracing::warn!("failed to send command to host child: {e}");
self.stdin = None; // pipe is dead; stop trying
}
}
/// Whether the child is still running.
pub fn is_alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
matches!(self.child.as_mut().map(Child::try_wait), Some(Ok(None)))
}
/// The last captured stderr lines, joined — for error display.
pub fn stderr_tail(&self) -> String {
self.stderr_tail.lock().unwrap().join("\n")
}
/// Gracefully stop the child: SIGINT (so the host runs its ctrl-c teardown
/// — tears down capture, closes the endpoint), with a ~2 s grace period
/// before a hard kill. Idempotent.
pub fn stop(&mut self) {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return; // already exited
}
let _ = kill(Pid::from_raw(self.child.id() as i32), Signal::SIGINT);
for _ in 0..40 {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for ChildProc {
fn drop(&mut self) {
// Closing the window (dropping the app, hence the session) must not
// orphan a live host child streaming to viewers.
self.stop();
// Leaving a host/viewer screen, or closing the window, must not orphan
// a live child — but it must also not *block*. eframe runs this drop
// synchronously while it destroys the window, so a grace-period wait
// here freezes the window mid-close: the first click looks like it did
// nothing (the stream just drops) and the window only goes away on a
// second click. So SIGINT now — synchronously, so the host always gets
// its ctrl-c teardown (capture down, endpoint closed) even if we exit
// right after — then reap on a detached thread instead of waiting.
let Some(mut child) = self.child.take() else {
return;
};
if matches!(child.try_wait(), Ok(Some(_))) {
return; // already exited; nothing to signal or reap
}
let _ = kill(Pid::from_raw(child.id() as i32), Signal::SIGINT);
std::thread::spawn(move || {
for _ in 0..40 {
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = child.kill();
let _ = child.wait();
});
}
}
@@ -205,10 +247,14 @@ mod tests {
}
#[test]
fn viewer_count_round_trips() {
fn viewer_join_leave_round_trip() {
assert!(matches!(
parse(Event::ViewerCount { active: 2, max: 4 }),
ChildEvent::ViewerCount { active: 2, max: 4 }
parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }),
ChildEvent::ViewerJoined { id, active: 2, max: 4 } if id == "nodeXYZ"
));
assert!(matches!(
parse(Event::ViewerLeft { id: "nodeXYZ", active: 1, max: 4 }),
ChildEvent::ViewerLeft { id, active: 1, max: 4 } if id == "nodeXYZ"
));
}
+742 -48
View File
@@ -8,30 +8,599 @@
//! child's JSON event stream (see [`crate::common::output`]) to drive what it
//! shows. That keeps the fragile capture stack sealed in a separate process:
//! the GUI can be closed or crash without taking a live stream down.
//!
//! ## Windowing: why we hand-roll the loop instead of using `eframe`
//!
//! The "keep running in the tray" feature needs to truly *hide* the window
//! while a stream keeps running in the child. On Wayland there is no
//! unmap-but-keep-alive request in xdg-shell, so winit's `set_visible(false)`
//! is a deliberate no-op there — the only way to make a toplevel vanish is to
//! **destroy its surface** and recreate it later. `eframe::run_native` owns the
//! one window it will never let you drop, so we replace it with a hand-rolled
//! [`winit::application::ApplicationHandler`] + `glutin` + [`egui_glow`] loop:
//!
//! * **Hide to tray** ([`Gfx::hide`]) parks the GL context as *not-current*
//! and drops the [`winit::window::Window`] + its GL surface — the Wayland
//! surface is genuinely gone, natively, on both Wayland and X11.
//! * **Show** ([`Gfx::show`], on a tray click) recreates the window + surface
//! and makes the parked context current again.
//!
//! The GL context, `glutin` display/config, and the `egui_glow` painter (with
//! its uploaded font/texture atlas) and `egui-winit` state (with its clipboard
//! connection) are **kept** across the cycle — only the OS window and its
//! surface churn. That preserves the hard-won Wayland clipboard integration and
//! avoids re-uploading textures on every show.
//!
//! Wakeups are routed through winit's [`winit::event_loop::EventLoopProxy`]
//! (see [`Waker`] and [`tray`]) rather than egui's repaint callback, because a
//! child event or tray click must wake the loop even while the window is
//! dropped and no egui frame is running.
mod child;
mod tray;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, Instant};
use eframe::egui;
use egui_glow::EguiGlow;
use egui_glow::egui_winit;
use egui_glow::glow::{self, HasContext as _};
use glutin::config::{Config, ConfigTemplateBuilder};
use glutin::context::{
ContextApi, ContextAttributesBuilder, NotCurrentContext, NotCurrentGlContext as _,
PossiblyCurrentContext, PossiblyCurrentGlContext as _,
};
use glutin::display::{Display, GetGlDisplay as _, GlDisplay as _};
use glutin::surface::{
GlSurface as _, Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface,
};
use glutin_winit::{ApiPreference, DisplayBuilder};
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
use winit::raw_window_handle::HasWindowHandle as _;
use winit::window::{Window, WindowAttributes, WindowId};
use self::child::{ChildEvent, ChildProc};
use self::tray::{TrayAction, TrayHandle, TrayStatus};
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the
/// main thread (a winit requirement), which is where `main` calls it from.
/// Initial / minimum window size, in logical points.
const INNER_SIZE: [f64; 2] = [520.0, 480.0];
const MIN_INNER_SIZE: [f64; 2] = [460.0, 380.0];
/// Events delivered to the winit loop from off the UI thread (or from egui).
pub enum UserEvent {
/// Something changed off the UI thread — a headless child emitted a JSON
/// event or exited (see [`Waker`]). Drain it on the next tick, and repaint
/// if a window is currently shown.
Wake,
/// A system-tray icon/menu action (see [`tray`]).
Tray(TrayAction),
}
/// A cheap, cloneable handle that wakes the winit event loop from any thread.
///
/// Used by the headless-child reader threads: pinging this wakes the loop
/// **even when the window has been dropped to the tray** (no egui frame is
/// running then, so egui's own repaint callback would go quiet after the first
/// request — see the egui repaint bookkeeping). That's what keeps join/leave
/// desktop notifications and the tray tooltip live while hidden.
#[derive(Clone)]
pub struct Waker {
proxy: EventLoopProxy<UserEvent>,
}
impl Waker {
/// Wake the loop. A send error only means the loop has already exited, in
/// which case there is nothing left to wake.
pub fn wake(&self) {
let _ = self.proxy.send_event(UserEvent::Wake);
}
}
/// Window attributes used for both the first window and every recreated one, so
/// a window restored from the tray is identical to the original.
fn window_attributes() -> WindowAttributes {
use winit::dpi::LogicalSize;
// Wayland sources the titlebar/taskbar icon from the .desktop file matched
// by this app_id (NOT from `with_window_icon`, which Wayland ignores) — it
// must equal the installed pixelpass.desktop. `with_name(general, instance)`
// sets the Wayland app_id to `general`.
use winit::platform::wayland::WindowAttributesExtWayland as _;
let mut attrs = WindowAttributes::default()
.with_title("PixelPass")
.with_inner_size(LogicalSize::new(INNER_SIZE[0], INNER_SIZE[1]))
.with_min_inner_size(LogicalSize::new(MIN_INNER_SIZE[0], MIN_INNER_SIZE[1]))
// Stay unmapped until the first frame is painted, to avoid a flash of an
// empty window (on Wayland the surface only maps on the first swap
// anyway; this matters mostly for X11).
.with_visible(false)
.with_name("pixelpass", "pixelpass");
// X11 titlebar/taskbar icon (`_NET_WM_ICON`), embedded at compile time so
// the binary stays self-contained. Wayland ignores it (uses app_id above);
// X11 falls back to it. Non-fatal on failure.
match eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")) {
Ok(icon) => match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
Ok(winit_icon) => attrs = attrs.with_window_icon(Some(winit_icon)),
Err(e) => tracing::warn!("could not build X11 window icon: {e}"),
},
Err(e) => tracing::warn!("could not load embedded window icon: {e}"),
}
attrs
}
/// Build a GL surface for `window` using the established display + config.
fn create_surface(
display: &Display,
config: &Config,
window: &Window,
) -> anyhow::Result<Surface<WindowSurface>> {
let (w, h): (u32, u32) = window.inner_size().into();
let attrs = SurfaceAttributesBuilder::<WindowSurface>::new().build(
window.window_handle()?.as_raw(),
NonZeroU32::new(w).unwrap_or(NonZeroU32::MIN),
NonZeroU32::new(h).unwrap_or(NonZeroU32::MIN),
);
// SAFETY: `window` outlives the surface — both live in the same `WinState`
// and are dropped together — so the raw handle stays valid for the
// surface's lifetime.
let surface = unsafe { display.create_window_surface(config, &attrs)? };
Ok(surface)
}
/// GL + egui state that persists for the whole GUI session. Only the OS window
/// and its surface are dropped/recreated on hide/show (see the module docs);
/// everything here outlives the cycle.
struct Gfx {
gl: Arc<glow::Context>,
gl_display: Display,
gl_config: Config,
egui_glow: EguiGlow,
/// egui→window command state (title changes etc.), threaded through
/// [`egui_winit::process_viewport_commands`] each frame.
viewport_info: egui::ViewportInfo,
win: WinState,
}
/// Whether the window is currently mapped. The GL *context* is preserved in
/// both states; `Between` is only a momentary placeholder during a transition.
// Exactly one `WinState` exists (it's a field of the single `Gfx`), never a
// collection of them, so the inter-variant size gap costs ~250 idle bytes once
// — not worth boxing the window/surface that every frame touches.
#[allow(clippy::large_enum_variant)]
enum WinState {
Shown {
window: Window,
surface: Surface<WindowSurface>,
context: PossiblyCurrentContext,
},
Hidden {
context: NotCurrentContext,
},
/// Transient placeholder held only inside [`Gfx::hide`] / [`Gfx::show`].
Between,
}
impl Gfx {
/// Full first-time initialization: pick a GL config, create the window,
/// context, and surface, make the context current, and build the egui glow
/// integration. Runs once, from `resumed`.
fn create(event_loop: &ActiveEventLoop) -> anyhow::Result<Self> {
let template = ConfigTemplateBuilder::new()
.prefer_hardware_accelerated(None)
.with_depth_size(0)
.with_stencil_size(0)
.with_transparency(false);
let (maybe_window, gl_config) = DisplayBuilder::new()
.with_preference(ApiPreference::FallbackEgl)
.with_window_attributes(Some(window_attributes()))
.build(event_loop, template, |mut configs| {
configs.next().expect("no GL config matched")
})
.map_err(|e| anyhow::anyhow!("failed to choose a GL config: {e}"))?;
let gl_display = gl_config.display();
// On EGL/Wayland the window is built during `build`; on GLX it is
// deferred until the visual is known, so finalize it here.
let window = match maybe_window {
Some(w) => w,
None => glutin_winit::finalize_window(event_loop, window_attributes(), &gl_config)?,
};
let raw = window.window_handle()?.as_raw();
let ctx_attrs = ContextAttributesBuilder::new().build(Some(raw));
// Fall back to a GLES context if a core GL context can't be created.
let gles_attrs = ContextAttributesBuilder::new()
.with_context_api(ContextApi::Gles(None))
.build(Some(raw));
// SAFETY: `raw` comes from `window`, which lives at least as long as the
// context (both are owned by this `Gfx`).
let not_current = unsafe {
gl_display
.create_context(&gl_config, &ctx_attrs)
.or_else(|_| gl_display.create_context(&gl_config, &gles_attrs))
}
.map_err(|e| anyhow::anyhow!("failed to create a GL context: {e}"))?;
let surface = create_surface(&gl_display, &gl_config, &window)?;
let context = not_current
.make_current(&surface)
.map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?;
// Vsync, so the frame loop self-throttles and idles cheaply.
let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN));
// SAFETY: the loader is only called while `gl_display` is alive, which
// outlives the returned context.
let gl = Arc::new(unsafe {
glow::Context::from_loader_function(|s| match std::ffi::CString::new(s) {
Ok(name) => gl_display.get_proc_address(name.as_c_str()),
Err(_) => std::ptr::null(),
})
});
let egui_glow = EguiGlow::new(event_loop, gl.clone(), None, None, true);
window.set_visible(true);
window.request_redraw();
Ok(Self {
gl,
gl_display,
gl_config,
egui_glow,
viewport_info: egui::ViewportInfo::default(),
win: WinState::Shown {
window,
surface,
context,
},
})
}
fn shown_window(&self) -> Option<&Window> {
match &self.win {
WinState::Shown { window, .. } => Some(window),
_ => None,
}
}
/// Hide to tray: park the context as not-current and drop the window +
/// surface. The Wayland surface is genuinely destroyed (the only way to
/// hide a toplevel there).
fn hide(&mut self) {
match std::mem::replace(&mut self.win, WinState::Between) {
WinState::Shown {
window,
surface,
context,
} => match context.make_not_current() {
Ok(not_current) => {
// Order matters: the context must be made not-current before
// its surface is dropped.
drop(surface);
drop(window);
self.win = WinState::Hidden {
context: not_current,
};
}
Err(e) => {
tracing::error!("hide-to-tray: make_not_current failed: {e}");
// Leave `Between` (window already gone); a later Show will
// log that there's no parked context and the user can quit
// from the tray. This effectively never happens.
}
},
other => self.win = other, // already hidden / transient
}
}
/// Restore from the tray: recreate the window + surface and make the parked
/// context current again.
fn show(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
match std::mem::replace(&mut self.win, WinState::Between) {
WinState::Hidden { context } => {
let window =
glutin_winit::finalize_window(event_loop, window_attributes(), &self.gl_config)?;
let surface = create_surface(&self.gl_display, &self.gl_config, &window)?;
let context = context
.make_current(&surface)
.map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?;
let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN));
window.set_visible(true);
window.request_redraw();
self.win = WinState::Shown {
window,
surface,
context,
};
Ok(())
}
WinState::Shown {
window,
surface,
context,
} => {
// Already shown — just nudge a repaint and restore.
window.request_redraw();
self.win = WinState::Shown {
window,
surface,
context,
};
Ok(())
}
WinState::Between => {
anyhow::bail!("no parked GL context to restore the window from")
}
}
}
/// Run one egui frame and paint it. Returns how long egui wants to wait
/// before the next repaint (`Duration::MAX` == idle, sleep until an event).
/// No-op returning `MAX` if the window isn't currently shown.
fn paint_frame(&mut self, run_ui: impl FnMut(&mut egui::Ui)) -> Duration {
let WinState::Shown {
window,
surface,
context,
} = &self.win
else {
return Duration::MAX;
};
let eg = &mut self.egui_glow;
let raw_input = eg.egui_winit.take_egui_input(window);
let egui::FullOutput {
platform_output,
textures_delta,
shapes,
pixels_per_point,
viewport_output,
} = eg.egui_ctx.run_ui(raw_input, run_ui);
eg.egui_winit.handle_platform_output(window, platform_output);
// Apply any window commands egui emitted (we issue none directly, but
// egui may request e.g. IME changes) and read the next repaint delay.
let (repaint_delay, commands) = match viewport_output.get(&egui::ViewportId::ROOT) {
Some(out) => (out.repaint_delay, out.commands.clone()),
None => (Duration::MAX, Vec::new()),
};
if !commands.is_empty() {
let mut actions = Vec::new();
egui_winit::process_viewport_commands(
&eg.egui_ctx,
&mut self.viewport_info,
commands,
window,
&mut actions,
);
}
let clipped = eg.egui_ctx.tessellate(shapes, pixels_per_point);
for (id, image_delta) in &textures_delta.set {
eg.painter.set_texture(*id, image_delta);
}
let dimensions: [u32; 2] = window.inner_size().into();
// SAFETY: the context is current (we are in the `Shown` arm) and `gl`
// belongs to it.
unsafe {
self.gl.clear_color(0.08, 0.08, 0.08, 1.0);
self.gl.clear(glow::COLOR_BUFFER_BIT);
}
eg.painter
.paint_primitives(dimensions, pixels_per_point, &clipped);
for id in &textures_delta.free {
eg.painter.free_texture(*id);
}
if let Err(e) = surface.swap_buffers(context) {
tracing::warn!("GL swap_buffers failed: {e}");
}
repaint_delay
}
}
/// The winit application: owns the persistent GL/egui state ([`Gfx`]) and the
/// PixelPass UI logic ([`PixelPassApp`]), and routes events between them.
struct App {
state: PixelPassApp,
/// `None` until the first `resumed`; `Some` for the rest of the session
/// (the window inside may be Shown or Hidden).
gfx: Option<Gfx>,
/// When the next repaint is due, derived from egui's per-frame delay and
/// external wakes. `None` == idle (sleep until an event).
repaint_at: Option<Instant>,
}
impl App {
fn shown_window(&self) -> Option<&Window> {
self.gfx.as_ref().and_then(Gfx::shown_window)
}
fn request_redraw(&self) {
if let Some(w) = self.shown_window() {
w.request_redraw();
}
}
/// Run an egui frame now (if shown) and record the next repaint deadline.
fn redraw(&mut self) {
let App {
gfx,
state,
repaint_at,
..
} = self;
let Some(gfx) = gfx.as_mut() else { return };
let delay = gfx.paint_frame(|ui| state.draw(ui));
*repaint_at = (delay < Duration::MAX).then(|| Instant::now() + delay);
}
/// The window's close button: hide to the tray if enabled and a tray is
/// actually present, otherwise quit.
fn on_close(&mut self, event_loop: &ActiveEventLoop) {
let hide = self.state.close_to_tray
&& self
.state
.tray
.as_ref()
.is_some_and(TrayHandle::registered);
if hide {
if let Some(gfx) = self.gfx.as_mut() {
gfx.hide();
}
self.repaint_at = None;
} else {
event_loop.exit();
}
}
}
impl ApplicationHandler<UserEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.gfx.is_some() {
return; // already initialized
}
match Gfx::create(event_loop) {
Ok(gfx) => self.gfx = Some(gfx),
Err(e) => {
tracing::error!("GUI: could not create the window/GL context: {e}");
event_loop.exit();
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
if matches!(event, WindowEvent::CloseRequested) {
self.on_close(event_loop);
return;
}
if matches!(event, WindowEvent::RedrawRequested) {
self.redraw();
return;
}
if let WindowEvent::Resized(size) = &event
&& let Some(gfx) = self.gfx.as_ref()
&& let WinState::Shown {
surface, context, ..
} = &gfx.win
&& let (Some(w), Some(h)) = (NonZeroU32::new(size.width), NonZeroU32::new(size.height))
{
surface.resize(context, w, h);
}
// Feed the event to egui and repaint if it wants one.
let Some(gfx) = self.gfx.as_mut() else { return };
let WinState::Shown { window, .. } = &gfx.win else {
return;
};
let response = gfx.egui_glow.egui_winit.on_window_event(window, &event);
if response.repaint {
window.request_redraw();
}
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
match event {
UserEvent::Wake => {
// A child emitted/closed: update state (and fire notifications /
// tray tooltip) even while hidden, then repaint if shown.
self.state.tick();
self.request_redraw();
}
UserEvent::Tray(TrayAction::Show) => {
if let Some(gfx) = self.gfx.as_mut()
&& let Err(e) = gfx.show(event_loop)
{
tracing::error!("could not restore the window from the tray: {e}");
}
self.state.tick();
}
UserEvent::Tray(TrayAction::Quit) => {
event_loop.exit();
}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let flow = match self.repaint_at {
None => ControlFlow::Wait,
Some(at) if at > Instant::now() => ControlFlow::WaitUntil(at),
Some(_) => {
// Due now: ask for a frame if there's a window to paint into;
// otherwise (hidden) drop the pending repaint and sleep.
if let Some(w) = self.shown_window() {
w.request_redraw();
} else {
self.repaint_at = None;
}
ControlFlow::Wait
}
};
event_loop.set_control_flow(flow);
}
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
// Release GL resources only if the context is current (window shown);
// when hidden the context isn't current, and the process is exiting
// anyway so the driver reclaims everything.
if let Some(gfx) = self.gfx.as_mut()
&& matches!(gfx.win, WinState::Shown { .. })
{
gfx.egui_glow.painter.destroy();
}
}
}
/// Launch the GUI. Blocks until the window is closed (or the tray Quit is
/// chosen). Runs on the main thread, a winit requirement, which is where `main`
/// calls it from.
pub fn run() -> anyhow::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([520.0, 480.0])
.with_min_inner_size([460.0, 380.0])
.with_title("PixelPass"),
..Default::default()
let event_loop = EventLoop::<UserEvent>::with_user_event()
.build()
.map_err(|e| anyhow::anyhow!("failed to build the event loop: {e}"))?;
event_loop.set_control_flow(ControlFlow::Wait);
let proxy = event_loop.create_proxy();
let waker = Waker {
proxy: proxy.clone(),
};
// The tray runs on its own thread and wakes us via the proxy.
let tray = tray::start(proxy.clone());
let close_to_tray = crate::common::config::load()
.map(|c| c.gui.close_to_tray)
.unwrap_or(false);
let state = PixelPassApp {
screen: Screen::default(),
host: HostState::default(),
viewer: ViewerState::default(),
tray,
close_to_tray,
waker,
};
let mut app = App {
state,
gfx: None,
repaint_at: None,
};
eframe::run_native(
"PixelPass",
options,
Box::new(|_cc| Ok(Box::new(PixelPassApp::default()))),
)
.map_err(|e| anyhow::anyhow!("GUI failed to start: {e}"))
event_loop
.run_app(&mut app)
.map_err(|e| anyhow::anyhow!("GUI event loop error: {e}"))
}
/// Best-effort clipboard write. Returns whether it succeeded so callers can
@@ -78,6 +647,34 @@ fn short_id(id: &str) -> String {
}
}
/// Fire a desktop notification, on a detached thread so the D-Bus round-trip
/// can't stall the egui frame. Best-effort: with no notification daemon it
/// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this
/// needs no system libdbus and no GTK event loop.)
fn notify(summary: &'static str, body: String) {
std::thread::spawn(move || {
if let Err(e) = notify_rust::Notification::new()
.appname("PixelPass")
.summary(summary)
.body(&body)
.show()
{
tracing::warn!("desktop notification failed: {e}");
}
});
}
/// Persist just the close-to-tray preference, preserving the rest of the
/// on-disk config (e.g. the bandwidth section the headless child may have
/// written). Best-effort: a write failure is logged, not surfaced.
fn persist_close_to_tray(value: bool) {
let mut cfg = crate::common::config::load().unwrap_or_default();
cfg.gui.close_to_tray = value;
if let Err(e) = crate::common::config::save(&cfg) {
tracing::warn!("failed to save settings: {e}");
}
}
/// Which screen the single window is currently showing.
#[derive(Default, PartialEq)]
enum Screen {
@@ -85,6 +682,7 @@ enum Screen {
Menu,
Host,
Viewer,
Settings,
}
/// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality`
@@ -148,6 +746,7 @@ impl PlayerSel {
/// Host-screen state: the config form fields plus, once started, the running
/// child and the latest values parsed from its event stream.
#[derive(Default)]
struct HostState {
// form
quality: QualitySel,
@@ -168,26 +767,9 @@ struct HostState {
copied: bool,
last_refusal: Option<String>,
error: Option<String>,
}
impl Default for HostState {
fn default() -> Self {
Self {
quality: QualitySel::default(),
max_viewers: 0,
no_hwencode: false,
window: false,
proc: None,
ticket: None,
info: None,
active: 0,
max: 0,
capturing: false,
copied: false,
last_refusal: None,
error: None,
}
}
/// Endpoint ids of the currently-connected viewers, in arrival order.
/// Drives the per-viewer list and its Kick buttons.
viewers: Vec<String>,
}
/// The host config summary echoed back by the child's `host_info` event.
@@ -217,29 +799,61 @@ struct ViewerState {
error: Option<String>,
}
#[derive(Default)]
/// The PixelPass UI logic — screens, the running children, and the tray. Knows
/// nothing about windowing; [`App`] drives its [`PixelPassApp::draw`] each frame
/// and [`PixelPassApp::tick`] on each wake.
struct PixelPassApp {
screen: Screen,
host: HostState,
viewer: ViewerState,
/// System-tray handle; `None` if the tray couldn't start, in which case the
/// close button always quits (never hides).
tray: Option<TrayHandle>,
/// Persisted preference: when true (and a tray is present), the close button
/// hides to the tray instead of quitting. Loaded at startup, written on
/// toggle in Settings.
close_to_tray: bool,
/// Wakes the winit loop when a spawned child emits/exits.
waker: Waker,
}
impl eframe::App for PixelPassApp {
// eframe 0.34 hands us the central-panel `ui` directly.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Drain any pending child events before drawing this frame.
impl PixelPassApp {
/// Drain child output and reflect it into the tray. Runs on every wake,
/// whether or not a window is shown, so notifications and the tray tooltip
/// stay live while hidden.
fn tick(&mut self) {
self.pump_host_events();
self.pump_viewer_events();
self.sync_tray_status();
}
/// Render the current screen. Called from inside the egui frame.
fn draw(&mut self, ui: &mut egui::Ui) {
match self.screen {
Screen::Menu => self.menu(ui),
Screen::Host => self.host(ui),
Screen::Viewer => self.viewer(ui),
Screen::Settings => self.settings(ui),
}
}
/// Mirror current activity into the tray icon's tooltip/menu.
fn sync_tray_status(&mut self) {
let status = if self.host.proc.is_some() {
TrayStatus::Hosting {
active: self.host.active,
max: self.host.max,
}
} else if self.viewer.proc.is_some() {
TrayStatus::Viewing
} else {
TrayStatus::Idle
};
if let Some(tray) = &mut self.tray {
tray.set_status(status);
}
}
}
impl PixelPassApp {
fn menu(&mut self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space(24.0);
@@ -268,9 +882,52 @@ impl PixelPassApp {
self.screen = Screen::Viewer;
self.prefill_viewer_ticket();
}
ui.add_space(20.0);
if ui.button("⚙ Settings").clicked() {
self.screen = Screen::Settings;
}
});
}
fn settings(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
if ui.button("← Menu").clicked() {
self.screen = Screen::Menu;
}
ui.heading("Settings");
});
ui.separator();
ui.add_space(4.0);
let resp = ui.checkbox(
&mut self.close_to_tray,
"Keep running in the tray when I close the window",
);
if resp.changed() {
persist_close_to_tray(self.close_to_tray);
}
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Off: closing the window quits PixelPass.\n\
On: closing hides it to the system tray and any active stream \
keeps running reopen it from the tray icon.",
)
.small()
.weak(),
);
// The option does nothing without a tray to hide into; say so plainly.
if !self.tray.as_ref().is_some_and(TrayHandle::registered) {
ui.add_space(8.0);
ui.colored_label(
egui::Color32::from_rgb(220, 160, 60),
"⚠ No system tray detected — this option has no effect right now.",
);
}
}
// ── Host screen ──────────────────────────────────────────────────────
fn host(&mut self, ui: &mut egui::Ui) {
@@ -337,7 +994,7 @@ impl PixelPassApp {
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
.clicked()
{
self.start_host(ui.ctx().clone());
self.start_host();
}
ui.add_space(8.0);
ui.label(
@@ -362,6 +1019,23 @@ impl PixelPassApp {
ui.add_space(6.0);
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
// Per-viewer list with a Kick button each. Collect the click first so
// we're not borrowing self.host.viewers while we reach for the child.
let mut kick: Option<String> = None;
for id in &self.host.viewers {
ui.horizontal(|ui| {
ui.label(format!("• endpoint {}", short_id(id)));
if ui.small_button("Kick").clicked() {
kick = Some(id.clone());
}
});
}
if let Some(id) = kick
&& let Some(p) = &mut self.host.proc
{
p.send_command(&format!("kick {id}"));
}
if let Some(info) = &self.host.info {
ui.label(
egui::RichText::new(format!("{} · {}", info.display, info.capture))
@@ -443,7 +1117,7 @@ impl PixelPassApp {
}
}
fn start_host(&mut self, ctx: egui::Context) {
fn start_host(&mut self) {
self.host.error = None;
self.host.last_refusal = None;
self.host.ticket = None;
@@ -452,6 +1126,7 @@ impl PixelPassApp {
self.host.max = 0;
self.host.capturing = false;
self.host.copied = false;
self.host.viewers.clear();
let mut args = vec![
"--host".to_string(),
@@ -471,7 +1146,7 @@ impl PixelPassApp {
args.push("--window".to_string());
}
match ChildProc::spawn(&args, ctx) {
match ChildProc::spawn(&args, self.waker.clone()) {
Ok(p) => self.host.proc = Some(p),
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
}
@@ -483,6 +1158,7 @@ impl PixelPassApp {
self.host.capturing = false;
self.host.ticket = None;
self.host.copied = false;
self.host.viewers.clear();
}
/// Drain the host child's event channel into state, and detect an
@@ -543,9 +1219,27 @@ impl PixelPassApp {
cap_source: max_viewers_source,
});
}
ChildEvent::ViewerCount { active, max } => {
ChildEvent::ViewerJoined { id, active, max } => {
self.host.active = active;
self.host.max = max;
if !self.host.viewers.contains(&id) {
notify(
"PixelPass — viewer connected",
format!("endpoint {} is now watching ({active}/{max})", short_id(&id)),
);
self.host.viewers.push(id);
}
}
ChildEvent::ViewerLeft { id, active, max } => {
self.host.active = active;
self.host.max = max;
if self.host.viewers.iter().any(|v| v == &id) {
notify(
"PixelPass — viewer disconnected",
format!("endpoint {} left ({active}/{max})", short_id(&id)),
);
self.host.viewers.retain(|v| v != &id);
}
}
ChildEvent::Capture { state } => {
self.host.capturing = matches!(state, child::CaptureState::Started);
@@ -669,7 +1363,7 @@ impl PixelPassApp {
.on_disabled_hover_text("Paste a valid share code first.")
.clicked();
if decoded_id.is_some() && (connect_clicked || enter_pressed) {
self.start_viewer(ui.ctx().clone());
self.start_viewer();
}
}
@@ -713,7 +1407,7 @@ impl PixelPassApp {
self.viewer.focus_ticket = true;
}
fn start_viewer(&mut self, ctx: egui::Context) {
fn start_viewer(&mut self) {
self.viewer.error = None;
self.viewer.url = None;
self.viewer.launched = false;
@@ -721,7 +1415,7 @@ impl PixelPassApp {
let ticket = self.viewer.ticket_input.trim().to_string();
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
let args = vec![ticket, "--output".to_string(), "json".to_string()];
match ChildProc::spawn(&args, ctx) {
match ChildProc::spawn(&args, self.waker.clone()) {
Ok(p) => self.viewer.proc = Some(p),
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
}
+233
View File
@@ -0,0 +1,233 @@
//! System-tray (StatusNotifierItem) integration for the GUI.
//!
//! The tray runs on its **own dedicated thread** with its own current-thread
//! tokio runtime, fully decoupled from the winit event loop (which owns the
//! main thread) and from the process-wide `#[tokio::main]` runtime. It talks to
//! the egui app purely over winit's event channel and a status channel:
//!
//! * tray → app: a [`super::UserEvent::Tray`] carrying a [`TrayAction`]
//! (Show / Quit), pushed through the [`winit::event_loop::EventLoopProxy`].
//! Using the proxy (not egui's repaint) is essential: a tray click must
//! wake the winit loop even when the window has been **dropped** (hidden to
//! tray), so the loop can recreate it.
//! * app → tray: [`TrayStatus`] (idle / hosting / viewing), pushed on change.
//!
//! Why a separate thread instead of `Handle::current().spawn`: updating the
//! tray from the egui thread would need `block_on`, which panics when called
//! from inside the running runtime. Keeping ksni's async wholly on its own
//! runtime sidesteps that and keeps the frame loop non-blocking.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use ksni::TrayMethods;
use winit::event_loop::EventLoopProxy;
use super::UserEvent;
/// What the user picked from the tray icon or its menu (tray thread → app),
/// delivered as a [`UserEvent::Tray`].
pub enum TrayAction {
/// Left-click, or the "Show window" item: bring the window back.
Show,
/// The "Quit" item: really exit (the close button only hides to tray).
Quit,
}
/// What the tray icon's tooltip/menu reflect (app → tray thread).
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TrayStatus {
Idle,
Hosting { active: u32, max: u32 },
Viewing,
}
fn status_text(status: TrayStatus) -> String {
match status {
TrayStatus::Idle => "Idle".to_string(),
TrayStatus::Hosting { active, max } => {
format!("Hosting — {active} of {max} viewer(s) connected")
}
TrayStatus::Viewing => "Viewing a stream".to_string(),
}
}
/// Handle held by the egui app for the lifetime of the window. Dropping it
/// closes the app→tray channel, which ends the tray thread and removes the icon.
pub struct TrayHandle {
status_tx: tokio::sync::mpsc::UnboundedSender<TrayStatus>,
/// Set true once the tray actually registered with a StatusNotifier host.
/// The app must not divert the window's close to a tray that never appeared.
registered: Arc<AtomicBool>,
/// Last status pushed, so we don't spam D-Bus with no-op updates.
last_sent: Option<TrayStatus>,
}
impl TrayHandle {
/// Whether a system tray is actually showing our icon. Until this is true,
/// hiding the window would strand it with no way back.
pub fn registered(&self) -> bool {
self.registered.load(Ordering::Acquire)
}
/// Push a status change to the tray, deduped against the last one sent.
pub fn set_status(&mut self, status: TrayStatus) {
if self.last_sent != Some(status) {
let _ = self.status_tx.send(status);
self.last_sent = Some(status);
}
}
}
struct PixelPassTray {
status: TrayStatus,
/// ARGB pixmap, so the icon shows even where the themed "pixelpass" name
/// can't be resolved (e.g. running the dev binary before `make install`).
icon: Vec<ksni::Icon>,
/// Wakes the winit loop and delivers the action — works even when the
/// window has been dropped to the tray (no egui frame is running then).
proxy: EventLoopProxy<UserEvent>,
}
impl PixelPassTray {
fn notify(&self, action: TrayAction) {
let _ = self.proxy.send_event(UserEvent::Tray(action));
}
}
impl ksni::Tray for PixelPassTray {
fn id(&self) -> String {
"pixelpass".to_string()
}
fn title(&self) -> String {
"PixelPass".to_string()
}
// Themed icon (matches the installed hicolor/scalable/apps/pixelpass.svg);
// icon_pixmap below is the always-works fallback.
fn icon_name(&self) -> String {
"pixelpass".to_string()
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
self.icon.clone()
}
fn status(&self) -> ksni::Status {
ksni::Status::Active
}
fn tool_tip(&self) -> ksni::ToolTip {
ksni::ToolTip {
title: "PixelPass".to_string(),
description: status_text(self.status),
icon_name: "pixelpass".to_string(),
icon_pixmap: Vec::new(),
}
}
fn activate(&mut self, _x: i32, _y: i32) {
self.notify(TrayAction::Show);
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::{MenuItem, StandardItem};
vec![
// Non-clickable status line.
StandardItem {
label: status_text(self.status),
enabled: false,
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Show window".to_string(),
activate: Box::new(|t: &mut Self| t.notify(TrayAction::Show)),
..Default::default()
}
.into(),
StandardItem {
label: "Quit PixelPass".to_string(),
icon_name: "application-exit".to_string(),
activate: Box::new(|t: &mut Self| t.notify(TrayAction::Quit)),
..Default::default()
}
.into(),
]
}
}
/// Decode the embedded PNG (RGBA) and convert to the ARGB pixmap ksni wants.
/// Reuses eframe's PNG decoder so we don't take a direct `image` dependency.
fn load_icon() -> Option<Vec<ksni::Icon>> {
let icon =
eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")).ok()?;
let mut data = icon.rgba; // RGBA8, row-major
for px in data.chunks_exact_mut(4) {
px.rotate_right(1); // [r,g,b,a] -> [a,r,g,b], network byte order
}
Some(vec![ksni::Icon {
width: icon.width as i32,
height: icon.height as i32,
data,
}])
}
/// Start the tray on its own thread. Returns a handle for the app to drive it,
/// or `None` if the icon couldn't be decoded or the thread couldn't spawn (in
/// which case the GUI simply runs without a tray — close behaves as before).
pub fn start(proxy: EventLoopProxy<UserEvent>) -> Option<TrayHandle> {
let icon = load_icon()?;
let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::<TrayStatus>();
let registered = Arc::new(AtomicBool::new(false));
let registered_thread = registered.clone();
std::thread::Builder::new()
.name("pixelpass-tray".to_string())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
tracing::warn!("tray: could not build runtime: {e}");
return;
}
};
rt.block_on(async move {
let tray = PixelPassTray {
status: TrayStatus::Idle,
icon,
proxy,
};
let handle = match tray.spawn().await {
Ok(handle) => handle,
Err(e) => {
// No StatusNotifier host (no system tray) — degrade
// gracefully: the window keeps its normal close.
tracing::warn!("tray: not available, running without it: {e}");
return;
}
};
registered_thread.store(true, Ordering::Release);
// Apply status changes until the app drops its sender (on quit),
// which ends this loop, the runtime, the thread, and the icon.
while let Some(status) = status_rx.recv().await {
let _ = handle
.update(move |t: &mut PixelPassTray| t.status = status)
.await;
}
});
})
.ok()?;
Some(TrayHandle {
status_tx,
registered,
last_sent: None,
})
}
+97 -20
View File
@@ -10,6 +10,7 @@ use anyhow::{Result, bail};
use iroh::endpoint::{Connection, presets};
use iroh::{Endpoint, EndpointAddr};
use iroh_tickets::endpoint::EndpointTicket;
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
@@ -23,15 +24,28 @@ use crate::common::{
use self::pipeline::CaptureHandle;
use self::quality::EffectiveQuality;
/// Messages from per-viewer tasks to the capture supervisor.
/// Messages from per-viewer tasks (and the GUI command channel) to the
/// capture supervisor.
// The shared `Viewer` suffix is the point — these are all viewer lifecycle
// messages — so keep the descriptive names.
#[allow(clippy::enum_variant_names)]
enum SupervisorMsg {
/// A new viewer wants in. Supervisor replies with the local capture
/// HTTP port to connect to, or an error string if the host is full or
/// capture spawn failed.
AddViewer(oneshot::Sender<Result<u16, String>>),
/// A new viewer wants in. Supervisor replies with the local capture HTTP
/// port to connect to, or an error string if the host is full or capture
/// spawn failed. `cancel` is the viewer's own token — the supervisor keeps
/// it so a later `KickViewer` can tear this viewer's stream down.
AddViewer {
id: String,
cancel: CancellationToken,
reply: oneshot::Sender<Result<u16, String>>,
},
/// A viewer's session ended. Supervisor decrements the count and tears
/// down capture if it just hit zero.
RemoveViewer,
RemoveViewer { id: String },
/// Host asked (via the GUI command channel) to disconnect a viewer by
/// endpoint id. Cancels that viewer's token; the normal teardown path then
/// emits the `ViewerLeft`.
KickViewer { id: String },
}
pub async fn run(opts: HostOpts) -> Result<()> {
@@ -113,6 +127,15 @@ pub async fn run(opts: HostOpts) -> Result<()> {
sup_rx,
));
// Command channel for the GUI front-end: read `kick <endpoint-id>` lines
// off stdin. Only when machine-driven (`--output json`) — a human host has
// nothing to type here, and we don't want to swallow terminal input. Runs
// on a plain OS thread (not a tokio task) so a read parked on stdin can't
// hold up runtime shutdown on Ctrl+C; the thread dies with the process.
if output::json_enabled() {
spawn_kick_listener(sup_tx.clone());
}
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
drop(sup_tx);
@@ -159,9 +182,18 @@ async fn handle_peer(
cancel: CancellationToken,
) {
let remote = conn.remote_id();
let id = remote.to_string();
// This viewer's own kill switch: the supervisor holds a clone so a `kick`
// can cancel it, and the stream select! below watches it.
let peer_cancel = CancellationToken::new();
let (reply_tx, reply_rx) = oneshot::channel();
if sup_tx.send(SupervisorMsg::AddViewer(reply_tx)).await.is_err() {
let add = SupervisorMsg::AddViewer {
id: id.clone(),
cancel: peer_cancel.clone(),
reply: reply_tx,
};
if sup_tx.send(add).await.is_err() {
tracing::warn!(%remote, "supervisor channel closed; dropping peer");
return;
}
@@ -182,7 +214,7 @@ async fn handle_peer(
Ok(s) => s,
Err(e) => {
tracing::warn!(%remote, "accept_bi failed: {e:#}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
return;
}
};
@@ -193,7 +225,7 @@ async fn handle_peer(
Ok(t) => t,
Err(e) => {
tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
return;
}
};
@@ -207,10 +239,34 @@ async fn handle_peer(
_ = cancel.cancelled() => {
tracing::info!(%remote, "cancellation during stream");
}
_ = peer_cancel.cancelled() => {
tracing::info!(%remote, "kicked by host");
}
}
eprintln!("[pixelpass] viewer disconnected: {remote}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
}
/// Read `kick <endpoint-id>` lines off stdin and forward them to the
/// supervisor. Runs on a detached OS thread (see the call site for why). Ends
/// when stdin hits EOF (the GUI closed the pipe) or the supervisor is gone.
fn spawn_kick_listener(sup_tx: mpsc::Sender<SupervisorMsg>) {
use std::io::BufRead;
std::thread::spawn(move || {
let stdin = std::io::stdin();
for line in stdin.lock().lines().map_while(Result::ok) {
let Some(id) = line.trim().strip_prefix("kick ") else {
continue;
};
let msg = SupervisorMsg::KickViewer { id: id.trim().to_string() };
// blocking_send is valid here: this is a plain thread, not inside
// the tokio runtime. An Err means the supervisor closed — stop.
if sup_tx.blocking_send(msg).is_err() {
break;
}
}
});
}
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
@@ -225,11 +281,15 @@ async fn supervise(
mut rx: mpsc::Receiver<SupervisorMsg>,
) {
let mut handle: Option<CaptureHandle> = None;
let mut count: u32 = 0;
// Active viewers, keyed by endpoint id, holding each one's kill switch.
// The count is just `viewers.len()`. (A given endpoint connecting twice is
// a non-case here: each viewer process uses a fresh ephemeral identity.)
let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
SupervisorMsg::AddViewer(reply) => {
SupervisorMsg::AddViewer { id, cancel, reply } => {
let count = viewers.len() as u32;
if count >= max_viewers {
let reason =
format!("host is full ({count} of {max_viewers} viewers connected)");
@@ -255,16 +315,22 @@ async fn supervise(
}
let port = handle.as_ref().expect("handle was just set").local_port();
count += 1;
viewers.insert(id.clone(), cancel);
let active = viewers.len() as u32;
let _ = reply.send(Ok(port));
output::emit(output::Event::ViewerCount { active: count, max: max_viewers });
tracing::info!(active = count, cap = max_viewers, "viewer joined");
output::emit(output::Event::ViewerJoined { id: &id, active, max: max_viewers });
tracing::info!(active, cap = max_viewers, "viewer joined");
}
SupervisorMsg::RemoveViewer => {
count = count.saturating_sub(1);
output::emit(output::Event::ViewerCount { active: count, max: max_viewers });
tracing::info!(active = count, cap = max_viewers, "viewer left");
if count == 0
SupervisorMsg::RemoveViewer { id } => {
// A given viewer task only ever sends RemoveViewer once, but the
// map remove is the source of truth either way.
if viewers.remove(&id).is_none() {
continue;
}
let active = viewers.len() as u32;
output::emit(output::Event::ViewerLeft { id: &id, active, max: max_viewers });
tracing::info!(active, cap = max_viewers, "viewer left");
if active == 0
&& let Some(h) = handle.take()
{
tracing::info!("last viewer left — tearing down capture");
@@ -274,6 +340,17 @@ async fn supervise(
});
}
}
SupervisorMsg::KickViewer { id } => {
match viewers.get(&id) {
// Cancel the viewer's token; its handle_peer select! wakes,
// sends RemoveViewer, and the leave is emitted there.
Some(cancel) => {
tracing::info!(%id, "kicking viewer");
cancel.cancel();
}
None => tracing::debug!(%id, "kick for unknown/already-gone viewer"),
}
}
}
}
+6 -6
View File
@@ -13,7 +13,7 @@ pub async fn run(cli: Cli) -> Result<()> {
let theme = ColorfulTheme::default();
let choice = Select::with_theme(&theme)
.with_prompt("What do you want to do?")
.items(&[
.items([
"Host (share my screen)",
"View (watch someone else's screen)",
])
@@ -112,7 +112,7 @@ fn pick_quality(theme: &ColorfulTheme) -> Result<Quality> {
let choice = Select::with_theme(theme)
.with_prompt("What quality should the viewer(s) get?")
.items(&items)
.items(items)
.default(0)
.interact()?;
@@ -138,7 +138,7 @@ pub async fn run_reconfigure() -> Result<()> {
async fn preflight_if_needed(theme: &ColorfulTheme) {
let mut cfg = config::load().unwrap_or_default();
match cfg.bandwidth.status {
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => return,
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => (),
config::BandwidthStatus::Unmeasured => {
eprintln!();
eprintln!("First-time setup");
@@ -154,7 +154,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
let Ok(choice) = Select::with_theme(theme)
.with_prompt("What would you like to do?")
.items(&[
.items([
"Run the bandwidth test (recommended)",
"Skip — use the conservative default",
])
@@ -180,7 +180,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
eprintln!();
let Ok(choice) = Select::with_theme(theme)
.with_prompt("Last bandwidth test failed. Try again?")
.items(&[
.items([
"Yes — retry now",
"No — use the conservative default",
])
@@ -341,7 +341,7 @@ pub fn prompt_player() -> Result<Player> {
let theme = ColorfulTheme::default();
let choice = Select::with_theme(&theme)
.with_prompt("Connected. Pick a player to launch")
.items(&["mpv", "VLC"])
.items(["mpv", "VLC"])
.default(0)
.interact()?;
Ok(if choice == 0 { Player::Mpv } else { Player::Vlc })