From 9f3f8dd67af7d39122fcc046bc3d0b0981ce6ccd Mon Sep 17 00:00:00 2001 From: Morgan Date: Mon, 9 Mar 2026 17:42:20 +0900 Subject: [PATCH] Add files via upload --- Makefile | 46 ++++ app-tracker@local/extension.js | 83 ++++++ app-tracker@local/metadata.json | 7 + screentime-viewer | 436 ++++++++++++++++++++++++++++++++ screentimed | 428 +++++++++++++++++++++++++++++++ screentimed.desktop | 7 + screentimed.service | 13 + 7 files changed, 1020 insertions(+) create mode 100644 Makefile create mode 100644 app-tracker@local/extension.js create mode 100644 app-tracker@local/metadata.json create mode 100644 screentime-viewer create mode 100644 screentimed create mode 100644 screentimed.desktop create mode 100644 screentimed.service diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3691e1b --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +PREFIX ?= $(HOME)/.local +BINDIR = $(PREFIX)/bin +EXTENSIONDIR = $(HOME)/.local/share/gnome-shell/extensions/ +SERVICEDIR = $(HOME)/.config/systemd/user +DESKTOPDIR = $(HOME)/.local/share/applications + +EXTENTION = "app-tracker@local" + +.PHONY: all install install-extension install-daemon install-viewer uninstall + +all: + @echo "usage: make install" + @echo " make uninstall" + @echo " make enable" + +install: install-extension install-daemon install-viewer + +install-extension: + mkdir -p $(EXTENSIONDIR) + cp $(EXTENSION)/extension.js $(EXTENSIONDIR)/$(EXTENSION) + cp $(EXTENSION)/metadata.json $(EXTENSIONDIR)/$(EXTENSION) + +install-daemon: + mkdir -p $(BINDIR) + cp screentimed $(BINDIR)/screentimed + chmod +x $(BINDIR)/screentimed + mkdir -p $(SERVICEDIR) + cp screentimed.service $(SERVICEDIR)/ + +install-viewer: + mkdir -p $(BINDIR) + cp screentime-viewer $(BINDIR)/screentime-viewer + chmod +x $(BINDIR)/screentime-viewer + mkdir -p $(DESKTOPDIR) + cp screentime-viewer.desktop $(DESKTOPDIR)/ + +uninstall: + systemctl --user disable --now screentimed || true + gnome-extensions disable $(EXTENSION) || true + rm -v $(BINDIR)/screentimed + rm -v $(BINDIR)/screentime-viewer + rm -v $(SERVICEDIR)/screentimed.service + rm -v $(DESKTOPDIR)/screentime-viewer.desktop + rm -rv $(EXTENSIONDIR)/$(EXTENSION) + systemctl --user daemon-reload + @echo "uninstalled." diff --git a/app-tracker@local/extension.js b/app-tracker@local/extension.js new file mode 100644 index 0000000..217614a --- /dev/null +++ b/app-tracker@local/extension.js @@ -0,0 +1,83 @@ +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import Shell from 'gi://Shell'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const IFACE = ` + + + + + + + + + + + + + + +`; + +export default class FocusTracker extends Extension { + enable() { + this._currentApp = ''; + this._tracker = Shell.WindowTracker.get_default(); + this._appSystem = Shell.AppSystem.get_default(); + + this._dbus = Gio.DBusExportedObject.wrapJSObject(IFACE, this); + this._dbus.export(Gio.DBus.session, '/org/gnome/Shell/UsageTracker'); + + this._focusSig = this._tracker.connect('notify::focus-app', () => { + const id = this._resolveFocusId(); + if (id !== this._currentApp) { + this._currentApp = id; + this._dbus.emit_signal('FocusChanged', + new GLib.Variant('(s)', [id])); + } + }); + + this._appSig = this._appSystem.connect('app-state-changed', () => { + const ids = this._appSystem.get_running().map(a => this._resolveAppId(a)); + this._dbus.emit_signal('RunningAppsChanged', + new GLib.Variant('(as)', [ids])); + }); + } + + _resolveFocusId() { + const app = this._tracker.focus_app; + if (!app) + return ''; + return this._resolveAppId(app); + } + + _resolveAppId(app) { + const id = app.get_id(); + if (id.endsWith('.desktop')) + return id; + // fallback: use window class + const windows = app.get_windows(); + if (windows.length > 0) { + const wmClass = windows[0].get_wm_class(); + if (wmClass) + return `${wmClass}.desktop`; + } + return id; + } + + disable() { + this._tracker.disconnect(this._focusSig); + this._appSystem.disconnect(this._appSig); + this._dbus.unexport(); + this._dbus = null; + } + + GetFocus() { + return this._currentApp; + } + + GetRunningApps() { + return this._appSystem.get_running().map(a => this._resolveAppId(a)); + } +} diff --git a/app-tracker@local/metadata.json b/app-tracker@local/metadata.json new file mode 100644 index 0000000..1ff9365 --- /dev/null +++ b/app-tracker@local/metadata.json @@ -0,0 +1,7 @@ +{ + "uuid": "app-tracker@local", + "name": "Application Tracker", + "description": "Exposes active app over D-Bus", + "shell-version": ["48", "49", "50"], + "version": 1 +} diff --git a/screentime-viewer b/screentime-viewer new file mode 100644 index 0000000..5f23987 --- /dev/null +++ b/screentime-viewer @@ -0,0 +1,436 @@ +#!/usr/bin/python3 -sP + +import sys +from datetime import date, timedelta + +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +gi.require_version('Gio', '2.0') +gi.require_version('GLib', '2.0') +try: + gi.require_version('GioUnix', '2.0') + from gi.repository import GioUnix + _DesktopAppInfo = GioUnix.DesktopAppInfo +except (ValueError, ImportError): + from gi.repository import Gio as _Gio + _DesktopAppInfo = _Gio.DesktopAppInfo +from gi.repository import Gtk, Adw, Gio, GLib, Gdk + +SCREENTIMED_BUS = 'org.gnome.ScreenTime' +SCREENTIMED_PATH = '/org/gnome/ScreenTime' +SCREENTIMED_IFACE = 'org.gnome.ScreenTime' + + +def format_duration(seconds): + if seconds < 60: + return f'{seconds}s' + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + if h: + return f'{h}h {m}m' + return f'{m}m' + + +def get_app_info(app_id): + """Return (display_name, icon) from .desktop file, with fallbacks.""" + if not app_id.endswith('.desktop'): + return None, None # not a real app + try: + info = _DesktopAppInfo.new(app_id) + if info: + name = info.get_display_name() or info.get_name() + icon = info.get_icon() + return name, icon + except Exception: + pass + name = app_id.replace('.desktop', '').split('.')[-1] + return name, None + + +def merge_unknown(usage): + """Collapse entries without .desktop into a single 'Unknown' entry.""" + merged = {} + unknown = 0 + for app_id, secs in usage.items(): + if app_id.endswith('.desktop'): + merged[app_id] = secs + else: + unknown += secs + if unknown: + merged['__unknown__'] = unknown + return merged + + +# ── D-Bus client ────────────────────────────────────────── + +class ScreenTimeClient: + def __init__(self): + self.bus = Gio.bus_get_sync(Gio.BusType.SESSION) + + def _call(self, method, args=None, reply_type=None): + try: + result = self.bus.call_sync( + SCREENTIMED_BUS, SCREENTIMED_PATH, SCREENTIMED_IFACE, + method, + args, GLib.VariantType(reply_type) if reply_type else None, + Gio.DBusCallFlags.NONE, -1, None) + return result.unpack() + except GLib.Error as e: + print(f'D-Bus error: {e.message}', file=sys.stderr) + return None + + def get_usage(self, date_str): + r = self._call('GetUsage', GLib.Variant('(s)', (date_str,)), + '(a{su}a{su})') + if r: + return dict(r[0]), dict(r[1]) + return {}, {} + + def get_usage_by_hour(self, date_str): + r = self._call('GetUsageByHour', GLib.Variant('(s)', (date_str,)), + '(aa{su}aa{su})') + if r: + return [dict(h) for h in r[0]], [dict(h) for h in r[1]] + return [{} for _ in range(24)], [{} for _ in range(24)] + + +# ── Hourly bar chart ────────────────────────────────────── + +class HourlyChart(Gtk.DrawingArea): + def __init__(self): + super().__init__() + self.focused_hours = [{} for _ in range(24)] + self.running_hours = [{} for _ in range(24)] + self.set_content_height(110) + self.set_draw_func(self._draw) + + self.set_has_tooltip(True) + self.connect('query-tooltip', self._on_tooltip) + + def set_data(self, focused_hours, running_hours): + self.focused_hours = focused_hours + self.running_hours = running_hours + self.queue_draw() + + def _on_tooltip(self, widget, x, y, keyboard, tooltip): + width = self.get_width() + h = int((x / width) * 24) + h = max(0, min(23, h)) + + focused = self.focused_hours[h] + running = self.running_hours[h] + + if not sum(focused.values()) and not sum(running.values()): + return False + + lines = [f'{h:02d}:00 – {h + 1:02d}:00'] + + all_apps = set(focused.keys()) | set(running.keys()) + sorted_apps = sorted(all_apps, + key=lambda a: focused.get(a, 0), + reverse=True) + for app_id in sorted_apps[:6]: + f = focused.get(app_id, 0) + r = running.get(app_id, 0) + if app_id.endswith('.desktop'): + name, _ = get_app_info(app_id) + name = name or app_id + else: + name = 'Unknown' + if r > f + 30: + lines.append(f'{name}: {format_duration(f)} / {format_duration(r)}') + elif f: + lines.append(f'{name}: {format_duration(f)}') + elif r: + lines.append(f'{name}: running {format_duration(r)}') + + tooltip.set_markup('\n'.join(lines)) + return True + + def _get_colors(self): + """Get accent and foreground RGBA for Cairo drawing.""" + fg = self.get_color() # GTK4.10+ + + # try Adw 1.6+ accent API + accent = None + try: + sm = Adw.StyleManager.get_default() + ac = sm.get_accent_color() + accent = ac.to_rgba(sm.get_dark()) + except (AttributeError, TypeError): + pass + + if accent is None: + accent = Gdk.RGBA() + accent.parse('#3584e4') + + return accent, fg + + def _draw(self, area, cr, width, height): + f_totals = [sum(h.values()) for h in self.focused_hours] + r_totals = [sum(h.values()) for h in self.running_hours] + peak = max(max(r_totals), max(f_totals), 1) + + bar_w = width / 24 + pad = 2 + label_h = 14 + chart_h = height - label_h + + accent, fg = self._get_colors() + + # running bars + cr.set_source_rgba(accent.red, accent.green, accent.blue, 0.2) + + for i, total in enumerate(r_totals): + if total == 0: + continue + bar_h = (total / peak) * (chart_h - 4) + cr.rectangle(i * bar_w + pad, chart_h - bar_h, + bar_w - pad * 2, bar_h) + cr.fill() + + # focused bars + cr.set_source_rgba(accent.red, accent.green, accent.blue, 0.85) + + for i, total in enumerate(f_totals): + if total == 0: + continue + bar_h = (total / peak) * (chart_h - 4) + cr.rectangle(i * bar_w + pad, chart_h - bar_h, + bar_w - pad * 2, bar_h) + cr.fill() + + # labels + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.5) + cr.set_font_size(9) + for i in (0, 6, 12, 18): + cr.move_to(i * bar_w + bar_w / 2 - 6, height - 2) + cr.show_text(f'{i:02d}') + + +# ── App usage row ───────────────────────────────────────── + +class AppUsageRow(Adw.ActionRow): + def __init__(self, app_id, focused_secs, running_secs, max_running): + super().__init__() + + if app_id == '__unknown__': + name = 'Unknown' + icon = None + self.set_subtitle('Apps without .desktop entries') + else: + name, icon = get_app_info(app_id) + self.set_subtitle(app_id) + + self.set_title(name or app_id) + + if icon: + img = Gtk.Image.new_from_gicon(icon) + img.set_pixel_size(32) + self.add_prefix(img) + else: + img = Gtk.Image.new_from_icon_name('application-x-executable-symbolic') + img.set_pixel_size(32) + self.add_prefix(img) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + box.set_valign(Gtk.Align.CENTER) + + BAR_W = 100 + r_frac = min(1.0, running_secs / max_running) if max_running else 0 + f_frac = min(1.0, focused_secs / max_running) if max_running else 0 + + bar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + bar_box.set_size_request(BAR_W, -1) + + f_bar = Gtk.Box() + f_bar.set_size_request(max(2, int(BAR_W * f_frac)), 6) + f_bar.set_halign(Gtk.Align.START) + f_bar.add_css_class('usage-bar-focused') + bar_box.append(f_bar) + + if running_secs > focused_secs + 30: + r_bar = Gtk.Box() + r_bar.set_size_request(max(2, int(BAR_W * r_frac)), 4) + r_bar.set_halign(Gtk.Align.START) + r_bar.add_css_class('usage-bar-running') + bar_box.append(r_bar) + + box.append(bar_box) + + label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + f_label = Gtk.Label(label=format_duration(focused_secs)) + f_label.add_css_class('caption') + label_box.append(f_label) + + if running_secs > focused_secs + 30: + r_label = Gtk.Label(label=f'/ {format_duration(running_secs)}') + r_label.add_css_class('caption') + r_label.add_css_class('dim-label') + label_box.append(r_label) + + box.append(label_box) + self.add_suffix(box) + + +# ── Main window ─────────────────────────────────────────── + +class ScreenTimeWindow(Adw.ApplicationWindow): + def __init__(self, app): + super().__init__(application=app, title='Screen Time', + default_width=420, default_height=600) + + self.client = ScreenTimeClient() + self.current_date = date.today() + + css = Gtk.CssProvider() + css.load_from_string(''' + .usage-bar-focused { + background: @accent_bg_color; + border-radius: 3px; + min-height: 6px; + } + .usage-bar-running { + background: alpha(@accent_bg_color, 0.3); + border-radius: 3px; + min-height: 4px; + } + .total-time { + font-size: 2em; + font-weight: 800; + } + ''') + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), css, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + toolbar.add_top_bar(header) + + # navigation + nav_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + nav_box.set_halign(Gtk.Align.CENTER) + + prev_btn = Gtk.Button(icon_name='go-previous-symbolic') + prev_btn.connect('clicked', lambda _: self._change_date(-1)) + nav_box.append(prev_btn) + + self.date_label = Gtk.Label() + self.date_label.add_css_class('title-4') + nav_box.append(self.date_label) + + next_btn = Gtk.Button(icon_name='go-next-symbolic') + next_btn.connect('clicked', lambda _: self._change_date(1)) + nav_box.append(next_btn) + + header.set_title_widget(nav_box) + + # content + scroll = Gtk.ScrolledWindow(vexpand=True) + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.content.set_margin_start(16) + self.content.set_margin_end(16) + self.content.set_margin_top(16) + self.content.set_margin_bottom(16) + scroll.set_child(self.content) + toolbar.set_content(scroll) + + # total + self.total_label = Gtk.Label() + self.total_label.add_css_class('total-time') + self.total_label.set_margin_bottom(4) + self.content.append(self.total_label) + + self.total_sub = Gtk.Label() + self.total_sub.add_css_class('dim-label') + self.total_sub.set_margin_bottom(16) + self.content.append(self.total_sub) + + # hourly chart + hr_group = Adw.PreferencesGroup(title='Hourly') + self.hourly = HourlyChart() + hr_group.add(self.hourly) + self.content.append(hr_group) + + # app list + self.app_group = Adw.PreferencesGroup(title='Apps') + self.app_group.set_margin_top(16) + self.content.append(self.app_group) + + self.set_content(toolbar) + self._refresh() + + def _change_date(self, delta): + self.current_date += timedelta(days=delta) + self._refresh() + + def _refresh(self): + date_str = self.current_date.isoformat() + + if self.current_date == date.today(): + self.date_label.set_label('Today') + elif self.current_date == date.today() - timedelta(days=1): + self.date_label.set_label('Yesterday') + else: + self.date_label.set_label( + self.current_date.strftime('%a, %b %-d')) + + focused, running = self.client.get_usage(date_str) + focused = merge_unknown(focused) + running = merge_unknown(running) + f_hours, r_hours = self.client.get_usage_by_hour(date_str) + + # totals + total_focused = sum(focused.values()) + total_running = sum(running.values()) + self.total_label.set_label(format_duration(total_focused)) + if total_running > total_focused + 60: + self.total_sub.set_label( + f'{format_duration(total_running)} total running') + self.total_sub.set_visible(True) + else: + self.total_sub.set_visible(False) + + # hourly + self.hourly.set_data(f_hours, r_hours) + + # app list + self.content.remove(self.app_group) + self.app_group = Adw.PreferencesGroup(title='Apps') + self.app_group.set_margin_top(16) + self.content.append(self.app_group) + + all_apps = set(focused.keys()) | set(running.keys()) + sorted_apps = sorted(all_apps, + key=lambda a: focused.get(a, 0), + reverse=True) + max_running = max(running.values()) if running else 1 + + for app_id in sorted_apps: + f = focused.get(app_id, 0) + r = running.get(app_id, 0) + if r < 1 and f < 1: + continue + row = AppUsageRow(app_id, f, r, max_running) + self.app_group.add(row) + + +# ── App ─────────────────────────────────────────────────── + +class ScreenTimeApp(Adw.Application): + def __init__(self): + super().__init__(application_id='org.gnome.ScreenTime.Viewer') + + def do_activate(self): + win = self.get_active_window() + if not win: + win = ScreenTimeWindow(self) + win.present() + + +if __name__ == '__main__': + app = ScreenTimeApp() + app.run(sys.argv) diff --git a/screentimed b/screentimed new file mode 100644 index 0000000..23fc4f2 --- /dev/null +++ b/screentimed @@ -0,0 +1,428 @@ +#!/usr/bin/python3 -sP + +import logging +import os +import signal +import sqlite3 +import time + +import gi +gi.require_version('Gio', '2.0') +gi.require_version('GLib', '2.0') +from gi.repository import Gio, GLib + +log = logging.getLogger('screentimed') + +DB_DIR = os.path.join(GLib.get_user_data_dir(), 'screentime') +DB_PATH = os.path.join(DB_DIR, 'usage.db') + +FLUSH_INTERVAL = 60 + +TRACKER_BUS = 'org.gnome.Shell' +TRACKER_IFACE = 'org.gnome.Shell.UsageTracker' +TRACKER_PATH = '/org/gnome/Shell/UsageTracker' + +SCREENTIMED_BUS = 'org.gnome.ScreenTime' +SCREENTIMED_PATH = '/org/gnome/ScreenTime' +SCREENTIMED_IFACE_XML = ''' + + + + + + + + + + + + + + + + + + + + + +''' + + +# ── database ────────────────────────────────────────────── + +def open_db(): + os.makedirs(DB_DIR, exist_ok=True) + db = sqlite3.connect(DB_PATH) + db.execute('PRAGMA journal_mode=WAL') + db.execute(''' + CREATE TABLE IF NOT EXISTS focus_sessions ( + id INTEGER PRIMARY KEY, + app_id TEXT NOT NULL, + start_ts REAL NOT NULL, + end_ts REAL NOT NULL + ) + ''') + db.execute(''' + CREATE TABLE IF NOT EXISTS running_sessions ( + id INTEGER PRIMARY KEY, + app_id TEXT NOT NULL, + start_ts REAL NOT NULL, + end_ts REAL NOT NULL + ) + ''') + db.execute(''' + CREATE INDEX IF NOT EXISTS idx_focus_time + ON focus_sessions (start_ts, end_ts) + ''') + db.execute(''' + CREATE INDEX IF NOT EXISTS idx_running_time + ON running_sessions (start_ts, end_ts) + ''') + # migrate from old schema + try: + db.execute(''' + INSERT INTO focus_sessions (app_id, start_ts, end_ts) + SELECT app_id, start_ts, end_ts FROM sessions + ''') + db.execute('DROP TABLE sessions') + db.commit() + log.info('migrated old sessions table') + except sqlite3.OperationalError: + pass + db.commit() + return db + + +def _date_to_ts(date): + return time.mktime(time.strptime(date, '%Y-%m-%d')) + + +def _day_range(date): + start = _date_to_ts(date) + return start, start + 86400 + + +def _query_db(db, table, date): + return db.execute(f''' + SELECT app_id, start_ts, end_ts + FROM {table} + WHERE end_ts > unixepoch(:date) + AND start_ts < unixepoch(:date, '+1 day') + ''', {'date': date}).fetchall() + + +def _aggregate_daily(sessions, day_start, day_end): + usage = {} + for app_id, start, end in sessions: + overlap = min(end, day_end) - max(start, day_start) + if overlap > 0: + usage[app_id] = usage.get(app_id, 0) + int(overlap) + return usage + + +def _aggregate_hourly(sessions, day_start): + hours = [{} for _ in range(24)] + for app_id, start, end in sessions: + for h in range(24): + h_start = day_start + h * 3600 + h_end = h_start + 3600 + overlap = min(end, h_end) - max(start, h_start) + if overlap > 0: + hours[h][app_id] = hours[h].get(app_id, 0) + int(overlap) + return hours + + +def _clip_to_day(sessions, day_start, day_end): + """Return (app_id, clipped_start, clipped_end) tuples.""" + out = [] + for app_id, start, end in sessions: + s = max(start, day_start) + e = min(end, day_end) + if e > s: + out.append((app_id, s, e)) + return out + + +# ── tracker ─────────────────────────────────────────────── + +class Tracker: + def __init__(self, db): + self.db = db + self.focus_buf = [] + self.running_buf = [] + + self.current_app = '' + self.session_start = time.time() + self.mono_start = time.monotonic() + + self.running_apps = {} # app_id -> (wall_start, mono_start) + + self.idle = False + self.flush_timer_id = 0 + + # ── focus ── + + def on_focus_changed(self, app_id): + if self.idle: + return + self._close_focus() + self.current_app = app_id + self.session_start = time.time() + self.mono_start = time.monotonic() + log.debug('focus: %s', app_id or '(none)') + + def _close_focus(self): + elapsed = time.monotonic() - self.mono_start + if not self.current_app or elapsed < 1: + return + end_ts = self.session_start + elapsed + self.focus_buf.append((self.current_app, self.session_start, end_ts)) + + # ── running ── + + def on_running_changed(self, app_ids): + now_wall = time.time() + now_mono = time.monotonic() + + current = set(app_ids) + previous = set(self.running_apps.keys()) + + # closed apps + for app_id in previous - current: + wall_start, mono_start = self.running_apps.pop(app_id) + end_ts = wall_start + (now_mono - mono_start) + if end_ts - wall_start >= 1: + self.running_buf.append((app_id, wall_start, end_ts)) + log.debug('closed: %s', app_id) + + # new apps + for app_id in current - previous: + self.running_apps[app_id] = (now_wall, now_mono) + log.debug('opened: %s', app_id) + + def _close_all_running(self): + now_wall = time.time() + now_mono = time.monotonic() + for app_id, (wall_start, mono_start) in self.running_apps.items(): + end_ts = wall_start + (now_mono - mono_start) + if end_ts - wall_start >= 1: + self.running_buf.append((app_id, wall_start, end_ts)) + # reopen immediately + for app_id in list(self.running_apps): + self.running_apps[app_id] = (now_wall, now_mono) + + # ── idle ── + + def on_idle(self): + if self.idle: + return + self._close_focus() + self._close_all_running() + self.idle = True + self._flush_to_db() + self._stop_timer() + log.debug('idle (locked/blank)') + + def on_active(self): + if not self.idle: + return + self.idle = False + now_wall = time.time() + now_mono = time.monotonic() + self.session_start = now_wall + self.mono_start = now_mono + # reopen running timestamps + for app_id in list(self.running_apps): + self.running_apps[app_id] = (now_wall, now_mono) + self._start_timer() + log.debug('active (unlocked)') + + # ── flush ── + + def flush(self): + if self.idle: + return + self._close_focus() + self._close_all_running() + self._flush_to_db() + self.session_start = time.time() + self.mono_start = time.monotonic() + + def _flush_to_db(self): + if not self.focus_buf and not self.running_buf: + return + if self.focus_buf: + self.db.executemany( + 'INSERT INTO focus_sessions (app_id, start_ts, end_ts) VALUES (?, ?, ?)', + self.focus_buf) + if self.running_buf: + self.db.executemany( + 'INSERT INTO running_sessions (app_id, start_ts, end_ts) VALUES (?, ?, ?)', + self.running_buf) + self.db.commit() + log.debug('flushed %d focus + %d running', + len(self.focus_buf), len(self.running_buf)) + self.focus_buf.clear() + self.running_buf.clear() + + def _start_timer(self): + if self.flush_timer_id == 0: + self.flush_timer_id = GLib.timeout_add_seconds( + FLUSH_INTERVAL, self._on_timer) + + def _stop_timer(self): + if self.flush_timer_id != 0: + GLib.source_remove(self.flush_timer_id) + self.flush_timer_id = 0 + + def _on_timer(self): + self.flush() + return True + + # ── queries ── + + def _all_sessions(self, table, buf, date): + day_start, day_end = _day_range(date) + sessions = _query_db(self.db, table, date) + + for s in buf: + if s[2] > day_start and s[1] < day_end: + sessions.append(s) + + return sessions + + def _all_focus(self, date): + sessions = self._all_sessions('focus_sessions', self.focus_buf, date) + # add live focus + if not self.idle and self.current_app: + elapsed = time.monotonic() - self.mono_start + end_ts = self.session_start + elapsed + day_start, day_end = _day_range(date) + if end_ts > day_start and self.session_start < day_end: + sessions.append((self.current_app, self.session_start, end_ts)) + return sessions + + def _all_running(self, date): + sessions = self._all_sessions('running_sessions', self.running_buf, date) + # add live running + if not self.idle: + now_mono = time.monotonic() + day_start, day_end = _day_range(date) + for app_id, (wall_start, mono_start) in self.running_apps.items(): + end_ts = wall_start + (now_mono - mono_start) + if end_ts > day_start and wall_start < day_end: + sessions.append((app_id, wall_start, end_ts)) + return sessions + + def get_usage(self, date): + day_start, day_end = _day_range(date) + focused = _aggregate_daily(self._all_focus(date), day_start, day_end) + running = _aggregate_daily(self._all_running(date), day_start, day_end) + return focused, running + + def get_usage_by_hour(self, date): + day_start, _ = _day_range(date) + focused = _aggregate_hourly(self._all_focus(date), day_start) + running = _aggregate_hourly(self._all_running(date), day_start) + return focused, running + + def get_timeline(self, date): + day_start, day_end = _day_range(date) + focused = _clip_to_day(self._all_focus(date), day_start, day_end) + running = _clip_to_day(self._all_running(date), day_start, day_end) + return focused, running + + +# ── main ────────────────────────────────────────────────── + +def main(): + logging.basicConfig( + level=logging.DEBUG if os.environ.get('SCREENTIMED_DEBUG') else logging.INFO, + format='%(name)s: %(message)s', + ) + + db = open_db() + tracker = Tracker(db) + loop = GLib.MainLoop() + bus = Gio.bus_get_sync(Gio.BusType.SESSION) + + # listen to the extension + bus.signal_subscribe( + TRACKER_BUS, TRACKER_IFACE, 'FocusChanged', + TRACKER_PATH, None, Gio.DBusSignalFlags.NONE, + lambda *a: tracker.on_focus_changed(a[5].unpack()[0])) + + bus.signal_subscribe( + TRACKER_BUS, TRACKER_IFACE, 'RunningAppsChanged', + TRACKER_PATH, None, Gio.DBusSignalFlags.NONE, + lambda *a: tracker.on_running_changed(a[5].unpack()[0])) + + # pause tracking on lock/blank + bus.signal_subscribe( + 'org.gnome.ScreenSaver', 'org.gnome.ScreenSaver', 'ActiveChanged', + '/org/gnome/ScreenSaver', None, Gio.DBusSignalFlags.NONE, + lambda *a: tracker.on_idle() if a[5].unpack()[0] else tracker.on_active()) + + # fetch initial state + try: + result = bus.call_sync( + TRACKER_BUS, TRACKER_PATH, TRACKER_IFACE, 'GetFocus', + None, GLib.VariantType('(s)'), + Gio.DBusCallFlags.NONE, -1, None) + tracker.current_app = result.unpack()[0] + + result = bus.call_sync( + TRACKER_BUS, TRACKER_PATH, TRACKER_IFACE, 'GetRunningApps', + None, GLib.VariantType('(as)'), + Gio.DBusCallFlags.NONE, -1, None) + now_wall = time.time() + now_mono = time.monotonic() + for app_id in result.unpack()[0]: + tracker.running_apps[app_id] = (now_wall, now_mono) + + log.debug('initial: focus=%s, running=%s', + tracker.current_app, list(tracker.running_apps.keys())) + except GLib.Error: + log.debug('extension not yet available') + + # expose analytics over d-bus + node = Gio.DBusNodeInfo.new_for_xml(SCREENTIMED_IFACE_XML) + + def on_method_call(conn, sender, path, iface, method, params, invocation): + if method == 'GetUsageToday': + f, r = tracker.get_usage(time.strftime('%Y-%m-%d')) + invocation.return_value(GLib.Variant('(a{su}a{su})', (f, r))) + elif method == 'GetUsage': + f, r = tracker.get_usage(params.unpack()[0]) + invocation.return_value(GLib.Variant('(a{su}a{su})', (f, r))) + elif method == 'GetUsageByHour': + f, r = tracker.get_usage_by_hour(params.unpack()[0]) + invocation.return_value(GLib.Variant('(aa{su}aa{su})', (f, r))) + elif method == 'GetTimeline': + f, r = tracker.get_timeline(params.unpack()[0]) + invocation.return_value(GLib.Variant('(a(sdd)a(sdd))', (f, r))) + + bus.register_object_with_closures2( + SCREENTIMED_PATH, node.interfaces[0], + on_method_call, None, None) + + Gio.bus_own_name_on_connection( + bus, SCREENTIMED_BUS, Gio.BusNameOwnerFlags.NONE, None, None) + + tracker._start_timer() + + def on_term(): + tracker.flush() + loop.quit() + + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, on_term) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, on_term) + + log.info('tracking to %s', DB_PATH) + loop.run() + log.info('stopped') + + +if __name__ == '__main__': + main() diff --git a/screentimed.desktop b/screentimed.desktop new file mode 100644 index 0000000..0ef785a --- /dev/null +++ b/screentimed.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=ScreenTime +Comment=View your screen time usage +Exec=screentime-viewer +Icon=preferences-system-time-symbolic +Type=Application +Categories=GNOME;GTK;Utility; diff --git a/screentimed.service b/screentimed.service new file mode 100644 index 0000000..c2c898b --- /dev/null +++ b/screentimed.service @@ -0,0 +1,13 @@ +[Unit] +Description=Screen Time Tracking Daemon +After=gnome-shell.service +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/screentimed +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=graphical-session.target