diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index d8fa3cc..1a4e413 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -65,7 +65,17 @@ loopback; current phase is Phase 7. before the fix). Cross-compiling on the dev machine was considered and dropped — the on-Pi build works and the toolchain/container effort was not needed. -- **Pi Wi-Fi hotspot** (`scripts/pi-hotspot.sh on|off|status`): NetworkManager +- **GTK panel + waybar widget**: `screencast-gui` (gtkmm-4.0, behind + `-Dgui=true`; app internals now live in the `sc_app_core` static lib so + CLI and GUI share pipelines/session/state). `screencast waybar [--toggle]` + prints the waybar module line and toggles streaming via SIGTERM (stop) or + a detached re-exec spawn from `last-session.json` (start). State lives in + `$XDG_RUNTIME_DIR/screencast/sender.json` (written by the pipeline with + session id, receiver, bitrate, pid, start time; stale files are detected + by pid-liveness). The waybar config was wired into the user's bar + (`custom/screencast` before `custom/timetrack`, with backup) and the + binaries installed to /usr/local/bin. + - **Pi Wi-Fi hotspot** (`scripts/pi-hotspot.sh on|off|status`): NetworkManager AP mode (WPA2, ipv4 shared → built-in DHCP/NAT, Pi at 10.42.0.1). Takes over wlan0 while active; generated PSK stored in /etc/screencast-hotspot.conf. No application changes needed — the receiver already announces on all diff --git a/docs/PHASES.md b/docs/PHASES.md index 7990127..e050fe8 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -89,9 +89,10 @@ discovered addresses in reachability order (private IPv4 first). - [x] Jitter/reorder window on the receiver (16 packets / 60 ms). - [x] Hardware H.264 decode probe with software fallback (h264_v4l2m2m — the Pi's VideoCore path; --swdecode opts out). -- [ ] GTK4 sender GUI behind `meson -Dgui=true` + a waybar widget - (requested; plan approved scope: sender panel, state file, - `screencast waybar` subcommand). +- [x] GTK4 sender GUI behind `meson -Dgui=true` (gtkmm; receiver list, + bitrate, start/stop) plus a waybar widget (`screencast waybar`, + click-to-toggle, right-click opens the panel; state shared via + $XDG_RUNTIME_DIR/screencast/sender.json). - [ ] VAAPI hardware encode probe on the sender (deferred: software encode is not the bottleneck). - Deferred: .desktop file, packaging. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index b0e1b5c..6a24efe 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -134,6 +134,24 @@ A window manager only comes with the desktop-session alternative, where the receiver runs inside it (uncomment the `Environment=` lines in the service file as described above). +## GTK panel and waybar widget (Phase 7) + +Build the GUI alongside the CLI (`meson configure build -Dgui=true`, then +recompile; installs as `screencast-gui`): + +```sh +screencast-gui # receiver list → pick one → bitrate → Start +screencast waybar # one JSON line for a waybar custom module +screencast waybar --toggle # stop a running sender / restart the last +``` + +The waybar module (installed to this machine's `~/.config/waybar/` — see +`custom/screencast` there): `▶` while streaming (tooltip: receiver, bitrate, +elapsed), `⏸` while idle; left-click toggles streaming to the last receiver +via the state file, right-click opens the panel. The sender publishes its +state to `$XDG_RUNTIME_DIR/screencast/sender.json`, so every front-end — +CLI, GUI, widget — agrees on what is running. + ## Under the hood: resilience (Phase 7) The receiver absorbs loss in two stages and recovers actively: diff --git a/include/screencast/app/cli.h b/include/screencast/app/cli.h index 615783d..53b15f1 100644 --- a/include/screencast/app/cli.h +++ b/include/screencast/app/cli.h @@ -25,7 +25,11 @@ struct DiscoverCommand { int timeout_seconds = 3; }; -using Command = std::variant; +struct WaybarCommand { + bool toggle = false; // toggle streaming instead of printing status +}; + +using Command = std::variant; // Parse command line arguments. Prints usage and returns std::nullopt on error. // `argv` is `char const* const*` so both `main`'s `char**` and const arrays diff --git a/include/screencast/app/pipeline.h b/include/screencast/app/pipeline.h index 7657c3e..7acb5c9 100644 --- a/include/screencast/app/pipeline.h +++ b/include/screencast/app/pipeline.h @@ -19,6 +19,9 @@ struct SenderPipelineConfig { // Where encoded RTP packets are sent. Defaults to the local loopback so // a sender and receiver on one machine work without any configuration. Endpoint peer_rtp_endpoint{"127.0.0.1", 5004}; + // Session identity from signaling; written to the sender state file for + // status widgets (waybar) and one-click restarts. + std::string session_id; }; struct ReceiverPipelineConfig { diff --git a/src/app/cli.cpp b/src/app/cli.cpp index cbb2c4f..9aa0674 100644 --- a/src/app/cli.cpp +++ b/src/app/cli.cpp @@ -11,6 +11,7 @@ void print_usage() { std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate KBPS]\n" " screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]\n" " screencast --discover [--timeout SECONDS]\n" + " screencast waybar [--toggle] # for waybar widgets\n" "\n" "--send without --peer discovers a receiver on the LAN and requires\n" "that exactly one is found.\n", @@ -39,12 +40,14 @@ std::optional parse_cli(int argc, const char* const argv[]) { Send, Receive, Discover, + Waybar, }; Mode mode = Mode::None; SendCommand send; ReceiveCommand receive; DiscoverCommand discover; + WaybarCommand waybar; for (int index = 1; index < argc; ++index) { const std::string_view argument = argv[index]; @@ -67,6 +70,14 @@ std::optional parse_cli(int argc, const char* const argv[]) { return std::nullopt; } mode = Mode::Discover; + } else if (argument == "waybar") { + if (mode != Mode::None) { + print_usage(); + return std::nullopt; + } + mode = Mode::Waybar; + } else if (argument == "--toggle") { + waybar.toggle = true; } else if (argument == "--target") { std::string_view value; if (!next_argument(argc, argv, index, value)) { @@ -136,6 +147,9 @@ std::optional parse_cli(int argc, const char* const argv[]) { if (mode == Mode::Discover) { return Command{std::move(discover)}; } + if (mode == Mode::Waybar) { + return Command{std::move(waybar)}; + } print_usage(); return std::nullopt; } diff --git a/src/app/main.cpp b/src/app/main.cpp index 03af16d..1cc5680 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,9 +1,15 @@ #include "screencast/app/cli.h" #include "screencast/app/pipeline.h" +#include "sender_session.h" +#include "state_store.h" + #include "screencast/network/discovery.h" #include "screencast/network/signaling.h" +#include +#include + #include #include #include @@ -53,49 +59,6 @@ sc::Endpoint parse_endpoint(std::string_view address, std::uint16_t default_port #endif // SC_HAS_SENDER -std::string make_session_id() { - static std::mt19937 engine{std::random_device{}()}; - std::string id; - for (int i = 0; i < 16; ++i) { - id += "0123456789abcdef"[static_cast(engine()) & 0xF]; - } - return id; -} - -bool is_private_ipv4(std::string_view host) { - if (host.rfind("192.168.", 0) == 0 || host.rfind("10.", 0) == 0) { - return true; - } - if (host.rfind("172.", 0) == 0) { - const std::size_t second = host.find('.', 5); - if (second != std::string_view::npos) { - const int octet = std::stoi(std::string{host.substr(5, second - 5)}); - return octet >= 16 && octet <= 31; - } - } - return false; -} - -// Ordering for trying a receiver's addresses: private IPv4 first (LANs, -// most reliable), then public IPv4, ULA, and global IPv6. 6to4 (2002::) and -// link-local addresses last: 6to4 is frequently unreachable between LAN -// peers, and link-local needs a scope id to even route. -int address_preference(std::string_view host) { - if (host.find(':') == std::string_view::npos) { - return is_private_ipv4(host) ? 0 : 1; - } - if (host.rfind("fd", 0) == 0 || host.rfind("fc", 0) == 0) { - return 2; - } - if (host.rfind("2002:", 0) == 0) { - return 4; - } - if (host.rfind("fe80:", 0) == 0) { - return 5; - } - return 3; -} - std::vector discover_peers(int timeout_seconds, std::string& error) { std::vector peers; std::mutex mutex; @@ -140,161 +103,154 @@ std::vector discover_peers(int timeout_seconds, std::string& #ifdef SC_HAS_SENDER -// Offer, wait for the answer, and stream to the negotiated endpoint. The -// channel must already be connected. -int negotiate_and_stream(sc::SignalingChannel& channel, const sc::Endpoint& signaling, const sc::SendCommand& command) { - std::promise answer_promise; - auto answer_future = answer_promise.get_future(); - std::atomic answered{false}; - channel.on_message([&](const sc::SignalingMessage& message) { - if (const sc::SessionAnswer* answer = std::get_if(&message)) { - if (!answered.exchange(true)) { - answer_promise.set_value(*answer); - } - } - }); - - sc::SessionOffer offer; - offer.session_id = make_session_id(); - offer.codec_name = "h264"; - offer.frame_rate_num = 25; - offer.frame_rate_den = 1; - channel.send(offer); - - if (answer_future.wait_for(std::chrono::seconds(5)) != std::future_status::ready) { - std::cerr << "screencast: the receiver did not answer the session offer\n"; - channel.disconnect(); - return 1; - } - const sc::SessionAnswer answer = answer_future.get(); - // The channel stays open: the receiver sends PLI keyframe requests over - // it during the session. - - if (answer.session_id != offer.session_id) { - std::cerr << "screencast: session mismatch in the receiver's answer\n"; - return 1; - } - - // Stream to the negotiated RTP endpoint. An empty address means - // "the address you reached me on". - const sc::Endpoint rtp_endpoint = answer.rtp_endpoint.address.empty() - ? sc::Endpoint{signaling.address, answer.rtp_endpoint.port} - : answer.rtp_endpoint; - - sc::SenderPipelineConfig config; - if (command.target == "window") { - config.capture_target = sc::CaptureTargetWindow{}; - } else { - config.capture_target = sc::CaptureTargetWholeScreen{}; - } - config.peer_rtp_endpoint = rtp_endpoint; - config.encoder.bitrate_kbps = command.bitrate_kbps; - - std::cout << std::format("screencast: session {} established; streaming to {}:{}\n", - offer.session_id, - rtp_endpoint.address, - rtp_endpoint.port); - - sc::SenderPipeline pipeline{std::move(config)}; - if (!pipeline.start()) { - channel.disconnect(); - return 1; - } - channel.on_message([&](const sc::SignalingMessage& message) { - const sc::SessionPli* pli = std::get_if(&message); - if (pli != nullptr && pli->session_id == offer.session_id) { - pipeline.request_keyframe(); - std::cerr << "screencast: receiver requested a keyframe\n"; - } - }); - while (!g_interrupted.load()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - pipeline.stop(); - channel.disconnect(); - return 0; -} - int run_sender(const sc::SendCommand& command) { // Find the receiver's signaling endpoint: explicit --peer, or discover // exactly one receiver on the LAN. A receiver may resolve to several // addresses; try them in reachability order until the signaling // connection succeeds. - auto channel_result = sc::SignalingFactory::create_client(); - if (sc::is_network_error(channel_result)) { - std::cerr << std::format("screencast: {}\n", sc::network_error(channel_result).message); - return 1; - } - auto channel = std::move(sc::network_value(channel_result)); - sc::Endpoint signaling; + sc::CaptureTarget target = sc::CaptureTargetWholeScreen{}; + if (command.target == "window") { + target = sc::CaptureTargetWindow{}; + } + if (!command.peer_address.empty()) { signaling = parse_endpoint(command.peer_address, kDefaultSignalingPort); - if (!channel->connect(signaling)) { - std::cerr << std::format( - "screencast: failed to connect to the receiver at {}:{}\n", signaling.address, signaling.port); + } else { + std::string error; + std::vector peers = discover_peers(kSenderDiscoveryTimeoutSeconds, error); + if (!error.empty()) { + std::cerr << std::format("screencast: discovery failed: {}\n", error); return 1; } - return negotiate_and_stream(*channel, signaling, command); - } - - std::string error; - std::vector peers = discover_peers(kSenderDiscoveryTimeoutSeconds, error); - if (!error.empty()) { - std::cerr << std::format("screencast: discovery failed: {}\n", error); - return 1; - } - if (peers.empty()) { - std::cerr << "screencast: no receiver found on the LAN; run 'screencast --receive' on the " - "target machine, or pass --peer\n"; - return 1; - } - - // Group the addresses by receiver (name + signaling port): one entry - // per address, but they are all the same host. - std::map, std::vector> receivers; - for (const sc::DiscoveredPeer& peer : peers) { - receivers[{peer.service_name, peer.signaling_port}].push_back(peer.host); - } - if (receivers.size() > 1) { - for (const auto& [key, hosts] : receivers) { - std::cerr << std::format( - "screencast: {} at {}\n", - key.first, - std::accumulate(hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) { - return lhs.empty() ? rhs : lhs + ", " + rhs; - })); + if (peers.empty()) { + std::cerr << "screencast: no receiver found on the LAN; run 'screencast --receive' on the " + "target machine, or pass --peer\n"; + return 1; } - std::cerr << "screencast: multiple receivers found; pass --peer to choose one\n"; - return 1; - } - auto [name, port] = receivers.begin()->first; - std::vector hosts = receivers.begin()->second; - std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) { - return address_preference(lhs) < address_preference(rhs); - }); - std::cout << std::format("screencast: found receiver '{}'\n", name); - - bool connected = false; - std::string tried; - for (const std::string& host : hosts) { - if (channel->connect(sc::Endpoint{host, port})) { - signaling = sc::Endpoint{host, port}; - connected = true; - break; + // Group the addresses by receiver (name + signaling port): one entry + // per address, but they are all the same host. + std::map, std::vector> receivers; + for (const sc::DiscoveredPeer& peer : peers) { + receivers[{peer.service_name, peer.signaling_port}].push_back(peer.host); } - tried += (tried.empty() ? "" : ", ") + host; + if (receivers.size() > 1) { + for (const auto& [key, hosts] : receivers) { + std::cerr << std::format( + "screencast: {} at {}\n", + key.first, + std::accumulate( + hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) { + return lhs.empty() ? rhs : lhs + ", " + rhs; + })); + } + std::cerr << "screencast: multiple receivers found; pass --peer to choose one\n"; + return 1; + } + + auto [name, port] = receivers.begin()->first; + std::vector hosts = receivers.begin()->second; + std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) { + return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs); + }); + std::cout << std::format("screencast: found receiver '{}'\n", name); + signaling = sc::Endpoint{hosts.front(), port}; } - if (!connected) { - std::cerr << std::format("screencast: could not reach the receiver (tried {})\n", tried); + + auto session_result = sc::SenderSession::start(signaling, command.bitrate_kbps, target); + if (auto* error = std::get_if(&session_result)) { + std::cerr << std::format("screencast: {}\n", *error); return 1; } - return negotiate_and_stream(*channel, signaling, command); + auto session = std::move(std::get(session_result)); + std::cout << std::format("screencast: session {} streaming to {}\n", session.session_id(), session.receiver()); + + while (!g_interrupted.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + session.stop(); + return 0; } #endif // SC_HAS_SENDER +#ifdef SC_HAS_SENDER +void spawn_detached_sender(const std::string& peer, int bitrate_kbps) { + char self_path[4096] = {}; + const ssize_t length = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1); + if (length <= 0) { + return; + } + self_path[length] = '\0'; + + const pid_t child = fork(); + if (child != 0) { + return; // parent returns immediately; the child streams detached + } + setsid(); + (void)freopen("/dev/null", "w", stdout); + const std::string bitrate = std::to_string(bitrate_kbps); + (void)execl(self_path, + "screencast", + "--send", + "--peer", + peer.c_str(), + "--bitrate", + bitrate.c_str(), + static_cast(nullptr)); + _exit(127); +} +#endif + +void print_waybar_status() { + const auto state = sc::read_sender_state(); + if (!state.has_value()) { + std::cout << std::format("{{\"text\":\"\u23f8\",\"alt\":\"idle\",\"class\":\"idle\"," + "\"tooltip\":\"screencast idle \u2014 click to stream to the last receiver\n" + "right-click: open the panel\"}}\n"); + return; + } + + const std::int64_t elapsed_ms = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) + .count() - + state->started_epoch_ms; + const std::int64_t minutes = elapsed_ms / 60000; + const std::int64_t seconds = (elapsed_ms / 1000) % 60; + std::cout << std::format("{{\"text\":\"\u25b6\",\"alt\":\"streaming\",\"class\":\"streaming\"," + "\"tooltip\":\"screencast \u2192 {}\\n{} kbps \u00b7 {}m {:02}s\\nsession {}\"}}\n", + state->receiver, + state->bitrate_kbps, + minutes, + seconds, + state->session_id); +} + +int run_waybar(const sc::WaybarCommand& command) { + if (command.toggle) { + if (const auto state = sc::read_sender_state(); state.has_value()) { + (void)kill(static_cast(state->pid), SIGTERM); + // Give the graceful shutdown time to withdraw its state file so + // the immediately following status print reflects reality. + for (int attempt = 0; attempt < 20; ++attempt) { + if (!sc::read_sender_state().has_value()) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } else { +#ifdef SC_HAS_SENDER + if (const auto last = sc::read_last_session(); last.has_value()) { + spawn_detached_sender(last->peer, last->bitrate_kbps); + } +#endif + } + } + print_waybar_status(); + return 0; +} + int run_receiver(const sc::ReceiveCommand& command) { sc::ReceiverPipelineConfig config; config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast(command.local_rtp_port)}; @@ -334,7 +290,7 @@ int run_discover(const sc::DiscoverCommand& command) { } for (auto& [key, hosts] : receivers) { std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) { - return address_preference(lhs) < address_preference(rhs); + return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs); }); std::string joined = std::accumulate(hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) { @@ -368,5 +324,8 @@ int main(int argc, char* argv[]) { if (const sc::ReceiveCommand* receive = std::get_if(&*command)) { return run_receiver(*receive); } + if (const sc::WaybarCommand* waybar = std::get_if(&*command)) { + return run_waybar(*waybar); + } return run_discover(std::get(*command)); } diff --git a/src/app/meson.build b/src/app/meson.build index c686210..1ed0c33 100644 --- a/src/app/meson.build +++ b/src/app/meson.build @@ -1,3 +1,23 @@ +# Shared application internals used by both the CLI binary and the GUI: +# pipelines, session orchestration, and the state store. + +sc_app_core_sources = files( + 'pipelines.cpp', + 'sender_session.cpp', + 'state_store.cpp', +) + +sc_app_core = static_library('sc_app_core', + sc_app_core_sources, + include_directories : [sc_core_inc, include_directories('.')], + cpp_args : ['-DSC_HAS_SENDER=1'], + dependencies : [dep_json]) + +sc_app_core_dep = declare_dependency( + link_with : sc_app_core, + include_directories : include_directories('.'), + dependencies : [dep_json]) + # The screencast application binary wiring every module together. # Receiver-only builds (-Dsender=false) exclude the capture backend and the # sender pipeline. @@ -5,10 +25,9 @@ screencast_sources = [ 'cli.cpp', 'main.cpp', - 'pipelines.cpp', ] -screencast_dependencies = [sc_codec_dep, sc_network_dep, sc_render_dep] +screencast_dependencies = [sc_app_core_dep, sc_codec_dep, sc_network_dep, sc_render_dep] screencast_arguments = [] if build_sender @@ -16,7 +35,7 @@ if build_sender screencast_arguments += ['-DSC_HAS_SENDER=1'] endif -screencast = executable('screencast', +executable('screencast', screencast_sources, cpp_args : screencast_arguments, dependencies : screencast_dependencies, diff --git a/src/app/pipelines.cpp b/src/app/pipelines.cpp index 513c6e0..db31bc1 100644 --- a/src/app/pipelines.cpp +++ b/src/app/pipelines.cpp @@ -1,11 +1,15 @@ #include "screencast/app/pipeline.h" +#include "state_store.h" + #include "screencast/network/discovery.h" #include "screencast/network/h264_packetizer.h" #include "screencast/network/signaling.h" #include +#include + #include #include #include @@ -53,10 +57,27 @@ class SenderPipeline::Impl { transport_->set_peer(config_.peer_rtp_endpoint); run_thread_ = std::jthread([this](std::stop_token stop_token) { run(std::move(stop_token)); }); + + // Publish the session for status widgets and one-click restarts. + // The RTP peer is what a restart needs; the session id identifies it. + write_sender_state(SenderState{ + .session_id = config_.session_id, + .receiver = std::format("{}:{}", config_.peer_rtp_endpoint.address, config_.peer_rtp_endpoint.port), + .bitrate_kbps = config_.encoder.bitrate_kbps, + .pid = ::getpid(), + .started_epoch_ms = 0}); + // A restart targets the receiver's signaling endpoint; the RTP + // endpoint is re-negotiated from it. + std::string restart_peer = std::format("{}:5005", config_.peer_rtp_endpoint.address); + if (config_.signaling_server.has_value()) { + restart_peer = std::format("{}:{}", config_.signaling_server->address, config_.signaling_server->port); + } + write_last_session(LastSession{.peer = restart_peer, .bitrate_kbps = config_.encoder.bitrate_kbps}); return true; } void stop() { + remove_sender_state(); if (capture_ != nullptr) { capture_->stop(); } diff --git a/src/app/sender_session.cpp b/src/app/sender_session.cpp new file mode 100644 index 0000000..fa9d746 --- /dev/null +++ b/src/app/sender_session.cpp @@ -0,0 +1,177 @@ +#include "sender_session.h" + +#include "screencast/network/signaling.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sc { + +namespace { + +bool is_private_ipv4(std::string_view host) { + if (host.rfind("192.168.", 0) == 0 || host.rfind("10.", 0) == 0) { + return true; + } + if (host.rfind("172.", 0) == 0) { + const std::size_t second = host.find('.', 5); + if (second != std::string_view::npos) { + int octet = 0; + const auto [pointer, error] = std::from_chars(host.data() + 5, host.data() + second, octet); + if (error == std::errc{}) { + return octet >= 16 && octet <= 31; + } + } + } + return false; +} + +} // namespace + +int SenderSession::address_preference(std::string_view host) { + if (host.find(':') == std::string_view::npos) { + return is_private_ipv4(host) ? 0 : 1; + } + if (host.rfind("fd", 0) == 0 || host.rfind("fc", 0) == 0) { + return 2; + } + if (host.rfind("2002:", 0) == 0) { + return 4; + } + if (host.rfind("fe80:", 0) == 0) { + return 5; + } + return 3; +} + +std::variant +SenderSession::start(const Endpoint& signaling_endpoint, int bitrate_kbps, CaptureTarget target) { + auto channel_result = SignalingFactory::create_client(); + if (is_network_error(channel_result)) { + return network_error(channel_result).message; + } + auto channel = std::move(network_value(channel_result)); + if (!channel->connect(signaling_endpoint)) { + return std::format( + "failed to connect to the receiver at {}:{}", signaling_endpoint.address, signaling_endpoint.port); + } + + // Offer + answer. + static std::atomic session_counter{0}; + const std::string session_id = std::format( + "sc-{:x}-{:x}", + static_cast(std::chrono::steady_clock::now().time_since_epoch().count()) & 0xffffffffU, + session_counter.fetch_add(1)); + + std::promise answer_promise; + auto answer_future = answer_promise.get_future(); + std::atomic answered{false}; + channel->on_message([&](const SignalingMessage& message) { + if (const SessionAnswer* answer = std::get_if(&message)) { + if (!answered.exchange(true)) { + answer_promise.set_value(*answer); + } + } + }); + + SessionOffer offer; + offer.session_id = session_id; + offer.codec_name = "h264"; + offer.frame_rate_num = 25; + offer.frame_rate_den = 1; + channel->send(offer); + + if (answer_future.wait_for(std::chrono::seconds(5)) != std::future_status::ready) { + channel->disconnect(); + return std::string{"the receiver did not answer the session offer"}; + } + const SessionAnswer answer = answer_future.get(); + if (answer.session_id != session_id) { + channel->disconnect(); + return std::string{"session mismatch in the receiver's answer"}; + } + if (answer.rtp_endpoint.port == 0) { + channel->disconnect(); + return std::string{"the receiver did not provide an RTP port"}; + } + + // Stream to the negotiated endpoint; an empty address means "the + // address you reached me on". + const Endpoint rtp_endpoint = answer.rtp_endpoint.address.empty() + ? Endpoint{signaling_endpoint.address, answer.rtp_endpoint.port} + : answer.rtp_endpoint; + + SenderPipelineConfig config; + config.capture_target = target; + config.peer_rtp_endpoint = rtp_endpoint; + config.signaling_server = signaling_endpoint; + config.encoder.bitrate_kbps = bitrate_kbps; + config.session_id = session_id; + + auto pipeline = std::make_unique(std::move(config)); + if (!pipeline->start()) { + channel->disconnect(); + return std::string{"failed to start the sender pipeline"}; + } + + // PLI feedback over the still-open channel. + channel->on_message([pipeline = pipeline.get(), session_id](const SignalingMessage& message) { + const SessionPli* pli = std::get_if(&message); + if (pli != nullptr && pli->session_id == session_id) { + pipeline->request_keyframe(); + } + }); + + SenderSession session; + session.channel_ = std::move(channel); + session.pipeline_ = std::move(pipeline); + session.session_id_ = session_id; + session.receiver_ = std::format("{}:{}", rtp_endpoint.address, rtp_endpoint.port); + return session; +} + +SenderSession::SenderSession(SenderSession&& other) noexcept + : channel_(std::move(other.channel_)), + pipeline_(std::move(other.pipeline_)), + session_id_(std::move(other.session_id_)), + receiver_(std::move(other.receiver_)), + stopped_(other.stopped_) {} + +SenderSession& SenderSession::operator=(SenderSession&& other) noexcept { + stop(); + channel_ = std::move(other.channel_); + pipeline_ = std::move(other.pipeline_); + session_id_ = std::move(other.session_id_); + receiver_ = std::move(other.receiver_); + stopped_ = other.stopped_; + return *this; +} + +SenderSession::~SenderSession() { + stop(); +} + +void SenderSession::stop() { + if (stopped_) { + return; + } + stopped_ = true; + // The channel first: disconnecting joins its reader threads, so no PLI + // callback can race the pipeline teardown it points at. + if (channel_ != nullptr) { + channel_->disconnect(); + channel_ = nullptr; + } + if (pipeline_ != nullptr) { + pipeline_->stop(); + pipeline_ = nullptr; + } +} + +} // namespace sc diff --git a/src/app/sender_session.h b/src/app/sender_session.h new file mode 100644 index 0000000..5c492fb --- /dev/null +++ b/src/app/sender_session.h @@ -0,0 +1,55 @@ +#pragma once + +#include "screencast/app/pipeline.h" + +#include +#include +#include +#include +#include + +namespace sc { + +// A running sender session: it negotiated over signaling (keeping the +// channel open for PLI feedback) and drives the sender pipeline. Shared by +// the CLI, the GUI, and the waybar widget's one-click restart. +class SenderSession { + public: + // Blocking: connects, offers, waits for the answer, and starts the + // pipeline — which includes the portal's interactive source picker. + // Returns an error message on failure. + static std::variant + start(const Endpoint& signaling_endpoint, int bitrate_kbps, CaptureTarget target); + + // Ordering for trying a receiver's addresses: private IPv4 first (LANs, + // most reliable), then public IPv4, ULA, and global IPv6. 6to4 (2002::) + // and link-local addresses last: 6to4 is frequently unreachable between + // LAN peers, and link-local needs a scope id to even route. + static int address_preference(std::string_view host); + + SenderSession() = default; + ~SenderSession(); + + SenderSession(SenderSession&& other) noexcept; + SenderSession& operator=(SenderSession&& other) noexcept; + + // Graceful stop: withdraws state and closes the signaling channel. + void stop(); + + [[nodiscard]] const std::string& session_id() const { + return session_id_; + } + + [[nodiscard]] const std::string& receiver() const { + return receiver_; + } + + private: + std::unique_ptr channel_; + std::unique_ptr pipeline_; + std::string session_id_; + std::string receiver_; + bool stopped_ = false; +}; + +} // namespace sc diff --git a/src/app/state_store.cpp b/src/app/state_store.cpp new file mode 100644 index 0000000..c500428 --- /dev/null +++ b/src/app/state_store.cpp @@ -0,0 +1,140 @@ +#include "state_store.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace sc { +namespace { + +namespace fs = std::filesystem; + +fs::path runtime_dir() { + const char* xdg = std::getenv("XDG_RUNTIME_DIR"); + if (xdg != nullptr && *xdg != '\0') { + return fs::path{xdg} / "screencast"; + } + return fs::temp_directory_path() / ("screencast-" + std::to_string(::getuid())); +} + +fs::path config_dir() { + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + if (xdg != nullptr && *xdg != '\0') { + return fs::path{xdg} / "screencast"; + } + const char* home = std::getenv("HOME"); + return fs::path{home != nullptr ? home : "."} / ".config" / "screencast"; +} + +std::optional read_file(const fs::path& path) { + std::ifstream file{path, std::ios::binary}; + if (!file.is_open()) { + return std::nullopt; + } + std::ostringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} + +bool write_file(const fs::path& path, std::string_view contents) { + std::error_code error; + fs::create_directories(path.parent_path(), error); + std::ofstream file{path, std::ios::binary | std::ios::trunc}; + if (!file.is_open()) { + return false; + } + file.write(contents.data(), static_cast(contents.size())); + return file.good(); +} + +std::int64_t epoch_ms() { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + return std::chrono::duration_cast(now).count(); +} + +bool process_alive(int pid) { + if (pid <= 0) { + return false; + } + return ::kill(static_cast(pid), 0) == 0; +} + +} // namespace + +bool write_sender_state(const SenderState& state) { + nlohmann::json json; + json["session_id"] = state.session_id; + json["receiver"] = state.receiver; + json["bitrate_kbps"] = state.bitrate_kbps; + json["pid"] = state.pid; + json["started_epoch_ms"] = state.started_epoch_ms != 0 ? state.started_epoch_ms : epoch_ms(); + return write_file(runtime_dir() / "sender.json", json.dump() + "\n"); +} + +bool remove_sender_state() { + std::error_code error; + const bool removed = fs::remove(runtime_dir() / "sender.json", error); + return removed || !fs::exists(runtime_dir() / "sender.json"); +} + +std::optional read_sender_state() { + const std::optional contents = read_file(runtime_dir() / "sender.json"); + if (!contents.has_value()) { + return std::nullopt; + } + + const nlohmann::json json = nlohmann::json::parse(*contents, nullptr, /*allow_exceptions=*/false); + if (json.is_discarded() || !json.is_object()) { + return std::nullopt; + } + + SenderState state; + state.session_id = json.value("session_id", std::string{}); + state.receiver = json.value("receiver", std::string{}); + state.bitrate_kbps = json.value("bitrate_kbps", 0); + state.pid = json.value("pid", 0); + state.started_epoch_ms = json.value("started_epoch_ms", std::int64_t{0}); + + // A state file without a live process is a crash remnant: not streaming. + if (!process_alive(state.pid)) { + (void)remove_sender_state(); + return std::nullopt; + } + return state; +} + +void write_last_session(const LastSession& last) { + nlohmann::json json; + json["peer"] = last.peer; + json["bitrate_kbps"] = last.bitrate_kbps; + (void)write_file(config_dir() / "last-session.json", json.dump() + "\n"); +} + +std::optional read_last_session() { + const std::optional contents = read_file(config_dir() / "last-session.json"); + if (!contents.has_value()) { + return std::nullopt; + } + + const nlohmann::json json = nlohmann::json::parse(*contents, nullptr, /*allow_exceptions=*/false); + if (json.is_discarded() || !json.is_object()) { + return std::nullopt; + } + LastSession last; + last.peer = json.value("peer", std::string{}); + last.bitrate_kbps = json.value("bitrate_kbps", 0); + if (last.peer.empty() || last.bitrate_kbps <= 0) { + return std::nullopt; + } + return last; +} + +} // namespace sc diff --git a/src/app/state_store.h b/src/app/state_store.h new file mode 100644 index 0000000..5b505f6 --- /dev/null +++ b/src/app/state_store.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +namespace sc { + +// Live state of a running sender, written by the pipeline so status +// widgets (waybar) and one-click restarts work for every front-end. +struct SenderState { + std::string session_id; + std::string receiver; // "host:port" of the RTP endpoint + int bitrate_kbps = 0; + int pid = 0; + std::int64_t started_epoch_ms = 0; +}; + +// The last successfully started session, persisted across reboots so a +// single click can restart streaming without picking anything. +struct LastSession { + std::string peer; // "host" or "host:port" for the signaling endpoint + int bitrate_kbps = 0; +}; + +bool write_sender_state(const SenderState& state); +bool remove_sender_state(); +// Returns nullopt when no sender is running (a stale state file is +// treated as not streaming and is cleaned up). +std::optional read_sender_state(); + +void write_last_session(const LastSession& last); +std::optional read_last_session(); + +} // namespace sc diff --git a/src/gui/gui.cpp b/src/gui/gui.cpp new file mode 100644 index 0000000..27e1d68 --- /dev/null +++ b/src/gui/gui.cpp @@ -0,0 +1,309 @@ +// GTK4 sender panel: discover receivers on the LAN, pick one, set the +// bitrate, and start/stop streaming. The blocking parts (portal source +// picker, negotiation) run on worker threads so the UI stays responsive. + +#include "screencast/network/discovery.h" + +#include "sender_session.h" +#include "state_store.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sc::DiscoveredPeer; +using sc::Endpoint; + +struct ReceiverRow { + std::string name; + std::string host; + std::uint16_t signaling_port = 0; +}; + +class SenderWindow : public Gtk::ApplicationWindow { + public: + SenderWindow() { + set_title("screencast"); + set_default_size(440, 400); + + auto* box = Gtk::make_managed(Gtk::Orientation::VERTICAL, 8); + set_child(*box); + box->set_margin(12); + + // Receiver list header + refresh. + auto* header = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 8); + box->append(*header); + auto* title = Gtk::make_managed(); + title->set_text("Receivers"); + title->set_hexpand(true); + title->set_halign(Gtk::Align::START); + header->append(*title); + refresh_button_ = Gtk::make_managed(); + refresh_button_->set_label("Refresh"); + refresh_button_->signal_clicked().connect(sigc::mem_fun(*this, &SenderWindow::on_refresh)); + header->append(*refresh_button_); + + scrolled_ = Gtk::make_managed(); + scrolled_->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); + scrolled_->set_vexpand(true); + box->append(*scrolled_); + receiver_list_ = Gtk::make_managed(); + receiver_list_->set_selection_mode(Gtk::SelectionMode::SINGLE); + receiver_list_->signal_row_selected().connect( + [this](Gtk::ListBoxRow*) { Glib::signal_idle().connect_once([this] { update_sensitivity(); }); }); + scrolled_->set_child(*receiver_list_); + + // Bitrate. + auto* bitrate_box = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 8); + box->append(*bitrate_box); + bitrate_label_ = Gtk::make_managed(); + bitrate_label_->set_hexpand(true); + bitrate_label_->set_halign(Gtk::Align::START); + bitrate_box->append(*bitrate_label_); + bitrate_scale_ = Gtk::make_managed(Gtk::Orientation::HORIZONTAL); + bitrate_scale_->set_range(500.0, 20000.0); + bitrate_scale_->set_value(4000.0); + bitrate_scale_->set_increments(500.0, 1000.0); + bitrate_scale_->set_draw_value(false); + bitrate_scale_->set_hexpand(true); + bitrate_scale_->signal_value_changed().connect([this] { update_bitrate_label(); }); + bitrate_box->append(*bitrate_scale_); + update_bitrate_label(); + + start_button_ = Gtk::make_managed(); + start_button_->set_label("Start"); + start_button_->signal_clicked().connect(sigc::mem_fun(*this, &SenderWindow::on_start_stop)); + box->append(*start_button_); + + status_label_ = Gtk::make_managed(); + status_label_->set_wrap(true); + status_label_->set_halign(Gtk::Align::START); + status_label_->set_valign(Gtk::Align::START); + status_label_->set_vexpand(true); + box->append(*status_label_); + + // Per-second status refresh (elapsed time, liveness heartbeat). + Glib::signal_timeout().connect_seconds( + [this]() -> bool { + update_status(); + return true; + }, + 1); + + update_sensitivity(); + on_refresh(); + } + + ~SenderWindow() override { + if (worker_.joinable()) { + worker_.join(); + } + } + + private: + void update_bitrate_label() { + bitrate_label_->set_text(std::format("Bitrate: {} kbps", current_bitrate())); + } + + int current_bitrate() const { + return static_cast(bitrate_scale_->get_value()); + } + + void clear_receiver_rows() { + for (Gtk::Widget* row : receiver_rows_) { + receiver_list_->remove(*row); + } + receiver_rows_.clear(); + receivers_.clear(); + } + + void on_refresh() { + refresh_button_->set_sensitive(false); + status("discovering receivers…"); + clear_receiver_rows(); + + worker_ = std::jthread([this](std::stop_token stop_token) { + std::vector peers; + std::mutex mutex; + std::string error; + + auto discovery_result = sc::DiscoveryFactory::create_avahi(); + if (sc::is_network_error(discovery_result)) { + error = sc::network_error(discovery_result).message; + } else { + auto discovery = std::move(sc::network_value(discovery_result)); + if (!discovery->browse([&](const DiscoveredPeer& peer) { + std::lock_guard lock(mutex); + const bool known = std::any_of(peers.begin(), peers.end(), [&](const DiscoveredPeer& existing) { + return existing.service_name == peer.service_name && + existing.signaling_port == peer.signaling_port; + }); + if (!known) { + peers.push_back(peer); + } + })) { + error = discovery->last_error(); + } + if (!stop_token.stop_requested()) { + std::this_thread::sleep_for(std::chrono::seconds(3)); + } + discovery->stop(); + } + + Glib::signal_idle().connect_once([this, peers = std::move(peers), error = std::move(error)]() mutable { + receivers_ = group_receivers(std::move(peers)); + for (const ReceiverRow& row : receivers_) { + auto* label = Gtk::make_managed(); + label->set_text(std::format("{}\n{}", row.name, row.host)); + label->set_halign(Gtk::Align::START); + receiver_list_->append(*label); + receiver_rows_.push_back(label); + } + refresh_button_->set_sensitive(true); + update_sensitivity(); + status(error.empty() ? (receivers_.empty() ? "no receivers found" + : std::format("{} receiver(s)", receivers_.size())) + : "discovery failed: " + error); + }); + }); + } + + static std::vector group_receivers(std::vector peers) { + std::map, std::vector> grouped; + for (DiscoveredPeer& peer : peers) { + grouped[{peer.service_name, peer.signaling_port}].push_back(std::move(peer.host)); + } + std::vector rows; + for (auto& [key, hosts] : grouped) { + std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) { + return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs); + }); + ReceiverRow row; + row.name = key.first; + row.host = std::move(hosts.front()); + row.signaling_port = key.second; + rows.push_back(std::move(row)); + } + return rows; + } + + void on_start_stop() { + if (session_.has_value()) { + auto session = std::move(*session_); + session_ = std::nullopt; + start_button_->set_sensitive(false); + status("stopping…"); + worker_ = std::jthread([this, session = std::move(session)](std::stop_token) mutable { + session.stop(); + Glib::signal_idle().connect_once([this] { + start_button_->set_label("Start"); + update_sensitivity(); + status("idle"); + }); + }); + return; + } + + const int index = selected_index(); + if (index < 0) { + status("pick a receiver first"); + return; + } + const ReceiverRow& receiver = receivers_[static_cast(index)]; + const Endpoint signaling{receiver.host, receiver.signaling_port}; + const int bitrate = current_bitrate(); + + start_button_->set_sensitive(false); + status(std::format("connecting to {}… (choose a source in the portal dialog)", receiver.name)); + + worker_ = std::jthread([this, signaling, bitrate](std::stop_token) { + auto result = sc::SenderSession::start(signaling, bitrate, sc::CaptureTargetWholeScreen{}); + if (auto* error = std::get_if(&result)) { + Glib::signal_idle().connect_once([this, message = *error] { + start_button_->set_label("Start"); + update_sensitivity(); + status("failed: " + message); + }); + return; + } + // sigc++ slots require copyable lambdas; the move-only session + // travels via shared_ptr. + auto session = std::make_shared(std::move(std::get(result))); + Glib::signal_idle().connect_once([this, session] { + const std::string receiver_text = session->receiver(); + const std::string session_id = session->session_id(); + session_ = std::move(*session); + start_button_->set_label("Stop"); + update_sensitivity(); + status(std::format("streaming to {} (session {})", receiver_text, session_id)); + }); + }); + } + + int selected_index() const { + Gtk::ListBoxRow* row = receiver_list_->get_selected_row(); + return row != nullptr ? row->get_index() : -1; + } + + void update_sensitivity() { + if (session_.has_value()) { + start_button_->set_sensitive(true); + refresh_button_->set_sensitive(false); + return; + } + refresh_button_->set_sensitive(true); + start_button_->set_sensitive(selected_index() >= 0); + } + + void update_status() { + if (!session_.has_value()) { + return; + } + const auto state = sc::read_sender_state(); + if (state.has_value()) { + const std::int64_t elapsed_ms = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count() - + state->started_epoch_ms; + status(std::format("streaming to {}\nsession {}\nelapsed {}m {:02}s", + state->receiver, + state->session_id, + elapsed_ms / 60000, + (elapsed_ms / 1000) % 60)); + } + } + + void status(const std::string& text) { + status_label_->set_text(text); + } + + std::vector receivers_; + std::vector receiver_rows_; + std::optional session_; + std::jthread worker_; + Gtk::Button* refresh_button_ = nullptr; + Gtk::ScrolledWindow* scrolled_ = nullptr; + Gtk::ListBox* receiver_list_ = nullptr; + Gtk::Scale* bitrate_scale_ = nullptr; + Gtk::Label* bitrate_label_ = nullptr; + Gtk::Button* start_button_ = nullptr; + Gtk::Label* status_label_ = nullptr; +}; + +} // namespace + +int main(int argc, char** argv) { + auto app = Gtk::Application::create("io.github.screen_cast.panel"); + return app->make_window_and_run(argc, argv); +} diff --git a/src/gui/meson.build b/src/gui/meson.build new file mode 100644 index 0000000..bdaa1f8 --- /dev/null +++ b/src/gui/meson.build @@ -0,0 +1,15 @@ +# GTK4 sender panel, behind the `gui` option (default: false). The core +# CLI and tests never need GTK. + +dep_gtkmm = dependency('gtkmm-4.0') + +screencast_gui_sources = files( + 'gui.cpp', +) + +executable('screencast-gui', + screencast_gui_sources, + include_directories : [sc_core_inc, include_directories('../app')], + dependencies : [dep_gtkmm, sc_app_core_dep, sc_capture_dep, sc_codec_dep, sc_network_dep, + sc_render_dep], + install : true) \ No newline at end of file diff --git a/src/meson.build b/src/meson.build index 1476027..283d533 100644 --- a/src/meson.build +++ b/src/meson.build @@ -32,3 +32,7 @@ subdir('network') subdir('render') subdir('app') + +if get_option('gui') + subdir('gui') +endif