Skip to content

Commit d887cb7

Browse files
authored
Add Linux desktop app with GTK4 (#8)
Native Linux system tray app using GTK4. Same config (~/.config/fuso/clocks.json), same card layout as the macOS app. Light mode, auto-refresh every second, config hot-reload. Also adds a CI workflow to verify both CLI and Linux builds on push/PR. Closes #7
1 parent 7de8022 commit d887cb7

File tree

5 files changed

+547
-0
lines changed

5 files changed

+547
-0
lines changed

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
cli:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- name: Build CLI
15+
working-directory: cli
16+
run: cargo build
17+
18+
linux:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
- name: Install GTK4 dev dependencies
23+
run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev
24+
- name: Build Linux app
25+
working-directory: linux
26+
run: cargo build

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ DerivedData/
77
Fuso.app/
88
thoughts/
99
cli/target/
10+
linux/target/

linux/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "fuso-linux"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "Fuso — track your team's timezones (Linux desktop app)"
6+
license = "MIT"
7+
8+
[dependencies]
9+
gtk4 = "0.9"
10+
chrono = "0.4"
11+
chrono-tz = "0.10"
12+
dirs = "6"
13+
serde = { version = "1", features = ["derive"] }
14+
serde_json = "1"
15+
iana-time-zone = "0.1"
16+
glib = "0.20"

linux/src/config.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
use chrono::{Datelike, Timelike};
2+
use chrono_tz::Tz;
3+
use serde::Deserialize;
4+
use std::collections::HashMap;
5+
use std::fs;
6+
use std::path::PathBuf;
7+
8+
#[derive(Deserialize, Clone)]
9+
pub struct Config {
10+
pub clocks: Vec<ClockEntry>,
11+
}
12+
13+
#[derive(Deserialize, Clone)]
14+
pub struct ClockEntry {
15+
pub name: String,
16+
pub city: String,
17+
pub timezone: String,
18+
pub flag: Option<String>,
19+
pub status: Option<StatusSchedule>,
20+
}
21+
22+
#[derive(Deserialize, Clone)]
23+
pub struct StatusSchedule {
24+
pub blocks: HashMap<String, StatusBlock>,
25+
pub months: HashMap<String, String>,
26+
}
27+
28+
#[derive(Deserialize, Clone)]
29+
pub struct StatusBlock {
30+
pub label: String,
31+
pub start: String,
32+
pub end: String,
33+
}
34+
35+
pub enum Availability {
36+
Busy(String),
37+
Available,
38+
DayOff,
39+
}
40+
41+
pub fn config_path() -> PathBuf {
42+
let home = dirs::home_dir().expect("could not find home directory");
43+
home.join(".config/fuso/clocks.json")
44+
}
45+
46+
pub fn load_config() -> Config {
47+
let path = config_path();
48+
49+
if !path.exists() {
50+
if let Some(parent) = path.parent() {
51+
fs::create_dir_all(parent).ok();
52+
}
53+
let json = serde_json::to_string_pretty(&serde_json::json!({
54+
"clocks": [{
55+
"name": "Me",
56+
"city": "New York",
57+
"timezone": "America/New_York"
58+
}]
59+
}))
60+
.unwrap();
61+
fs::write(&path, json).ok();
62+
return Config {
63+
clocks: vec![ClockEntry {
64+
name: "Me".into(),
65+
city: "New York".into(),
66+
timezone: "America/New_York".into(),
67+
flag: None,
68+
status: None,
69+
}],
70+
};
71+
}
72+
73+
let data = fs::read_to_string(&path).expect("could not read config file");
74+
serde_json::from_str(&data).expect("invalid config format")
75+
}
76+
77+
pub fn timezone_to_flag(tz: &str) -> &'static str {
78+
match tz {
79+
s if s.starts_with("America/New_York")
80+
| s.starts_with("America/Chicago")
81+
| s.starts_with("America/Denver")
82+
| s.starts_with("America/Los_Angeles")
83+
| s.starts_with("America/Phoenix")
84+
| s.starts_with("America/Anchorage")
85+
| s.starts_with("Pacific/Honolulu") =>
86+
{
87+
"\u{1f1fa}\u{1f1f8}"
88+
}
89+
s if s.starts_with("America/Sao_Paulo")
90+
| s.starts_with("America/Fortaleza")
91+
| s.starts_with("America/Manaus")
92+
| s.starts_with("America/Bahia")
93+
| s.starts_with("America/Belem")
94+
| s.starts_with("America/Recife")
95+
| s.starts_with("America/Cuiaba")
96+
| s.starts_with("America/Campo_Grande")
97+
| s.starts_with("America/Rio_Branco")
98+
| s.starts_with("America/Porto_Velho")
99+
| s.starts_with("America/Maceio")
100+
| s.starts_with("America/Araguaina") =>
101+
{
102+
"\u{1f1e7}\u{1f1f7}"
103+
}
104+
"Asia/Tokyo" => "\u{1f1ef}\u{1f1f5}",
105+
"Europe/London" | "Europe/Dublin" => "\u{1f1ec}\u{1f1e7}",
106+
"Europe/Paris" => "\u{1f1eb}\u{1f1f7}",
107+
"Europe/Berlin" => "\u{1f1e9}\u{1f1ea}",
108+
"Europe/Rome" => "\u{1f1ee}\u{1f1f9}",
109+
"Europe/Madrid" => "\u{1f1ea}\u{1f1f8}",
110+
"Europe/Lisbon" => "\u{1f1f5}\u{1f1f9}",
111+
"Europe/Amsterdam" => "\u{1f1f3}\u{1f1f1}",
112+
"Europe/Zurich" => "\u{1f1e8}\u{1f1ed}",
113+
"Europe/Vienna" => "\u{1f1e6}\u{1f1f9}",
114+
"Europe/Prague" => "\u{1f1e8}\u{1f1ff}",
115+
"Europe/Warsaw" => "\u{1f1f5}\u{1f1f1}",
116+
"Europe/Stockholm" => "\u{1f1f8}\u{1f1ea}",
117+
"Europe/Oslo" => "\u{1f1f3}\u{1f1f4}",
118+
"Europe/Copenhagen" => "\u{1f1e9}\u{1f1f0}",
119+
"Europe/Helsinki" => "\u{1f1eb}\u{1f1ee}",
120+
"Europe/Moscow" => "\u{1f1f7}\u{1f1fa}",
121+
"Europe/Istanbul" => "\u{1f1f9}\u{1f1f7}",
122+
"Asia/Shanghai" => "\u{1f1e8}\u{1f1f3}",
123+
"Asia/Hong_Kong" => "\u{1f1ed}\u{1f1f0}",
124+
"Asia/Seoul" => "\u{1f1f0}\u{1f1f7}",
125+
"Asia/Singapore" => "\u{1f1f8}\u{1f1ec}",
126+
"Asia/Kolkata" => "\u{1f1ee}\u{1f1f3}",
127+
"Asia/Dubai" => "\u{1f1e6}\u{1f1ea}",
128+
"Asia/Bangkok" => "\u{1f1f9}\u{1f1ed}",
129+
"Asia/Jakarta" => "\u{1f1ee}\u{1f1e9}",
130+
"Asia/Taipei" => "\u{1f1f9}\u{1f1fc}",
131+
"Asia/Riyadh" => "\u{1f1f8}\u{1f1e6}",
132+
"Asia/Jerusalem" => "\u{1f1ee}\u{1f1f1}",
133+
"Australia/Sydney" | "Australia/Melbourne" | "Australia/Perth" | "Australia/Brisbane" => {
134+
"\u{1f1e6}\u{1f1fa}"
135+
}
136+
"Pacific/Auckland" => "\u{1f1f3}\u{1f1ff}",
137+
"America/Toronto" | "America/Vancouver" | "America/Edmonton" => "\u{1f1e8}\u{1f1e6}",
138+
"America/Mexico_City" => "\u{1f1f2}\u{1f1fd}",
139+
"America/Argentina/Buenos_Aires" => "\u{1f1e6}\u{1f1f7}",
140+
"America/Santiago" => "\u{1f1e8}\u{1f1f1}",
141+
"America/Bogota" => "\u{1f1e8}\u{1f1f4}",
142+
"America/Lima" => "\u{1f1f5}\u{1f1ea}",
143+
"Africa/Johannesburg" => "\u{1f1ff}\u{1f1e6}",
144+
"Africa/Lagos" => "\u{1f1f3}\u{1f1ec}",
145+
"Africa/Cairo" => "\u{1f1ea}\u{1f1ec}",
146+
"Africa/Nairobi" => "\u{1f1f0}\u{1f1ea}",
147+
_ => "\u{1f30d}",
148+
}
149+
}
150+
151+
fn parse_time(t: &str) -> u32 {
152+
let parts: Vec<u32> = t.split(':').filter_map(|p| p.parse().ok()).collect();
153+
if parts.len() == 2 {
154+
parts[0] * 60 + parts[1]
155+
} else {
156+
0
157+
}
158+
}
159+
160+
pub fn current_availability(entry: &ClockEntry, now: chrono::DateTime<Tz>) -> Option<Availability> {
161+
let schedule = entry.status.as_ref()?;
162+
163+
let month_key = format!("{}-{:02}", now.year(), now.month());
164+
let month_str = schedule.months.get(&month_key)?;
165+
let day = now.day() as usize;
166+
167+
if day < 1 || day > month_str.len() {
168+
return None;
169+
}
170+
171+
let block_id = &month_str[day - 1..day];
172+
let now_minutes = now.hour() * 60 + now.minute();
173+
174+
if block_id != "0" {
175+
if let Some(block) = schedule.blocks.get(block_id) {
176+
let start = parse_time(&block.start);
177+
let end = parse_time(&block.end);
178+
179+
if end > start {
180+
if now_minutes >= start && now_minutes < end {
181+
return Some(Availability::Busy(block.label.clone()));
182+
}
183+
} else if end < start && now_minutes >= start {
184+
return Some(Availability::Busy(block.label.clone()));
185+
}
186+
}
187+
}
188+
189+
let yesterday = now - chrono::Duration::days(1);
190+
let y_month_key = format!("{}-{:02}", yesterday.year(), yesterday.month());
191+
if let Some(y_month_str) = schedule.months.get(&y_month_key) {
192+
let y_day = yesterday.day() as usize;
193+
if y_day >= 1 && y_day <= y_month_str.len() {
194+
let y_block_id = &y_month_str[y_day - 1..y_day];
195+
if y_block_id != "0" {
196+
if let Some(y_block) = schedule.blocks.get(y_block_id) {
197+
let y_start = parse_time(&y_block.start);
198+
let y_end = parse_time(&y_block.end);
199+
if y_end < y_start && now_minutes < y_end {
200+
return Some(Availability::Busy(y_block.label.clone()));
201+
}
202+
}
203+
}
204+
}
205+
}
206+
207+
if block_id == "0" {
208+
Some(Availability::DayOff)
209+
} else {
210+
Some(Availability::Available)
211+
}
212+
}
213+
214+
pub fn relative_offset(local_tz: Tz, remote_tz: Tz, now: chrono::DateTime<chrono::Utc>) -> String {
215+
use chrono::Offset;
216+
let local_offset = now.with_timezone(&local_tz).offset().fix().local_minus_utc();
217+
let remote_offset = now.with_timezone(&remote_tz).offset().fix().local_minus_utc();
218+
let diff = remote_offset - local_offset;
219+
let hours = diff / 3600;
220+
let minutes = (diff.abs() % 3600) / 60;
221+
222+
if hours == 0 && minutes == 0 {
223+
return "local".into();
224+
}
225+
if minutes > 0 {
226+
format!("{:+}:{:02}h", hours, minutes)
227+
} else {
228+
format!("{:+}h", hours)
229+
}
230+
}
231+
232+
pub fn local_tz() -> Tz {
233+
iana_time_zone::get_timezone()
234+
.ok()
235+
.and_then(|s| s.parse().ok())
236+
.unwrap_or(chrono_tz::UTC)
237+
}

0 commit comments

Comments
 (0)