Skip to content

Commit d4882c6

Browse files
committed
New Chronos System Tray & Bullet-Proof Buffers as Timer Breaks (Fingers Crossed)
1 parent 2d74fb4 commit d4882c6

8 files changed

Lines changed: 727 additions & 1 deletion

File tree

assets/chronos.ico

47.1 KB
Binary file not shown.

commands/start.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,17 @@ def _is_break_or_buffer_block(block):
165165
return False
166166
if bool(block.get("is_buffer")) or bool(block.get("is_break")):
167167
return True
168+
# Dynamic/template buffer rows often carry buffer_type without explicit flags.
169+
if str(block.get("buffer_type") or "").strip():
170+
return True
168171
subtype = str(block.get("subtype") or "").strip().lower()
169172
if subtype in {"buffer", "break"}:
170173
return True
171174
schedule_type = str(block.get("type") or block.get("schedule_type") or "").strip().lower()
172175
if schedule_type in {"buffer", "break"}:
173176
return True
177+
if "buffer" in schedule_type:
178+
return True
174179
return False
175180

176181

commands/tray.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import os
2+
import sys
3+
import subprocess
4+
5+
6+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
7+
TRAY_SCRIPT = os.path.join(ROOT_DIR, "utilities", "tray", "app.py")
8+
PID_DIR = os.path.join(ROOT_DIR, "user", "Temp")
9+
PID_PATH = os.path.join(PID_DIR, "tray.pid")
10+
11+
12+
def _read_pid():
13+
try:
14+
with open(PID_PATH, "r", encoding="utf-8") as fh:
15+
raw = fh.read().strip()
16+
return int(raw) if raw else None
17+
except Exception:
18+
return None
19+
20+
21+
def _clear_pid():
22+
try:
23+
if os.path.exists(PID_PATH):
24+
os.remove(PID_PATH)
25+
except Exception:
26+
pass
27+
28+
29+
def _pid_alive(pid):
30+
if not isinstance(pid, int) or pid <= 0:
31+
return False
32+
if os.name == "nt":
33+
try:
34+
proc = subprocess.run(
35+
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
36+
stdout=subprocess.PIPE,
37+
stderr=subprocess.PIPE,
38+
text=True,
39+
check=False,
40+
)
41+
out = (proc.stdout or "").strip().lower()
42+
return bool(out) and (not out.startswith("info: no tasks")) and (str(pid) in out)
43+
except Exception:
44+
return False
45+
try:
46+
os.kill(pid, 0)
47+
return True
48+
except OSError:
49+
return False
50+
51+
52+
def _pythonw_executable():
53+
current = sys.executable or "python"
54+
folder = os.path.dirname(current)
55+
candidate = os.path.join(folder, "pythonw.exe")
56+
if os.name == "nt" and os.path.exists(candidate):
57+
return candidate
58+
return current
59+
60+
61+
def _start_tray():
62+
if not os.path.exists(TRAY_SCRIPT):
63+
print("Tray app not found.")
64+
return
65+
pid = _read_pid()
66+
if pid and _pid_alive(pid):
67+
print(f"Tray already running (pid {pid}).")
68+
return
69+
py = _pythonw_executable()
70+
kwargs = {
71+
"cwd": ROOT_DIR,
72+
"stdout": subprocess.DEVNULL,
73+
"stderr": subprocess.DEVNULL,
74+
}
75+
if os.name == "nt":
76+
flags = 0
77+
flags |= getattr(subprocess, "DETACHED_PROCESS", 0)
78+
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
79+
if flags:
80+
kwargs["creationflags"] = flags
81+
else:
82+
kwargs["start_new_session"] = True
83+
proc = subprocess.Popen([py, TRAY_SCRIPT], **kwargs)
84+
print(f"Tray start requested (pid {proc.pid}).")
85+
86+
87+
def _stop_tray():
88+
pid = _read_pid()
89+
if not pid:
90+
print("Tray not running.")
91+
return
92+
if not _pid_alive(pid):
93+
_clear_pid()
94+
print("Tray not running.")
95+
return
96+
try:
97+
if os.name == "nt":
98+
subprocess.run(
99+
["taskkill", "/PID", str(pid), "/T", "/F"],
100+
stdout=subprocess.DEVNULL,
101+
stderr=subprocess.DEVNULL,
102+
check=False,
103+
)
104+
else:
105+
os.kill(pid, 15)
106+
except Exception as e:
107+
print(f"Failed to stop tray: {e}")
108+
return
109+
_clear_pid()
110+
print("Tray stopped.")
111+
112+
113+
def _status_tray():
114+
pid = _read_pid()
115+
if pid and _pid_alive(pid):
116+
print(f"Tray running (pid {pid}).")
117+
return
118+
if pid and not _pid_alive(pid):
119+
_clear_pid()
120+
print("Tray not running.")
121+
122+
123+
def run(args, properties):
124+
sub = str(args[0]).strip().lower() if args else "status"
125+
if sub in {"help", "-h", "--help"}:
126+
print(get_help_message())
127+
return
128+
if sub == "start":
129+
_start_tray()
130+
return
131+
if sub == "stop":
132+
_stop_tray()
133+
return
134+
if sub == "restart":
135+
_stop_tray()
136+
_start_tray()
137+
return
138+
if sub == "status":
139+
_status_tray()
140+
return
141+
print(get_help_message())
142+
143+
144+
def get_help_message():
145+
return """
146+
Usage:
147+
tray start
148+
tray stop
149+
tray restart
150+
tray status
151+
"""

modules/timer/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,12 +1075,17 @@ def _is_break_or_buffer_block(block):
10751075
return False
10761076
if bool(block.get("is_buffer")) or bool(block.get("is_break")):
10771077
return True
1078+
# Dynamic/template buffer rows often carry buffer_type without explicit flags.
1079+
if str(block.get("buffer_type") or "").strip():
1080+
return True
10781081
subtype = str(block.get("subtype") or "").strip().lower()
10791082
if subtype in {"buffer", "break"}:
10801083
return True
10811084
schedule_type = str(block.get("type") or block.get("schedule_type") or "").strip().lower()
10821085
if schedule_type in {"buffer", "break"}:
10831086
return True
1087+
if "buffer" in schedule_type:
1088+
return True
10841089
return False
10851090

10861091

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ playsound>=1.2.2
1515
# CLI UX (autocomplete/suggestions)
1616
prompt_toolkit>=3.0.43
1717

18+
# Desktop tray app + icon rendering
19+
pystray>=0.19.5
20+
Pillow>=10.3.0
21+

tray_launcher.bat

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@echo off
2+
setlocal
3+
4+
cd /d "%~dp0"
5+
6+
set "PYTHONW_EXE=pythonw"
7+
if exist ".venv\Scripts\pythonw.exe" set "PYTHONW_EXE=.venv\Scripts\pythonw.exe"
8+
9+
start "" /B "%PYTHONW_EXE%" utilities/tray/app.py
10+
11+
exit /b

utilities/dashboard/widgets/timer/index.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function mount(el, context) {
7171
<div class="row" style="gap:8px; align-items:center;">
7272
<button class="btn btn-primary" id="twStart" data-ui-id="widget.timer.start_button">Start</button>
7373
<button class="btn" id="twStartDay" data-ui-id="widget.timer.start_day_button">Start Day</button>
74+
<button class="btn" id="twTray" data-ui-id="widget.timer.open_tray_button">Open Tray</button>
7475
<button class="btn" id="twPause" data-ui-id="widget.timer.pause_resume_button">Pause</button>
7576
<button class="btn btn-secondary" id="twCancel" data-ui-id="widget.timer.cancel_button">Cancel</button>
7677
<div class="spacer"></div>
@@ -95,6 +96,7 @@ export function mount(el, context) {
9596
const bindNameEl = el.querySelector('#twBindName');
9697
const startBtn = el.querySelector('#twStart');
9798
const startDayBtn = el.querySelector('#twStartDay');
99+
const trayBtn = el.querySelector('#twTray');
98100
const pauseBtn = el.querySelector('#twPause');
99101
const cancelBtn = el.querySelector('#twCancel');
100102
const refreshBtn = el.querySelector('#twRefresh');
@@ -398,6 +400,7 @@ export function mount(el, context) {
398400
else await start();
399401
});
400402
startDayBtn?.addEventListener('click', () => startDayRun());
403+
trayBtn?.addEventListener('click', () => openTray());
401404
pauseBtn.addEventListener('click', async () => {
402405
const s = String(lastTimerStatus || 'idle').toLowerCase();
403406
if (s === 'paused') {
@@ -453,7 +456,29 @@ export function mount(el, context) {
453456
startDayBtn.disabled = false;
454457
}
455458
}
456-
459+
async function openTray() {
460+
if (!trayBtn || trayBtn.disabled) return;
461+
const prev = trayBtn.textContent;
462+
trayBtn.disabled = true;
463+
trayBtn.textContent = 'Opening...';
464+
try {
465+
const resp = await fetch(apiBase() + '/api/cli', {
466+
method: 'POST',
467+
headers: { 'Content-Type': 'application/json' },
468+
body: JSON.stringify({ command: 'tray', args: ['start'], properties: {} })
469+
});
470+
const data = await resp.json().catch(() => ({}));
471+
if (!resp.ok || data.ok === false) {
472+
throw new Error(data.error || data.stderr || `HTTP ${resp.status}`);
473+
}
474+
} catch (err) {
475+
console.error('[Chronos][Timer] Open tray failed', err);
476+
alert(`Failed to open tray: ${err?.message || err}`);
477+
} finally {
478+
trayBtn.textContent = prev;
479+
trayBtn.disabled = false;
480+
}
481+
}
457482
function resetDisplayForSelected() {
458483
const p = profiles[profSel.value] || {};
459484
const sec = (p.focus_minutes ? Number(p.focus_minutes) : 25) * 60;
@@ -504,3 +529,5 @@ export function mount(el, context) {
504529
if (rse) rse.addEventListener('pointerdown', (ev) => { const r = el.getBoundingClientRect(); edgeDrag(r, (e, sr) => { el.style.width = Math.max(MIN_TIMER_WIDTH, e.clientX - sr.left) + 'px'; el.style.height = Math.max(MIN_TIMER_HEIGHT, e.clientY - sr.top) + 'px'; })(ev); });
505530
}
506531

532+
533+

0 commit comments

Comments
 (0)