Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/coop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ breakd-core = { path = "../core" }
serde.workspace = true
thiserror.workspace = true
uuid.workspace = true

[dev-dependencies]
serde_json.workspace = true
47 changes: 47 additions & 0 deletions crates/coop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoopPolicy>,
}

/// 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)]
Expand Down Expand Up @@ -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::<CoopSnapshot>(encoded).unwrap(),
snapshot
);
}
}
4 changes: 4 additions & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub socket_path: String,
}
Expand Down
47 changes: 35 additions & 12 deletions crates/daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -222,6 +226,7 @@ pub async fn run() -> Result<()> {
TrayAction::Command(command) => {
match execute_command(
&command,
CommandOrigin::Local,
&clock,
&state_store,
&mut scheduler,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion crates/relay/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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,
Expand Down
Loading