diff --git a/client_lib/src/config.rs b/client_lib/src/config.rs index 7ef9a0dd..c44ef8a1 100644 --- a/client_lib/src/config.rs +++ b/client_lib/src/config.rs @@ -5,25 +5,14 @@ use serde::{Deserialize, Serialize}; use std::cmp; use std::fs::File; use std::io::Read; -use std::time::Duration; use yaml_rust::YamlLoader; -#[derive(Debug, Serialize, Deserialize)] -pub enum SnapshotManagement { - /// Always render the latest snapshot. - Single, -} - #[derive(Debug, Serialize, Deserialize)] pub struct ClientConfig { /// Hostname of the machine running the controller. pub server_hostname: String, /// Virtual video channel to listen to. pub video_channel: u64, - /// UNUSED - preserved until client machines are updated - pub render_delay: Duration, - /// UNUSED - preserved until client machines are updated - pub snapshot_management: SnapshotManagement, pub x_resolution: u32, pub y_resolution: u32, /// If true, set the window to fullscreen on creation. @@ -61,7 +50,6 @@ impl ClientConfig { ClientConfig { server_hostname: host, video_channel, - render_delay: Default::default(), x_resolution, y_resolution, fullscreen, @@ -72,7 +60,6 @@ impl ClientConfig { y_center: f64::from(y_resolution / 2), transformation, log_level_debug, - snapshot_management: SnapshotManagement::Single, } } diff --git a/scripts/build-app.sh b/scripts/build-app.sh index ac31320b..60a544c2 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -65,12 +65,14 @@ for bin in tunnelclient tunnel-bootstrap bootstrap-deploy; do chmod +x "$APP/Contents/MacOS/$bin" done -# Helper script for viewing logs. -cat > "$APP/Contents/MacOS/view-logs.sh" <<'LOGSCRIPT' +# Helper script for viewing logs. Lives in Resources/ rather than MacOS/ +# because Contents/MacOS/ is conventionally Mach-O-only and codesign +# refuses non-Mach-O subcomponents there when signing without --deep. +cat > "$APP/Contents/Resources/view-logs.sh" <<'LOGSCRIPT' #!/bin/bash log stream --predicate 'subsystem == "com.generalelectrix.tunnels"' LOGSCRIPT -chmod +x "$APP/Contents/MacOS/view-logs.sh" +chmod +x "$APP/Contents/Resources/view-logs.sh" cp "$ICNS" "$APP/Contents/Resources/Tunnels.icns" cp "$PROJECT_DIR/controller_templates/tunnels.touchosc" "$APP/Contents/Resources/tunnels.touchosc" @@ -111,8 +113,26 @@ cat > "$APP/Contents/Info.plist" < PLIST +echo "==> Signing helper binaries..." +# Each helper gets its own ad-hoc identity. tunnelclient especially must NOT +# inherit the bundle's CFBundleIdentifier — it gets pushed to remote render +# machines by tunnel-bootstrap and runs there free-standing, without the +# bundle's Info.plist context. Identifying as the bundle there causes macOS +# TCC's Local Network privacy gate to silently deny its outbound LAN +# connection to the show host. (See v2026.04.18-1 regression.) +for bin in tunnelclient tunnel-bootstrap bootstrap-deploy; do + codesign -s - --force \ + --identifier "com.generalelectrix.tunnels.$bin" \ + "$APP/Contents/MacOS/$bin" +done + echo "==> Signing app bundle..." -codesign -s - --force --deep --identifier com.generalelectrix.tunnels "$APP" +# Bundle-level sign handles the main executable AND the resources +# manifest. No --deep — helpers are pre-signed above with their own +# identifiers, and we want to preserve those. +codesign -s - --force \ + --identifier com.generalelectrix.tunnels \ + "$APP" echo "==> Creating DMG..." BG_PNG="$PROJECT_DIR/dist/dmg-background.png" diff --git a/tunnelclient/src/show.rs b/tunnelclient/src/show.rs index 4a5de7a4..1fe7cde4 100644 --- a/tunnelclient/src/show.rs +++ b/tunnelclient/src/show.rs @@ -1,12 +1,13 @@ use anyhow::{Result, anyhow}; use client_lib::config::ClientConfig; -use graphics::clear; +use graphics::{CircleArc, Context, clear}; use log::{error, info}; use opengl_graphics::{GlGraphics, OpenGL}; use piston_window::prelude::*; use sdl2_window::Sdl2Window; use std::sync::{Arc, Mutex}; use std::thread; +use std::time::{Duration, Instant}; use tunnelclient::draw::Draw; use tunnels_lib::RunFlag; use tunnels_lib::Snapshot; @@ -21,6 +22,8 @@ pub struct Show { cfg: ClientConfig, run_flag: RunFlag, window: PistonWindow, + /// Reference instant for animating the waiting-for-snapshot spinner. + start_time: Instant, } impl Show { @@ -57,6 +60,7 @@ impl Show { cfg, run_flag, window, + start_time: Instant::now(), }) } @@ -78,21 +82,44 @@ impl Show { } /// Render a frame to the window. + /// + /// Always clears to black, then either draws the latest snapshot's + /// layers or — if no snapshot has arrived yet — a small spinner + /// indicating the client is up and waiting. The unconditional clear + /// is what keeps an unfed client from showing uninitialized GPU + /// memory as static gray noise. fn render(&mut self, args: &RenderArgs) { - let Some(snapshot) = self.snapshot_manager.lock().unwrap().clone() else { - return; - }; - + let snapshot = self.snapshot_manager.lock().unwrap().clone(); self.gl.draw(args.viewport(), |c, gl| { - // Clear the screen. clear([0.0, 0.0, 0.0, 1.0], gl); - - // Draw everything. - snapshot.layers.draw(&c, gl, &self.cfg); + match snapshot { + Some(snapshot) => snapshot.layers.draw(&c, gl, &self.cfg), + None => draw_waiting_spinner(&c, gl, &self.cfg, self.start_time.elapsed()), + } }); } } +/// Draw a small dark-gray rotating arc at screen center as a "this client +/// is alive but hasn't received a snapshot yet" indicator. +fn draw_waiting_spinner(c: &Context, gl: &mut GlGraphics, cfg: &ClientConfig, elapsed: Duration) { + use std::f64::consts::{PI, TAU}; + let cx = f64::from(cfg.x_resolution) / 2.0; + let cy = f64::from(cfg.y_resolution) / 2.0; + let radius = 20.0; + let thickness = 2.0; + // One revolution every 2 seconds. + let phase = elapsed.as_secs_f64() * 0.5 * TAU; + let arc = 1.5 * PI; // 270° + let bounds = [cx - radius, cy - radius, radius * 2.0, radius * 2.0]; + CircleArc::new([0.25, 0.25, 0.25, 1.0], thickness, phase, phase + arc).draw( + bounds, + &c.draw_state, + c.transform, + gl, + ); +} + /// Spawn a thread to receive snapshots. /// Inject them into the provided manager. /// The thread runs until the run flag is tripped.