diff --git a/Cargo.lock b/Cargo.lock index a7ed3e4..35e5794 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,6 +173,7 @@ version = "0.1.11" dependencies = [ "breakd-core", "serde", + "serde_json", "thiserror", "uuid", ] @@ -281,8 +282,12 @@ version = "0.1.11" dependencies = [ "breakd-config", "breakd-core", + "breakd-ipc", "glib", "gtk4", + "serde", + "serde_json", + "tokio", ] [[package]] diff --git a/README.md b/README.md index 9def218..5a1e324 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,10 @@ the internet. The package is also available directly as `.#breakd-relay`. breakd settings ``` -The settings window covers scheduling, break actions, strict mode, display behavior, idle reset, and tray visibility. It validates changes before saving and reloads the running daemon. Advanced monitor, recovery, message, and logging options remain available in TOML: +The settings window covers scheduling, break actions, strict mode, display +behavior, collaboration, idle reset, and tray visibility. It validates changes +before saving and reloads the running daemon. Advanced monitor, recovery, +message, and logging options remain available in TOML: ```bash mkdir -p ~/.config/breakd @@ -178,6 +181,12 @@ Co-op mode lets one host own the schedule while guests mirror its next break, active break, pause state, and permitted actions. Each computer still renders its own native overlay with its own monitor and message settings. +Open `breakd settings` and select **Collaboration** to host, copy an invite, +join, leave, and inspect the live room status without using the terminal. A +Tailscale host can enter a MagicDNS name and port such as +`lambda-1.example.ts.net:8787`; breakd turns it into the relay URL automatically. +The relay still needs to be running and reachable on that address. + Start the relay on a server (or use a relay you trust), then create a room: ```bash @@ -197,12 +206,20 @@ under different Tailscale IPv4 addresses to its owner and guest; MagicDNS maps the same hostname correctly for both. The invite is consumed by `breakd coop join` and will not join a room when opened in a browser. -The host remains authoritative: a guest's skip, postpone, pause, resume, reset, -or manual-break command is sent to the host, and the resulting host snapshot is -mirrored back. Both systems should have normal network time synchronization -enabled because scheduled starts use absolute timestamps. If snapshots stop for -10 seconds, the guest starts a fresh local schedule; reconnecting adopts the -host again. Leave at any time with `breakd coop leave`. +The host remains authoritative for anything that coordinates the room: cadence, +break duration and kind, pause state, strict/skip/postpone rules, manual resume, +and notification enablement and lead times. A guest's skip, postpone, pause, +resume, reset, or manual-break command is sent to the host, and the resulting +host snapshot is mirrored back. If manual resume is enabled by the host, a key +press or click from any participant resumes the room after the countdown reaches +zero. + +Presentation stays local: each participant keeps their own monitor selection, +display mode, opacity, pointer behavior, messages, completion sound, and tray +preference. Both systems should have normal network time synchronization enabled +because scheduled starts use absolute timestamps. If snapshots stop for 10 +seconds, the guest starts a fresh local schedule; reconnecting adopts the host +again. Leave at any time with `breakd coop leave`. The relay has no database, accounts, schedule engine, or desktop dependencies. It retains only the latest snapshot while a host is connected. Room tokens are diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index 3f565d5..dfe15ea 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -10,3 +10,6 @@ breakd-core = { path = "../core" } serde.workspace = true thiserror.workspace = true uuid.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/coop/src/lib.rs b/crates/coop/src/lib.rs index 642ef30..389d231 100644 --- a/crates/coop/src/lib.rs +++ b/crates/coop/src/lib.rs @@ -86,6 +86,21 @@ pub struct CoopSnapshot { pub postpone_count: u32, pub can_skip: bool, pub can_postpone: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, +} + +/// Host-owned behavior that affects when or how every participant takes a +/// break. Presentation-only settings such as monitor selection, colors, +/// opacity, messages, and completion sounds intentionally stay local. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoopPolicy { + pub notifications_enabled: bool, + pub mini_notification_lead_ms: u64, + pub long_notification_lead_ms: u64, + pub rest_notification_lead_ms: u64, + pub allow_postpone_during_lockout: bool, + pub inhibit_shortcuts: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -297,5 +312,37 @@ mod tests { command ); assert!(CoopAction::from_command(&Command::Status).is_none()); + assert_eq!( + CoopAction::from_command(&Command::ResumeBreak) + .unwrap() + .into_command(), + Command::ResumeBreak + ); + } + + #[test] + fn snapshots_without_the_optional_policy_remain_compatible() { + let snapshot = CoopSnapshot { + host_id: Uuid::nil(), + revision: 1, + generated_unix_ms: 10, + paused: false, + resume_at_unix_ms: None, + phase: CoopPhase::Unavailable { + reason: "test".into(), + }, + minis_since_long: 0, + longs_since_rest: 0, + postpone_count: 0, + can_skip: false, + can_postpone: false, + policy: None, + }; + let encoded = serde_json::to_value(&snapshot).unwrap(); + assert!(encoded.get("policy").is_none()); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + snapshot + ); } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 670376a..2b1d6a2 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -563,6 +563,10 @@ pub struct OverlaySpec { pub can_skip: bool, pub can_postpone: bool, pub manual_resume: bool, + #[serde(default)] + pub allow_postpone_during_lockout: bool, + #[serde(default)] + pub inhibit_shortcuts: bool, pub message: Option, pub socket_path: String, } diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 49927b8..f696e7c 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -129,7 +129,11 @@ pub async fn run() -> Result<()> { let mut shortcut_guard = HyprlandShortcutGuard::new(instance.hyprland_submap()); shortcut_guard.initialize(&config).await; shortcut_guard - .reconcile(&config, &scheduler.status(now)) + .reconcile( + &config, + scheduler.shortcut_inhibition_enabled(), + &scheduler.status(now), + ) .await; let mut ticker = interval(Duration::from_millis(250)); @@ -222,6 +226,7 @@ pub async fn run() -> Result<()> { TrayAction::Command(command) => { match execute_command( &command, + CommandOrigin::Local, &clock, &state_store, &mut scheduler, @@ -313,6 +318,7 @@ pub async fn run() -> Result<()> { let command = action.into_command(); match execute_command( &command, + CommandOrigin::CoopGuest, &clock, &state_store, &mut scheduler, @@ -366,7 +372,9 @@ pub async fn run() -> Result<()> { } else if tray_enabled { tray.update(next_tray_state).await; } - shortcut_guard.reconcile(&config, &status).await; + shortcut_guard + .reconcile(&config, scheduler.shortcut_inhibition_enabled(), &status) + .await; } shortcut_guard.release().await; Ok(()) @@ -426,6 +434,7 @@ async fn handle_request( let request_id = incoming.request.request_id; let result = execute_command( &incoming.request.command, + CommandOrigin::Local, clock, state_store, scheduler, @@ -457,9 +466,16 @@ async fn handle_request( } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CommandOrigin { + Local, + CoopGuest, +} + #[allow(clippy::too_many_arguments)] async fn execute_command( command: &Command, + origin: CommandOrigin, clock: &LinuxClock, state_store: &StateStore, scheduler: &mut Scheduler, @@ -628,8 +644,12 @@ async fn execute_command( } command => { let previous = scheduler.state().clone(); - let effects = scheduler - .handle_command(command, now) + let effects = + if origin == CommandOrigin::CoopGuest && matches!(command, Command::ResumeBreak) { + scheduler.handle_coop_resume_request(now) + } else { + scheduler.handle_command(command, now) + } .map_err(anyhow::Error::from)?; persist_if_changed(state_store, scheduler, &previous)?; apply_effects( @@ -685,7 +705,7 @@ impl HyprlandShortcutGuard { } async fn initialize(&mut self, config: &breakd_core::AppConfig) { - if !submap_fallback_enabled(config) { + if !submap_fallback_available(config) { return; } let Some(client) = &self.client else { @@ -702,8 +722,14 @@ impl HyprlandShortcutGuard { } } - async fn reconcile(&mut self, config: &breakd_core::AppConfig, status: &SchedulerStatus) { - let should_block = submap_fallback_enabled(config) + async fn reconcile( + &mut self, + config: &breakd_core::AppConfig, + inhibit_shortcuts: bool, + status: &SchedulerStatus, + ) { + let should_block = submap_fallback_available(config) + && inhibit_shortcuts && matches!( status.state.as_str(), "mini-break" | "long-break" | "rest-break" @@ -783,11 +809,8 @@ impl HyprlandShortcutGuard { } } -fn submap_fallback_enabled(config: &breakd_core::AppConfig) -> bool { - config.hyprland.enabled - && config.hyprland.submap_fallback - && config.strict.mode != breakd_core::StrictMode::Off - && config.strict.inhibit_shortcuts +fn submap_fallback_available(config: &breakd_core::AppConfig) -> bool { + config.hyprland.enabled && config.hyprland.submap_fallback } fn persist_if_changed( diff --git a/crates/relay/src/main.rs b/crates/relay/src/main.rs index 5ee1158..7474c29 100644 --- a/crates/relay/src/main.rs +++ b/crates/relay/src/main.rs @@ -413,7 +413,7 @@ fn init_logging() { #[cfg(test)] mod tests { - use breakd_coop::{CoopAction, CoopPhase, CoopSnapshot, ScheduledBreak}; + use breakd_coop::{CoopAction, CoopPhase, CoopPolicy, CoopSnapshot, ScheduledBreak}; use breakd_core::{BreakKind, DueBreakId}; use super::*; @@ -526,6 +526,14 @@ mod tests { postpone_count: 0, can_skip: false, can_postpone: false, + policy: Some(CoopPolicy { + notifications_enabled: true, + mini_notification_lead_ms: 20_000, + long_notification_lead_ms: 40_000, + rest_notification_lead_ms: 60_000, + allow_postpone_during_lockout: true, + inhibit_shortcuts: true, + }), }; route_message( &rooms, diff --git a/crates/scheduler/src/lib.rs b/crates/scheduler/src/lib.rs index ad8a411..6c76ded 100644 --- a/crates/scheduler/src/lib.rs +++ b/crates/scheduler/src/lib.rs @@ -1,4 +1,4 @@ -use breakd_coop::{CoopPhase, CoopSnapshot, ScheduledBreak, SharedBreak}; +use breakd_coop::{CoopPhase, CoopPolicy, CoopSnapshot, ScheduledBreak, SharedBreak}; use breakd_core::{ AppConfig, BreakKind, BreakSessionId, ClockSample, Command, DueBreakId, DurationMs, MissedBreakPolicy, OverlaySpec, StrictMode, @@ -51,6 +51,8 @@ pub struct ActiveBreak { pub manual_resume: bool, #[serde(default)] pub completion_sound_emitted: bool, + #[serde(default)] + pub resume_requested: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -194,6 +196,8 @@ pub struct Scheduler { socket_path: String, state: SchedulerState, last_clock: ClockSample, + coop_policy: Option, + last_notified_due: Option, } impl Scheduler { @@ -204,6 +208,8 @@ impl Scheduler { boot_id, socket_path, last_clock: now, + coop_policy: None, + last_notified_due: None, }; if scheduler.config.startup.start_paused { scheduler.state = SchedulerState::PausedIndefinitely { @@ -244,6 +250,8 @@ impl Scheduler { socket_path, state, last_clock: now, + coop_policy: None, + last_notified_due: None, }; if matches!(scheduler.state, SchedulerState::Recovering { .. }) { scheduler.state = Self::fresh_running(&scheduler.config, now); @@ -361,6 +369,38 @@ impl Scheduler { } } + /// A guest overlay can reach its wall-clock deadline a fraction before the + /// host due to clock and scheduler jitter. Queue only that forwarded + /// request when it arrives within one second of the authoritative host + /// deadline; local commands continue to require an already-finished break. + pub fn handle_coop_resume_request( + &mut self, + now: ClockSample, + ) -> Result, SchedulerError> { + self.last_clock = now; + let mut active = self + .active_break() + .cloned() + .ok_or(SchedulerError::NoActiveBreak)?; + if !active.manual_resume { + return Err(SchedulerError::NotAwaitingResume); + } + if awaiting_manual_resume(&active, now) { + return Ok(self.finish_active(active, now)); + } + const EARLY_RESUME_TOLERANCE_MS: u64 = 1_000; + if active.ends_boot_ms.saturating_sub(now.boottime_ms) > EARLY_RESUME_TOLERANCE_MS { + return Err(SchedulerError::NotAwaitingResume); + } + active.resume_requested = true; + self.state = match active.due.kind { + BreakKind::Mini => SchedulerState::MiniBreak { active }, + BreakKind::Long => SchedulerState::LongBreak { active }, + BreakKind::Rest => SchedulerState::RestBreak { active }, + }; + Ok(Vec::new()) + } + pub fn status(&self, now: ClockSample) -> SchedulerStatus { let context = self.context(); let active = self.active_break(); @@ -411,6 +451,16 @@ impl Scheduler { } } + /// Whether the active coordination policy asks this desktop to inhibit + /// compositor shortcuts. The guest keeps its local integration mechanism, + /// while the host decides whether inhibition is required. + pub fn shortcut_inhibition_enabled(&self) -> bool { + self.coop_policy.as_ref().map_or( + self.config.strict.mode != StrictMode::Off && self.config.strict.inhibit_shortcuts, + |policy| policy.inhibit_shortcuts, + ) + } + pub fn coop_snapshot( &mut self, host_id: uuid::Uuid, @@ -492,6 +542,15 @@ impl Scheduler { postpone_count: status.postpone_count, can_skip: status.can_skip, can_postpone: status.can_postpone, + policy: Some(CoopPolicy { + notifications_enabled: self.config.notifications.enabled, + mini_notification_lead_ms: self.config.notifications.mini_lead.as_millis(), + long_notification_lead_ms: self.config.notifications.long_lead.as_millis(), + rest_notification_lead_ms: self.config.notifications.rest_lead.as_millis(), + allow_postpone_during_lockout: self.config.strict.allow_postpone_during_lockout, + inhibit_shortcuts: self.config.strict.mode != StrictMode::Off + && self.config.strict.inhibit_shortcuts, + }), }, prepared_pending, ) @@ -502,6 +561,7 @@ impl Scheduler { snapshot: &CoopSnapshot, now: ClockSample, ) -> Vec { + self.coop_policy = snapshot.policy.clone(); let old_visible = visible_active(&self.state).map(|active| active.session_id); let context = |pending: Option, next_due_mono_ms: u64| ScheduleContext { cycle_started_mono_ms: now.monotonic_ms, @@ -544,6 +604,7 @@ impl Scheduler { ), manual_resume: next.manual_resume, completion_sound_emitted: false, + resume_requested: false, }; match next.kind { BreakKind::Mini => SchedulerState::MiniBreak { active }, @@ -588,6 +649,7 @@ impl Scheduler { strict_until_boot_ms, manual_resume: active.manual_resume, completion_sound_emitted: active.completion_sound_emitted, + resume_requested: false, }; match active.due.kind { BreakKind::Mini => SchedulerState::MiniBreak { active }, @@ -632,6 +694,7 @@ impl Scheduler { } pub fn reset_after_coop_disconnect(&mut self, now: ClockSample) -> Vec { + self.coop_policy = None; self.last_clock = now; self.reset(now) } @@ -670,12 +733,18 @@ impl Scheduler { if now.monotonic_ms >= context.next_due_mono_ms { return self.begin_scheduled_break(kind, context, now); } + let due_id = context + .pending + .as_ref() + .expect("pre-break context has a pending break") + .id; self.state = match kind { BreakKind::Mini => SchedulerState::PreMiniBreak { context }, BreakKind::Long => SchedulerState::PreLongBreak { context }, BreakKind::Rest => SchedulerState::PreRestBreak { context }, }; - if self.config.notifications.enabled { + if self.notifications_enabled() && self.last_notified_due != Some(due_id) { + self.last_notified_due = Some(due_id); return vec![Effect::Notify { summary: format!("{} break soon", title_kind(kind)), body: format!("Starts in {}", DurationMs::from_millis(lead)), @@ -738,6 +807,13 @@ impl Scheduler { } fn notification_lead(&self, kind: BreakKind) -> u64 { + if let Some(policy) = &self.coop_policy { + return match kind { + BreakKind::Mini => policy.mini_notification_lead_ms, + BreakKind::Long => policy.long_notification_lead_ms, + BreakKind::Rest => policy.rest_notification_lead_ms, + }; + } match kind { BreakKind::Mini => self.config.notifications.mini_lead.as_millis(), BreakKind::Long => self.config.notifications.long_lead.as_millis(), @@ -745,6 +821,14 @@ impl Scheduler { } } + fn notifications_enabled(&self) -> bool { + self.coop_policy + .as_ref() + .map_or(self.config.notifications.enabled, |policy| { + policy.notifications_enabled + }) + } + fn begin_scheduled_break( &mut self, kind: BreakKind, @@ -819,6 +903,7 @@ impl Scheduler { strict_until_boot_ms, manual_resume, completion_sound_emitted: false, + resume_requested: false, }; let effect = Effect::StartOverlay(self.overlay_spec(&active, now)); self.state = match kind { @@ -830,7 +915,7 @@ impl Scheduler { } fn expire_active(&mut self, mut active: ActiveBreak, now: ClockSample) -> Vec { - if !active.manual_resume { + if !active.manual_resume || active.resume_requested { return self.finish_active(active, now); } @@ -1160,6 +1245,13 @@ impl Scheduler { |policy| policy.can_postpone, ), manual_resume: active.manual_resume, + allow_postpone_during_lockout: self + .coop_policy + .as_ref() + .map_or(self.config.strict.allow_postpone_during_lockout, |policy| { + policy.allow_postpone_during_lockout + }), + inhibit_shortcuts: self.shortcut_inhibition_enabled(), message, socket_path: self.socket_path.clone(), } @@ -1619,6 +1711,68 @@ mod tests { )); } + #[test] + fn coop_host_owns_manual_resume_and_notification_timing() { + let mut host = test_scheduler(); + host.config.completion.manual_resume = true; + host.config.notifications.enabled = true; + host.config.notifications.mini_lead = DurationMs::from_millis(400); + host.config.strict.mode = StrictMode::Entire; + host.config.strict.allow_postpone_during_lockout = true; + host.config.strict.inhibit_shortcuts = true; + + let mut guest = test_scheduler(); + guest.config.completion.manual_resume = false; + guest.config.notifications.enabled = false; + guest.config.notifications.mini_lead = DurationMs::from_millis(10); + guest.config.strict.mode = StrictMode::Off; + guest.config.strict.allow_postpone_during_lockout = false; + guest.config.strict.inhibit_shortcuts = false; + + let (mut snapshot, _) = host.coop_snapshot(uuid::Uuid::nil(), 1, clock(0)); + guest.adopt_coop_snapshot(&snapshot, clock(0)); + let notification = guest.handle_event(SchedulerEvent::Tick, clock(600)); + assert!(matches!(notification.as_slice(), [Effect::Notify { .. }])); + + snapshot.revision = 2; + guest.adopt_coop_snapshot(&snapshot, clock(610)); + assert!( + guest + .handle_event(SchedulerEvent::Tick, clock(610)) + .is_empty(), + "regular snapshots must not repeat the same notification" + ); + + let effects = guest.handle_event(SchedulerEvent::Tick, clock(1_000)); + let [Effect::StartOverlay(spec)] = effects.as_slice() else { + panic!("expected the mirrored break overlay"); + }; + assert!(spec.manual_resume); + assert!(spec.allow_postpone_during_lockout); + assert!(spec.inhibit_shortcuts); + assert!(guest.status(clock(1_100)).awaiting_resume); + } + + #[test] + fn a_manual_resume_request_just_before_deadline_is_honored_at_zero() { + let mut scheduler = test_scheduler(); + scheduler.config.completion.manual_resume = true; + scheduler.handle_command(&Command::Mini, clock(0)).unwrap(); + + assert!( + scheduler + .handle_coop_resume_request(clock(99)) + .unwrap() + .is_empty() + ); + let effects = scheduler.handle_event(SchedulerEvent::Tick, clock(100)); + assert!(matches!( + effects.as_slice(), + [Effect::PlayCompletionSound, Effect::StopOverlay { .. }] + )); + assert!(matches!(scheduler.state(), SchedulerState::Running { .. })); + } + #[test] fn a_working_snapshot_arriving_just_after_deadline_starts_without_flicker() { let mut host = test_scheduler(); diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index c2e2f8a..5ddbd0b 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -8,5 +8,9 @@ rust-version.workspace = true [dependencies] breakd-config = { path = "../config" } breakd-core = { path = "../core" } +breakd-ipc = { path = "../ipc" } glib.workspace = true gtk4.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 9a1fdfd..d621e0c 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -1,17 +1,22 @@ use std::{ cell::{Cell, RefCell}, - process::Command, + process::Command as ProcessCommand, rc::Rc, + sync::{Mutex, OnceLock}, + time::Duration, }; use breakd_core::{ - AppConfig, CompletionSound, ContentSelector, DisplayMode, DurationMs, PointerMode, StrictMode, + AppConfig, Command, CompletionSound, ContentSelector, DisplayMode, DurationMs, PointerMode, + StrictMode, }; use gtk::{gio, prelude::*}; use gtk4 as gtk; +use serde::Deserialize; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const RELEASES_URL: &str = "https://github.com/simonwinther/breakd/releases/latest"; +static IPC_RUNTIME: OnceLock, String>> = OnceLock::new(); pub fn run() -> Result<(), String> { let instance = breakd_config::RuntimeInstance::current(); @@ -88,6 +93,43 @@ struct PostponeWidgets { maximum: gtk::SpinButton, } +#[derive(Clone)] +struct CollaborationControls { + page: gtk::ScrolledWindow, + relay_entry: gtk::Entry, + join_entry: gtk::Entry, + invite_output: gtk::Entry, + role_value: gtk::Label, + connection_value: gtk::Label, + participants_value: gtk::Label, + relay_value: gtk::Label, + error_value: gtk::Label, + host_button: gtk::Button, + join_button: gtk::Button, + leave_button: gtk::Button, + copy_button: gtk::Button, + busy: Rc>, + refreshing: Rc>, +} + +#[derive(Debug, Deserialize)] +struct CoopUiStatus { + mode: String, + relay_url: Option, + connected: bool, + host_present: bool, + guest_count: usize, + following_host: bool, + last_error: Option, + invite: Option, +} + +enum CoopUiAction { + Host(String), + Join(String), + Leave, +} + impl SettingsWidgets { fn collect(&self, base: &AppConfig) -> Result { let mut config = base.clone(); @@ -160,6 +202,7 @@ fn build_window( let (schedule_page, schedule_widgets) = schedule_page(&initial); let (actions_page, action_widgets) = actions_page(&initial); let (desktop_page, desktop_widgets) = desktop_page(&initial); + let collaboration = collaboration_page(&initial); let widgets = SettingsWidgets { mini_interval: schedule_widgets.0, mini_duration: schedule_widgets.1, @@ -203,6 +246,7 @@ fn build_window( stack.add_titled(&schedule_page, Some("schedule"), "Schedule"); stack.add_titled(&actions_page, Some("actions"), "Actions"); stack.add_titled(&desktop_page, Some("desktop"), "Desktop"); + stack.add_titled(&collaboration.page, Some("collaboration"), "Collaboration"); stack.set_visible_child_name("schedule"); let switcher = gtk::StackSwitcher::builder() @@ -238,6 +282,7 @@ fn build_window( status_bar.add_css_class("settings-status-bar"); status_bar.append(&status); status_bar.append(&version_corner()); + collaboration.connect(status.clone()); let root = gtk::Box::new(gtk::Orientation::Vertical, 12); root.append(&introduction); @@ -265,7 +310,7 @@ fn build_window( let state_for_save = state.clone(); save.connect_clicked(move |button| { - let current = state_for_save.borrow().clone(); + let current = breakd_config::load().unwrap_or_else(|_| state_for_save.borrow().clone()); let config = match widgets.collect(¤t) { Ok(config) => config, Err(error) => { @@ -777,6 +822,396 @@ fn desktop_page(config: &AppConfig) -> (gtk::ScrolledWindow, DesktopPageWidgets) ) } +fn collaboration_page(config: &AppConfig) -> CollaborationControls { + let role_value = collaboration_value_label(); + let connection_value = collaboration_value_label(); + let participants_value = collaboration_value_label(); + let relay_value = collaboration_value_label(); + let error_value = gtk::Label::new(None); + error_value.set_halign(gtk::Align::Start); + error_value.set_wrap(true); + error_value.set_visible(false); + error_value.add_css_class("settings-error"); + let status_group = settings_group( + "Room status", + "Connection state is refreshed from the running breakd daemon.", + &[ + settings_row("Role", "Your role in the current room.", &role_value), + settings_row( + "Connection", + "Whether schedule snapshots are flowing.", + &connection_value, + ), + settings_row( + "Participants", + "Guests connected to the host room.", + &participants_value, + ), + settings_row("Relay", "The active WebSocket relay.", &relay_value), + ], + ); + status_group.append(&error_value); + + let relay_entry = gtk::Entry::builder() + .text(config.coop.relay_url.as_deref().unwrap_or_default()) + .placeholder_text("lambda-1.example.ts.net:8787") + .width_chars(30) + .max_width_chars(48) + .build(); + relay_entry.set_tooltip_text(Some( + "A Tailscale DNS name or complete ws:// / wss:// relay URL", + )); + let host_button = gtk::Button::with_label("Host new room"); + host_button.add_css_class("suggested-action"); + let invite_output = gtk::Entry::builder() + .editable(false) + .placeholder_text("Your invite appears here") + .width_chars(30) + .max_width_chars(48) + .build(); + invite_output.add_css_class("settings-secret"); + let copy_button = gtk::Button::with_label("Copy invite"); + copy_button.set_sensitive(false); + let invite_controls = gtk::Box::new(gtk::Orientation::Horizontal, 8); + invite_controls.append(&invite_output); + invite_controls.append(©_button); + let host_group = settings_group( + "Host a room", + "Use your host's Tailscale MagicDNS name so every shared user reaches the correct address. The relay must already be listening on that port.", + &[ + settings_row( + "Relay address", + "A DNS name with port, or a complete WebSocket URL.", + &relay_entry, + ), + settings_row( + "Create or rotate room", + "Generates a new secret invite and invalidates this host's previous room.", + &host_button, + ), + settings_row( + "Invite", + "Share this complete value privately with your collaborators.", + &invite_controls, + ), + ], + ); + + let join_entry = gtk::Entry::builder() + .placeholder_text("ws://host:8787/ws#breakd=...") + .width_chars(30) + .max_width_chars(48) + .build(); + join_entry.add_css_class("settings-secret"); + let join_button = gtk::Button::with_label("Join room"); + join_button.add_css_class("suggested-action"); + let join_group = settings_group( + "Join a room", + "Paste the complete invite sent by the host. Do not open it in a browser.", + &[ + settings_row( + "Room invite", + "Includes the relay URL and secret room token.", + &join_entry, + ), + settings_row( + "Follow host", + "Adopt the host's schedule and coordination policy.", + &join_button, + ), + ], + ); + + let leave_button = gtk::Button::with_label("Leave room"); + leave_button.add_css_class("destructive-action"); + leave_button.set_sensitive(config.coop.mode != breakd_core::CoopMode::Off); + let leave_group = settings_group( + "Leave collaboration", + "Leaving clears the room secret and starts a fresh local schedule.", + &[settings_row( + "Disconnect", + "Stop hosting or following the current room.", + &leave_button, + )], + ); + + CollaborationControls { + page: settings_page(&[status_group, host_group, join_group, leave_group]), + relay_entry, + join_entry, + invite_output, + role_value, + connection_value, + participants_value, + relay_value, + error_value, + host_button, + join_button, + leave_button, + copy_button, + busy: Rc::new(Cell::new(false)), + refreshing: Rc::new(Cell::new(false)), + } +} + +fn collaboration_value_label() -> gtk::Label { + let label = gtk::Label::new(Some("—")); + label.set_halign(gtk::Align::End); + label.set_wrap(true); + label.set_width_chars(22); + label.set_max_width_chars(32); + label.set_xalign(1.0); + label.set_selectable(true); + label +} + +impl CollaborationControls { + fn connect(&self, settings_status: gtk::Label) { + let controls = self.clone(); + let status = settings_status.clone(); + self.host_button.connect_clicked(move |_| { + controls.run_action( + CoopUiAction::Host(controls.relay_entry.text().to_string()), + status.clone(), + ); + }); + + let controls = self.clone(); + let status = settings_status.clone(); + self.join_button.connect_clicked(move |_| { + controls.run_action( + CoopUiAction::Join(controls.join_entry.text().to_string()), + status.clone(), + ); + }); + + let controls = self.clone(); + let status = settings_status.clone(); + self.join_entry.connect_activate(move |_| { + controls.run_action( + CoopUiAction::Join(controls.join_entry.text().to_string()), + status.clone(), + ); + }); + + let controls = self.clone(); + let status = settings_status.clone(); + self.leave_button.connect_clicked(move |_| { + controls.run_action(CoopUiAction::Leave, status.clone()); + }); + + let invite = self.invite_output.clone(); + let status = settings_status.clone(); + self.copy_button.connect_clicked(move |_| { + if invite.text().is_empty() { + return; + } + if let Some(display) = gtk::gdk::Display::default() { + display.clipboard().set_text(&invite.text()); + set_status(&status, "Co-op invite copied to the clipboard.", false); + } + }); + + self.refresh(); + let controls = self.clone(); + glib::timeout_add_seconds_local(1, move || { + controls.refresh(); + glib::ControlFlow::Continue + }); + } + + fn run_action(&self, action: CoopUiAction, settings_status: gtk::Label) { + if self.busy.replace(true) { + return; + } + self.set_action_buttons(false); + self.set_local_error(None); + set_status(&settings_status, "Updating co-op room...", false); + + let controls = self.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || perform_coop_action(action)).await; + controls.busy.set(false); + match result { + Ok(Ok(status)) => { + controls.apply_status(&status); + set_status( + &settings_status, + match ( + status.mode.as_str(), + status.connected, + status.following_host, + ) { + ("host", true, _) => "Co-op room is hosted and ready.", + ("host", false, _) => "Room created; connecting to the co-op relay.", + ("guest", _, true) => "Joined the co-op room and following its host.", + ("guest", _, false) => "Invite saved; connecting to the co-op host.", + _ => "Left the co-op room; local schedule reset.", + }, + false, + ); + } + Ok(Err(error)) => { + controls.set_action_buttons(true); + controls.set_local_error(Some(&error)); + set_status(&settings_status, &error, true); + } + Err(_) => { + controls.set_action_buttons(true); + controls.set_local_error(Some("Co-op action failed unexpectedly.")); + set_status(&settings_status, "Co-op action failed unexpectedly.", true); + } + } + }); + } + + fn refresh(&self) { + if self.busy.get() || self.refreshing.replace(true) { + return; + } + let controls = self.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(load_coop_status).await; + controls.refreshing.set(false); + match result { + Ok(Ok(status)) => controls.apply_status(&status), + Ok(Err(error)) => controls.set_local_error(Some(&error)), + Err(_) => controls.set_local_error(Some("Could not read co-op status.")), + } + }); + } + + fn apply_status(&self, status: &CoopUiStatus) { + self.role_value.set_text(match status.mode.as_str() { + "host" => "Host", + "guest" => "Guest", + _ => "Local only", + }); + self.connection_value.set_text(match status.mode.as_str() { + "host" if status.connected => "Relay connected", + "host" => "Connecting to relay…", + "guest" if status.following_host => "Following host schedule", + "guest" if status.connected && status.host_present => "Waiting for first snapshot…", + "guest" if status.connected => "Waiting for host…", + "guest" => "Connecting to relay…", + _ => "Not in a room", + }); + let participants = match status.mode.as_str() { + "host" => format!( + "{} connected guest{}", + status.guest_count, + if status.guest_count == 1 { "" } else { "s" } + ), + "guest" if status.host_present => "Host is present".into(), + "guest" => "Host is unavailable".into(), + _ => "—".into(), + }; + self.participants_value.set_text(&participants); + self.relay_value + .set_text(status.relay_url.as_deref().unwrap_or("—")); + self.invite_output + .set_text(status.invite.as_deref().unwrap_or_default()); + self.copy_button.set_sensitive( + status + .invite + .as_ref() + .is_some_and(|invite| !invite.is_empty()), + ); + self.leave_button.set_sensitive(status.mode != "off"); + self.set_action_buttons(true); + self.set_local_error(status.last_error.as_deref()); + } + + fn set_action_buttons(&self, enabled: bool) { + self.host_button.set_sensitive(enabled); + self.join_button.set_sensitive(enabled); + if !enabled { + self.leave_button.set_sensitive(false); + self.copy_button.set_sensitive(false); + } + } + + fn set_local_error(&self, error: Option<&str>) { + self.error_value.set_text(error.unwrap_or_default()); + self.error_value + .set_visible(error.is_some_and(|error| !error.is_empty())); + } +} + +fn perform_coop_action(action: CoopUiAction) -> Result { + let command = match action { + CoopUiAction::Host(input) => { + let relay = normalize_relay_input(&input)?; + Command::CoopHost { relay_url: relay } + } + CoopUiAction::Join(invite) => { + let invite = invite.trim(); + if invite.is_empty() { + return Err("Paste a complete co-op invite before joining.".into()); + } + Command::CoopJoin { + invite: invite.to_owned(), + } + } + CoopUiAction::Leave => Command::CoopLeave, + }; + request_daemon(command)?; + load_coop_status() +} + +fn load_coop_status() -> Result { + serde_json::from_value(request_daemon(Command::CoopStatus)?) + .map_err(|error| format!("Invalid co-op status: {error}")) +} + +fn request_daemon(command: Command) -> Result { + let runtime = IPC_RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map(Mutex::new) + .map_err(|error| format!("Could not initialize co-op IPC: {error}")) + }); + let runtime = runtime.as_ref().map_err(Clone::clone)?; + let runtime = runtime + .lock() + .map_err(|_| "Co-op IPC became unavailable.".to_owned())?; + let response = runtime + .block_on(async { + tokio::time::timeout( + Duration::from_secs(2), + breakd_ipc::request(breakd_config::socket_path(), command), + ) + .await + }) + .map_err(|_| "The breakd daemon response timed out.".to_owned())? + .map_err(|error| format!("Could not contact the breakd daemon: {error}"))?; + if !response.ok { + return Err(response.message); + } + Ok(response.data.unwrap_or(serde_json::Value::Null)) +} + +fn normalize_relay_input(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err("Enter a Tailscale DNS name, IP address, or relay URL.".into()); + } + if input.contains('#') { + return Err("Enter the relay address without a room-invite fragment.".into()); + } + let mut relay = if input.starts_with("ws://") || input.starts_with("wss://") { + input.to_owned() + } else { + format!("ws://{input}") + }; + let authority_start = relay.find("://").map_or(0, |index| index + 3); + if !relay[authority_start..].contains('/') { + relay.push_str("/ws"); + } + Ok(relay) +} + fn version_corner() -> gtk::Box { let corner = gtk::Box::new(gtk::Orientation::Horizontal, 0); corner.set_halign(gtk::Align::End); @@ -803,7 +1238,7 @@ fn version_corner() -> gtk::Box { } fn fetch_latest_release_tag() -> Option { - let output = Command::new("curl") + let output = ProcessCommand::new("curl") .args([ "-fsSL", "--max-time", @@ -1150,7 +1585,7 @@ fn save_and_reload(config: &AppConfig, restart_required: bool) -> Result ( gtk::Box, gtk::Label, @@ -330,17 +327,24 @@ fn build_content( Option, Option, ) { + let layout = panel_layout(monitor_width, monitor_height); let panel = gtk::Box::new(gtk::Orientation::Vertical, 18); panel.set_halign(gtk::Align::Center); panel.set_valign(gtk::Align::Center); - panel.set_width_request(560); + panel.set_width_request(layout.content_width); panel.add_css_class("breakd-panel"); + if layout.compact { + panel.add_css_class("breakd-panel-compact"); + } let title = gtk::Label::new(Some(match spec.kind { breakd_core::BreakKind::Mini => "Mini break", breakd_core::BreakKind::Long => "Long break", breakd_core::BreakKind::Rest => "Rest break", })); + title.set_wrap(true); + title.set_wrap_mode(gtk::pango::WrapMode::WordChar); + title.set_justify(gtk::Justification::Center); title.add_css_class("breakd-title"); panel.append(&title); @@ -350,18 +354,31 @@ fn build_content( let resume_prompt = gtk::Label::new(Some("Press any key or click to continue")); resume_prompt.set_visible(false); + resume_prompt.set_wrap(true); + resume_prompt.set_wrap_mode(gtk::pango::WrapMode::WordChar); + resume_prompt.set_justify(gtk::Justification::Center); resume_prompt.add_css_class("breakd-resume"); panel.append(&resume_prompt); if let Some(message) = &spec.message { let message_label = gtk::Label::new(Some(message)); message_label.set_wrap(true); + message_label.set_wrap_mode(gtk::pango::WrapMode::WordChar); + message_label.set_max_width_chars(48); + message_label.set_xalign(0.5); message_label.set_justify(gtk::Justification::Center); message_label.add_css_class("breakd-message"); panel.append(&message_label); } - let actions = gtk::Box::new(gtk::Orientation::Horizontal, 10); + let actions = gtk::Box::new( + if layout.vertical_actions { + gtk::Orientation::Vertical + } else { + gtk::Orientation::Horizontal + }, + 10, + ); actions.set_halign(gtk::Align::Center); actions.set_homogeneous(true); let skip = spec.can_skip.then(|| { @@ -382,10 +399,44 @@ fn build_content( if skip.is_some() || postpone.is_some() { panel.append(&actions); } - window.set_child(Some(&panel)); + let frame = gtk::Box::new(gtk::Orientation::Vertical, 0); + frame.set_halign(gtk::Align::Center); + frame.set_valign(gtk::Align::Center); + frame.set_margin_top(16); + frame.set_margin_bottom(16); + frame.set_margin_start(16); + frame.set_margin_end(16); + frame.append(&panel); + let scroller = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .child(&frame) + .build(); + window.set_child(Some(&scroller)); (panel, countdown, resume_prompt, skip, postpone) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PanelLayout { + content_width: i32, + compact: bool, + vertical_actions: bool, +} + +fn panel_layout(monitor_width: i32, monitor_height: i32) -> PanelLayout { + let compact = monitor_width < 600 || monitor_height < 540; + // Leave room for the outer margins and CSS padding; width requests apply + // to the panel content rather than its complete rendered box. + let horizontal_reserve = if compact { 96 } else { 160 }; + PanelLayout { + content_width: monitor_width + .saturating_sub(horizontal_reserve) + .clamp(120, 560), + compact, + vertical_actions: monitor_width < 480, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OverlayAction { Skip, @@ -603,10 +654,17 @@ fn install_css(config: &AppConfig) { border-radius: 8px; padding: 36px 42px; }} + .breakd-panel-compact {{ + padding: 20px 24px; + }} .breakd-title {{ font-size: 26px; font-weight: 600; }} .breakd-countdown {{ font-size: 64px; font-weight: 700; }} .breakd-resume {{ font-size: 18px; font-weight: 600; }} .breakd-message {{ font-size: 18px; }} + .breakd-panel-compact .breakd-title {{ font-size: 21px; }} + .breakd-panel-compact .breakd-countdown {{ font-size: 42px; }} + .breakd-panel-compact .breakd-resume, + .breakd-panel-compact .breakd-message {{ font-size: 15px; }} .breakd-action {{ min-width: 120px; min-height: 42px; font-size: 16px; }} "#, opacity = config.display.opacity, @@ -795,6 +853,19 @@ mod tests { assert_eq!(format_countdown(Duration::from_secs(65)), "01:05"); } + #[test] + fn panel_layout_stays_inside_small_monitors() { + let small = panel_layout(360, 480); + assert_eq!(small.content_width, 264); + assert!(small.compact); + assert!(small.vertical_actions); + + let large = panel_layout(1_920, 1_080); + assert_eq!(large.content_width, 560); + assert!(!large.compact); + assert!(!large.vertical_actions); + } + #[test] fn parses_rgb_color() { assert_eq!(parse_hex_color("#101418"), Some((16, 20, 24))); diff --git a/docs/coop.md b/docs/coop.md index cf62762..efd9d62 100644 --- a/docs/coop.md +++ b/docs/coop.md @@ -87,6 +87,12 @@ Run `breakd coop host` again to make a new token and invalidate the previous roo from that host. `breakd coop leave` clears the relay URL and token and resets a fresh local schedule. +The same lifecycle is available under the **Collaboration** tab in +`breakd settings`. The host form accepts either a complete WebSocket URL or a +Tailscale MagicDNS name with a port, and the join form accepts the complete +invite. Hosting creates a new token, so clicking it again rotates the room just +like the CLI command. + `breakd coop host` waits until the host is connected before printing its final status. `breakd coop join` waits for the first authoritative host snapshot, so a successful return means `connected`, `host_present`, and `following_host` are all @@ -97,16 +103,20 @@ background and `breakd coop status` shows its latest state and error. The host publishes at most one regular snapshot per second and immediately after local or guest-requested actions. A working snapshot includes the next break's -absolute Unix start time, duration, type, stable due ID, and strict/manual-resume -policy. That lets both schedulers start the same session locally without waiting -for a round trip at the deadline. Active-break and pause snapshots let a guest -join midway through a room. - -Guests use their own display, content, sound, and monitor configuration. They do -not run idle, lock, or suspend transitions while following the host. Native -WebSocket ping frames keep the one connection alive, and reconnects use bounded -exponential backoff. The client rejects stale revisions and messages larger than -128 KiB. +absolute Unix start time, duration, type, stable due ID, strict/manual-resume +policy, and notification policy. That lets both schedulers start the same +session locally without waiting for a round trip at the deadline. Active-break +and pause snapshots let a guest join midway through a room. + +The host owns settings that must agree across participants: cadence, break +duration and kind, pause state, strict/skip/postpone rules, manual resume, and +notification enablement and lead times. Manual-resume input from any participant +is validated by the host and completes the break for the room. Guests use their +own display, content, opacity, pointer, sound, tray, and monitor configuration. +They do not run idle, lock, or suspend transitions while following the host. +Native WebSocket ping frames keep the one connection alive, and reconnects use +bounded exponential backoff. The client rejects stale revisions and messages +larger than 128 KiB. When the configured `coop.disconnect_grace` elapses without a snapshot (10 seconds by default), a guest discards the mirrored state and begins a fresh local