├── zentime.toml ├── timer ├── README.md ├── src │ ├── util.rs │ ├── timer_action.rs │ ├── pomodoro_timer_action.rs │ ├── pomodoro_timer │ │ ├── on_end_handler.rs │ │ ├── on_tick_handler.rs │ │ ├── postponed_short_break.rs │ │ ├── postponed_long_break.rs │ │ ├── state.rs │ │ ├── interval.rs │ │ ├── long_break.rs │ │ └── short_break.rs │ ├── pomodoro_timer.rs │ ├── config.rs │ ├── lib.rs │ └── timer.rs ├── Cargo.toml └── examples │ ├── minimal.rs │ ├── async.rs │ └── simple.rs ├── src ├── server │ ├── bell.wav │ ├── timer_output.rs │ ├── status.rs │ ├── sound.rs │ ├── notification.rs │ └── start.rs ├── client │ ├── terminal_io.rs │ ├── terminal_io │ │ ├── terminal_event.rs │ │ ├── input.rs │ │ ├── default_interface.rs │ │ └── output.rs │ ├── one_shot_connection.rs │ ├── start.rs │ └── connection.rs ├── ViewConfig.md ├── subcommands.rs ├── server.rs ├── client.rs ├── lib.rs ├── subcommands │ ├── query_server_once.rs │ ├── postpone.rs │ ├── reset_timer.rs │ ├── skip_timer.rs │ ├── toggle_timer.rs │ ├── set_timer.rs │ └── server.rs ├── config.rs ├── default_cmd.rs ├── ipc.rs └── main.rs ├── assets ├── zentime-screenshot.png └── zellij-layout-screenshot.png ├── docker └── linux │ └── Dockerfile ├── .editorconfig ├── justfile ├── .gitignore ├── zentime.example.toml ├── .github └── workflows │ ├── build.yaml │ └── release.yaml ├── zentime.dev.toml ├── LICENSE ├── Cargo.toml └── README.md /zentime.toml: -------------------------------------------------------------------------------- 1 | [timers] 2 | timer = 500 3 | -------------------------------------------------------------------------------- /timer/README.md: -------------------------------------------------------------------------------- 1 | # zentime-rs-timer 2 | 3 | Timer component of zentime-rs 4 | -------------------------------------------------------------------------------- /src/server/bell.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/on3iro/zentime-rs/HEAD/src/server/bell.wav -------------------------------------------------------------------------------- /assets/zentime-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/on3iro/zentime-rs/HEAD/assets/zentime-screenshot.png -------------------------------------------------------------------------------- /assets/zellij-layout-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/on3iro/zentime-rs/HEAD/assets/zellij-layout-screenshot.png -------------------------------------------------------------------------------- /docker/linux/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust 2 | 3 | WORKDIR /zentime 4 | 5 | Run apt update 6 | Run apt install -y libasound2-dev 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | trim_trailing_whitespace = true 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 4 7 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | env-linux: 2 | docker build -f docker/linux/Dockerfile -t zentime-linux . 3 | docker run --volume $(pwd):/zentime -it zentime-linux bash 4 | 5 | -------------------------------------------------------------------------------- /src/client/terminal_io.rs: -------------------------------------------------------------------------------- 1 | //! Zentime client terminal io 2 | 3 | mod default_interface; 4 | pub mod input; 5 | pub mod output; 6 | pub mod terminal_event; 7 | -------------------------------------------------------------------------------- /src/ViewConfig.md: -------------------------------------------------------------------------------- 1 | # Type of client interface 2 | 3 | Supports: 4 | 5 | * default - TUI interface including keyboard shortcuts 6 | * minimal - minimal colored output 7 | -------------------------------------------------------------------------------- /src/subcommands.rs: -------------------------------------------------------------------------------- 1 | pub mod postpone; 2 | pub mod query_server_once; 3 | pub mod reset_timer; 4 | pub mod server; 5 | pub mod set_timer; 6 | pub mod skip_timer; 7 | pub mod toggle_timer; 8 | -------------------------------------------------------------------------------- /src/server.rs: -------------------------------------------------------------------------------- 1 | //! Zentime server utilities 2 | 3 | pub mod notification; 4 | pub mod sound; 5 | mod start; 6 | pub mod status; 7 | mod timer_output; 8 | 9 | pub use start::start; 10 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | //! Code related to zentime terminal clients (e.g. async connection handling, terminal io etc.) 2 | 3 | mod connection; 4 | 5 | pub mod one_shot_connection; 6 | pub mod start; 7 | pub mod terminal_io; 8 | 9 | pub use start::start; 10 | -------------------------------------------------------------------------------- /src/server/timer_output.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use zentime_rs_timer::pomodoro_timer::ViewState; 3 | 4 | /// Carries the timer state as view state 5 | #[derive(Debug, Clone, Serialize, Deserialize)] 6 | pub enum TimerOutputAction { 7 | Timer(ViewState), 8 | } 9 | -------------------------------------------------------------------------------- /timer/src/util.rs: -------------------------------------------------------------------------------- 1 | //! Small helper fns 2 | 3 | /// Transform a duration into a formatted timer string like "29:30" (mm:ss) 4 | pub fn seconds_to_time(duration: u64) -> String { 5 | let min = duration / 60; 6 | let sec = duration % 60; 7 | format!("{:02}:{:02}", min, sec) 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | 13 | # Added by cargo 14 | 15 | /target 16 | 17 | .DS_STORE 18 | -------------------------------------------------------------------------------- /zentime.example.toml: -------------------------------------------------------------------------------- 1 | [timers] 2 | # Timer length in seconds 3 | timer = 1500 # => 25 minutes 4 | 5 | # Minor break length in seconds 6 | minor_break = 300 # => 5 minutes 7 | 8 | # Major break length in seconds 9 | major_break = 900 # => 15 minutes 10 | 11 | # Number of intervals before major break 12 | intervals = 4 13 | 14 | [notifications] 15 | # Enable/Disable bell 16 | enable_bell = true 17 | 18 | # Notification bell volume 19 | volume = 0.5 20 | 21 | # Show OS-notification 22 | show_notification = true 23 | -------------------------------------------------------------------------------- /timer/src/timer_action.rs: -------------------------------------------------------------------------------- 1 | //! Action enum that can be passed to the timer on each tick to interact with it 2 | 3 | /// Various control actions to transition into new states 4 | #[derive(Debug, Copy, Clone)] 5 | pub enum TimerAction { 6 | /// Set current timer to a specific time in seconds 7 | SetTimer(u64), 8 | 9 | /// Either start or pause the current timer 10 | PlayPause, 11 | 12 | /// Ends the currently blocking timer loop, such that the consuming code 13 | /// is able to continue 14 | End, 15 | } 16 | -------------------------------------------------------------------------------- /timer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zentime-rs-timer" 3 | version = "0.13.0" 4 | edition = "2021" 5 | description = "Pausable productivity timer" 6 | license = "MIT" 7 | repository = "https://github.com/on3iro/zentime-rs" 8 | readme = "README.md" 9 | keywords = ["timer", "pomodoro", "productivity"] 10 | categories = ["command-line-utilities"] 11 | 12 | [lib] 13 | name = "zentime_rs_timer" 14 | 15 | [dependencies] 16 | serde = { version = "1", features = ["derive"] } 17 | 18 | [dev-dependencies] 19 | tokio = { version = "1", features = ["full"] } 20 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn( 2 | missing_docs, 3 | missing_copy_implementations, 4 | missing_debug_implementations 5 | )] 6 | //! Zentime is a client/server based CLI pomodor/productivity timer written in Rust. 7 | //! This crate consists of a binary and a library crate. 8 | //! The library crate is a collection of tools to interact with zentime server, clients, configuration and to handle 9 | //! inter-process-communication. 10 | //! 11 | #![doc = include_str!("../README.md")] 12 | 13 | pub mod client; 14 | pub mod config; 15 | pub mod ipc; 16 | pub mod server; 17 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer_action.rs: -------------------------------------------------------------------------------- 1 | //! Action enum that can be passed to the timer on each tick to interact with it 2 | 3 | /// Various control actions to transition into new states 4 | #[derive(Debug, Copy, Clone)] 5 | pub enum PomodoroTimerAction { 6 | /// NoOp 7 | None, 8 | 9 | /// Either start or pause the current timer 10 | PlayPause, 11 | 12 | /// Skip to the next timer (break or focus) 13 | Skip, 14 | 15 | /// Reset timer 16 | ResetTimer, 17 | 18 | /// Postpone a break 19 | PostponeBreak, 20 | 21 | /// Set current timer to a specific time in seconds 22 | SetTimer(u64), 23 | } 24 | -------------------------------------------------------------------------------- /timer/examples/minimal.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | use std::thread; 3 | use zentime_rs_timer::pomodoro_timer::PomodoroTimer; 4 | 5 | fn main() { 6 | // Run timer in its own thread so it does not block the current one 7 | thread::spawn(move || { 8 | PomodoroTimer::new( 9 | Default::default(), 10 | Rc::new(move |state, msg, _| { 11 | println!("{} {}", state.round, msg.unwrap()); 12 | }), 13 | Rc::new(move |view_state| { 14 | println!("{:?}", view_state); 15 | None 16 | }), 17 | ) 18 | .init() 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/client/terminal_io/terminal_event.rs: -------------------------------------------------------------------------------- 1 | //! Terminal event handled by a client 2 | 3 | use zentime_rs_timer::pomodoro_timer::ViewState; 4 | 5 | /// Describes a message passed from a connection to the [TerminalOutputTask] 6 | #[derive(Debug)] 7 | pub enum TerminalEvent { 8 | /// Rendering information with a [ViewState] 9 | View(ViewState), 10 | 11 | /// The timer received an [AppAction::Quit] and forwards 12 | /// this information to the view 13 | Quit { 14 | /// Optinal message to display on quit 15 | msg: Option, 16 | 17 | /// Determines if the quit should happen with an error display 18 | error: bool, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | name: build 3 | jobs: 4 | build: 5 | runs-on: macos-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions-rs/toolchain@v1 9 | with: 10 | toolchain: stable 11 | - name: cache 12 | uses: Swatinem/rust-cache@v1 13 | - name: lint 14 | uses: actions-rs/clippy-check@v1 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | - name: build 18 | uses: actions-rs/cargo@v1 19 | with: 20 | command: build 21 | use-cross: true 22 | - name: test 23 | uses: actions-rs/cargo@v1 24 | with: 25 | command: test 26 | args: --workspace 27 | -------------------------------------------------------------------------------- /zentime.dev.toml: -------------------------------------------------------------------------------- 1 | [view] 2 | interface = 'raw' 3 | 4 | [timers] 5 | timer = 10 6 | minor_break = 10 7 | major_break = 15 8 | intervals = 2 9 | postpone_limit = 3 10 | postpone_timer = 10 11 | 12 | [notifications] 13 | enable_bell = true 14 | volume = 0.5 15 | show_notification = true 16 | break_suggestions = [ 17 | "Do some push ups\nLet your eyes focus something in the distance-Fold worward and relax", 18 | "Do chair pose\nDo tree pose-Do the 'tripple-thumb'", 19 | "Do up-dog\nWalk a bit around-Rotate shoulders and arms", 20 | "Stretch your shoulders and arms-Do a posture exercise", 21 | "Do a twist and stretch", 22 | "Do a warrior two", 23 | "Do a nadi shodana", 24 | "Do pigeon pose", 25 | "Do a plank\nRotate Head-Do a childs pose", 26 | ] 27 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer/on_end_handler.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use crate::timer::TimerEndHandler; 4 | 5 | use super::state::PomodoroTimerState; 6 | 7 | /// Describes pomodoro timer kind 8 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 9 | pub enum TimerKind { 10 | /// Always used when the current timer is not a break timer 11 | Interval, 12 | 13 | /// Only used for breaks 14 | Break, 15 | } 16 | 17 | pub type OnTimerEnd = Rc, TimerKind)>; 18 | 19 | /// Handler which is passed to our timer implementation 20 | pub struct OnEndHandler { 21 | pub on_timer_end: OnTimerEnd, 22 | pub state: PomodoroTimerState, 23 | pub notification: Option<&'static str>, 24 | pub kind: TimerKind, 25 | } 26 | 27 | impl TimerEndHandler for OnEndHandler { 28 | fn call(&mut self) { 29 | (self.on_timer_end)(self.state, self.notification, self.kind); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer.rs: -------------------------------------------------------------------------------- 1 | //! Pomodoro timer implementation. 2 | //! When instantiated this runs instances of [Timer] internally and allows the transitioning 3 | //! between various states like [Interval], [ShortBreak] or [LongBreak]. 4 | //! 5 | //! To communicate with "the outside world" two distinct closures are used: 6 | //! 7 | //! [OnTimerEnd] will be called whenever the internal timer has ended by reaching 0. 8 | //! This closure won't run on other occasions, like [PomodoroTimerAction::Skip], though. 9 | //! 10 | //! [OnTick] will be called on every tick of a running internal timer and can be used to 11 | //! reveive the current timer state and to send [PomodoroTimerActions]. 12 | //! 13 | mod interval; 14 | mod long_break; 15 | mod on_end_handler; 16 | mod on_tick_handler; 17 | mod postponed_long_break; 18 | mod postponed_short_break; 19 | mod short_break; 20 | mod state; 21 | 22 | pub use on_end_handler::TimerKind; 23 | pub use state::{PomodoroTimer, ViewState}; 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Theo Salzmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /timer/src/config.rs: -------------------------------------------------------------------------------- 1 | //! Configuration of a [Timer] 2 | use serde::{Deserialize, Serialize}; 3 | 4 | /// Timer configuration which determines certain aspects of the timer, 5 | /// like the duration of `intervals` and break lengths. 6 | #[derive(Debug, Deserialize, Serialize, Clone, Copy)] 7 | pub struct PomodoroTimerConfig { 8 | /// Timer in seconds 9 | pub timer: u64, 10 | 11 | /// Minor break time in seconds 12 | pub minor_break: u64, 13 | 14 | /// Major break time in seconds 15 | pub major_break: u64, 16 | 17 | /// Intervals before major break 18 | pub intervals: u64, 19 | 20 | /// Determines how often a break may be postponed. 21 | /// A value of 0 denotes, that postponing breaks is not allowed and the feature is 22 | /// disabled. 23 | pub postpone_limit: u16, 24 | 25 | /// Determines how long each postpone timer runs (in seconds) 26 | pub postpone_timer: u64, 27 | } 28 | 29 | impl Default for PomodoroTimerConfig { 30 | fn default() -> Self { 31 | PomodoroTimerConfig { 32 | timer: 1500, 33 | minor_break: 300, 34 | major_break: 900, 35 | intervals: 4, 36 | postpone_limit: 0, 37 | postpone_timer: 300, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/subcommands/query_server_once.rs: -------------------------------------------------------------------------------- 1 | use futures::io::BufReader; 2 | use zentime_rs::client::one_shot_connection::one_shot_connection; 3 | use zentime_rs::ipc::ClientToServerMsg; 4 | use zentime_rs::ipc::{InterProcessCommunication, ServerToClientMsg}; 5 | 6 | #[tokio::main] 7 | pub async fn query_server_once() { 8 | let (reader, mut writer) = match one_shot_connection().await { 9 | Ok(c) => c, 10 | Err(error) => panic!("Could not conenct to server: {}", error), 11 | }; 12 | 13 | let mut reader = BufReader::new(reader); 14 | 15 | if let Err(err) = 16 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Sync, &mut writer).await 17 | { 18 | panic!("Could not sync with server: {}", err) 19 | }; 20 | 21 | let msg_result = 22 | InterProcessCommunication::recv_ipc_message::(&mut reader).await; 23 | 24 | if let Ok(ServerToClientMsg::Timer(state)) = msg_result { 25 | println!( 26 | "{} {} {}", 27 | state.round, 28 | state.time, 29 | if state.is_break { "Break" } else { "Focus" } 30 | ); 31 | } 32 | 33 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Detach, &mut writer) 34 | .await 35 | .ok(); 36 | } 37 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zentime-rs" 3 | version = "0.15.0" 4 | edition = "2021" 5 | description = "Pomodoro and productivity timer written in Rust" 6 | license = "MIT" 7 | repository = "https://github.com/on3iro/zentime-rs" 8 | readme = "README.md" 9 | keywords = ["timer", "pomodoro", "productivity"] 10 | categories = ["command-line-utilities"] 11 | 12 | [workspace] 13 | members = ["timer"] 14 | 15 | [[bin]] 16 | name = "zentime" 17 | path = "src/main.rs" 18 | 19 | [dependencies] 20 | anyhow = { version = "1", features = ["backtrace"] } 21 | async-trait = "0.1" 22 | clap = { version = "4", features = ["derive"] } 23 | crossbeam = "0.8" 24 | crossterm = { version = "0.25.0", features = ["event-stream"] } 25 | daemonize = { version = "0.4" } 26 | env_logger = "0.10" 27 | figment = { version = "0.10", features = ["toml"] } 28 | futures = "0.3" 29 | interprocess = { version = "1.2", features = ["tokio_support", "signals"]} 30 | log = "0.4" 31 | notify-rust = "4" 32 | rand = { version = "0.8", features = ["std", "std_rng"] } 33 | rmp-serde = "1.1" 34 | rodio = "0.12" 35 | serde = { version = "1", features = ["derive"] } 36 | shellexpand = "2.1.0" 37 | sysinfo = "0.26.8" 38 | thiserror = "1.0" 39 | tokio = { version = "1", features = ["full"] } 40 | tokio-stream = "0.1" 41 | tui = "0.19.0" 42 | zentime-rs-timer = { path = "./timer", version = "0.*" } 43 | -------------------------------------------------------------------------------- /src/subcommands/postpone.rs: -------------------------------------------------------------------------------- 1 | use futures::io::BufReader; 2 | use zentime_rs::client::one_shot_connection::one_shot_connection; 3 | use zentime_rs::ipc::ClientToServerMsg; 4 | use zentime_rs::ipc::InterProcessCommunication; 5 | use zentime_rs::ipc::ServerToClientMsg; 6 | 7 | #[tokio::main] 8 | pub async fn postpone(silent: bool) { 9 | let (reader, mut writer) = match one_shot_connection().await { 10 | Ok(c) => c, 11 | Err(error) => panic!("Could not conenct to server: {}", error), 12 | }; 13 | 14 | let mut reader = BufReader::new(reader); 15 | 16 | if let Err(err) = 17 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::PostPone, &mut writer).await 18 | { 19 | panic!("Could not send to the server: {}", err) 20 | }; 21 | 22 | let msg_result = 23 | InterProcessCommunication::recv_ipc_message::(&mut reader).await; 24 | 25 | if !silent { 26 | if let Ok(ServerToClientMsg::Timer(state)) = msg_result { 27 | println!( 28 | "{} {} {}", 29 | state.round, 30 | state.time, 31 | if state.is_break { "Break" } else { "Focus" } 32 | ); 33 | } 34 | } 35 | 36 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Detach, &mut writer) 37 | .await 38 | .ok(); 39 | } 40 | -------------------------------------------------------------------------------- /src/subcommands/reset_timer.rs: -------------------------------------------------------------------------------- 1 | use futures::io::BufReader; 2 | use zentime_rs::client::one_shot_connection::one_shot_connection; 3 | use zentime_rs::ipc::ClientToServerMsg; 4 | use zentime_rs::ipc::InterProcessCommunication; 5 | use zentime_rs::ipc::ServerToClientMsg; 6 | 7 | #[tokio::main] 8 | pub async fn reset_timer(silent: bool) { 9 | let (reader, mut writer) = match one_shot_connection().await { 10 | Ok(c) => c, 11 | Err(error) => panic!("Could not conenct to server: {}", error), 12 | }; 13 | 14 | let mut reader = BufReader::new(reader); 15 | 16 | if let Err(err) = 17 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Reset, &mut writer).await 18 | { 19 | panic!("Could not send to the server: {}", err) 20 | }; 21 | 22 | let msg_result = 23 | InterProcessCommunication::recv_ipc_message::(&mut reader).await; 24 | 25 | if !silent { 26 | if let Ok(ServerToClientMsg::Timer(state)) = msg_result { 27 | println!( 28 | "{} {} {}", 29 | state.round, 30 | state.time, 31 | if state.is_break { "Break" } else { "Focus" } 32 | ); 33 | } 34 | } 35 | 36 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Detach, &mut writer) 37 | .await 38 | .ok(); 39 | } 40 | -------------------------------------------------------------------------------- /src/subcommands/skip_timer.rs: -------------------------------------------------------------------------------- 1 | use futures::io::BufReader; 2 | use zentime_rs::client::one_shot_connection::one_shot_connection; 3 | use zentime_rs::ipc::ClientToServerMsg; 4 | use zentime_rs::ipc::InterProcessCommunication; 5 | use zentime_rs::ipc::ServerToClientMsg; 6 | 7 | #[tokio::main] 8 | pub async fn skip_timer(silent: bool) { 9 | let (reader, mut writer) = match one_shot_connection().await { 10 | Ok(c) => c, 11 | Err(error) => panic!("Could not conenct to server: {}", error), 12 | }; 13 | 14 | let mut reader = BufReader::new(reader); 15 | 16 | if let Err(err) = 17 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Skip, &mut writer).await 18 | { 19 | panic!("Could not send to the server: {}", err) 20 | }; 21 | 22 | let msg_result = 23 | InterProcessCommunication::recv_ipc_message::(&mut reader).await; 24 | 25 | if !silent { 26 | if let Ok(ServerToClientMsg::Timer(state)) = msg_result { 27 | println!( 28 | "{} {} {}", 29 | state.round, 30 | state.time, 31 | if state.is_break { "Break" } else { "Focus" } 32 | ); 33 | } 34 | } 35 | 36 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Detach, &mut writer) 37 | .await 38 | .ok(); 39 | } 40 | -------------------------------------------------------------------------------- /src/subcommands/toggle_timer.rs: -------------------------------------------------------------------------------- 1 | use futures::io::BufReader; 2 | use zentime_rs::client::one_shot_connection::one_shot_connection; 3 | use zentime_rs::ipc::ClientToServerMsg; 4 | use zentime_rs::ipc::InterProcessCommunication; 5 | use zentime_rs::ipc::ServerToClientMsg; 6 | 7 | #[tokio::main] 8 | pub async fn toggle_timer(silent: bool) { 9 | let (reader, mut writer) = match one_shot_connection().await { 10 | Ok(c) => c, 11 | Err(error) => panic!("Could not conenct to server: {}", error), 12 | }; 13 | 14 | let mut reader = BufReader::new(reader); 15 | 16 | if let Err(err) = 17 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::PlayPause, &mut writer).await 18 | { 19 | panic!("Could not send to the server: {}", err) 20 | }; 21 | 22 | let msg_result = 23 | InterProcessCommunication::recv_ipc_message::(&mut reader).await; 24 | 25 | if !silent { 26 | if let Ok(ServerToClientMsg::Timer(state)) = msg_result { 27 | println!( 28 | "{} {} {}", 29 | state.round, 30 | state.time, 31 | if state.is_break { "Break" } else { "Focus" } 32 | ); 33 | } 34 | } 35 | 36 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Detach, &mut writer) 37 | .await 38 | .ok(); 39 | } 40 | -------------------------------------------------------------------------------- /src/subcommands/set_timer.rs: -------------------------------------------------------------------------------- 1 | use futures::io::BufReader; 2 | use zentime_rs::client::one_shot_connection::one_shot_connection; 3 | use zentime_rs::ipc::ClientToServerMsg; 4 | use zentime_rs::ipc::InterProcessCommunication; 5 | use zentime_rs::ipc::ServerToClientMsg; 6 | 7 | #[tokio::main] 8 | pub async fn set_timer(silent: bool, time: u64) { 9 | let (reader, mut writer) = match one_shot_connection().await { 10 | Ok(c) => c, 11 | Err(error) => panic!("Could not connect to server: {}", error), 12 | }; 13 | 14 | let mut reader = BufReader::new(reader); 15 | 16 | if let Err(err) = 17 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::SetTimer(time), &mut writer).await 18 | { 19 | panic!("Could not send to the server: {}", err) 20 | }; 21 | 22 | let msg_result = 23 | InterProcessCommunication::recv_ipc_message::(&mut reader).await; 24 | 25 | if !silent { 26 | if let Ok(ServerToClientMsg::Timer(state)) = msg_result { 27 | println!( 28 | "{} {} {}", 29 | state.round, 30 | state.time, 31 | if state.is_break { "Break" } else { "Focus" } 32 | ); 33 | } 34 | } 35 | 36 | InterProcessCommunication::send_ipc_message(ClientToServerMsg::Detach, &mut writer) 37 | .await 38 | .ok(); 39 | } 40 | -------------------------------------------------------------------------------- /src/client/one_shot_connection.rs: -------------------------------------------------------------------------------- 1 | //! Creates a connection for single reads/writes from/to the server 2 | use crate::ipc::get_socket_name; 3 | use crate::server::status::server_status; 4 | use crate::server::status::ServerStatus; 5 | use interprocess::local_socket::tokio::LocalSocketStream; 6 | use interprocess::local_socket::tokio::OwnedReadHalf; 7 | use interprocess::local_socket::tokio::OwnedWriteHalf; 8 | 9 | /// Creates a connection to the zentime server (if one is running) and returns 10 | /// a tuple of [OwnedReadHalf] and an [OwnedWriteHalf]. 11 | /// If no server is running the current process is terminated. 12 | /// 13 | /// NOTE: 14 | /// If you just want to read from the server, you still need to write [ClientToServerMsg::Sync] 15 | /// first and make sure that the writer isn't being dropped before you read. Otherwise you will 16 | /// encount EOF on the socket! 17 | /// 18 | /// NOTE: 19 | /// Also make sure to send a detach message to the server as well 20 | pub async fn one_shot_connection() -> anyhow::Result<(OwnedReadHalf, OwnedWriteHalf)> { 21 | // check if server is running -> if not, quit 22 | if server_status() == ServerStatus::Stopped { 23 | println!("No zentime server running"); 24 | std::process::exit(0); 25 | } 26 | 27 | // connect to server 28 | let socket_name = get_socket_name(); 29 | let connection = LocalSocketStream::connect(socket_name).await?; 30 | 31 | Ok(connection.into_split()) 32 | } 33 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer/on_tick_handler.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use crate::{pomodoro_timer_action::PomodoroTimerAction, TimerAction}; 4 | 5 | use super::{ 6 | interval::Interval, 7 | state::{PomodoroState, PomodoroTimer, ViewState}, 8 | }; 9 | 10 | pub type OnTick = Rc Option>; 11 | 12 | pub struct PostponeHandlerConfig { 13 | pub postpone_limit: u16, 14 | pub postponed_count: u16, 15 | } 16 | 17 | pub trait PomodoroActionHandler { 18 | fn can_postpone(postpone_config: PostponeHandlerConfig) -> bool { 19 | let PostponeHandlerConfig { 20 | postpone_limit, 21 | postponed_count, 22 | } = postpone_config; 23 | postpone_limit > 0 && postponed_count < postpone_limit 24 | } 25 | 26 | fn get_timer(&self) -> PomodoroTimer; 27 | 28 | fn handle_action(&self, action: PomodoroTimerAction) -> Option { 29 | let timer = PomodoroActionHandler::::get_timer(self); 30 | 31 | match action { 32 | PomodoroTimerAction::PlayPause => Some(TimerAction::PlayPause), 33 | PomodoroTimerAction::Skip => Some(TimerAction::End), 34 | 35 | PomodoroTimerAction::ResetTimer => { 36 | PomodoroTimer::::reset(timer.config, timer.callbacks).init(); 37 | None 38 | } 39 | 40 | PomodoroTimerAction::SetTimer(time) => Some(TimerAction::SetTimer(time)), 41 | 42 | _ => None, 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/server/status.rs: -------------------------------------------------------------------------------- 1 | //! Code related to server status information 2 | use std::fmt::Display; 3 | 4 | use sysinfo::{ProcessExt, System, SystemExt}; 5 | 6 | /// Current status of the zentime server 7 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 8 | pub enum ServerStatus { 9 | /// The server is active and running 10 | Running, 11 | 12 | /// No server process is currently running 13 | Stopped, 14 | } 15 | 16 | impl Display for ServerStatus { 17 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 18 | match self { 19 | ServerStatus::Running => write!(f, "running"), 20 | ServerStatus::Stopped => write!(f, "not running"), 21 | } 22 | } 23 | } 24 | 25 | /// Gets the current status of the zentime server, by checking if a process is running 26 | /// which was started by a `zentime server`-command. 27 | pub fn server_status() -> ServerStatus { 28 | let system = System::new_all(); 29 | 30 | let mut zentime_process_instances = system.processes_by_name("zentime"); 31 | 32 | // WHY: 33 | // We identify a server process by its command (e.g. "zentime server start") and assume that 34 | // there is no other way, that the word "start" is part of a server command 35 | // 36 | // NOTE: During debug builds we use a different socket and therefore the server is not 37 | // shared with the production one 38 | let server_is_running = if cfg!(debug_assertions) { 39 | zentime_process_instances.any(|p| { 40 | p.cmd()[0].contains(&String::from("target/debug")) 41 | && p.cmd().contains(&String::from("server")) 42 | && p.cmd().contains(&String::from("start")) 43 | }) 44 | } else { 45 | zentime_process_instances.any(|p| { 46 | !p.cmd()[0].contains(&String::from("target/debug")) 47 | && p.cmd().contains(&String::from("server")) 48 | && p.cmd().contains(&String::from("start")) 49 | }) 50 | }; 51 | 52 | if server_is_running { 53 | ServerStatus::Running 54 | } else { 55 | ServerStatus::Stopped 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/server/sound.rs: -------------------------------------------------------------------------------- 1 | //! Sound playback related functions 2 | use log::{error, info}; 3 | use rodio::decoder::DecoderError; 4 | use rodio::StreamError; 5 | use std::io::Cursor; 6 | use std::thread; 7 | use thiserror::Error; 8 | 9 | // Code copied from: https://github.com/yuizho/pomors/blob/master/src/sound.rs 10 | 11 | /// Error type that describes error that could happen before/during audio playback 12 | #[derive(Debug, Error)] 13 | pub enum AudioPlaybackError { 14 | /// Denotes that the given [SoundFile] could not be decoded 15 | #[error("Could not decode audio data")] 16 | DecodeError(#[from] DecoderError), 17 | 18 | /// Denotes that no output device could be found 19 | #[error("Failed to find output device")] 20 | DeviceNotFound(#[from] StreamError), 21 | 22 | /// The sink on the device to playback the sound could not be created 23 | #[error("Could not play back sound file because sink could not be created")] 24 | SinkNotCreated, 25 | } 26 | 27 | /// Play the sound file from sound_file path or the default sound file 28 | pub fn play(sound_file: Option, volume: f32) -> Result<(), AudioPlaybackError> { 29 | let custom_sound = match sound_file { 30 | Some(path) => match std::fs::read(path) { 31 | Ok(bytes) => Some(SoundFile::Custom(bytes)), 32 | Err(error) => { 33 | error!("Could not read custom sound file: {}", error); 34 | None 35 | } 36 | }, 37 | None => None, 38 | }; 39 | 40 | let sound_file = custom_sound.unwrap_or_else(|| { 41 | info!("No custom sound file provided, falling back to default sound"); 42 | SoundFile::Default 43 | }); 44 | 45 | let audio = rodio::Decoder::new(Cursor::new(sound_file.get_bytes()))?; 46 | 47 | thread::spawn(move || -> Result<(), AudioPlaybackError> { 48 | let (_stream, stream_handle) = rodio::OutputStream::try_default()?; 49 | let sink = 50 | rodio::Sink::try_new(&stream_handle).map_err(|_| AudioPlaybackError::SinkNotCreated)?; 51 | sink.append(audio); 52 | sink.set_volume(volume); 53 | sink.sleep_until_end(); 54 | Ok(()) 55 | }) 56 | .join() 57 | .unwrap()?; 58 | 59 | Ok(()) 60 | } 61 | 62 | trait FileData { 63 | fn get_bytes(&self) -> Vec; 64 | } 65 | 66 | enum SoundFile { 67 | Default, 68 | Custom(Vec), 69 | } 70 | 71 | impl FileData for SoundFile { 72 | fn get_bytes(&self) -> Vec { 73 | match self { 74 | SoundFile::Default => include_bytes!("bell.wav").to_vec(), 75 | SoundFile::Custom(bytes) => bytes.to_owned(), 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Code related to the runtime configuration of zentime 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::path::PathBuf; 5 | use zentime_rs_timer::config::PomodoroTimerConfig; 6 | 7 | use figment::{ 8 | providers::{Format, Serialized, Toml}, 9 | Figment, 10 | }; 11 | 12 | /// Configuration of notifications which are being send to the OS after each 13 | /// interval/break 14 | #[derive(Deserialize, Serialize, Clone, Debug)] 15 | pub struct NotificationConfig { 16 | /// Enable/Disable bell 17 | pub enable_bell: bool, 18 | 19 | /// Soundfile to be played back on each interval end. 20 | /// Will default to a bell sound, if `None` 21 | pub sound_file: Option, 22 | 23 | /// Notification bell volume 24 | pub volume: f32, 25 | 26 | /// Show OS-notification 27 | pub show_notification: bool, 28 | 29 | /// A random suggestion will be picked on each break and shown inside the 30 | /// notification text. 31 | pub break_suggestions: Option>, 32 | } 33 | 34 | impl Default for NotificationConfig { 35 | fn default() -> Self { 36 | NotificationConfig { 37 | volume: 0.5, 38 | sound_file: None, 39 | enable_bell: true, 40 | show_notification: true, 41 | break_suggestions: None, 42 | } 43 | } 44 | } 45 | 46 | /// Configuration of the interface 47 | #[derive(Deserialize, Serialize, Clone, Debug)] 48 | pub struct ViewConfig { 49 | #[doc = include_str!("./ViewConfig.md")] 50 | pub interface: String, 51 | 52 | /// Suppresses the output of one-shot commands 53 | /// (e.g. `zentime skip` or `zentime toggle-timer`) 54 | pub silent: bool, 55 | } 56 | 57 | impl Default for ViewConfig { 58 | fn default() -> Self { 59 | Self { 60 | interface: "default".to_string(), 61 | silent: false, 62 | } 63 | } 64 | } 65 | 66 | /// Zentime configuration 67 | #[derive(Deserialize, Serialize, Clone, Default, Debug)] 68 | pub struct Config { 69 | /// Interface configuration 70 | pub view: ViewConfig, 71 | 72 | /// Configuration of the timer itself 73 | pub timers: PomodoroTimerConfig, 74 | 75 | /// Configuration for OS notifications 76 | pub notifications: NotificationConfig, 77 | } 78 | 79 | /// Creates a base configuration [Figment] by trying to open a configuration file 80 | /// from a given path and merging its configuration with the zentime default configuration. 81 | pub fn create_base_config(config_path: &str) -> Figment { 82 | let mut path_buffer = PathBuf::new(); 83 | path_buffer.push(shellexpand::tilde(config_path.trim()).as_ref()); 84 | 85 | Figment::from(Serialized::defaults(Config::default())).merge(Toml::file(path_buffer)) 86 | } 87 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer/postponed_short_break.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | use crate::{ 4 | config::PomodoroTimerConfig, 5 | timer::{Running, TimerStatus, TimerTickHandler}, 6 | Timer, TimerAction, 7 | }; 8 | 9 | use super::{ 10 | on_end_handler::OnEndHandler, 11 | on_tick_handler::PomodoroActionHandler, 12 | short_break::ShortBreak, 13 | state::{Callbacks, PomodoroState, PomodoroTimer, PomodoroTimerState, ViewState}, 14 | TimerKind, 15 | }; 16 | 17 | /// Pomodoro timer state designating a postponed short break 18 | #[derive(Debug, Copy, Clone)] 19 | pub struct PostponedShortBreak {} 20 | 21 | impl PomodoroState for PostponedShortBreak {} 22 | 23 | struct PostponeShortBreakTickHandler { 24 | pomodoro_timer: PomodoroTimer, 25 | } 26 | 27 | impl PomodoroActionHandler for PostponeShortBreakTickHandler { 28 | fn get_timer(&self) -> PomodoroTimer { 29 | self.pomodoro_timer.clone() 30 | } 31 | } 32 | 33 | impl TimerTickHandler for PostponeShortBreakTickHandler { 34 | fn call(&mut self, status: TimerStatus) -> Option { 35 | let callbacks = self.pomodoro_timer.callbacks.clone(); 36 | let state = self.pomodoro_timer.shared_state; 37 | 38 | let result = (callbacks.on_tick)(ViewState { 39 | is_break: false, 40 | is_postponed: true, 41 | postpone_count: state.postponed_count, 42 | round: state.round, 43 | time: status.current_time.to_string(), 44 | is_paused: status.is_paused, 45 | }); 46 | 47 | if let Some(action) = result { 48 | self.handle_action(action) 49 | } else { 50 | None 51 | } 52 | } 53 | } 54 | 55 | impl PomodoroTimer { 56 | pub(crate) fn init(self) { 57 | Timer::::new( 58 | self.config.postpone_timer, 59 | Some(OnEndHandler { 60 | on_timer_end: self.callbacks.on_timer_end.clone(), 61 | state: self.shared_state, 62 | notification: None, 63 | kind: TimerKind::Interval, 64 | }), 65 | Some(PostponeShortBreakTickHandler { 66 | pomodoro_timer: self.clone(), 67 | }), 68 | ) 69 | .init(); 70 | 71 | Self::next(self.config, self.callbacks, self.shared_state) 72 | } 73 | 74 | fn next(config: PomodoroTimerConfig, callbacks: Callbacks, shared_state: PomodoroTimerState) { 75 | PomodoroTimer { 76 | shared_state, 77 | config, 78 | callbacks, 79 | marker: PhantomData::, 80 | } 81 | .init(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer/postponed_long_break.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | use crate::{ 4 | config::PomodoroTimerConfig, 5 | timer::{Running, TimerStatus, TimerTickHandler}, 6 | Timer, TimerAction, 7 | }; 8 | 9 | use super::{ 10 | long_break::LongBreak, 11 | on_end_handler::OnEndHandler, 12 | on_tick_handler::PomodoroActionHandler, 13 | state::{Callbacks, PomodoroState, PomodoroTimer, PomodoroTimerState, ViewState}, 14 | TimerKind, 15 | }; 16 | 17 | /// Pomodoro timer state designating a postponed long break 18 | #[derive(Debug, Copy, Clone)] 19 | pub struct PostponedLongBreak {} 20 | 21 | impl PomodoroState for PostponedLongBreak {} 22 | 23 | struct PostponeLongBreakTickHandler { 24 | pomodoro_timer: PomodoroTimer, 25 | } 26 | 27 | impl PomodoroActionHandler for PostponeLongBreakTickHandler { 28 | fn get_timer(&self) -> PomodoroTimer { 29 | self.pomodoro_timer.clone() 30 | } 31 | } 32 | 33 | impl TimerTickHandler for PostponeLongBreakTickHandler { 34 | fn call(&mut self, status: TimerStatus) -> Option { 35 | let callbacks = self.pomodoro_timer.callbacks.clone(); 36 | let state = self.pomodoro_timer.shared_state; 37 | 38 | let result = (callbacks.on_tick)(ViewState { 39 | is_break: false, 40 | is_postponed: true, 41 | postpone_count: state.postponed_count, 42 | round: state.round, 43 | time: status.current_time.to_string(), 44 | is_paused: status.is_paused, 45 | }); 46 | 47 | if let Some(action) = result { 48 | self.handle_action(action) 49 | } else { 50 | None 51 | } 52 | } 53 | } 54 | 55 | impl PomodoroTimer { 56 | /// Starts the timer loop on a `PomodoroTimer` 57 | pub fn init(self) { 58 | Timer::::new( 59 | self.config.postpone_timer, 60 | Some(OnEndHandler { 61 | on_timer_end: self.callbacks.on_timer_end.clone(), 62 | state: self.shared_state, 63 | notification: None, 64 | kind: TimerKind::Interval, 65 | }), 66 | Some(PostponeLongBreakTickHandler { 67 | pomodoro_timer: self.clone(), 68 | }), 69 | ) 70 | .init(); 71 | 72 | Self::next(self.config, self.callbacks, self.shared_state) 73 | } 74 | 75 | fn next(config: PomodoroTimerConfig, callbacks: Callbacks, shared_state: PomodoroTimerState) { 76 | PomodoroTimer { 77 | shared_state, 78 | config, 79 | callbacks, 80 | marker: PhantomData::, 81 | } 82 | .init(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /timer/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn( 2 | missing_docs, 3 | missing_copy_implementations, 4 | missing_debug_implementations 5 | )] 6 | 7 | //! Pomodoro/Productivity timer that can transition between various states ([Paused]/[Running]), 8 | //! tracks intervals and can be configured. 9 | //! 10 | //! ## Example 11 | //! 12 | //! ``` 13 | //! use std::sync::mpsc::{self, RecvTimeoutError}; 14 | //! use std::sync::mpsc::{Receiver, Sender}; 15 | //! use std::thread; 16 | //! use std::rc::Rc; 17 | //! use std::time::Duration; 18 | //! use zentime_rs_timer::config::PomodoroTimerConfig; 19 | //! use zentime_rs_timer::pomodoro_timer_action::PomodoroTimerAction; 20 | //! use zentime_rs_timer::pomodoro_timer::{ PomodoroTimer, TimerKind, ViewState }; 21 | //! 22 | //! let (terminal_input_sender, terminal_input_receiver): (Sender, Receiver) = 23 | //! mpsc::channel(); 24 | //! let (view_sender, view_receiver): (Sender, Receiver) = 25 | //! mpsc::channel(); 26 | //! 27 | //! let config = PomodoroTimerConfig::default(); 28 | //! 29 | //! // Run timer in its own thread so it does not block the current one 30 | //! thread::spawn(move || { 31 | //! let timer = PomodoroTimer::new( 32 | //! config, 33 | //! Rc::new(move |state, msg, _| { 34 | //! println!("{} {}", state.round, msg.unwrap()); 35 | //! }), 36 | //! Rc::new(move |view_state| -> Option { 37 | //! view_sender.send(view_state).unwrap(); 38 | //! 39 | //! let input = terminal_input_receiver.recv_timeout(Duration::from_millis(100)); 40 | //! 41 | //! match input { 42 | //! Ok(action) => Some(action), 43 | //! Err(RecvTimeoutError::Disconnected) => std::process::exit(0), 44 | //! _ => None, 45 | //! } 46 | //! }), 47 | //! ); 48 | //! 49 | //! timer.init(); 50 | //! }); 51 | //! 52 | //! let action_jh = thread::spawn(move || { 53 | //! // Start the timer 54 | //! terminal_input_sender.send(PomodoroTimerAction::PlayPause).unwrap(); 55 | //! 56 | //! // Render current timer state three seconds in a row 57 | //! for _ in 0..3 { 58 | //! thread::sleep(Duration::from_millis(100)); 59 | //! if let Ok(state) = view_receiver.recv() { 60 | //! println!("{}", state.time) 61 | //! } 62 | //! } 63 | //! 64 | //! # std::process::exit(0); 65 | //! }); 66 | //! 67 | //! action_jh.join().unwrap(); 68 | //! ``` 69 | 70 | pub use timer::Timer; 71 | pub use timer_action::TimerAction; 72 | 73 | pub mod config; 74 | pub mod pomodoro_timer; 75 | pub mod pomodoro_timer_action; 76 | pub mod timer; 77 | pub mod timer_action; 78 | pub mod util; 79 | -------------------------------------------------------------------------------- /src/server/notification.rs: -------------------------------------------------------------------------------- 1 | //! OS-Notification and sound playback related functions. 2 | 3 | use super::sound::{play, AudioPlaybackError}; 4 | use crate::config::NotificationConfig; 5 | use anyhow::bail; 6 | use log::error; 7 | use notify_rust::{Notification, NotificationHandle}; 8 | use rand::{seq::SliceRandom, thread_rng}; 9 | use std::fmt::Write; 10 | use thiserror::Error; 11 | 12 | /// Something went wrong during notification dispatch 13 | #[derive(Debug, Error)] 14 | pub enum NotificationDispatchError { 15 | /// Denotes that the given [SoundFile] could not be decoded 16 | #[error("Could not play sound file")] 17 | SoundPlayback(#[from] AudioPlaybackError), 18 | 19 | /// Denotes that something went wrong while zentime tried to send 20 | /// a system notification. 21 | /// NOTE: This case should currently not happen, because the underlying 22 | /// call to the [notify_rust] library will always return with `Ok` 23 | #[error("Could not send OS notification")] 24 | OperatingSystemNotification(#[from] anyhow::Error), 25 | } 26 | 27 | /// Play a sound file and send an OS-notification. 28 | pub fn dispatch_notification( 29 | config: NotificationConfig, 30 | notification_string: Option<&str>, 31 | should_show_suggestion: bool, 32 | ) -> Result<(), NotificationDispatchError> { 33 | if config.enable_bell { 34 | play(config.sound_file, config.volume)?; 35 | } 36 | 37 | if !config.show_notification || notification_string.is_none() { 38 | return Ok(()); 39 | }; 40 | 41 | let mut notification = notification_string.unwrap().to_string(); 42 | 43 | if !should_show_suggestion { 44 | send(¬ification)?; 45 | return Ok(()); 46 | } 47 | 48 | let suggestions = match config.break_suggestions { 49 | Some(suggestions) => suggestions, 50 | None => vec![], 51 | }; 52 | 53 | let random_suggestion = suggestions.choose(&mut thread_rng()); 54 | 55 | if let Some(suggestion) = random_suggestion { 56 | if let Err(error) = write!(notification, "\n\n{}", suggestion) { 57 | error!("Could not concatenate random suggestion to notification message"); 58 | return Err(NotificationDispatchError::OperatingSystemNotification( 59 | anyhow::Error::new(error), 60 | )); 61 | }; 62 | } 63 | 64 | send(¬ification)?; 65 | 66 | Ok(()) 67 | } 68 | 69 | /// Send a OS-notificaion 70 | fn send(message: &str) -> anyhow::Result { 71 | match Notification::new() 72 | .summary("\u{25EF} zentime") 73 | .body(message) 74 | .show() 75 | { 76 | Ok(handle) => Ok(handle), 77 | Err(error) => { 78 | // Currently show() will always return ok() (as per the definition of) 79 | // notify_rust. However if they API changes one day an we are indeed able to receive 80 | // errors, we wan't it to be logged in some way. 81 | error!("Error on notification: {:?}", error); 82 | bail!(error) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /timer/examples/async.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | use std::sync::mpsc::channel; 3 | use std::thread::{self, sleep}; 4 | use std::time::Duration; 5 | use tokio::task; 6 | use zentime_rs_timer::config::PomodoroTimerConfig; 7 | use zentime_rs_timer::pomodoro_timer::PomodoroTimer; 8 | use zentime_rs_timer::pomodoro_timer_action::PomodoroTimerAction; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | let (terminal_input_sender, terminal_input_receiver) = channel(); 13 | let (view_sender, mut view_receiver) = tokio::sync::mpsc::unbounded_channel(); 14 | 15 | let config = PomodoroTimerConfig::default(); 16 | 17 | // Run timer in its own thread so it does not block the current one 18 | thread::spawn(move || { 19 | let timer = PomodoroTimer::new( 20 | config, 21 | Rc::new(move |state, msg, _| { 22 | println!("{} {}", state.round, msg.unwrap()); 23 | }), 24 | Rc::new(move |state| -> Option { 25 | view_sender.send(state).unwrap(); 26 | 27 | sleep(Duration::from_secs(1)); 28 | 29 | let input = terminal_input_receiver.try_recv(); 30 | 31 | match input { 32 | Ok(action) => Some(action), 33 | _ => None, 34 | } 35 | }), 36 | ); 37 | 38 | timer.init() 39 | }); 40 | 41 | task::spawn(async move { 42 | // Start the timer 43 | terminal_input_sender 44 | .send(PomodoroTimerAction::PlayPause) 45 | .unwrap(); 46 | 47 | // Render current timer state three seconds in a row 48 | for _ in 0..3 { 49 | let state = view_receiver.recv().await.unwrap(); 50 | println!("{}", state.time); 51 | tokio::time::sleep(Duration::from_secs(1)).await; 52 | } 53 | 54 | // Pause the timer 55 | terminal_input_sender 56 | .send(PomodoroTimerAction::PlayPause) 57 | .unwrap(); 58 | let state = view_receiver.recv().await.unwrap(); 59 | 60 | // NOTE: 61 | // The received messages after pausing can be a bit irritating, 62 | // depending on how long you pause the timer this is because our task 63 | // is sleeping while the timer thread is still sending messages. 64 | // Each recv() below is basically just catching up with the timer. 65 | // For example if we would wait 3 seconds instead of one, we would 66 | // see 24:27 three times in a row, because these messages have already been queued. 67 | println!( 68 | "Paused at {}, waiting 1 seconds before resuming", 69 | state.time 70 | ); 71 | 72 | tokio::time::sleep(Duration::from_secs(1)).await; 73 | 74 | // Start the timer again 75 | terminal_input_sender 76 | .send(PomodoroTimerAction::PlayPause) 77 | .unwrap(); 78 | 79 | // Render current timer state three seconds in a row 80 | for _ in 0..3 { 81 | let state = view_receiver.recv().await.unwrap(); 82 | println!("{}", state.time); 83 | tokio::time::sleep(Duration::from_secs(1)).await; 84 | } 85 | }) 86 | .await 87 | .unwrap(); 88 | } 89 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer/state.rs: -------------------------------------------------------------------------------- 1 | use super::{interval::Interval, on_end_handler::OnTimerEnd, on_tick_handler::OnTick}; 2 | use crate::config::PomodoroTimerConfig; 3 | use serde::{Deserialize, Serialize}; 4 | use std::{fmt::Debug, marker::PhantomData}; 5 | 6 | /// General trait describing the various states a pomodoro timer can be in 7 | pub trait PomodoroState {} 8 | 9 | /// State which is shared between timers when they transition from one timer state to the 10 | /// next. Some of its information is also being shared with the [OnTick] closure. 11 | #[derive(Clone, Copy, Debug)] 12 | pub struct PomodoroTimerState { 13 | /// The current pomodoro round. 14 | /// This is incremented after each break. 15 | pub round: u64, 16 | 17 | /// Times the current break has been postponed - if a limit for postponing 18 | /// has been set this will be used to determine if postponing is possible or not. 19 | pub postponed_count: u16, 20 | } 21 | 22 | /// Information that will be handed to the [on_tick] closure continously 23 | #[derive(Debug, Clone, Serialize, Deserialize)] 24 | pub struct ViewState { 25 | /// Denotes if the current timer is a break timer 26 | pub is_break: bool, 27 | 28 | /// Denotes if the timer is currently in a postponed state or not 29 | pub is_postponed: bool, 30 | 31 | /// Denotes how often the current timer has already been postponed 32 | pub postpone_count: u16, 33 | 34 | /// Denotes the current interval round 35 | pub round: u64, 36 | 37 | /// Denotes the current time of the timer 38 | pub time: String, 39 | 40 | /// Denotes if the timer is currently paused 41 | pub is_paused: bool, 42 | } 43 | 44 | #[derive(Clone)] 45 | pub struct Callbacks { 46 | pub on_timer_end: OnTimerEnd, 47 | pub on_tick: OnTick, 48 | } 49 | 50 | impl Debug for Callbacks { 51 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 52 | f.debug_struct("PomodoroTimer") 53 | .field("on_timer_end", &"callback closure") 54 | .field("on_tick", &"callback closure") 55 | .finish() 56 | } 57 | } 58 | 59 | /// A Pomodoro-timer instance 60 | #[derive(Clone)] 61 | pub struct PomodoroTimer { 62 | /// User configuration of the pomodoro timer 63 | pub config: PomodoroTimerConfig, 64 | 65 | /// Callback handlers 66 | pub callbacks: Callbacks, 67 | 68 | /// State which is shared between PomodoroTimer-transitions 69 | pub shared_state: PomodoroTimerState, 70 | 71 | /// Marker designating in which typestate we are currently in 72 | pub marker: PhantomData, 73 | } 74 | 75 | impl Debug for PomodoroTimer { 76 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 77 | f.debug_struct("PomodoroTimer") 78 | .field("config", &self.config) 79 | .field("on_timer_end", &"[closure] without context") 80 | .field("on_tick", &"[closure] without context") 81 | .finish() 82 | } 83 | } 84 | 85 | impl PomodoroTimer { 86 | /// Resets the pomodoro timer to the very first interval 87 | pub fn reset(config: PomodoroTimerConfig, callbacks: Callbacks) -> PomodoroTimer { 88 | PomodoroTimer::new(config, callbacks.on_timer_end, callbacks.on_tick) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/client/start.rs: -------------------------------------------------------------------------------- 1 | //! Module containing code relevant to starting a zentime terminal client. 2 | //! See [`start`] 3 | 4 | use crate::client::terminal_io::output::TerminalOutputTask; 5 | use std::sync::Arc; 6 | 7 | use crate::client::terminal_io::input::TerminalInputTask; 8 | use crate::client::terminal_io::output::TerminalOut; 9 | use crate::config::Config; 10 | use futures::future::FutureExt; 11 | use futures::lock::Mutex; 12 | use tokio::sync::mpsc::unbounded_channel; 13 | use tokio::try_join; 14 | 15 | use super::connection::ClientConnectionTask; 16 | use crate::client::terminal_io::output::DefaultInterface; 17 | use crate::client::terminal_io::output::MinimalInterface; 18 | 19 | /// Start a single zentime client and connect it to the zentime server. 20 | /// This makes sure we have tokio tasks in place to: 21 | /// * listen to incoming input events 22 | /// * render output to the terminal 23 | /// * hanndle communication over a client-server connection via IPC-message passing 24 | /// 25 | /// # Example 26 | /// 27 | /// ```no_run 28 | /// use zentime_rs::client::start; 29 | /// use zentime_rs::config::create_base_config; 30 | /// use zentime_rs::config::Config; 31 | /// 32 | /// #[tokio::main] 33 | /// async fn main() { 34 | /// let config: Config = create_base_config("./some/path/config.toml") 35 | /// .extract() 36 | /// .expect("Could not create config"); 37 | /// start(config).await; 38 | /// } 39 | /// ``` 40 | pub async fn start(config: Config) { 41 | let (terminal_in_tx, terminal_in_rx) = unbounded_channel(); 42 | let (terminal_out_tx, terminal_out_rx) = unbounded_channel(); 43 | 44 | let interface_type = config.view.interface.clone(); 45 | 46 | let terminal_out: Box = init_interface(interface_type); 47 | 48 | let thread_safe_terminal_out = Arc::new(Mutex::new(terminal_out)); 49 | 50 | let input_handler = TerminalInputTask::spawn(terminal_in_tx); 51 | let view_handler = TerminalOutputTask::spawn(thread_safe_terminal_out.clone(), terminal_out_rx); 52 | let connection_handler = ClientConnectionTask::spawn(terminal_in_rx, terminal_out_tx); 53 | 54 | let join_result = try_join! { 55 | connection_handler.flatten(), 56 | input_handler.flatten(), 57 | view_handler.flatten(), 58 | }; 59 | 60 | if let Err(error) = join_result { 61 | thread_safe_terminal_out 62 | .lock() 63 | .await 64 | .quit(Some(format!("ERROR: {}", error)), true) 65 | } 66 | } 67 | 68 | /// Determine which terminal interface should be used. 69 | fn init_interface(interface_type: String) -> Box { 70 | match interface_type.as_str() { 71 | "minimal" => match MinimalInterface::new() { 72 | Ok(interface) => { 73 | // We move up one line to replace the initial prompt ending with our timer 74 | let ansi_move_line_up_escape = "\x1B[A"; 75 | print!("{}", ansi_move_line_up_escape); 76 | Box::new(interface) 77 | }, 78 | Err(error) => { 79 | panic!("Could not initialize interface: {}", error); 80 | } 81 | }, 82 | _ => match DefaultInterface::new() { 83 | Ok(interface) => Box::new(interface), 84 | Err(error) => { 85 | panic!("Could not initialize interface: {}", error); 86 | } 87 | }, 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /timer/examples/simple.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | use std::sync::mpsc::{self, RecvTimeoutError}; 3 | use std::sync::mpsc::{Receiver, Sender}; 4 | use std::thread; 5 | use std::time::Duration; 6 | use zentime_rs_timer::config::PomodoroTimerConfig; 7 | use zentime_rs_timer::pomodoro_timer::{PomodoroTimer, ViewState}; 8 | use zentime_rs_timer::pomodoro_timer_action::PomodoroTimerAction; 9 | 10 | fn main() { 11 | let (terminal_input_sender, terminal_input_receiver): ( 12 | Sender, 13 | Receiver, 14 | ) = mpsc::channel(); 15 | let (view_sender, view_receiver): (Sender, Receiver) = mpsc::channel(); 16 | 17 | let config = PomodoroTimerConfig::default(); 18 | 19 | // Run timer in its own thread so it does not block the current one 20 | thread::spawn(move || { 21 | let timer = PomodoroTimer::new( 22 | config, 23 | Rc::new(move |state, msg, _| { 24 | println!("{} {}", state.round, msg.unwrap()); 25 | }), 26 | Rc::new(move |state| -> Option { 27 | view_sender.send(state).unwrap(); 28 | 29 | let input = terminal_input_receiver.recv_timeout(Duration::from_secs(1)); 30 | 31 | match input { 32 | Ok(action) => Some(action), 33 | Err(RecvTimeoutError::Disconnected) => None, // Handle error in a real scenario 34 | _ => None, 35 | } 36 | }), 37 | ); 38 | 39 | timer.init() 40 | }); 41 | 42 | let action_jh = thread::spawn(move || { 43 | // Start the timer 44 | terminal_input_sender 45 | .send(PomodoroTimerAction::PlayPause) 46 | .unwrap(); 47 | 48 | // Render current timer state three seconds in a row 49 | for _ in 0..3 { 50 | thread::sleep(Duration::from_secs(1)); 51 | if let Ok(state) = view_receiver.recv() { 52 | println!("{}", state.time) 53 | } 54 | } 55 | 56 | // Pause the timer 57 | terminal_input_sender 58 | .send(PomodoroTimerAction::PlayPause) 59 | .unwrap(); 60 | let state = view_receiver.recv().unwrap(); 61 | 62 | // NOTE: 63 | // The received messages after pausing can be a bit irritating, 64 | // depending on how long you pause the timer this is because our thread 65 | // is sleeping while the paused timer thread is still sending messages submitting its 66 | // (state which ofcourse will always be the same, as long as the timer is paused). 67 | // Each recv() below is basically just catching up with the timer. 68 | // For example if we would wait 3 seconds instead of one, we would 69 | // see 24:27 three times in a row, because these messages have already been queued. 70 | println!( 71 | "Paused at {}, waiting 1 seconds before resuming", 72 | state.time 73 | ); 74 | 75 | thread::sleep(Duration::from_secs(1)); 76 | 77 | // Start the timer again 78 | terminal_input_sender 79 | .send(PomodoroTimerAction::PlayPause) 80 | .unwrap(); 81 | 82 | // Render current timer state three seconds in a row 83 | for _ in 0..3 { 84 | thread::sleep(Duration::from_secs(1)); 85 | if let Ok(state) = view_receiver.recv() { 86 | println!("{}", state.time) 87 | } 88 | } 89 | }); 90 | 91 | action_jh.join().unwrap(); 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | release: 7 | runs-on: macos-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v2 11 | with: 12 | ref: 'refs/heads/main' 13 | token: ${{ secrets.ACTION_GITHUB_BOT }} 14 | 15 | ################## 16 | # Version Upping # 17 | ################## 18 | 19 | - name: Extract version 20 | id: extract-version 21 | run: | 22 | echo "tag-name=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 23 | 24 | # Note: it's important that this is being run before the version is upped 25 | # because a commit will be created during the process 26 | - name: Set git config 27 | run: | 28 | # setup the username and email. 29 | git config user.name "GitHub Actions Bot" 30 | git config user.email "<>" 31 | 32 | - name: Replace crate version in Cargo.toml 33 | # Explanation: 34 | # The command below uses OSX's sed command, which differs slightly from GNU sed, because its being run in 35 | # a mac OS enviroment. 36 | # 37 | # -E use modern regex for substitution 38 | # -i '' write replacement back to file instead of stdout 39 | # 1,/version/ only replace the first occurence 40 | run: | 41 | sed -E -i '' "1,/version/ s/(version = )\"[0-9]+.[0-9]+.[0-9]+\"/\1\"${{ steps.extract-version.outputs.tag-name }}\"/" ./Cargo.toml 42 | 43 | - name: Commit 44 | run: | 45 | git add --all 46 | git commit -m "NEW-VERSION ${{ steps.extract-version.outputs.tag-name }}" 47 | 48 | - name: Push version commit 49 | run: git push origin main 50 | 51 | ######### 52 | # Build # 53 | ######### 54 | 55 | - uses: actions-rs/toolchain@v1 56 | with: 57 | toolchain: stable 58 | - name: build 59 | uses: actions-rs/cargo@v1 60 | with: 61 | command: build 62 | args: --release --all-features 63 | use-cross: true 64 | - name: test 65 | uses: actions-rs/cargo@v1 66 | with: 67 | command: test 68 | args: --workspace 69 | 70 | ########### 71 | # Publish # 72 | ########### 73 | 74 | - name: compress 75 | run: tar -C target/release -czf zentime.tar.gz zentime 76 | 77 | - name: Add artefact to github release 78 | uses: softprops/action-gh-release@v1 79 | with: 80 | files: zentime.tar.gz 81 | 82 | - name: Update Homebrew formula 83 | uses: mislav/bump-homebrew-formula-action@v1 84 | with: 85 | # A PR will be sent to github.com/Homebrew/homebrew-core to update this formula: 86 | formula-name: zentime 87 | formula-path: Formula/zentime.rb 88 | homebrew-tap: on3iro/homebrew-zentime 89 | download-url: https://github.com/on3iro/zentime-rs/releases/download/${{ steps.extract-version.outputs.tag-name }}/zentime.tar.gz 90 | base-branch: main 91 | env: 92 | COMMITTER_TOKEN: ${{ secrets.ZENTIME_TAP_TOKEN }} 93 | 94 | - name: Clean git repo in preparation for cargo publish 95 | run: git reset --hard && git clean -df 96 | 97 | - name: Publish to crates.io 98 | uses: actions-rs/cargo@v1 99 | with: 100 | command: publish 101 | args: --token ${{ secrets.CRATES_IO_TOKEN }} 102 | -------------------------------------------------------------------------------- /src/client/terminal_io/input.rs: -------------------------------------------------------------------------------- 1 | //! Code related to async client terminal input handling 2 | 3 | use crossterm::event::{EventStream, KeyCode, KeyEvent, KeyModifiers}; 4 | use tokio::sync::mpsc::UnboundedSender; 5 | use tokio::task::yield_now; 6 | use tokio::{spawn, task::JoinHandle}; 7 | use tokio_stream::StreamExt; 8 | 9 | use crossterm::event::Event; 10 | 11 | /// Actions triggered by user terminal input on a client 12 | #[derive(Copy, Clone, Debug)] 13 | pub enum ClientInputAction { 14 | /// Quit Timer and terminate server 15 | Quit, 16 | 17 | /// Detach current client without terminating server 18 | Detach, 19 | 20 | /// NoOp 21 | None, 22 | 23 | /// Either start or pause the current timer 24 | PlayPause, 25 | 26 | /// Skip to the next timer (break or focus) 27 | Skip, 28 | 29 | /// Resets the timer back to the first interval 30 | Reset, 31 | 32 | /// Postpones the current break, if possible (see [PomodoroTimerConfig]) 33 | PostPone, 34 | } 35 | 36 | /// Tokio task handling terminal input events 37 | #[derive(Copy, Clone, Debug)] 38 | pub struct TerminalInputTask {} 39 | 40 | impl TerminalInputTask { 41 | /// Spanws the task and converts incoming terminal input events into [ClientInputAction]s and 42 | /// sends them to the client. 43 | pub async fn spawn(input_worker_tx: UnboundedSender) -> JoinHandle<()> { 44 | spawn(async move { 45 | let mut stream = EventStream::new(); 46 | 47 | loop { 48 | let result = stream.next().await; 49 | if let Some(Ok(event)) = result { 50 | if let Err(error) = input_worker_tx.send(handle_input(event)) { 51 | // TODO: handle this more gracefully 52 | panic!("Could not send ClientInputAction: {}", error) 53 | }; 54 | } 55 | 56 | yield_now().await; 57 | } 58 | }) 59 | } 60 | } 61 | 62 | /// Keymap from terminal input events to [ClientInputAction] 63 | fn handle_input(event: Event) -> ClientInputAction { 64 | if let Event::Key(key_event) = event { 65 | match key_event { 66 | KeyEvent { 67 | code: KeyCode::Char('q'), 68 | .. 69 | } 70 | | KeyEvent { 71 | code: KeyCode::Char('c'), 72 | modifiers: KeyModifiers::CONTROL, 73 | .. 74 | } => { 75 | return ClientInputAction::Quit; 76 | } 77 | 78 | KeyEvent { 79 | code: KeyCode::Char('d'), 80 | .. 81 | } => { 82 | return ClientInputAction::Detach; 83 | } 84 | 85 | KeyEvent { 86 | code: KeyCode::Char(' '), 87 | .. 88 | } => { 89 | return ClientInputAction::PlayPause; 90 | } 91 | 92 | KeyEvent { 93 | code: KeyCode::Char('s'), 94 | .. 95 | } => { 96 | return ClientInputAction::Skip; 97 | } 98 | 99 | KeyEvent { 100 | code: KeyCode::Char('p'), 101 | .. 102 | } => { 103 | return ClientInputAction::PostPone 104 | } 105 | 106 | KeyEvent { 107 | code: KeyCode::Char('r'), 108 | .. 109 | } => { 110 | return ClientInputAction::Reset; 111 | } 112 | 113 | _ => {} 114 | } 115 | } 116 | 117 | ClientInputAction::None 118 | } 119 | -------------------------------------------------------------------------------- /src/default_cmd.rs: -------------------------------------------------------------------------------- 1 | use std::env::current_dir; 2 | use std::process; 3 | use sysinfo::Pid; 4 | use zentime_rs::client::start; 5 | use zentime_rs::config::Config; 6 | use zentime_rs::server::status::server_status; 7 | use zentime_rs::server::status::ServerStatus; 8 | 9 | use sysinfo::ProcessExt; 10 | use sysinfo::System; 11 | use sysinfo::SystemExt; 12 | use tokio::process::Command; 13 | 14 | use crate::CommonArgs; 15 | 16 | #[tokio::main] 17 | pub async fn default_cmd(common_args: &CommonArgs, config: Config) { 18 | let system = System::new_all(); 19 | 20 | // We need to spawn a server process before we can attach our client 21 | if server_status() == ServerStatus::Stopped { 22 | // WHY: 23 | // We want to get information about the current zentime process, e.g. 24 | // the path to its executable. That way this does also work in ci or during 25 | // development, where one might not have added a specific zentime binary to their path. 26 | let current_process = system 27 | .process(Pid::from(process::id() as i32)) 28 | .expect("Could not retrieve information for current zentime process"); 29 | 30 | let current_dir = current_dir() 31 | .expect("Could not get current directory") 32 | .into_os_string(); 33 | 34 | let server_args = get_server_args(common_args); 35 | 36 | if let Err(error) = Command::new(current_process.exe()) 37 | .arg("server") 38 | .arg("start") 39 | .args(server_args) 40 | .current_dir(current_dir) 41 | .spawn() 42 | .expect("Could not start server daemon") 43 | .wait() 44 | .await 45 | { 46 | panic!("Server exited unexpectedly: {}", error) 47 | }; 48 | } 49 | 50 | start(config).await; 51 | } 52 | 53 | fn get_server_args(common_args: &CommonArgs) -> Vec { 54 | let mut args: Vec = vec![ 55 | // Config path 56 | "-c".to_string(), 57 | common_args.config.to_string(), 58 | ]; 59 | 60 | if let Some(postpone_limit) = &common_args.server_config.timers.postpone_limit { 61 | args.push("--postpone-limit".to_string()); 62 | args.push(postpone_limit.to_string()); 63 | } 64 | 65 | if let Some(postpone_timer) = &common_args.server_config.timers.postpone_timer { 66 | args.push("--postpone-timer".to_string()); 67 | args.push(postpone_timer.to_string()); 68 | } 69 | 70 | if let Some(enable_bell) = &common_args.server_config.notifications.enable_bell { 71 | args.push("--enable-bell".to_string()); 72 | args.push(enable_bell.to_string()); 73 | } 74 | 75 | if let Some(sound_file) = &common_args.server_config.notifications.sound_file { 76 | args.push("--sound-file".to_string()); 77 | args.push(sound_file.to_string()); 78 | } 79 | 80 | if let Some(volume) = &common_args.server_config.notifications.volume { 81 | args.push("--volume".to_string()); 82 | args.push(volume.to_string()); 83 | } 84 | 85 | if let Some(show_notification) = &common_args.server_config.notifications.show_notification { 86 | args.push("--show-notification".to_string()); 87 | args.push(show_notification.to_string()); 88 | } 89 | 90 | if let Some(timer) = &common_args.server_config.timers.timer { 91 | args.push("--timer".to_string()); 92 | args.push(timer.to_string()); 93 | } 94 | 95 | if let Some(minor_break) = &common_args.server_config.timers.minor_break { 96 | args.push("--minor-break".to_string()); 97 | args.push(minor_break.to_string()); 98 | } 99 | 100 | if let Some(major_break) = &common_args.server_config.timers.major_break { 101 | args.push("--major-break".to_string()); 102 | args.push(major_break.to_string()); 103 | } 104 | 105 | if let Some(intervals) = &common_args.server_config.timers.intervals { 106 | args.push("--intervals".to_string()); 107 | args.push(intervals.to_string()) 108 | } 109 | 110 | args 111 | } 112 | -------------------------------------------------------------------------------- /src/subcommands/server.rs: -------------------------------------------------------------------------------- 1 | use daemonize::Daemonize; 2 | use figment::providers::Serialized; 3 | use interprocess::local_socket::tokio::LocalSocketStream; 4 | use log::{error, info}; 5 | use std::env::current_dir; 6 | use std::fs::File; 7 | use std::thread::sleep; 8 | use std::time::Duration; 9 | use zentime_rs::config::create_base_config; 10 | use zentime_rs::config::Config; 11 | use zentime_rs::ipc::get_socket_name; 12 | use zentime_rs::ipc::ClientToServerMsg; 13 | use zentime_rs::ipc::InterProcessCommunication; 14 | use zentime_rs::server::start; 15 | use zentime_rs::server::status::server_status; 16 | 17 | use crate::CommonArgs; 18 | 19 | const DEFAULT_OUT_FILE: &str = "/tmp/zentime.d.out"; 20 | const DEFAULT_ERROR_FILE: &str = "/tmp/zentime.d.err"; 21 | const DEBUG_OUT_FILE: &str = "/tmp/zentime_debug.d.out"; 22 | const DEBUG_ERROR_FILE: &str = "/tmp/zentime_debug.d.err"; 23 | 24 | /// Daemonizes the current process and then starts a zentime server instance in it (if there isn't 25 | /// another server already running - otherwise the process terminates). 26 | /// 27 | /// NOTE: It's important, that we run this synchronously. 28 | /// [server::start()] will then create a tokio runtime, after the process has been 29 | /// deamonized 30 | pub fn start_daemonized(args: &CommonArgs) { 31 | let stdout_path = if cfg!(debug_assertions) { 32 | DEBUG_OUT_FILE 33 | } else { 34 | DEFAULT_OUT_FILE 35 | }; 36 | let stdout = File::create(stdout_path) 37 | .unwrap_or_else(|error| panic!("Could not create {}: {}", stdout_path, error)); 38 | 39 | let stderr_path = if cfg!(debug_assertions) { 40 | DEBUG_ERROR_FILE 41 | } else { 42 | DEFAULT_ERROR_FILE 43 | }; 44 | let stderr = File::create(stderr_path) 45 | .unwrap_or_else(|error| panic!("Could not create {}: {}", stderr_path, error)); 46 | 47 | let current_directory = current_dir() 48 | .expect("Could not get current directory") 49 | .into_os_string(); 50 | 51 | let daemonize = Daemonize::new() 52 | .working_directory(current_directory) 53 | .stdout(stdout) // Redirect stdout to `/tmp/daemon.out`. 54 | .stderr(stderr); // Redirect stderr to `/tmp/daemon.err`. 55 | 56 | if let Err(error) = daemonize.start() { 57 | panic!("Could not daemonize server process: {}", error); 58 | }; 59 | 60 | info!("Daemonized server process"); 61 | 62 | let config = get_server_config(args); 63 | 64 | if let Err(error) = start(config) { 65 | error!("A server error occured: {}", error); 66 | }; 67 | } 68 | 69 | fn get_server_config(args: &CommonArgs) -> Config { 70 | let config_path = &args.config; 71 | info!("Creating config from path: {}", config_path); 72 | 73 | create_base_config(config_path) 74 | .merge(Serialized::defaults(args.server_config.clone())) 75 | .extract() 76 | .expect("Could not create config") 77 | } 78 | 79 | /// Stops a currently running zentime server (there can only ever be a single instance - all 80 | /// clients will automatically shutdown, when their connection closes). 81 | #[tokio::main] 82 | pub async fn stop() { 83 | let socket_name = get_socket_name(); 84 | 85 | let mut connection_tries = 0; 86 | 87 | info!("Connecting to server..."); 88 | 89 | let connection = loop { 90 | connection_tries += 1; 91 | 92 | if connection_tries == 4 { 93 | panic!("Could not connect to server"); 94 | } 95 | 96 | let result = LocalSocketStream::connect(socket_name).await; 97 | 98 | if let Ok(conn) = result { 99 | break conn; 100 | } else { 101 | sleep(Duration::from_millis(200)); 102 | } 103 | }; 104 | 105 | info!("Shutting down..."); 106 | 107 | let (_, mut writer) = connection.into_split(); 108 | 109 | let msg = ClientToServerMsg::Quit; 110 | InterProcessCommunication::send_ipc_message(msg, &mut writer) 111 | .await 112 | .expect("Could not send Quit message"); 113 | 114 | info!("Done."); 115 | } 116 | 117 | /// Prints the current status of the zentime server 118 | pub fn status() { 119 | println!("Server is {}", server_status()); 120 | } 121 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer/interval.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | use crate::{ 4 | config::PomodoroTimerConfig, 5 | timer::{Paused, TimerStatus, TimerTickHandler}, 6 | Timer, TimerAction, 7 | }; 8 | 9 | use super::{ 10 | long_break::LongBreak, 11 | on_end_handler::{OnEndHandler, OnTimerEnd}, 12 | on_tick_handler::{OnTick, PomodoroActionHandler}, 13 | short_break::ShortBreak, 14 | state::{Callbacks, PomodoroState, PomodoroTimer, PomodoroTimerState, ViewState}, 15 | TimerKind, 16 | }; 17 | 18 | /// Pomodoro timer state designating a focus interval 19 | #[derive(Debug, Copy, Clone)] 20 | pub struct Interval {} 21 | 22 | impl PomodoroState for Interval {} 23 | 24 | struct IntervalTickHandler { 25 | pomodoro_timer: PomodoroTimer, 26 | } 27 | 28 | impl PomodoroActionHandler for IntervalTickHandler { 29 | fn get_timer(&self) -> PomodoroTimer { 30 | self.pomodoro_timer.clone() 31 | } 32 | } 33 | 34 | impl TimerTickHandler for IntervalTickHandler { 35 | fn call(&mut self, status: TimerStatus) -> Option { 36 | let callbacks = self.pomodoro_timer.callbacks.clone(); 37 | let state = self.pomodoro_timer.shared_state; 38 | 39 | let result = (callbacks.on_tick)(ViewState { 40 | is_break: false, 41 | is_postponed: false, 42 | postpone_count: state.postponed_count, 43 | round: state.round, 44 | time: status.current_time.to_string(), 45 | is_paused: status.is_paused, 46 | }); 47 | 48 | if let Some(action) = result { 49 | self.handle_action(action) 50 | } else { 51 | None 52 | } 53 | } 54 | } 55 | 56 | impl PomodoroTimer { 57 | /// Creates a new pomodoro timer with the initial pomodoro state and returns it 58 | /// To actually run the timer and listen for input etc., [self.init] has to be called 59 | /// on the returned timer. 60 | pub fn new(config: PomodoroTimerConfig, on_timer_end: OnTimerEnd, on_tick: OnTick) -> Self { 61 | let shared_state = PomodoroTimerState { 62 | round: 1, 63 | postponed_count: 0, 64 | }; 65 | 66 | Self { 67 | shared_state, 68 | config, 69 | callbacks: Callbacks { 70 | on_timer_end, 71 | on_tick, 72 | }, 73 | marker: PhantomData, 74 | } 75 | } 76 | 77 | /// Runs the timer so that the inner timer loop is started and the [OnTick] 78 | /// closure is being called continously. 79 | /// NOTE: This does not mean that the timer starts counting. 80 | /// The internal [Timer] will be initialized in a paused state, waiting for 81 | /// a [TimerAction:PlayPause]-action (triggered in turn by a [PomodoroTimerAction::PlayPause]) 82 | pub fn init(self) { 83 | let is_major_break = self.shared_state.round % self.config.intervals == 0; 84 | 85 | Timer::::new( 86 | self.config.timer, 87 | Some(OnEndHandler { 88 | on_timer_end: self.callbacks.on_timer_end.clone(), 89 | state: self.shared_state, 90 | notification: Some("Good job, take a break!"), 91 | kind: TimerKind::Interval, 92 | }), 93 | Some(IntervalTickHandler { 94 | pomodoro_timer: self.clone(), 95 | }), 96 | ) 97 | .init(); 98 | 99 | Self::next( 100 | self.config, 101 | self.callbacks, 102 | self.shared_state, 103 | is_major_break, 104 | ) 105 | } 106 | 107 | fn next( 108 | config: PomodoroTimerConfig, 109 | callbacks: Callbacks, 110 | shared_state: PomodoroTimerState, 111 | is_major_break: bool, 112 | ) { 113 | let state = PomodoroTimerState { 114 | postponed_count: 0, 115 | ..shared_state 116 | }; 117 | 118 | if is_major_break { 119 | PomodoroTimer { 120 | shared_state: state, 121 | config, 122 | callbacks, 123 | marker: PhantomData::, 124 | } 125 | .init() 126 | } else { 127 | PomodoroTimer { 128 | shared_state: state, 129 | config, 130 | callbacks, 131 | marker: PhantomData::, 132 | } 133 | .init() 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer/long_break.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | use crate::{ 4 | config::PomodoroTimerConfig, 5 | pomodoro_timer_action::PomodoroTimerAction, 6 | timer::{Paused, TimerStatus, TimerTickHandler}, 7 | Timer, TimerAction, 8 | }; 9 | 10 | use super::{ 11 | interval::Interval, 12 | on_end_handler::OnEndHandler, 13 | on_tick_handler::{PomodoroActionHandler, PostponeHandlerConfig}, 14 | postponed_long_break::PostponedLongBreak, 15 | state::{Callbacks, PomodoroState, PomodoroTimer, PomodoroTimerState, ViewState}, 16 | TimerKind, 17 | }; 18 | 19 | /// Pomodoro timer state designating a long break 20 | #[derive(Debug, Copy, Clone)] 21 | pub struct LongBreak {} 22 | 23 | impl PomodoroState for LongBreak {} 24 | 25 | struct LongBreakTickHandler { 26 | pomodoro_timer: PomodoroTimer, 27 | } 28 | 29 | impl PomodoroActionHandler for LongBreakTickHandler { 30 | fn get_timer(&self) -> PomodoroTimer { 31 | self.pomodoro_timer.clone() 32 | } 33 | 34 | fn handle_action(&self, action: PomodoroTimerAction) -> Option { 35 | let timer = self.get_timer(); 36 | 37 | let PomodoroTimer { 38 | config, 39 | callbacks, 40 | shared_state: state, 41 | .. 42 | } = timer; 43 | 44 | let postpone_config = PostponeHandlerConfig { 45 | postpone_limit: config.postpone_limit, 46 | postponed_count: state.postponed_count, 47 | }; 48 | 49 | match action { 50 | PomodoroTimerAction::PostponeBreak if Self::can_postpone(postpone_config) => { 51 | let state = PomodoroTimerState { 52 | postponed_count: state.postponed_count + 1, 53 | ..state 54 | }; 55 | 56 | PomodoroTimer::::postpone(config, callbacks, state); 57 | 58 | None 59 | } 60 | PomodoroTimerAction::PlayPause => Some(TimerAction::PlayPause), 61 | PomodoroTimerAction::Skip => Some(TimerAction::End), 62 | PomodoroTimerAction::SetTimer(time) => Some(TimerAction::SetTimer(time)), 63 | 64 | PomodoroTimerAction::ResetTimer => { 65 | PomodoroTimer::::reset(config, callbacks).init(); 66 | None 67 | } 68 | 69 | _ => None, 70 | } 71 | } 72 | } 73 | 74 | impl TimerTickHandler for LongBreakTickHandler { 75 | fn call(&mut self, status: TimerStatus) -> Option { 76 | let callbacks = self.pomodoro_timer.callbacks.clone(); 77 | let state = self.pomodoro_timer.shared_state; 78 | 79 | let result = (callbacks.on_tick)(ViewState { 80 | is_break: true, 81 | is_postponed: false, 82 | postpone_count: state.postponed_count, 83 | round: state.round, 84 | time: status.current_time.to_string(), 85 | is_paused: status.is_paused, 86 | }); 87 | 88 | if let Some(action) = result { 89 | self.handle_action(action) 90 | } else { 91 | None 92 | } 93 | } 94 | } 95 | 96 | impl PomodoroTimer { 97 | /// Starts the timer loop on a `PomodoroTimer` 98 | pub fn init(self) { 99 | let next_shared_state = PomodoroTimerState { 100 | round: self.shared_state.round + 1, 101 | postponed_count: self.shared_state.postponed_count, 102 | }; 103 | 104 | Timer::::new( 105 | self.config.major_break, 106 | Some(OnEndHandler { 107 | on_timer_end: self.callbacks.on_timer_end.clone(), 108 | state: self.shared_state, 109 | notification: Some("Break is over"), 110 | kind: TimerKind::Break, 111 | }), 112 | Some(LongBreakTickHandler { 113 | pomodoro_timer: self.clone(), 114 | }), 115 | ) 116 | .init(); 117 | 118 | Self::next(self.config, self.callbacks, next_shared_state) 119 | } 120 | 121 | fn postpone( 122 | config: PomodoroTimerConfig, 123 | callbacks: Callbacks, 124 | shared_state: PomodoroTimerState, 125 | ) { 126 | PomodoroTimer { 127 | shared_state, 128 | config, 129 | callbacks, 130 | marker: PhantomData::, 131 | } 132 | .init(); 133 | } 134 | 135 | fn next(config: PomodoroTimerConfig, callbacks: Callbacks, shared_state: PomodoroTimerState) { 136 | PomodoroTimer { 137 | shared_state, 138 | config, 139 | callbacks, 140 | marker: PhantomData::, 141 | } 142 | .init(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /timer/src/pomodoro_timer/short_break.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | use crate::{ 4 | config::PomodoroTimerConfig, 5 | pomodoro_timer_action::PomodoroTimerAction, 6 | timer::{Paused, TimerStatus, TimerTickHandler}, 7 | Timer, TimerAction, 8 | }; 9 | 10 | use super::{ 11 | interval::Interval, 12 | on_end_handler::OnEndHandler, 13 | on_tick_handler::{PomodoroActionHandler, PostponeHandlerConfig}, 14 | postponed_short_break::PostponedShortBreak, 15 | state::{Callbacks, PomodoroState, PomodoroTimer, PomodoroTimerState, ViewState}, 16 | TimerKind, 17 | }; 18 | 19 | /// Pomodoro timer state designating a short break 20 | #[derive(Debug, Copy, Clone)] 21 | pub struct ShortBreak {} 22 | 23 | impl PomodoroState for ShortBreak {} 24 | 25 | struct ShortBreakTickHandler { 26 | pomodoro_timer: PomodoroTimer, 27 | } 28 | 29 | impl PomodoroActionHandler for ShortBreakTickHandler { 30 | fn get_timer(&self) -> PomodoroTimer { 31 | self.pomodoro_timer.clone() 32 | } 33 | 34 | fn handle_action(&self, action: PomodoroTimerAction) -> Option { 35 | let timer = self.get_timer(); 36 | 37 | let PomodoroTimer { 38 | config, 39 | callbacks, 40 | shared_state: state, 41 | .. 42 | } = timer; 43 | 44 | let postpone_config = PostponeHandlerConfig { 45 | postpone_limit: config.postpone_limit, 46 | postponed_count: state.postponed_count, 47 | }; 48 | 49 | match action { 50 | PomodoroTimerAction::PostponeBreak if Self::can_postpone(postpone_config) => { 51 | let state = PomodoroTimerState { 52 | postponed_count: state.postponed_count + 1, 53 | ..state 54 | }; 55 | 56 | PomodoroTimer::::postpone(config, callbacks, state); 57 | 58 | None 59 | } 60 | PomodoroTimerAction::PlayPause => Some(TimerAction::PlayPause), 61 | PomodoroTimerAction::Skip => Some(TimerAction::End), 62 | 63 | PomodoroTimerAction::ResetTimer => { 64 | PomodoroTimer::::reset(config, callbacks).init(); 65 | None 66 | } 67 | 68 | PomodoroTimerAction::SetTimer(time) => Some(TimerAction::SetTimer(time)), 69 | 70 | _ => None, 71 | } 72 | } 73 | } 74 | 75 | impl TimerTickHandler for ShortBreakTickHandler { 76 | fn call(&mut self, status: TimerStatus) -> Option { 77 | let callbacks = self.pomodoro_timer.callbacks.clone(); 78 | let state = self.pomodoro_timer.shared_state; 79 | 80 | let result = (callbacks.on_tick)(ViewState { 81 | is_break: true, 82 | is_postponed: false, 83 | postpone_count: state.postponed_count, 84 | round: state.round, 85 | time: status.current_time.to_string(), 86 | is_paused: status.is_paused, 87 | }); 88 | 89 | if let Some(action) = result { 90 | self.handle_action(action) 91 | } else { 92 | None 93 | } 94 | } 95 | } 96 | 97 | impl PomodoroTimer { 98 | /// Starts the timer loop on a `PomodoroTimer` 99 | pub fn init(self) { 100 | let next_shared_state = PomodoroTimerState { 101 | round: self.shared_state.round + 1, 102 | postponed_count: self.shared_state.postponed_count, 103 | }; 104 | 105 | Timer::::new( 106 | self.config.minor_break, 107 | Some(OnEndHandler { 108 | on_timer_end: self.callbacks.on_timer_end.clone(), 109 | state: self.shared_state, 110 | notification: Some("Break is over"), 111 | kind: TimerKind::Break, 112 | }), 113 | Some(ShortBreakTickHandler { 114 | pomodoro_timer: self.clone(), 115 | }), 116 | ) 117 | .init(); 118 | 119 | Self::next(self.config, self.callbacks, next_shared_state) 120 | } 121 | 122 | fn postpone( 123 | config: PomodoroTimerConfig, 124 | callbacks: Callbacks, 125 | shared_state: PomodoroTimerState, 126 | ) { 127 | PomodoroTimer { 128 | shared_state, 129 | config, 130 | callbacks, 131 | marker: PhantomData::, 132 | } 133 | .init(); 134 | } 135 | 136 | fn next(config: PomodoroTimerConfig, callbacks: Callbacks, shared_state: PomodoroTimerState) { 137 | PomodoroTimer { 138 | shared_state, 139 | config, 140 | callbacks, 141 | marker: PhantomData::, 142 | } 143 | .init(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/client/terminal_io/default_interface.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use zentime_rs_timer::pomodoro_timer::ViewState; 3 | 4 | use std::io::Stdout; 5 | use tui::{ 6 | backend::CrosstermBackend, 7 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 8 | style::{Color, Style}, 9 | text::{Span, Spans}, 10 | widgets::{Block, Borders, Paragraph, Tabs}, 11 | Terminal as TuiTerminal, 12 | }; 13 | 14 | /// Default interface 15 | pub fn render( 16 | terminal: &mut TuiTerminal>, 17 | timer_state: ViewState, 18 | ) -> anyhow::Result<()> { 19 | terminal 20 | .draw(|frame| { 21 | let rect = frame.size(); 22 | let layout = layout(rect); 23 | 24 | // Rendered at the bottom 25 | let key_tabs = key_binding_info(timer_state.is_break); 26 | frame.render_widget(key_tabs, layout[1]); 27 | 28 | // Top layout 29 | let inner_layout = inner_layout(layout[0]); 30 | 31 | // Rendered to the left 32 | let timer_info = timer_info(&timer_state); 33 | frame.render_widget(timer_info, inner_layout[0]); 34 | 35 | // Rendered to the right 36 | let timer = timer(&timer_state.time); 37 | frame.render_widget(timer, inner_layout[1]) 38 | }) 39 | .context("Could not render to terminal")?; 40 | Ok(()) 41 | } 42 | 43 | /// Base layout of the default interface 44 | /// ┌───────────────────────────────────────────────┐ 45 | /// │ │ 46 | /// │ │ 47 | /// │ A │ 48 | /// │ │ 49 | /// └───────────────────────────────────────────────┘ 50 | /// ┌───────────────────────────────────────────────┐ 51 | /// │ B │ 52 | /// └───────────────────────────────────────────────┘ 53 | fn layout(rect: Rect) -> Vec { 54 | Layout::default() 55 | .direction(Direction::Vertical) 56 | .margin(2) 57 | .constraints( 58 | [ 59 | Constraint::Max(4), 60 | Constraint::Max(3), 61 | Constraint::Length(1), 62 | ] 63 | .as_ref(), 64 | ) 65 | .split(rect) 66 | } 67 | 68 | /// Inner layout of the default interface rendered into the base layout part A 69 | /// ┌──────────────────┐ ┌────────────────────────────────┐ 70 | /// │ │ │ │ 71 | /// │ A │ │ B │ 72 | /// │ │ │ │ 73 | /// │ │ │ │ 74 | /// └──────────────────┘ └────────────────────────────────┘ 75 | fn inner_layout(rect: Rect) -> Vec { 76 | Layout::default() 77 | .direction(Direction::Horizontal) 78 | .constraints([Constraint::Percentage(40), Constraint::Percentage(60)].as_ref()) 79 | .split(rect) 80 | } 81 | 82 | /// Keyboard shortcuts of the default interface 83 | /// ┌─────────────────────────────────────────────────────────┐ 84 | /// │ [Q]uit │ [D]etach │ [S]kip │ Space: Play/Pause │ 85 | /// └─────────────────────────────────────────────────────────┘ 86 | fn key_binding_info(is_break: bool) -> Tabs<'static> { 87 | let keybindings = vec![ 88 | "[Q]uit", 89 | "[D]etach", 90 | "[S]kip", 91 | if is_break { "[P]ostpone" } else { "" }, 92 | "Space: Play/Pause", 93 | ]; 94 | 95 | let keybinding_spans = keybindings 96 | .iter() 97 | .map(|key| { 98 | Spans::from(vec![Span::styled( 99 | *key, 100 | Style::default().fg(Color::DarkGray), 101 | )]) 102 | }) 103 | .collect(); 104 | 105 | Tabs::new(keybinding_spans).block( 106 | Block::default() 107 | .borders(Borders::ALL) 108 | .style(Style::default().fg(Color::DarkGray)), 109 | ) 110 | } 111 | 112 | /// Timer information of the default interface (interval/round number, break/focus) 113 | fn timer_info(state: &ViewState) -> Paragraph { 114 | let rounds = format!("Round: {}", state.round); 115 | let timer_kind = if state.is_break { 116 | Span::styled("Break", Style::default().fg(Color::Yellow)) 117 | } else if state.is_postponed { 118 | Span::styled("Postponed", Style::default().fg(Color::Red)) 119 | } else { 120 | Span::styled("Focus", Style::default().fg(Color::Blue)) 121 | }; 122 | 123 | let postponed_count = if state.is_postponed { 124 | Span::styled( 125 | format!(" ({})", state.postpone_count), 126 | Style::default().fg(Color::DarkGray), 127 | ) 128 | } else { 129 | Span::styled("", Style::default()) 130 | }; 131 | 132 | let info_text = vec![ 133 | Spans::from(vec![timer_kind, postponed_count]), 134 | Spans::from(vec![Span::styled(rounds, Style::default().fg(Color::Gray))]), 135 | ]; 136 | 137 | Paragraph::new(info_text) 138 | .block(Block::default().title("zentime").borders(Borders::ALL)) 139 | .style(Style::default().fg(Color::White)) 140 | .alignment(Alignment::Left) 141 | } 142 | 143 | /// Timer of the default interface 144 | fn timer(time: &str) -> Paragraph { 145 | Paragraph::new(time) 146 | .block(Block::default().borders(Borders::ALL)) 147 | .style(Style::default().fg(Color::Cyan)) 148 | .alignment(Alignment::Center) 149 | } 150 | -------------------------------------------------------------------------------- /src/ipc.rs: -------------------------------------------------------------------------------- 1 | //! Utilities to handle zentime inter-process-communication 2 | 3 | use anyhow::{bail, Context}; 4 | use futures::io::BufReader; 5 | use futures::{AsyncReadExt, AsyncWriteExt}; 6 | use interprocess::local_socket::tokio::{OwnedReadHalf, OwnedWriteHalf}; 7 | use interprocess::local_socket::NameTypeSupport; 8 | use serde::{Deserialize, Serialize}; 9 | use zentime_rs_timer::pomodoro_timer::ViewState; 10 | use std::fmt::Debug; 11 | 12 | const DEFAULT_SOCKET_PATH: &str = "/tmp/zentime.sock"; 13 | const DEFAULT_SOCKET_NAMESPACE: &str = "@zentime.sock"; 14 | const DEBUG_SOCKET_PATH: &str = "/tmp/zentime_debug.sock"; 15 | const DEBUG_SOCKET_NAMESPACE: &str = "@zentime_debug.sock"; 16 | 17 | /// Get zentime socket name over which server and clients may connect 18 | pub fn get_socket_name() -> &'static str { 19 | // This scoping trick allows us to nicely contain the import inside the `match`, so that if 20 | // any imports of variants named `Both` happen down the line, they won't collide with the 21 | // enum we're working with here. Maybe someone should make a macro for this. 22 | use NameTypeSupport::*; 23 | 24 | if cfg!(debug_assertions) { 25 | match NameTypeSupport::query() { 26 | OnlyPaths => DEBUG_SOCKET_PATH, 27 | OnlyNamespaced | Both => DEBUG_SOCKET_NAMESPACE, 28 | } 29 | } else { 30 | match NameTypeSupport::query() { 31 | OnlyPaths => DEFAULT_SOCKET_PATH, 32 | OnlyNamespaced | Both => DEFAULT_SOCKET_NAMESPACE, 33 | } 34 | } 35 | } 36 | 37 | /// A message from the zentime server to the client 38 | #[derive(Debug, Clone, Serialize, Deserialize)] 39 | pub enum ServerToClientMsg { 40 | /// Aggregated state of the timer which a client can display 41 | Timer(ViewState), 42 | } 43 | 44 | /// A message from a client to the zentime server 45 | #[derive(Debug, Copy, Clone, Serialize, Deserialize)] 46 | pub enum ClientToServerMsg { 47 | /// Command the server to shutdown and close all connections 48 | Quit, 49 | 50 | /// Detach from the server 51 | Detach, 52 | 53 | /// Command the server to Play/Pause the timer 54 | PlayPause, 55 | 56 | /// Command the server to skip to the next interval 57 | Skip, 58 | 59 | /// Command the server to reset the timer back to interval 1 60 | Reset, 61 | 62 | /// Currently it's necessary for a client to write at least once to a socket 63 | /// connection to synchronize with the server. 64 | /// For one-shot zentime commands we therefore use this sync msg to synchronize 65 | /// with the server. 66 | Sync, 67 | 68 | /// Command the server to postpone the current break, if possible 69 | PostPone, 70 | 71 | /// Sets current timer to a specific time (in seconds) 72 | SetTimer(u64) 73 | } 74 | 75 | /// Service handling communication between processes over the zentime socket. 76 | /// Multiple clients may exist alongside a single (usually daemonized) zentime server instance. 77 | #[derive(Debug, Copy, Clone, Serialize, Deserialize)] 78 | pub struct InterProcessCommunication {} 79 | 80 | impl InterProcessCommunication { 81 | /// Writes a message to the zentime socket. 82 | /// The message is encoded via [rmp_serde::encode] which uses [Messagepack](https://msgpack.org/) to encode type information. 83 | pub async fn send_ipc_message(msg: M, writer: &mut OwnedWriteHalf) -> anyhow::Result<()> 84 | where 85 | M: Serialize + for<'a> Deserialize<'a> + Debug, 86 | { 87 | let encoded_msg = 88 | rmp_serde::encode::to_vec::(&msg).context(format!("Could not encode {:?}", msg))?; 89 | let msg_length = 90 | u32::try_from(encoded_msg.len()).context("Could not cast msg length to u32")?; 91 | let msg_length = msg_length.to_le_bytes(); 92 | 93 | // Write msg with length to the stream (see [Self::recv_ipc_message] for why this is needed) 94 | writer 95 | .write_all(&msg_length) 96 | .await 97 | .context("Could not write message length to stream")?; 98 | 99 | // Write actual msg to the stream 100 | writer 101 | .write_all(&encoded_msg) 102 | .await 103 | .context(format!("Could not write {:?} to stream", msg))?; 104 | 105 | Ok(()) 106 | } 107 | 108 | /// Writes a message to the zentime socket. 109 | /// The message is decoded via [rmp_serde::decode] which uses [Messagepack](https://msgpack.org/) to decode type information. 110 | pub async fn recv_ipc_message(reader: &mut BufReader) -> anyhow::Result 111 | where 112 | M: Serialize + for<'a> Deserialize<'a> + Debug, 113 | { 114 | // Read message length, so that we can make an exact read of the actual message afterwards 115 | let mut buffer = [0_u8; 4]; 116 | 117 | reader 118 | .read_exact(&mut buffer) 119 | .await 120 | .context("Could not read msg length")?; 121 | let msg_length = u32::from_le_bytes(buffer); 122 | 123 | let mut buffer = [0_u8; 1024]; 124 | 125 | // Read message of previously determined length, decode and return it 126 | if let Err(error) = reader 127 | .read_exact( 128 | &mut buffer[0..usize::try_from(msg_length) 129 | .context("Could not convert msg length to usize")?], 130 | ) 131 | .await 132 | { 133 | match error.kind() { 134 | std::io::ErrorKind::UnexpectedEof => { 135 | bail!("Buffer slice has not been filled entirely: {:?}", error) 136 | } 137 | _ => bail!("Could not read into buffer: {:?}", error), 138 | } 139 | }; 140 | 141 | match rmp_serde::from_slice::( 142 | &buffer[0..usize::try_from(msg_length) 143 | .context("Could not convert msg length to usize")?], 144 | ) { 145 | Ok(msg) => Ok(msg), 146 | Err(error) => bail!("Could not decode msg: {:?}", error), 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/client/terminal_io/output.rs: -------------------------------------------------------------------------------- 1 | //! Code related to client async terminal output handling 2 | 3 | use crate::client::terminal_io::default_interface::render; 4 | use anyhow::Context; 5 | use crossterm::cursor::Hide; 6 | use crossterm::style::Stylize; 7 | use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; 8 | use crossterm::{cursor::Show, event::DisableMouseCapture, execute, terminal::disable_raw_mode}; 9 | use futures::lock::Mutex; 10 | use zentime_rs_timer::pomodoro_timer::ViewState; 11 | use std::io::Write; 12 | use std::sync::Arc; 13 | use std::{io::Stdout, process}; 14 | use tokio::sync::mpsc::UnboundedReceiver; 15 | use tokio::task::{spawn, JoinHandle}; 16 | use tui::{backend::CrosstermBackend, Terminal as TuiTerminal}; 17 | 18 | use super::terminal_event::TerminalEvent; 19 | 20 | /// Tokio task which continouusly renders the current view state to the terminal output. 21 | #[derive(Copy, Clone, Debug)] 22 | pub struct TerminalOutputTask {} 23 | 24 | impl TerminalOutputTask { 25 | /// Spawns a tokio task which continously handles terminal output 26 | pub async fn spawn( 27 | terminal_out: Arc>>, 28 | mut out_rx: UnboundedReceiver, 29 | ) -> JoinHandle<()> { 30 | spawn(async move { 31 | loop { 32 | match out_rx.recv().await { 33 | Some(TerminalEvent::View(state)) => { 34 | if let Err(error) = terminal_out.lock().await.render(state) { 35 | return terminal_out 36 | .lock() 37 | .await 38 | .quit(Some(format!("ERROR: {}", error)), true); 39 | } 40 | } 41 | Some(TerminalEvent::Quit { msg, error }) => { 42 | return terminal_out.lock().await.quit(msg, error); 43 | } 44 | None => continue, 45 | } 46 | } 47 | }) 48 | } 49 | } 50 | 51 | /// Trait representing a terminal output 52 | pub trait TerminalOut { 53 | /// Renders the current [ViewState] 54 | fn render(&mut self, state: ViewState) -> anyhow::Result<()>; 55 | 56 | /// Gracefully quits the [Self] so that raw-mode, alternate screens etc. 57 | /// are restored to their default. 58 | fn quit(&mut self, msg: Option, is_error: bool); 59 | } 60 | 61 | /// Implementation of a [TerminalOut] 62 | /// Uses a [TuiTerminal] with a [CrosstermBackend] to render. 63 | #[allow(missing_debug_implementations)] 64 | #[derive()] 65 | pub struct DefaultInterface { 66 | tui_terminal: TuiTerminal>, 67 | } 68 | 69 | impl DefaultInterface { 70 | /// Creates a new default interface 71 | pub fn new() -> anyhow::Result { 72 | let backend = CrosstermBackend::new(std::io::stdout()); 73 | execute!(std::io::stdout(), EnterAlternateScreen) 74 | .context("Can't execute crossterm macros")?; 75 | let mut terminal = 76 | TuiTerminal::new(backend).context("Tui-Terminal could not be created")?; 77 | enable_raw_mode().context("Can't run in raw mode")?; 78 | terminal.clear().context("Terminal could not be cleared")?; 79 | terminal.hide_cursor().context("Could not hide cursor")?; 80 | 81 | Ok(Self { 82 | tui_terminal: terminal, 83 | }) 84 | } 85 | } 86 | 87 | impl TerminalOut for DefaultInterface { 88 | fn render(&mut self, state: ViewState) -> anyhow::Result<()> { 89 | render(&mut self.tui_terminal, state) 90 | } 91 | 92 | fn quit(&mut self, msg: Option, is_error: bool) { 93 | disable_raw_mode().expect("Could not disable raw mode"); 94 | self.tui_terminal 95 | .show_cursor() 96 | .expect("Could not show cursor"); 97 | self.tui_terminal.clear().expect("Could not clear terminal"); 98 | execute!(std::io::stdout(), DisableMouseCapture, LeaveAlternateScreen) 99 | .expect("Could not execute crossterm macros"); 100 | 101 | println!("\n{}", msg.unwrap_or_else(|| String::from(""))); 102 | 103 | process::exit(i32::from(is_error)) 104 | } 105 | } 106 | 107 | /// Minimal interface which uses a [Crossterm] to display colors, hide the cursor and enable raw mode. 108 | /// The actual rendering happens with simple `print!`-macro-calls. 109 | #[derive(Debug, Copy, Clone)] 110 | pub struct MinimalInterface {} 111 | 112 | impl MinimalInterface { 113 | /// Creates a new minimal interface and also enables raw mode and hides the cursor. 114 | pub fn new() -> anyhow::Result { 115 | enable_raw_mode().context("Can't run in raw mode")?; 116 | 117 | execute!(std::io::stdout(), Hide).context("Could not execute crossterm macros")?; 118 | Ok(Self {}) 119 | } 120 | } 121 | 122 | impl TerminalOut for MinimalInterface { 123 | fn render(&mut self, state: ViewState) -> anyhow::Result<()> { 124 | let timer = format!(" {} ", state.time.white()); 125 | let round = format!("Round: {}", state.round); 126 | let timer_kind = if state.is_break { 127 | "Break".yellow() 128 | } else if state.is_postponed { 129 | "Postpone".red() 130 | } else { 131 | "Focus".blue() 132 | }; 133 | 134 | let postponed_count = if state.is_postponed { 135 | format!(" ({})", state.postpone_count).dark_grey() 136 | } else { 137 | "".to_string().white() 138 | }; 139 | 140 | let ansi_erase_line_escape = "\x1B[2K"; 141 | let ansi_move_cursor_to_start_of_line_escape = "\r"; 142 | 143 | print!( 144 | "{}{}{} {} {}{}", 145 | ansi_move_cursor_to_start_of_line_escape, 146 | ansi_erase_line_escape, 147 | if state.is_paused { timer.on_dark_green() } else { timer.on_dark_red() }, 148 | round.green(), 149 | timer_kind, 150 | postponed_count 151 | ); 152 | 153 | Ok(std::io::stdout().flush()?) 154 | } 155 | 156 | fn quit(&mut self, msg: Option, is_error: bool) { 157 | disable_raw_mode().expect("Could not disable raw mode"); 158 | execute!(std::io::stdout(), Show, DisableMouseCapture) 159 | .expect("Could not execute crossterm macros"); 160 | 161 | println!("\r\n{}", msg.unwrap_or_else(|| String::from(""))); 162 | 163 | process::exit(i32::from(is_error)) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Build](https://github.com/on3iro/zentime-rs/actions/workflows/release.yaml) 2 | [crates.io](https://crates.io/crates/zentime-rs) 3 | [docs.rs](https://docs.rs/zentime-rs/latest/zentime_rs/) 4 | 5 | > [!Important] 6 | > Unfortunately I currently don't have time to dedicate to the project and especially to fix async issues which are present even in development. 7 | 8 | # TOC 9 | 10 | - [TOC](#toc) 11 | - [Features](#features) 12 | - [Example with multiple clients + display inside the left status bar of tmux](#example-with-multiple-clients--display-inside-the-left-status-bar-of-tmux) 13 | - [Installation](#installation) 14 | - [Homebrew](#homebrew) 15 | - [Cargo](#cargo) 16 | - [Nix](#nix) 17 | - [Configuration](#configuration) 18 | - [Logs](#logs) 19 | - [Zellij integration example](#zellij-integration-example) 20 | - [Tmux integration example](#tmux-integration-example) 21 | - [Usage as library](#usage-as-library) 22 | 23 | A simple terminal based pomodoro/productivity timer written in Rust. 24 | 25 | ## Features 26 | 27 | - Timer suited for the pomodoro technique 28 | - Socket-based Client/Server-Architecture, where multiple clients can attach to a single timer server 29 | - Server is terminal independent and runs as a daemon 30 | - TUI-interface with keymaps + and a minimal TUI-interface 31 | - CLI commands to interact with the timer without attaching a client (e.g. for integration into tools such as tmux) 32 | 33 | ### Example with multiple clients + display inside the left status bar of tmux 34 | 35 | ![](./assets/zentime-screenshot.png) 36 | 37 | ## Installation 38 | 39 | > NOTE: The timer has currently only been tested on Mac and Linux, but might also work on Windows (please let me know if you tried it succesfully). 40 | 41 | ### Homebrew 42 | 43 | ```ignore 44 | brew tap on3iro/zentime 45 | brew install zentime 46 | ``` 47 | 48 | ### Cargo 49 | 50 | ```ignore 51 | cargo install zentime-rs 52 | ``` 53 | 54 | ### Nix 55 | 56 | > Coming soon 57 | 58 | ## Configuration 59 | 60 | The default location for the configuration file is `/home//.config/zentime/zentime.toml`. 61 | To get an overview of available configuration options please have a look at the [example configuration](./zentime.example.toml). 62 | 63 | For an overview of all available configuration keys, check out the [docs](https://docs.rs/zentime-rs/latest/zentime_rs/config/struct.Config.html). 64 | Note that each key (`view`, `timers` etc.) corresponds to the header of a [toml table](https://toml.io/en/v1.0.0#table) while 65 | clicking on the type inside the docs shows you the available configuration fields. 66 | 67 | ## Logs 68 | 69 | Logs are being written to: 70 | 71 | - `/tmp/zentime.d.err` - this captures any panics 72 | - `/tmp/zentime.d.out` - this captures error/warn/info etc. logs 73 | 74 | The default log level is `warn`. 75 | You can configure the log level by running zentime with `RUST_LOG= zentime`. 76 | Here's an overview of [available log levels](https://docs.rs/log/0.4.17/log/enum.Level.html). 77 | 78 | ## Zellij integration example 79 | 80 | I've found that currently the easiest way to get some integration with zentime into zellij, is to create a custom layout and also create some shell aliases. 81 | 82 | For example you could use the following layout as base for a 'zellij-zentime'-layout: 83 | 84 | ```kdl 85 | layout { 86 | pane split_direction="vertical" size=1 { 87 | pane { 88 | size 30 89 | borderless true 90 | name "zentime" 91 | command "zentime" 92 | } 93 | pane borderless=true { 94 | plugin location="zellij:tab-bar" 95 | } 96 | } 97 | } 98 | ``` 99 | 100 | ![](./assets/zellij-layout-screenshot.png) 101 | 102 | You might need to adjust the size of the zentime pane depending on the terminal font you are using. 103 | 104 | > WARNING: 105 | > There currently is no way to isolate regular panes in zellij from the tab-sync mechanism. 106 | > (Only plugin panes are currently isolated) 107 | > This means that you might accidentally change your timer, when you use tab-sync mode. 108 | > I already created a [feature request](https://github.com/zellij-org/zellij/issues/2285) to create isolated panes and am planning to contribute and create a PR if the maintainer is fine with that. 109 | 110 | > NOTE: I actually wanted to write a plugin for zellij. However unfortunately this is currently not that easy for 111 | > two reasons: 112 | > 113 | > 1. The zellij plugin system is currently being rebuilt and it doesn't really make sense to built a "legacy"-plugin right now 114 | > 2. Our zentime client library code makes use of a lot of async code, which does not yet compile to WASI 115 | 116 | Because zellij also does not yet allow arbitary commands to be configured with keyboard shortcuts, 117 | you basically have three options to interact with zentime: 118 | 119 | 1. Just manually switch to the pane and use our regular zentime shortcuts 120 | 2. Manually run commands/trigger another zentime client inside the pane you are working in anyway right now 121 | 3. **Recommnded**: (This is basically just an alteration of 2. -> Create bash/zsh/ aliases for commands like `zentime skip`, `zentime toggle-timer` etc. 122 | 123 | If you go the third route I would recommend to prefix each command with `-s` (`--silent`) as you probably are not interested in the minor output zentime gives you under these circumstances. 124 | 125 | ## Tmux integration example 126 | 127 | To display the current timer state inside the tmux status bar you could use `zentime once` which will be queried by tmux on each status bar update. 128 | Simply add the following snippet to your `.tmux.conf`: 129 | 130 | ```conf ignore 131 | set -g status-left " #(zentime once) " 132 | ``` 133 | 134 | If you would like to add shortcuts (e.g. to toggle pause/play) from inside tmux you could add bindings like this: 135 | 136 | ```conf ignore 137 | bind t run-shell "zentime toggle-timer > /dev/null" 138 | bind y run-shell "zentime skip > /dev/null" 139 | ``` 140 | 141 | ## Usage as library 142 | 143 | Zentime is built in such a way, that it should be possible to build custom clients etc. to attach to the server. 144 | To do so one should use the modules provided by the [library crate](https://docs.rs/zentime-rs/latest/zentime_rs). 145 | More documentation/examples on how to use these, will follow soon. 146 | 147 | > NOTE: The API of the library crate is not yet stable and might change on minor version updates. 148 | > As soon as this crate reaches 1.0.0 status, breaking changes will only ever happen on major versions. 149 | -------------------------------------------------------------------------------- /src/client/connection.rs: -------------------------------------------------------------------------------- 1 | use crate::client::terminal_io::input::ClientInputAction; 2 | use std::thread::sleep; 3 | use std::time::Duration; 4 | 5 | use crate::ipc::ClientToServerMsg; 6 | use crate::ipc::InterProcessCommunication; 7 | use crate::ipc::ServerToClientMsg; 8 | use anyhow::Context; 9 | use interprocess::local_socket::tokio::OwnedWriteHalf; 10 | 11 | use crate::ipc::get_socket_name; 12 | use futures::io::BufReader; 13 | use interprocess::local_socket::tokio::LocalSocketStream; 14 | use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; 15 | use tokio::task::JoinHandle; 16 | use tokio::{select, task::yield_now}; 17 | 18 | use super::terminal_io::terminal_event::TerminalEvent; 19 | 20 | /// Tokio task handling the connection between the client and the zentime server 21 | pub struct ClientConnectionTask {} 22 | 23 | impl ClientConnectionTask { 24 | pub async fn spawn( 25 | terminal_in_rx: UnboundedReceiver, 26 | terminal_out_tx: UnboundedSender, 27 | ) -> JoinHandle<()> { 28 | let socket_name = get_socket_name(); 29 | 30 | let mut connection_tries = 0; 31 | 32 | // Try to receive a connection to the server (will timeout after the third attempt) 33 | let connection = loop { 34 | connection_tries += 1; 35 | 36 | if connection_tries == 4 { 37 | terminal_out_tx 38 | .send(TerminalEvent::Quit { 39 | msg: Some(String::from("\nCould not connect to server")), 40 | error: true, 41 | }) 42 | .expect("Could not send to terminal out"); 43 | } 44 | 45 | let result = LocalSocketStream::connect(socket_name).await; 46 | 47 | if let Ok(conn) = result { 48 | break conn; 49 | } else { 50 | sleep(Duration::from_millis(200)); 51 | } 52 | }; 53 | 54 | tokio::spawn(async move { 55 | if let Err(error) = 56 | handle_connection(connection, terminal_out_tx.clone(), terminal_in_rx).await 57 | { 58 | terminal_out_tx 59 | .send(TerminalEvent::Quit { 60 | msg: Some(format!("{}.\nServer connection closed.", error)), 61 | error: true, 62 | }) 63 | .expect("Could not send to terminal out"); 64 | } 65 | }) 66 | } 67 | } 68 | 69 | /// Continously handle the connection to the server by reacting to incoming 70 | /// [ServerToClientMsg] and terminal input events. 71 | async fn handle_connection( 72 | connection: LocalSocketStream, 73 | terminal_out_tx: UnboundedSender, 74 | mut terminal_in_rx: UnboundedReceiver, 75 | ) -> anyhow::Result<()> { 76 | // This consumes our connection and splits it into two halves, 77 | // so that we could concurrently act on both. 78 | let (reader, mut writer) = connection.into_split(); 79 | let mut reader = BufReader::new(reader); 80 | 81 | loop { 82 | select! { 83 | msg = InterProcessCommunication::recv_ipc_message::(&mut reader) => { 84 | let msg = msg.context("Could not receive message from socket")?; 85 | handle_server_to_client_msg(msg, &terminal_out_tx).context("Could not handle server to client message")?; 86 | }, 87 | value = terminal_in_rx.recv() => { 88 | if let Some(action) = value { 89 | handle_client_input_action(action, &terminal_out_tx, &mut writer).await.context("Could not handle input action")?; 90 | } 91 | } 92 | }; 93 | 94 | yield_now().await; 95 | } 96 | } 97 | 98 | /// Handle incoming [ClientInputAction]s 99 | async fn handle_client_input_action( 100 | action: ClientInputAction, 101 | terminal_out_tx: &UnboundedSender, 102 | writer: &mut OwnedWriteHalf, 103 | ) -> anyhow::Result<()> { 104 | match action { 105 | // Command server to shutdown and quit the current client 106 | ClientInputAction::Quit => { 107 | let msg = ClientToServerMsg::Quit; 108 | InterProcessCommunication::send_ipc_message(msg, writer) 109 | .await 110 | .context("Could not send IPC message")?; 111 | 112 | // Shutdown current client 113 | terminal_out_tx 114 | .send(TerminalEvent::Quit { 115 | msg: Some(String::from("Cya!")), 116 | error: false, 117 | }) 118 | .context("Could not send to terminal out")?; 119 | } 120 | 121 | // Quit the current client (but keep the server running) 122 | ClientInputAction::Detach => { 123 | let msg = ClientToServerMsg::Detach; 124 | InterProcessCommunication::send_ipc_message(msg, writer) 125 | .await 126 | .context("Could not send IPC message")?; 127 | 128 | // Shutdown current client, but keep server running 129 | terminal_out_tx 130 | .send(TerminalEvent::Quit { 131 | msg: None, 132 | error: false, 133 | }) 134 | .context("Could not send to terminal out")?; 135 | } 136 | 137 | // Postpone the current break, if possible (validation happens inside the 138 | // [PomodoroTimer] run by the server itself) 139 | ClientInputAction::PostPone => { 140 | let msg = ClientToServerMsg::PostPone; 141 | InterProcessCommunication::send_ipc_message(msg, writer) 142 | .await 143 | .context("Could not send IPC message")?; 144 | } 145 | 146 | // NoOp 147 | ClientInputAction::None => return Ok(()), 148 | 149 | // Command the server to pause or play the timer 150 | ClientInputAction::PlayPause => { 151 | let msg = ClientToServerMsg::PlayPause; 152 | InterProcessCommunication::send_ipc_message(msg, writer) 153 | .await 154 | .context("Could not send IPC message")?; 155 | } 156 | 157 | // Command the server to skip to the next interval 158 | ClientInputAction::Skip => { 159 | let msg = ClientToServerMsg::Skip; 160 | InterProcessCommunication::send_ipc_message(msg, writer) 161 | .await 162 | .context("Could not send IPC message")?; 163 | } 164 | 165 | ClientInputAction::Reset => { 166 | let msg = ClientToServerMsg::Reset; 167 | InterProcessCommunication::send_ipc_message(msg, writer) 168 | .await 169 | .context("Could not send IPC message")?; 170 | } 171 | } 172 | 173 | Ok(()) 174 | } 175 | 176 | /// Handle incoming [ServerToClientMsg]s (e.g. by sending incoming timer state to the 177 | /// [TerminalOutputTask]). 178 | fn handle_server_to_client_msg( 179 | msg: ServerToClientMsg, 180 | terminal_out_tx: &UnboundedSender, 181 | ) -> anyhow::Result<()> { 182 | match msg { 183 | ServerToClientMsg::Timer(state) => { 184 | terminal_out_tx 185 | .send(TerminalEvent::View(state)) 186 | .context("Could not send to terminal out")?; 187 | } 188 | } 189 | 190 | Ok(()) 191 | } 192 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::default_cmd::default_cmd; 2 | use clap::{Parser, Subcommand}; 3 | use env_logger::Env; 4 | 5 | mod default_cmd; 6 | mod subcommands; 7 | use figment::providers::Serialized; 8 | use serde::{Deserialize, Serialize}; 9 | use subcommands::{ 10 | postpone::postpone, 11 | query_server_once::query_server_once, 12 | reset_timer::reset_timer, 13 | server::{start_daemonized, status, stop}, 14 | set_timer::set_timer, 15 | skip_timer::skip_timer, 16 | toggle_timer::toggle_timer, 17 | }; 18 | use zentime_rs::config::{create_base_config, Config}; 19 | 20 | #[derive(clap::Args)] 21 | pub struct CommonArgs { 22 | /// Sets a custom config file 23 | #[arg(short, long, default_value = "~/.config/zentime/zentime.toml")] 24 | config: String, 25 | 26 | #[command(flatten)] 27 | server_config: ServerConfig, 28 | } 29 | 30 | /// This should match [Config::NotificationConfig], but makes fields optional, so that they are not 31 | /// required by clap. If no value is provided and therefore the `Option` is `None`, we skip 32 | /// serializing the value. 33 | #[derive(clap::Args, Serialize, Deserialize, Clone, Debug)] 34 | #[serde(rename(serialize = "NotificationConfig"))] 35 | struct ClapNotificationConfig { 36 | /// Enable/Disable bell 37 | #[serde(skip_serializing_if = "Option::is_none")] 38 | #[arg(long)] 39 | pub enable_bell: Option, 40 | 41 | /// Path to soundfile which is played back on each interval end 42 | #[serde(skip_serializing_if = "Option::is_none")] 43 | #[arg(long)] 44 | pub sound_file: Option, 45 | 46 | /// Notification bell volume 47 | #[serde(skip_serializing_if = "Option::is_none")] 48 | #[arg(long)] 49 | pub volume: Option, 50 | 51 | /// Show OS-notification 52 | #[serde(skip_serializing_if = "Option::is_none")] 53 | #[arg(long)] 54 | pub show_notification: Option, 55 | } 56 | 57 | /// This should match [zentime-rs-timer::config::TimerConfig], but makes fields optional, so that they are not 58 | /// required by clap. If no value is provided and therefore the `Option` is `None`, we skip 59 | /// serializing the value. 60 | #[derive(clap::Args, Serialize, Deserialize, Copy, Clone, Debug)] 61 | #[serde(rename(serialize = "TimerConfig"))] 62 | struct ClapTimerConfig { 63 | /// Timer in seconds 64 | #[serde(skip_serializing_if = "Option::is_none")] 65 | #[arg(long)] 66 | pub timer: Option, 67 | 68 | /// Minor break time in seconds 69 | #[serde(skip_serializing_if = "Option::is_none")] 70 | #[arg(long)] 71 | pub minor_break: Option, 72 | 73 | /// Major break time in seconds 74 | #[serde(skip_serializing_if = "Option::is_none")] 75 | #[arg(long)] 76 | pub major_break: Option, 77 | 78 | /// Intervals before major break 79 | #[serde(skip_serializing_if = "Option::is_none")] 80 | #[arg(long)] 81 | pub intervals: Option, 82 | 83 | /// Determines how often a break may be postponed. 84 | /// A value of 0 denotes, that postponing breaks is not allowed and the feature is 85 | /// disabled. 86 | #[serde(skip_serializing_if = "Option::is_none")] 87 | #[arg(long)] 88 | pub postpone_limit: Option, 89 | 90 | /// Determines how long each postpone timer runs (in seconds) 91 | #[serde(skip_serializing_if = "Option::is_none")] 92 | #[arg(long)] 93 | pub postpone_timer: Option, 94 | } 95 | 96 | #[derive(clap::Args, Serialize, Deserialize, Clone, Debug)] 97 | #[serde(rename(serialize = "Config"))] 98 | pub struct ServerConfig { 99 | #[command(flatten)] 100 | timers: ClapTimerConfig, 101 | 102 | #[command(flatten)] 103 | notifications: ClapNotificationConfig, 104 | } 105 | 106 | /// This should match [Config::ViewConfig], but makes fields optional, so that they are not 107 | /// required by clap. If no value is provided and therefore the `Option` is `None`, we skip 108 | /// serializing the value. 109 | #[derive(clap::Args, Serialize, Deserialize, Clone, Debug)] 110 | #[serde(rename(serialize = "ViewConfig"))] 111 | struct ClapViewConfig { 112 | #[serde(skip_serializing_if = "Option::is_none")] 113 | #[arg(long, short = 'i', long_help = include_str!("./ViewConfig.md"), verbatim_doc_comment)] 114 | pub interface: Option, 115 | 116 | /// Suppresses the output of one-shot commands 117 | /// (e.g. `zentime skip` or `zentime toggle-timer`) 118 | #[arg(long, short = 's')] 119 | pub silent: bool, 120 | } 121 | 122 | #[derive(clap::Args, Serialize, Deserialize, Clone, Debug)] 123 | #[serde(rename(serialize = "Config"))] 124 | pub struct ClientConfig { 125 | #[command(flatten)] 126 | view: ClapViewConfig, 127 | } 128 | 129 | /// Starts the timer or attaches to an already running timer 130 | #[derive(Parser)] 131 | #[command( 132 | author, 133 | version, 134 | about, 135 | long_about = "When run without command: starts the zentime server if necessary and attaches a client to it." 136 | )] 137 | struct Cli { 138 | #[command(flatten)] 139 | common_args: CommonArgs, 140 | 141 | #[command(flatten)] 142 | client_config: ClientConfig, 143 | 144 | #[command(subcommand)] 145 | command: Option, 146 | } 147 | 148 | /// Available cli sub commands 149 | #[derive(Subcommand)] 150 | enum Commands { 151 | /// Runs a single [ViewState]-query against the server and 152 | /// terminates the connection afterwards. 153 | /// This is useful for integration with other tools such as tmux, to integrate 154 | /// zentime into a status bar etc. 155 | Once, 156 | 157 | /// Toggles between timer play/pause 158 | ToggleTimer, 159 | 160 | /// Skips to next timer interval 161 | Skip, 162 | 163 | /// Resets the timer to the first interval 164 | Reset, 165 | 166 | /// Postpones the current break (if possible) 167 | Postpone, 168 | 169 | /// Sets current timer to a specific time in seconds 170 | SetTimer { time: u64 }, 171 | 172 | /// Interact with the zentime server 173 | Server { 174 | #[command(subcommand)] 175 | command: ServerCommands, 176 | }, 177 | } 178 | 179 | #[derive(Subcommand)] 180 | enum ServerCommands { 181 | /// Start the zentime server 182 | Start { 183 | #[command(flatten)] 184 | common_args: CommonArgs, 185 | }, 186 | 187 | /// Stop the zentime server and close all client connections 188 | Stop, 189 | 190 | /// Check if the zentime server is currently running 191 | Status, 192 | } 193 | 194 | fn main() { 195 | env_logger::Builder::from_env(Env::default().default_filter_or("warn")) 196 | .target(env_logger::Target::Stdout) 197 | .init(); 198 | let cli = Cli::parse(); 199 | 200 | if let Some(Commands::Server { command }) = &cli.command { 201 | match command { 202 | ServerCommands::Start { common_args } => start_daemonized(common_args), 203 | ServerCommands::Stop => stop(), 204 | ServerCommands::Status => status(), 205 | } 206 | 207 | return; 208 | } 209 | 210 | let config_path = &cli.common_args.config; 211 | let config: Config = get_client_config(config_path, &cli.client_config); 212 | 213 | match &cli.command { 214 | Some(Commands::Server { command }) => match command { 215 | ServerCommands::Start { common_args } => start_daemonized(common_args), 216 | ServerCommands::Stop => stop(), 217 | ServerCommands::Status => status(), 218 | }, 219 | 220 | Some(Commands::Postpone) => { 221 | postpone(config.view.silent); 222 | } 223 | 224 | Some(Commands::Once) => { 225 | query_server_once(); 226 | } 227 | 228 | Some(Commands::ToggleTimer) => { 229 | toggle_timer(config.view.silent); 230 | } 231 | 232 | Some(Commands::Skip) => { 233 | skip_timer(config.view.silent); 234 | } 235 | 236 | Some(Commands::Reset) => { 237 | reset_timer(config.view.silent); 238 | } 239 | 240 | Some(Commands::SetTimer { time }) => { 241 | set_timer(config.view.silent, time.to_owned()); 242 | } 243 | 244 | None => default_cmd(&cli.common_args, config), 245 | } 246 | } 247 | 248 | /// Creates the config relevant for client side commands 249 | fn get_client_config(config_path: &str, client_config: &ClientConfig) -> Config { 250 | create_base_config(config_path) 251 | .merge(Serialized::defaults(client_config)) 252 | .extract() 253 | .expect("Could not create config") 254 | } 255 | -------------------------------------------------------------------------------- /timer/src/timer.rs: -------------------------------------------------------------------------------- 1 | //! Timer implementation 2 | //! The timer hast the ability to toggle playback and end. 3 | //! It will call a given `on_tick` closure on every tick update and 4 | //! an `on_timer_end`-closure, when it's done (either by receiving a [TimerAction::End]) or 5 | //! when the internal timer is down to 0 6 | 7 | use serde::{Deserialize, Serialize}; 8 | use std::fmt::{Debug, Display}; 9 | 10 | use crate::timer_action::TimerAction; 11 | use crate::util::seconds_to_time; 12 | use std::time::{Duration, Instant}; 13 | 14 | // NOTE: I tried to use the typestate approach, like it's described here: 15 | // https://cliffle.com/blog/rust-typestate/ 16 | 17 | /// Information that will be handed to the [on_tick] closure continously 18 | #[derive(Debug, Clone, Serialize, Deserialize)] 19 | pub struct CurrentTime(String); 20 | 21 | impl Display for CurrentTime { 22 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 | write!(f, "{}", &self.0) 24 | } 25 | } 26 | 27 | /// Status which is continouusly handed to the callback function on each tick 28 | #[derive(Debug, Clone, Serialize, Deserialize)] 29 | pub struct TimerStatus { 30 | /// Current time of the timer 31 | pub current_time: CurrentTime, 32 | 33 | /// Denotes if timer is paused or running 34 | pub is_paused: bool, 35 | } 36 | 37 | /// Empty trait implemented by structs (e.g. Paused, Running) 38 | pub trait TimerState {} 39 | 40 | /// State specific to a paused timer 41 | #[derive(Clone, Copy, Debug)] 42 | pub struct Paused { 43 | remaining_time: Duration, 44 | } 45 | 46 | /// State specific to a running timer 47 | #[derive(Clone, Copy, Debug)] 48 | pub struct Running { 49 | target_time: Instant, 50 | } 51 | 52 | impl TimerState for Paused {} 53 | impl TimerState for Running {} 54 | 55 | /// Handler which is called whenever a timer ends by running out 56 | pub trait TimerEndHandler { 57 | /// Callback 58 | fn call(&mut self); 59 | } 60 | 61 | /// Handler which is called on each timer tick 62 | pub trait TimerTickHandler { 63 | /// Callback 64 | fn call(&mut self, status: TimerStatus) -> Option; 65 | } 66 | 67 | /// Timer which can either be in a paused state or a running state. 68 | /// To instantiate the timer run `Timer::new()`. 69 | /// To actually start it call `Timer::init()` 70 | /// 71 | /// ## Example 72 | /// 73 | /// ``` 74 | /// use zentime_rs_timer::timer::{Timer, TimerEndHandler, TimerTickHandler, TimerStatus, Paused}; 75 | /// use zentime_rs_timer::TimerAction; 76 | /// use std::thread; 77 | /// 78 | /// // Handler which is passed to our timer implementation 79 | /// pub struct OnEndHandler { } 80 | /// 81 | /// impl TimerEndHandler for OnEndHandler { 82 | /// fn call(&mut self) { 83 | /// println!("Hi from timer"); 84 | /// } 85 | /// } 86 | /// 87 | /// pub struct OnTickHandler {} 88 | /// 89 | /// impl TimerTickHandler for OnTickHandler { 90 | /// fn call(&mut self, status: TimerStatus) -> Option { 91 | /// println!("{}", status.current_time); 92 | /// None 93 | /// } 94 | /// } 95 | /// 96 | /// // Run timer in its own thread so it does not block the current one 97 | /// thread::spawn(move || { 98 | /// Timer::::new( 99 | /// 10, 100 | /// Some(OnEndHandler {}), 101 | /// Some(OnTickHandler {}) 102 | /// ) 103 | /// .init(); 104 | /// }); 105 | /// ``` 106 | pub struct Timer { 107 | time: u64, 108 | 109 | /// Callback closure which is called at the end of each timer 110 | on_timer_end: Option>, 111 | 112 | /// Callback closure which is being run on each tick 113 | on_tick: Option>, 114 | 115 | /// Internal state data associated with a certain timer state (e.g. [Paused] or [Running]) 116 | internal_state: S, 117 | } 118 | 119 | impl Debug for Timer { 120 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 121 | f.debug_struct("Timer") 122 | .field("time", &self.time) 123 | .field("on_timer_end", &"[closure] without context") 124 | .field("internal_state", &self.internal_state) 125 | .field("on_tick", &"[closure] without context") 126 | .finish() 127 | } 128 | } 129 | 130 | impl Timer {} 131 | 132 | /// Implementation of the [Paused] state for [Timer] 133 | impl Timer { 134 | /// Creates a new timer in paused state. 135 | /// You have to call [Self::init()] to start the timer 136 | pub fn new(time: u64, on_timer_end: Option, on_tick: Option) -> Self 137 | where 138 | E: TimerEndHandler + 'static, 139 | T: TimerTickHandler + 'static, 140 | { 141 | let remaining_time = Duration::from_secs(time); 142 | 143 | Self { 144 | time, 145 | on_timer_end: on_timer_end.map(|x| Box::new(x) as Box), 146 | on_tick: on_tick.map(|x| Box::new(x) as Box), 147 | internal_state: Paused { remaining_time }, 148 | } 149 | } 150 | 151 | /// Puts the paused timer into a waiting state waiting for input (e.g. to unpause the timer 152 | /// and transition it into a running state). 153 | pub fn init(mut self) { 154 | loop { 155 | let time = self.internal_state.remaining_time.as_secs(); 156 | 157 | let Some(ref mut callback) = self.on_tick else { continue }; 158 | if let Some(action) = callback.call(TimerStatus { 159 | is_paused: true, 160 | current_time: CurrentTime(seconds_to_time(time)), 161 | }) { 162 | match action { 163 | TimerAction::SetTimer(time) => { 164 | self.internal_state.remaining_time = Duration::from_secs(time) 165 | } 166 | 167 | TimerAction::PlayPause => { 168 | self.unpause(); 169 | break; 170 | } 171 | 172 | // Returns from the blocking loop, so that the calling code 173 | // can resume execution 174 | TimerAction::End => return, 175 | } 176 | } 177 | } 178 | } 179 | 180 | /// Transitions the paused timer into a running timer 181 | fn unpause(self) { 182 | Timer { 183 | on_timer_end: self.on_timer_end, 184 | on_tick: self.on_tick, 185 | time: self.time, 186 | internal_state: Running { 187 | target_time: Instant::now() + self.internal_state.remaining_time, 188 | }, 189 | } 190 | .init() 191 | } 192 | } 193 | 194 | impl Timer { 195 | /// Creates a new timer in paused state. 196 | /// You have to call [Self::init()] to start the timer 197 | pub fn new(time: u64, on_timer_end: Option, on_tick: Option) -> Self 198 | where 199 | E: TimerEndHandler + 'static, 200 | T: TimerTickHandler + 'static, 201 | { 202 | let remaining_time = Duration::from_secs(time); 203 | 204 | Self { 205 | time, 206 | on_timer_end: on_timer_end.map(|x| Box::new(x) as Box), 207 | on_tick: on_tick.map(|x| Box::new(x) as Box), 208 | internal_state: Running { 209 | target_time: Instant::now() + remaining_time, 210 | }, 211 | } 212 | } 213 | 214 | /// Transitions the running timer into a paused timer state and calls `init()` on_interval_end 215 | /// it, so that the new timer is ready to receive an [TimerInputAction] 216 | fn pause(self) { 217 | Timer { 218 | time: self.time, 219 | on_tick: self.on_tick, 220 | on_timer_end: self.on_timer_end, 221 | internal_state: Paused { 222 | remaining_time: self.internal_state.target_time - Instant::now(), 223 | }, 224 | } 225 | .init(); 226 | } 227 | 228 | /// Runs the timer and awaits input. 229 | /// Depending on the input [TimerInputAction] the timer might transition into a paused state or skip to the next interval. 230 | pub fn init(mut self) { 231 | while self.internal_state.target_time > Instant::now() { 232 | let time = (self.internal_state.target_time - Instant::now()).as_secs(); 233 | 234 | let Some(ref mut callback) = self.on_tick else { continue }; 235 | if let Some(action) = callback.call(TimerStatus { 236 | is_paused: false, 237 | current_time: CurrentTime(seconds_to_time(time)), 238 | }) { 239 | match action { 240 | TimerAction::PlayPause => { 241 | return self.pause(); 242 | } 243 | 244 | // Returns from the blocking loop, so that the calling code 245 | // can resume execution 246 | TimerAction::End => return, 247 | TimerAction::SetTimer(time) => { 248 | self.internal_state.target_time = Instant::now() + Duration::from_secs(time) 249 | } 250 | } 251 | } 252 | } 253 | 254 | if let Some(mut on_timer_end) = self.on_timer_end { 255 | on_timer_end.call() 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/server/start.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::ipc::{ 3 | get_socket_name, ClientToServerMsg, InterProcessCommunication, ServerToClientMsg, 4 | }; 5 | use crate::server::notification::dispatch_notification; 6 | use crate::server::timer_output::TimerOutputAction; 7 | use anyhow::Context; 8 | use crossbeam::channel::{unbounded, Sender}; 9 | use interprocess::local_socket::tokio::OwnedWriteHalf; 10 | use log::{error, info}; 11 | use tokio::task::{spawn_blocking, yield_now}; 12 | use zentime_rs_timer::pomodoro_timer::{PomodoroTimer, TimerKind}; 13 | use zentime_rs_timer::pomodoro_timer_action::PomodoroTimerAction; 14 | 15 | use std::rc::Rc; 16 | use std::sync::Arc; 17 | use tokio::select; 18 | use tokio::sync::{self, broadcast::Receiver as BroadcastReceiver}; 19 | 20 | use futures::io::BufReader; 21 | use interprocess::local_socket::tokio::{LocalSocketListener, LocalSocketStream}; 22 | 23 | use std::time::Duration; 24 | use tokio::fs::{metadata, remove_file}; 25 | 26 | 27 | use super::status::{server_status, ServerStatus}; 28 | 29 | /// Starts the server by opening the zentime socket and listening for incoming connections. 30 | /// This will just quit if another zentime server process is already running. 31 | /// 32 | /// NOTE: 33 | /// This spawns a tokio runtime and should therefore not be run inside another tokio runtime. 34 | #[tokio::main] 35 | pub async fn start(config: Config) -> anyhow::Result<()> { 36 | let socket_name = get_socket_name(); 37 | 38 | let socket_file_already_exists = metadata(socket_name).await.is_ok(); 39 | 40 | if socket_file_already_exists && server_status() == ServerStatus::Running { 41 | info!("Server is already running. Terminating this process..."); 42 | // Apparently a server is already running and we don't need to do anything 43 | return Ok(()); 44 | } 45 | 46 | if socket_file_already_exists { 47 | info!("Socket file already exists - removing file"); 48 | 49 | // We have a dangling socket file without an attached server process. 50 | // In that case we simply remove the file and start a new server process 51 | remove_file(socket_name) 52 | .await 53 | .context("Could not remove existing socket file")? 54 | }; 55 | 56 | info!("Start listening for connections..."); 57 | 58 | listen(config, socket_name) 59 | .await 60 | .context("Error while listening for connections")?; 61 | 62 | Ok(()) 63 | } 64 | 65 | /// This starts a blocking tokio task which runs the actual synchronous timer logic, but 66 | /// also listens for incoming client connections and spawns a new async task for each incoming 67 | /// connection. 68 | async fn listen(config: Config, socket_name: &str) -> anyhow::Result<()> { 69 | info!("Binding to socket..."); 70 | let listener = 71 | LocalSocketListener::bind(socket_name).context("Could not bind to local socket")?; 72 | 73 | let (timer_input_sender, timer_input_receiver) = unbounded(); 74 | let (timer_output_sender, _timer_output_receiver) = sync::broadcast::channel(24); 75 | 76 | let timer_output_sender = Arc::new(timer_output_sender.clone()); 77 | // Arc clone to create a reference to our sender which can be consumed by the 78 | // timer thread. This is necessary because we need a reference to this sender later on 79 | // to continuously subscribe to it on incoming client connections 80 | let timer_out_tx = timer_output_sender.clone(); 81 | 82 | spawn_blocking(move || { 83 | info!("Starting timer..."); 84 | 85 | PomodoroTimer::new( 86 | config.timers, 87 | Rc::new(move |_, msg, kind| { 88 | let result = dispatch_notification( 89 | config.clone().notifications, 90 | msg, 91 | kind == TimerKind::Interval 92 | ); 93 | 94 | if let Err(error) = result { 95 | error!("{}", error); 96 | } 97 | }), 98 | Rc::new(move |view_state| { 99 | // Update the view 100 | timer_out_tx.send(TimerOutputAction::Timer(view_state)).ok(); 101 | 102 | // Handle app actions and hand them to the timer caller 103 | match timer_input_receiver.recv_timeout(Duration::from_millis(100)) { 104 | Ok(action) => Some(action), 105 | _ => Some(PomodoroTimerAction::None), 106 | } 107 | }), 108 | ) 109 | .init() 110 | }); 111 | 112 | // Set up our loop boilerplate that processes our incoming connections. 113 | loop { 114 | let connection = listener 115 | .accept() 116 | .await 117 | .context("There was an error with an incoming connection")?; 118 | 119 | let input_tx = timer_input_sender.clone(); 120 | let output_rx = timer_output_sender.subscribe(); 121 | 122 | // Spawn new parallel asynchronous tasks onto the Tokio runtime 123 | // and hand the connection over to them so that multiple clients 124 | // could be processed simultaneously in a lightweight fashion. 125 | tokio::spawn(async move { 126 | info!("New connection received."); 127 | if let Err(error) = handle_conn(connection, input_tx, output_rx).await { 128 | error!("Could not handle connection: {}", error); 129 | }; 130 | }); 131 | } 132 | } 133 | 134 | /// Describe the things we do when we've got a connection ready. 135 | /// This will continously send the current timer state to the client and also listen for incoming 136 | /// [ClientToServerMsg]s. 137 | async fn handle_conn( 138 | conn: LocalSocketStream, 139 | timer_input_sender: Sender, 140 | mut timer_output_receiver: BroadcastReceiver, 141 | ) -> anyhow::Result<()> { 142 | // Split the connection into two halves to process 143 | // received and sent data concurrently. 144 | let (reader, mut writer) = conn.into_split(); 145 | let mut reader = BufReader::new(reader); 146 | 147 | loop { 148 | select! { 149 | msg = InterProcessCommunication::recv_ipc_message::(&mut reader) => { 150 | let msg = msg.context("Could not receive message from socket")?; 151 | if let CloseConnection::Yes = handle_client_to_server_msg(msg, &timer_input_sender) 152 | .await 153 | .context("Could not handle client to server message")? { 154 | break; 155 | }; 156 | }, 157 | value = timer_output_receiver.recv() => { 158 | let action = value.context("Could not receive output from timer")?; 159 | handle_timer_output_action(action, &mut writer).await.context("Couuld not handle timer output action")?; 160 | } 161 | } 162 | 163 | yield_now().await; 164 | } 165 | 166 | info!("Closing connection"); 167 | Ok(()) 168 | } 169 | 170 | enum CloseConnection { 171 | Yes, 172 | No, 173 | } 174 | 175 | async fn handle_client_to_server_msg( 176 | msg: ClientToServerMsg, 177 | timer_input_sender: &Sender, 178 | ) -> anyhow::Result { 179 | match msg { 180 | // Shutdown server 181 | ClientToServerMsg::Quit => { 182 | info!("\nClient told server to shutdown"); 183 | 184 | info!("Cleaning up socket file"); 185 | let socket_name = get_socket_name(); 186 | remove_file(socket_name) 187 | .await 188 | .context("Could not remove existing socket file")?; 189 | 190 | info!("Shutting down..."); 191 | std::process::exit(0); 192 | } 193 | 194 | ClientToServerMsg::Reset => { 195 | timer_input_sender 196 | .send(PomodoroTimerAction::ResetTimer) 197 | .context("Could not send ResetTimer to timer")?; 198 | } 199 | 200 | // Play/Pause the timer 201 | ClientToServerMsg::PlayPause => { 202 | timer_input_sender 203 | .send(PomodoroTimerAction::PlayPause) 204 | .context("Could not send Play/Pause to timer")?; 205 | } 206 | 207 | // Skip to next timer interval 208 | ClientToServerMsg::Skip => { 209 | timer_input_sender 210 | .send(PomodoroTimerAction::Skip) 211 | .context("Could not send Skip to timer")?; 212 | } 213 | 214 | // Try to postpone the current break (limited by pomodoro timer config and state) 215 | ClientToServerMsg::PostPone => { 216 | timer_input_sender 217 | .send(PomodoroTimerAction::PostponeBreak) 218 | .context("Could not send Skip to timer")?; 219 | } 220 | 221 | // Close connection, because client has detached 222 | ClientToServerMsg::Detach => { 223 | info!("Client detached."); 224 | return Ok(CloseConnection::Yes); 225 | } 226 | 227 | ClientToServerMsg::Sync => { 228 | info!("Client synced with server"); 229 | } 230 | 231 | // Set timer to a specific time 232 | ClientToServerMsg::SetTimer(time) => { 233 | timer_input_sender 234 | .send(PomodoroTimerAction::SetTimer(time)) 235 | .context("Could not send SetTimer to timer")?; 236 | }, 237 | } 238 | 239 | Ok(CloseConnection::No) 240 | } 241 | 242 | async fn handle_timer_output_action( 243 | action: TimerOutputAction, 244 | writer: &mut OwnedWriteHalf, 245 | ) -> anyhow::Result<()> { 246 | let TimerOutputAction::Timer(state) = action; 247 | let msg = ServerToClientMsg::Timer(state); 248 | InterProcessCommunication::send_ipc_message(msg, writer) 249 | .await 250 | .context("Could not send IPC message from server to client")?; 251 | 252 | Ok(()) 253 | } 254 | --------------------------------------------------------------------------------