` tag in the [`io.github.zefr0x.ianny.metainfo.xml`](io.github.zefr0x.ianny.metainfo.xml) file with information about the release and a link to the CHANGELOG file.
18 | 4. Create a git commit with all of those changes.
19 | 5. Create a signed git tag with a `v` letter followed by the version number e.g. for `v1.5.3` you should do `git tag -s v1.5.3`.
20 | 6. Push changes to the remote using `git push origin main --tags`
21 |
22 | ## Official Packages
23 |
24 | - [Meson](https://mesonbuild.com/) is used as a build system and [Cargo](https://doc.rust-lang.org/cargo/) as dependencies manager.
25 |
26 | ### AUR
27 |
28 | - [ianny-git](https://aur.archlinux.org/packages/ianny-git)
29 |
30 | ## What should be packaged?
31 |
32 | Only stable [releases](#releases) should be packaged. Neither an `alpha` nor a `beta` release should be.
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Ianny | عَيْنِي
4 |
5 | [](https://github.com/zefr0x/ianny/actions/workflows/release.yml)
6 |
7 | Simple, light-weight, easy to use, and effective [Linux](https://en.wikipedia.org/wiki/Linux) [Wayland]() desktop utility that helps with preventing [repetitive strain injuries](https://en.wikipedia.org/wiki/Repetitive_strain_injury) by keeping track of usage patterns and periodically informing the user to take breaks.
8 |
9 | ---
10 |
11 | [
Install
](#installation)
12 | [
Contribute
](CONTRIBUTING.md)
13 | [
Packaging
](PACKAGING.md)
14 |
15 | ---
16 |
17 |
18 |
19 | ## Features
20 |
21 | - ⚙ Simple config to tweak its behavior.
22 | - 🚀 Auto start it with your desktop environment.
23 | - 🚫 [X11](https://en.wikipedia.org/wiki/X_Window_System) is not supported.
24 | - 🚫 Microsoft Windows is definitely not supported.
25 |
26 | ## Requirements
27 |
28 | - [Wayland Compositor]() that optionally implements [`ext_idle_notifier_v1`](https://wayland.app/protocols/ext-idle-notify-v1)
29 | - [Notification Daemon](https://wiki.archlinux.org/title/Desktop_notifications#Notification_servers) that implements [`org.freedesktop.Notifications`](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html)
30 | - [libdbus-1.so](https://www.freedesktop.org/wiki/Software/dbus/) installed in your system
31 | - [Linux libc](https://en.wikipedia.org/wiki/C_standard_library) via either [glibc](https://www.gnu.org/software/libc/) or [musl libc](https://musl.libc.org/)
32 |
33 | ## Installation
34 |
35 | [](https://repology.org/project/ianny/versions)
36 |
37 | ### Arch Linux
38 |
39 | All packages are available on AUR, you can:
40 |
41 | - Build locally from latest stable release: [ianny](https://aur.archlinux.org/packages/ianny)
42 | - Build locally from latest Git commit: [ianny-git](https://aur.archlinux.org/packages/ianny-git)
43 | - Use the binary built by GitHub: [ianny-bin](https://aur.archlinux.org/packages/ianny-bin)
44 |
45 | ### Download Binary From GitHub
46 |
47 | For every new release a GitHub workflow will build a binary in GitHub servers and will upload it as a release asset in GitHub releases.
48 |
49 | You can find the latest GitHub release [here](https://github.com/zefr0x/ianny/releases/latest) or the releases page [here](https://github.com/zefr0x/ianny/releases).
50 |
51 | ## Build
52 |
53 | > [!Note]
54 | > You need to have [`cargo`](https://doc.rust-lang.org/cargo/), [`meson`](https://mesonbuild.com/) and [`libdbus-1-dev`](https://www.freedesktop.org/wiki/Software/dbus/) installed in your system.
55 |
56 | > [!NOTE]
57 | > For cross compilation you will need to set the `rustc_target` meson option, and create [`.cargo/config.toml`](https://doc.rust-lang.org/cargo/reference/config.html) file to set a `linker` to be used for your target.
58 |
59 | ```shell
60 | git clone https://github.com/zefr0x/ianny.git
61 |
62 | cd ianny
63 |
64 | # Checkout to a release tag e.g. v1.0.1
65 | git checkout vx.x.x
66 |
67 | meson setup builddir -Dbuildtype=release
68 | meson compile -C builddir
69 | ```
70 |
71 | You will find the binary in `./builddir/src/ianny`
72 |
73 | To install:
74 |
75 | ```shell
76 | meson install -C builddir
77 | ```
78 |
79 | # Usage
80 |
81 | You just need to execute the binary either directly or by enabling it to auto start with your desktop environment's settings, since it provides a `.desktop` file for auto-start.
82 |
83 | # Config
84 |
85 | The defaults might not fit your needs, so you can change them via a config file.
86 |
87 | The config file is `$XDG_CONFIG_HOME/io.github.zefr0x.ianny/config.toml` or by default `~/.config/io.github.zefr0x.ianny/config.toml`. Just create it and specify the options you need with the [toml format](https://toml.io/):
88 |
89 | ```toml
90 | [timer]
91 | # Enabling this will only consider user input alone for idle state, e.g. you will not have breaks when watching videos or playing music without any user input.
92 | ignore_idle_inhibitors = false
93 | # Timer will stop and reset when you are idle for this amount of seconds.
94 | idle_timeout = 240
95 | # Active duration that activates a break.
96 | short_break_timeout = 1200
97 | long_break_timeout = 3840
98 | # Breaks duration.
99 | short_break_duration = 120
100 | long_break_duration = 240
101 |
102 | [notification]
103 | show_progress_bar = true
104 | # Minimum delay of updating the progress bar (lower than 1s may return an error).
105 | minimum_update_delay = 1
106 | ```
107 |
108 | > [!Note]
109 | > Time specified in seconds
110 |
111 | ## Q&A
112 |
113 | Q: What does `Ianny` mean?
114 |
115 | - It is an Arabic word `عَيْنِي` that could be translated to `My Eye` in English.
116 |
117 | ## Inspired by
118 |
119 | - [KDE's RSIBreak](https://userbase.kde.org/RSIBreak)
120 |
--------------------------------------------------------------------------------
/assets/io.github.zefr0x.ianny.svg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zefr0x/ianny/441853b5994edfab247df3523b9f29a1da973a9c/assets/io.github.zefr0x.ianny.svg
--------------------------------------------------------------------------------
/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {}
2 |
--------------------------------------------------------------------------------
/io.github.zefr0x.ianny.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Version=1.5
3 | Type=Application
4 | Name=Ianny
5 | Name[ar]=عَيْنِي
6 | GenericName=Break Notifier
7 | GenericName[ar]=مُنَبِّهُ اِستِرَاحَة
8 | Comment=Help preventing repetitive strain injuries by keeping track of usage patterns and periodically informing user to take breaks.
9 | Comment[ar]=يُساعِدُ فِي تَجَنُّبِ إصَابَاتِ الإجهَادِ المُتَكَرِّرِ مِن خِلَالِ تَتَبُّعِ أنمَاطِ الاِستِخدَامِ وَتَذكِيِرِ المُستَخدِمِ بِشَكلٍ دَورِيٍّ لِأخذِ اِستِرَاحَات
10 | Exec=ianny
11 | NoDisplay=true
12 | Terminal=false
13 | SingleMainWindow=true
14 | Categories=Utility
15 | Keywords=RSI;eye;break;
16 | Keywords[ar]=إجهاد;عين;استراحة;
17 | X-GNOME-Autostart-enabled=true
18 |
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | _default:
2 | @just --list
3 |
4 | lint_all:
5 | pre-commit run --all-files
6 |
7 | pot:
8 | # Generate .pot file from sorce code using `xtr` (https://github.com/woboq/tr).
9 | xtr --output po/io.github.zefr0x.ianny.pot --package-name Ianny src/main.rs
10 | sed -i 1,2d po/io.github.zefr0x.ianny.pot
11 |
12 | update_po:
13 | for lang in `cat ./po/LINGUAS`; do \
14 | msgmerge --update ./po/${lang}.po ./po/io.github.zefr0x.ianny.pot; \
15 | done
16 |
17 | todo:
18 | rg "(.(TODO|FIXME|FIX|HACK|WARN|PREF|NOTE): )|(todo!)" --glob !{{ file_name(justfile()) }}
19 |
20 | # vim: set ft=make :
21 |
--------------------------------------------------------------------------------
/meson.build:
--------------------------------------------------------------------------------
1 | project('ianny', 'rust',
2 | version : '2.1.1',
3 | license : 'GPL3',
4 | default_options : ['warning_level=2'])
5 |
6 | application_id = 'io.github.zefr0x.ianny'
7 |
8 | # Get options
9 | buildtype = get_option('buildtype')
10 |
11 | prefix = get_option('prefix')
12 | bindir = prefix / get_option('bindir')
13 |
14 | rustc_target = get_option('rustc_target')
15 |
16 | # Check for deps
17 | dbus_dep = dependency('dbus-1', version : '>=1.6.0')
18 |
19 | desktop_utils = find_program('desktop-file-validate', required: false)
20 | cargo = find_program('cargo', required: true)
21 |
22 | # Desktop file
23 | desktop_file ='@0@.desktop'.format(application_id)
24 |
25 | if desktop_utils.found()
26 | test('Validate desktop file', desktop_utils,
27 | args: [desktop_file]
28 | )
29 | endif
30 |
31 | # Build summaries
32 | summary(
33 | {
34 | 'Build Type': buildtype,
35 | },
36 | section: 'Build Summary',
37 | )
38 |
39 | # Other meson.build files
40 | subdir('src/')
41 | subdir('po/')
42 |
43 |
44 | # Install .desktop files
45 | install_data(
46 | desktop_file,
47 | install_dir: 'share/applications'
48 | )
49 |
50 | install_data(
51 | desktop_file,
52 | install_dir: '/etc/xdg/autostart/'
53 | )
54 |
--------------------------------------------------------------------------------
/meson_options.txt:
--------------------------------------------------------------------------------
1 | option (
2 | 'rustc_target',
3 | description: 'Rust target triple',
4 | type: 'string',
5 | value: ''
6 | )
7 |
8 | # By default it will use a special cargo-home just for the build.
9 | option(
10 | 'cargo-home',
11 | type: 'string',
12 | value: ''
13 | )
14 |
15 | # By default it will connect to crates.io to check and downlaod missing rust deps.
16 | option(
17 | 'offline-build',
18 | type: 'boolean',
19 | value: false
20 | )
21 |
--------------------------------------------------------------------------------
/po/LINGUAS:
--------------------------------------------------------------------------------
1 | ar
2 |
--------------------------------------------------------------------------------
/po/ar.po:
--------------------------------------------------------------------------------
1 | # This file is distributed under the same license as the Ianny package.
2 | # zefr0x <>, 2023-2024.
3 | #
4 | msgid ""
5 | msgstr ""
6 | "Project-Id-Version: Ianny VERSION\n"
7 | "Report-Msgid-Bugs-To: \n"
8 | "POT-Creation-Date: 2024-02-16 08:11+0000\n"
9 | "PO-Revision-Date: 2024-02-16 11:15+0300\n"
10 | "Last-Translator: zefr0x <>\n"
11 | "Language-Team: Arabic <>\n"
12 | "Language: ar\n"
13 | "MIME-Version: 1.0\n"
14 | "Content-Type: text/plain; charset=UTF-8\n"
15 | "Content-Transfer-Encoding: 8bit\n"
16 | "Plural-Forms: nplurals=6; plural= n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
17 | "&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
18 | "X-Generator: Gtranslator 45.3\n"
19 |
20 | #: src/main.rs:27
21 | msgid "Take a break for"
22 | msgstr "خُذ اِستِراحَةً لِمُدَّةِ"
23 |
24 | #: src/main.rs:35
25 | msgid "minute"
26 | msgid_plural "minutes"
27 | msgstr[0] "لَحظَة"
28 | msgstr[1] "دَقِيقَةٍ وَاحِدَة"
29 | msgstr[2] "دَقِيقَتانِ اِثنَتان"
30 | msgstr[3] "دَقِائِق"
31 | msgstr[4] "دَقِيقَة"
32 | msgstr[5] "دَقِيقَة"
33 |
34 | #: src/main.rs:40
35 | msgid " and"
36 | msgstr " وَ"
37 |
38 | #: src/main.rs:48
39 | msgid "second"
40 | msgid_plural "seconds"
41 | msgstr[0] "لَحظَة"
42 | msgstr[1] "ثَانِيَةٌ وَاحِدَة"
43 | msgstr[2] "ثَانِيَتَانِ اِثنَتان"
44 | msgstr[3] "ثَوانِي"
45 | msgstr[4] "ثَانِيَة"
46 | msgstr[5] "ثَانِيَة"
47 |
48 | #: src/main.rs:54
49 | msgid "Break Time!"
50 | msgstr "وَقتُ اِستِراحَة!"
51 |
52 | #: src/main.rs:57
53 | msgid "Ianny"
54 | msgstr "عَيْنِي"
55 |
--------------------------------------------------------------------------------
/po/io.github.zefr0x.ianny.pot:
--------------------------------------------------------------------------------
1 | # This file is distributed under the same license as the Ianny package.
2 | # FIRST AUTHOR , YEAR.
3 | #
4 | #, fuzzy
5 | msgid ""
6 | msgstr ""
7 | "Project-Id-Version: Ianny VERSION\n"
8 | "Report-Msgid-Bugs-To: \n"
9 | "POT-Creation-Date: 2024-02-16 08:11+0000\n"
10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11 | "Last-Translator: FULL NAME \n"
12 | "Language-Team: LANGUAGE \n"
13 | "Language: \n"
14 | "MIME-Version: 1.0\n"
15 | "Content-Type: text/plain; charset=UTF-8\n"
16 | "Content-Transfer-Encoding: 8bit\n"
17 |
18 | #: src/main.rs:27
19 | msgid "Take a break for"
20 | msgstr ""
21 |
22 | #: src/main.rs:35
23 | msgid "minute"
24 | msgid_plural "minutes"
25 | msgstr[0] ""
26 | msgstr[1] ""
27 |
28 | #: src/main.rs:40
29 | msgid " and"
30 | msgstr ""
31 |
32 | #: src/main.rs:48
33 | msgid "second"
34 | msgid_plural "seconds"
35 | msgstr[0] ""
36 | msgstr[1] ""
37 |
38 | #: src/main.rs:54
39 | msgid "Break Time!"
40 | msgstr ""
41 |
42 | #: src/main.rs:57
43 | msgid "Ianny"
44 | msgstr ""
45 |
--------------------------------------------------------------------------------
/po/meson.build:
--------------------------------------------------------------------------------
1 | i18n = import('i18n')
2 |
3 | i18n.gettext(application_id)
4 |
--------------------------------------------------------------------------------
/src/config.rs:
--------------------------------------------------------------------------------
1 | use log::info;
2 |
3 | #[derive(Default, Debug, serde::Deserialize)]
4 | #[serde(default)]
5 | pub struct Config {
6 | pub notification: Notification,
7 | pub timer: Timer,
8 | }
9 |
10 | #[derive(Debug, serde::Deserialize)]
11 | #[serde(default)]
12 | pub struct Notification {
13 | pub show_progress_bar: bool,
14 | pub minimum_update_delay: u64,
15 | }
16 |
17 | #[derive(Debug, serde::Deserialize)]
18 | #[serde(default)]
19 | pub struct Timer {
20 | pub ignore_idle_inhibitors: bool,
21 | pub idle_timeout: u32, // Seconds
22 | pub short_break_timeout: u64, // Seconds
23 | pub long_break_timeout: u64, // Seconds
24 | pub short_break_duration: u64, // Seconds
25 | pub long_break_duration: u64, // Seconds
26 | }
27 |
28 | impl Default for Notification {
29 | fn default() -> Self {
30 | Self {
31 | show_progress_bar: true,
32 | minimum_update_delay: 1,
33 | }
34 | }
35 | }
36 |
37 | impl Default for Timer {
38 | fn default() -> Self {
39 | Self {
40 | ignore_idle_inhibitors: false,
41 | idle_timeout: 240, // Seconds (7 minutes)
42 | short_break_timeout: 1200, // Seconds (20 minutes)
43 | long_break_timeout: 3840, // Seconds (64 minutes)
44 | short_break_duration: 120, // Seconds (2 minutes)
45 | long_break_duration: 240, // Seconds (7 minutes)
46 | }
47 | }
48 | }
49 |
50 | impl Config {
51 | pub fn load() -> Self {
52 | let config_file = Self::get_config_file();
53 |
54 | toml::from_str(&std::fs::read_to_string(&config_file).map_or_else(
55 | |_| String::new(),
56 | |content| {
57 | info!("Read config from: {}", &config_file.to_string_lossy());
58 | content
59 | },
60 | ))
61 | .expect("Failed to parse conifg file")
62 | }
63 |
64 | fn get_config_file() -> std::path::PathBuf {
65 | xdg::BaseDirectories::with_prefix(crate::APP_ID)
66 | .get_config_file("config.toml")
67 | .expect("Can't find XDG base config directory")
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | mod config;
2 | mod wayland;
3 |
4 | use core::{fmt::Write, ops::AddAssign, time::Duration};
5 | use std::{
6 | env,
7 | sync::{LazyLock, mpsc},
8 | time::Instant,
9 | };
10 |
11 | use gettextrs::{gettext, ngettext};
12 | use log::{error, info};
13 | use single_instance::SingleInstance;
14 |
15 | const APP_ID: &str = "io.github.zefr0x.ianny";
16 |
17 | static CONFIG: LazyLock = LazyLock::new(|| {
18 | let config = config::Config::load();
19 |
20 | info!("{:?}", &config);
21 |
22 | config
23 | });
24 |
25 | /// Display a break notification for specific duration than return the real system time it toke
26 | /// while displaying this notification.
27 | fn show_break_notification(
28 | break_time: Duration,
29 | notification_sound_hint: notify_rust::Hint,
30 | ) -> Duration {
31 | use notify_rust::{Hint, Notification, Timeout, Urgency};
32 |
33 | let minutes = break_time.as_secs() / 60;
34 | let seconds = break_time.as_secs() % 60;
35 |
36 | let mut message = gettext("Take a break for");
37 |
38 | if minutes != 0 {
39 | // FIX: Languages where number should be after the word.
40 | write!(
41 | message,
42 | " {} {}",
43 | minutes,
44 | &ngettext("minute", "minutes", u32::try_from(minutes).unwrap())
45 | )
46 | .unwrap();
47 | }
48 | if minutes != 0 && seconds != 0 {
49 | message += &gettext(" and");
50 | }
51 | if seconds != 0 {
52 | // FIX: Languages where number should be after the word.
53 | write!(
54 | message,
55 | " {} {}",
56 | seconds,
57 | &ngettext("second", "seconds", u32::try_from(seconds).unwrap())
58 | )
59 | .unwrap();
60 | }
61 |
62 | let mut handle = Notification::new()
63 | .summary(&gettext("Break Time!"))
64 | .body(&message)
65 | .appname(&gettext("Ianny"))
66 | .hint(notification_sound_hint)
67 | .hint(Hint::Urgency(Urgency::Critical))
68 | .hint(Hint::Resident(true))
69 | .timeout(Timeout::Never)
70 | .show()
71 | .expect("Failed to send notification");
72 |
73 | let mut last_time = Instant::now();
74 | let mut accumulative_time = Duration::from_secs(0);
75 | #[expect(clippy::cast_precision_loss, reason = "Working with small numbers")]
76 | let step =
77 | CONFIG.notification.minimum_update_delay as f64 / break_time.as_secs_f64() * 100.0_f64;
78 | let step_duration = Duration::from_secs(CONFIG.notification.minimum_update_delay);
79 |
80 | let mut i: f64 = 0.0;
81 |
82 | #[expect(clippy::while_float, reason = "Precision is not an issue")]
83 | while i < 100.0_f64 {
84 | std::thread::sleep(step_duration);
85 | let last_time_copy = last_time;
86 | last_time = Instant::now();
87 | let time_diff = Instant::now().duration_since(last_time_copy);
88 |
89 | accumulative_time += time_diff;
90 |
91 | i += step * time_diff.div_duration_f64(step_duration);
92 |
93 | if CONFIG.notification.show_progress_bar {
94 | // FIX: Floating point problems leads to update when not needed.
95 | // HACK: The f64 data type is used to minimize the impact.
96 | #[expect(clippy::cast_possible_truncation, reason = "Truncation is intentional")]
97 | if (i as i32) != ((i - step) as i32) {
98 | // Progress bar update
99 | handle.hint(Hint::CustomInt("value".to_owned(), i as i32));
100 | }
101 | }
102 |
103 | handle.update();
104 | }
105 |
106 | handle.close();
107 |
108 | accumulative_time
109 | }
110 |
111 | fn main() -> ! {
112 | simple_logger::SimpleLogger::new().init().unwrap();
113 |
114 | // Check if the app is already running
115 | let app_instance = SingleInstance::new(APP_ID).unwrap();
116 | if !app_instance.is_single() {
117 | error!("{APP_ID} is already running.");
118 | std::process::exit(1);
119 | }
120 |
121 | // Find and load locale
122 | let app_lang = gettextrs::setlocale(
123 | gettextrs::LocaleCategory::LcAll,
124 | env::var("LC_ALL").unwrap_or_else(|_| {
125 | env::var("LC_CTYPE").unwrap_or_else(|_| env::var("LANG").unwrap_or_default())
126 | }),
127 | )
128 | .expect("Failed to set locale, please use a valid system locale and make sure it's enabled");
129 | gettextrs::textdomain(APP_ID).unwrap();
130 | // FIX: Also support /usr/local/share/locale/
131 | gettextrs::bindtextdomain(APP_ID, "/usr/share/locale").unwrap();
132 | gettextrs::bind_textdomain_codeset(APP_ID, "UTF-8").unwrap();
133 |
134 | info!("Application locale: {}", String::from_utf8_lossy(&app_lang));
135 |
136 | // Sync channel to share the idle/active state with the timer
137 | //
138 | // NOTE: Both idle and resume can happen during a break or timer pause,
139 | // so we need to buffer two messages in order to catch both.
140 | // Also we should guarantee that the main thread is not blocked
141 | // (only buffer two messages, and drop any new ones till processed),
142 | // and we must handle both messages sequentially before catching new pair
143 | // (one idle signal must be followed by at least one resume signal).
144 | // By limiting the buffer to two messages we also avoid repeating the
145 | // timer loop cycle for an already resumed idle state.
146 | let (signal_sender, signal_receiver) = mpsc::sync_channel(2);
147 |
148 | // Timer thread
149 | std::thread::spawn(move || -> ! {
150 | let pause_duration = core::cmp::min(
151 | gcd::binary_u64(
152 | CONFIG.timer.short_break_timeout,
153 | CONFIG.timer.long_break_timeout,
154 | ), // Calculate GCD
155 | u64::from(CONFIG.timer.idle_timeout) + 1, // NOTE: Extra one second to make sure
156 | ); // seconds
157 |
158 | let mut short_time_pased = 0; // seconds
159 | let mut long_time_pased = 0; // seconds
160 | let mut last_time = Instant::now();
161 |
162 | // TODO: Handle separate idle timeout for both long and short timers.
163 |
164 | // Timer loop.
165 | loop {
166 | std::thread::sleep(Duration::from_secs(pause_duration));
167 | // NOTE: Get around freezing after calculating time_diff and
168 | // before resetting last_time. Since the time between will
169 | // be dropped without having it in the next calculations.
170 | let last_time_copy = last_time;
171 | last_time = Instant::now();
172 |
173 | let time_diff = Instant::now().duration_since(last_time_copy).as_secs();
174 |
175 | if time_diff - pause_duration >= u64::from(CONFIG.timer.idle_timeout) {
176 | long_time_pased = 0;
177 | short_time_pased = 0;
178 | last_time = Instant::now();
179 |
180 | info!("Timer resetted since idle happend while process was suspended");
181 | } else {
182 | short_time_pased.add_assign(time_diff);
183 | long_time_pased.add_assign(time_diff);
184 | }
185 |
186 | if signal_receiver.try_recv() == Ok(wayland::Signal::Idled) {
187 | // Wait for change, tell user resume from idle.
188 | loop {
189 | if signal_receiver.recv() == Ok(wayland::Signal::Resumed) {
190 | // Clean the channel from any other event.
191 | while signal_receiver.try_recv().is_ok() {}
192 |
193 | // Reset timers.
194 | long_time_pased = 0;
195 | short_time_pased = 0;
196 | last_time = Instant::now();
197 |
198 | info!("Timer resetted");
199 | break;
200 | }
201 | }
202 | } else if long_time_pased >= CONFIG.timer.long_break_timeout {
203 | info!("Long break starts");
204 |
205 | show_break_notification(
206 | Duration::from_secs(CONFIG.timer.long_break_duration),
207 | notify_rust::Hint::SoundName("suspend-error".to_owned()), // Name or file
208 | );
209 |
210 | info!("Long break ends");
211 |
212 | // Reset timers.
213 | long_time_pased = 0;
214 | short_time_pased = 0;
215 | last_time = Instant::now();
216 | } else if short_time_pased >= CONFIG.timer.short_break_timeout {
217 | info!("Short break starts");
218 |
219 | if show_break_notification(
220 | Duration::from_secs(CONFIG.timer.short_break_duration),
221 | notify_rust::Hint::SoundName("suspend-error".to_owned()), // Name or file
222 | )
223 | .as_secs()
224 | - CONFIG.timer.short_break_duration
225 | >= u64::from(CONFIG.timer.idle_timeout)
226 | {
227 | long_time_pased = 0;
228 |
229 | info!("Long break timer resetted since idle happend during short break");
230 | }
231 |
232 | info!("Short break ends");
233 |
234 | // Reset timer.
235 | short_time_pased = 0;
236 | last_time = Instant::now();
237 | }
238 | }
239 | });
240 |
241 | // Connect to Wayland server
242 | let conn = wayland_client::Connection::connect_to_env()
243 | .expect("Not able to detect a wayland compositor");
244 |
245 | let mut event_queue = conn.new_event_queue::();
246 | let queue_handle = event_queue.handle();
247 |
248 | let display = conn.display();
249 |
250 | let _registry = display.get_registry(&queue_handle, ());
251 |
252 | // Create main state for the app to store shared things.
253 | let mut state = wayland::State::new(signal_sender);
254 |
255 | event_queue
256 | .roundtrip(&mut state)
257 | .expect("Failed to cause a synchronous round trip with the wayland server");
258 |
259 | // TODO: Make it a single threaded application.
260 |
261 | // Main loop.
262 | loop {
263 | event_queue
264 | .blocking_dispatch(&mut state)
265 | .expect("Failed to block waiting for events and dispatch them");
266 | }
267 | }
268 |
--------------------------------------------------------------------------------
/src/meson.build:
--------------------------------------------------------------------------------
1 | if get_option('cargo-home') == ''
2 | cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ]
3 | else
4 | cargo_env = [ 'CARGO_HOME=' + get_option('cargo-home') ]
5 | endif
6 |
7 | cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ]
8 | cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ]
9 | if rustc_target != ''
10 | cargo_options += [ '--target', rustc_target ]
11 | endif
12 |
13 | # When cargo-home already contain needed deps and we want to build offline.
14 | if get_option('offline-build')
15 | cargo_options += [ '--offline' ]
16 | endif
17 |
18 | if buildtype == 'plain' or buildtype == 'release' or buildtype == 'minsize'
19 | cargo_profile = 'release'
20 | cargo_options += [ '--release' ]
21 | else
22 | cargo_profile = 'debug'
23 | message('Building in debug mode')
24 | endif
25 |
26 | cargo_build = custom_target(
27 | 'cargo-build',
28 | build_by_default: true,
29 | build_always_stale: true,
30 | output: meson.project_name(),
31 | console: true,
32 | install: true,
33 | install_dir: bindir,
34 | command: [
35 | 'env',
36 | cargo_env,
37 | cargo, 'build',
38 | cargo_options,
39 | '&&',
40 | 'cp', 'src' / rustc_target / cargo_profile / meson.project_name(), '@OUTPUT@',
41 | ]
42 | )
43 |
--------------------------------------------------------------------------------
/src/wayland.rs:
--------------------------------------------------------------------------------
1 | use std::sync::mpsc;
2 |
3 | use log::{error, info, trace};
4 | use wayland_client::{
5 | Proxy,
6 | protocol::{wl_registry, wl_seat},
7 | };
8 | use wayland_protocols::ext::idle_notify::v1::client::{
9 | ext_idle_notification_v1, ext_idle_notifier_v1,
10 | };
11 |
12 | use crate::CONFIG;
13 |
14 | #[derive(Debug, Eq, PartialEq)]
15 | pub enum Signal {
16 | Idled,
17 | Resumed,
18 | }
19 |
20 | type GlobalName = u32;
21 |
22 | pub struct State {
23 | idle_notifier: Option<(GlobalName, ext_idle_notifier_v1::ExtIdleNotifierV1)>,
24 | idle_notification: Option,
25 | signal_sender: mpsc::SyncSender,
26 | }
27 |
28 | impl State {
29 | pub const fn new(signal_sender: mpsc::SyncSender) -> Self {
30 | Self {
31 | idle_notifier: None,
32 | idle_notification: None,
33 | signal_sender,
34 | }
35 | }
36 | }
37 |
38 | impl wayland_client::Dispatch for State {
39 | fn event(
40 | state: &mut Self,
41 | registry: &wl_registry::WlRegistry,
42 | event: wl_registry::Event,
43 | _data: &(),
44 | _conn: &wayland_client::Connection,
45 | queue_handle: &wayland_client::QueueHandle,
46 | ) {
47 | match event {
48 | wl_registry::Event::Global {
49 | name,
50 | interface,
51 | version,
52 | } => {
53 | match interface.as_str() {
54 | "wl_seat" => {
55 | // TODO: Support newest version of wl_seat.
56 | let wl_seat =
57 | registry.bind::(name, 1, queue_handle, ());
58 |
59 | trace!("Binded to {}", wl_seat.id());
60 | }
61 | "ext_idle_notifier_v1" => {
62 | let idle_notifier = registry
63 | .bind::(
64 | name,
65 | version,
66 | queue_handle,
67 | (),
68 | );
69 |
70 | trace!("Binded to {}", idle_notifier.id());
71 |
72 | state.idle_notifier = Some((name, idle_notifier));
73 | }
74 | _ => {}
75 | }
76 | }
77 | wl_registry::Event::GlobalRemove { name } => {
78 | if let Some((idle_notifier_name, idle_notifier)) = &state.idle_notifier {
79 | if name == *idle_notifier_name {
80 | idle_notifier.destroy();
81 | state.idle_notifier = None;
82 |
83 | trace!("Destroyed ext_idle_notifier_v1");
84 |
85 | if let Some(idle_notification) = &state.idle_notification {
86 | idle_notification.destroy();
87 | state.idle_notification = None;
88 |
89 | trace!("Destroyed ext_idle_notification_v1");
90 | }
91 | }
92 | }
93 | }
94 | _ => {}
95 | }
96 | }
97 | }
98 |
99 | impl wayland_client::Dispatch for State {
100 | fn event(
101 | state: &mut Self,
102 | seat: &wl_seat::WlSeat,
103 | _event: wl_seat::Event,
104 | _data: &(),
105 | _conn: &wayland_client::Connection,
106 | queue_handle: &wayland_client::QueueHandle,
107 | ) {
108 | // FIX: Support multiseat configuration.
109 | if let Some((_, idle_notifier)) = &state.idle_notifier {
110 | let idle_timeout = CONFIG.timer.idle_timeout * 1000; // milliseconds
111 |
112 | if let Some(idle_notification) = &state.idle_notification {
113 | idle_notification.destroy();
114 | state.idle_notification = None;
115 |
116 | trace!("Destroyed ext_idle_notification_v1");
117 | }
118 |
119 | let idle_notification = if CONFIG.timer.ignore_idle_inhibitors
120 | && idle_notifier.version()
121 | >= ext_idle_notifier_v1::REQ_GET_INPUT_IDLE_NOTIFICATION_SINCE
122 | {
123 | idle_notifier.get_input_idle_notification(idle_timeout, seat, queue_handle, ())
124 | } else {
125 | if CONFIG.timer.ignore_idle_inhibitors {
126 | error!(
127 | "Failed to ignore idle inhibitors, your wayland compositor's idle notifier does not support this feature."
128 | );
129 | }
130 |
131 | idle_notifier.get_idle_notification(idle_timeout, seat, queue_handle, ())
132 | };
133 |
134 | trace!("Created {}", idle_notification.id());
135 |
136 | state.idle_notification = Some(idle_notification);
137 | }
138 | }
139 | }
140 |
141 | impl wayland_client::Dispatch for State {
142 | fn event(
143 | _state: &mut Self,
144 | _idle_notifier: &ext_idle_notifier_v1::ExtIdleNotifierV1,
145 | _event: ext_idle_notifier_v1::Event,
146 | &(): &(),
147 | _conn: &wayland_client::Connection,
148 | _queue_handle: &wayland_client::QueueHandle,
149 | ) {
150 | // No events
151 | }
152 | }
153 |
154 | impl wayland_client::Dispatch for State {
155 | fn event(
156 | state: &mut Self,
157 | _idle_notification: &ext_idle_notification_v1::ExtIdleNotificationV1,
158 | event: ext_idle_notification_v1::Event,
159 | _data: &(),
160 | _conn: &wayland_client::Connection,
161 | _queue_handle: &wayland_client::QueueHandle,
162 | ) {
163 | match event {
164 | ext_idle_notification_v1::Event::Idled => {
165 | info!("Idled");
166 |
167 | match state.signal_sender.try_send(Signal::Idled) {
168 | Ok(()) | Err(mpsc::TrySendError::Full(_)) => (),
169 | Err(mpsc::TrySendError::Disconnected(_)) => {
170 | panic!("Timer disconnected, `Idled` signal could not be sent")
171 | }
172 | }
173 | }
174 | ext_idle_notification_v1::Event::Resumed => {
175 | info!("Resumed");
176 |
177 | match state.signal_sender.try_send(Signal::Resumed) {
178 | Ok(()) | Err(mpsc::TrySendError::Full(_)) => (),
179 | Err(mpsc::TrySendError::Disconnected(_)) => {
180 | panic!("Timer disconnected, `Resumed` signal could not be sent")
181 | }
182 | }
183 | }
184 | _ => {}
185 | }
186 | }
187 | }
188 |
--------------------------------------------------------------------------------