pragma Singleton // System date, time, and timezone. // // Deliberately NOT backed by the Panama settings store. The timezone and the // network-time setting belong to the machine, not to this desktop: they are // shared with every other session and with services that never see Panama's // JSON. Storing a copy would create a second answer to a question the system // already answers, which is the exact failure this settings rewrite exists to // remove. So this reads and writes `timedatectl` directly and holds no state of // its own beyond what it last observed. // // Setting the timezone or toggling NTP needs privilege. timedatectl asks // polkit, which shows the usual authentication dialog; on refusal the command // fails and the error is surfaced rather than the UI pretending it worked. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root property string timezone: "" property bool ntpEnabled: false property bool ntpSynchronized: false // Whether timedatectl has answered even once. The status query is async, so // for the first moment of the shell's life `ntpEnabled` is false because // nothing has looked, not because network time is off -- and setTime's // whole job is to refuse while it is on. Treating "not looked yet" as "off" // is the same wrong answer that looks fine as everywhere else in Panama. property bool statusRead: false // What timedatectl last said the three clocks read. localTime is what the // Clock card shows and what seeds the manual-set field, so it is kept // ticking (see clockTick below) rather than frozen at the last scan; // universalTime and rtcTime are facts, shown as read. property string localTime: "" property string universalTime: "" property string rtcTime: "" property string lastError: "" // Milliseconds between this shell's own clock and the one timedatectl // reported. Normally zero -- both read the same system clock -- but // deriving the displayed time from the system's own answer rather than // from QML's assumption means a clock that moves under us shows the move. property real systemOffsetMs: 0 // Set by the page while the Clock card is on screen. The tick is a local // recomputation, never another timedatectl call: a process per second to // learn a value the local clock already tracks exactly would be a // remarkable amount of work for a second hand. property bool tracking: false property var zones: [] readonly property bool busy: statusQuery.running || zonesQuery.running || writeRun.running // The shape `setTime` accepts, and the shape the field should be seeded // with. Seconds are optional because "set it to 9:30" is a whole request. readonly property var timePattern: new RegExp("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}(?::\\d{2})?$") // "America/New_York" -> "New York" for display, keeping the region as a // separate field so the list can be grouped and searched sensibly. function regionOf(zone: string): string { const slash = zone.indexOf("/"); return slash < 0 ? zone : zone.slice(0, slash); } function cityOf(zone: string): string { const slash = zone.indexOf("/"); return (slash < 0 ? zone : zone.slice(slash + 1)).replace(/_/g, " "); } Process { id: statusQuery command: ["timedatectl", "show", "-p", "Timezone", "-p", "NTP", "-p", "NTPSynchronized", "-p", "TimeUSec", "-p", "RTCTimeUSec"] stdout: StdioCollector { onStreamFinished: root.parseStatus(this.text) } onExited: (exitCode, exitStatus) => { if (exitCode !== 0) root.lastError = "Could not read the system clock settings."; } } Process { id: zonesQuery command: ["timedatectl", "list-timezones"] stdout: StdioCollector { onStreamFinished: { root.zones = this.text.split("\n") .map(line => line.trim()) .filter(line => line.length > 0); } } } Process { id: writeRun onExited: (exitCode, exitStatus) => { // A polkit refusal and a bad value both land here. Neither should // leave the UI showing a value the system did not take, so the // status is re-read either way. root.lastError = exitCode === 0 ? "" : "The system rejected that change, or authentication was canceled."; root.refresh(); } } Component.onCompleted: { root.refresh(); zonesQuery.running = true; } function parseStatus(text: string): void { for (const line of text.split("\n")) { const split = line.indexOf("="); if (split < 0) continue; const key = line.slice(0, split); const value = line.slice(split + 1); if (key === "Timezone") root.timezone = value; else if (key === "NTP") root.ntpEnabled = value === "yes"; else if (key === "NTPSynchronized") root.ntpSynchronized = value === "yes"; else if (key === "TimeUSec") root.anchorClock(value); else if (key === "RTCTimeUSec") root.rtcTime = root.tidyStamp(value); } root.statusRead = true; root.lastError = ""; } // timedatectl prints "Mon 2026-08-24 21:22:55 EDT". The weekday is a // duplicate of the date and the zone is already its own row, so what is // left is the part a clock actually shows. function tidyStamp(value: string): string { const match = String(value).match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/); return match ? match[1] : String(value).trim(); } function anchorClock(value: string): void { const stamp = root.tidyStamp(value); // Parsed as local wall time, which is what timedatectl printed. A // stamp this cannot read leaves the offset alone rather than jumping // the displayed clock by whatever the misparse happened to produce. const parsed = Date.parse(stamp.replace(" ", "T")); root.systemOffsetMs = Number.isFinite(parsed) ? parsed - Date.now() : 0; root.tickClock(); } function tickClock(): void { const now = new Date(Date.now() + root.systemOffsetMs); root.localTime = Qt.formatDateTime(now, "yyyy-MM-dd HH:mm:ss"); // toISOString is already UTC, which is the whole question here; doing // the offset arithmetic by hand is a way to get it wrong twice a year. root.universalTime = now.toISOString().slice(0, 19).replace("T", " ") + " UTC"; } Timer { id: clockTick interval: 1000 repeat: true running: root.tracking onTriggered: root.tickClock() } // Setting the clock by hand, which is only a coherent request while // network time is off. With NTP on, timedatectl refuses outright and a // toggle-then-set from the page would race the daemon putting the time // back -- so the refusal happens here, where it can be explained, rather // than as an opaque failure from a command the user did not type. function setTime(iso: string): bool { if (!root.statusRead) { root.lastError = "The clock settings have not been read yet."; root.refresh(); return false; } if (root.ntpEnabled) { root.lastError = "Turn off network time before setting the clock by hand."; return false; } const stamp = String(iso).trim(); if (!root.timePattern.test(stamp)) { root.lastError = "Enter the time as YYYY-MM-DD HH:MM, with optional seconds."; return false; } // Shape is not enough: "2026-13-45 99:99" matches the pattern and is // not a moment. Round-tripping through Date is what rejects it. const parsed = new Date(stamp.replace(" ", "T")); if (!Number.isFinite(parsed.getTime()) || Qt.formatDateTime(parsed, "yyyy-MM-dd HH:mm") !== stamp.slice(0, 16)) { root.lastError = "That is not a real date and time."; return false; } if (writeRun.running) return false; writeRun.exec(["timedatectl", "set-time", stamp]); return true; } function refresh(): void { if (!statusQuery.running) statusQuery.running = true; } // Only a timezone the system itself listed is ever passed on, so no // caller-supplied text reaches the command. function setTimezone(zone: string): bool { if (root.zones.indexOf(zone) < 0) { root.lastError = "That is not a timezone this system recognizes."; return false; } if (writeRun.running) return false; writeRun.exec(["timedatectl", "set-timezone", zone]); return true; } function setNtp(enabled: bool): bool { if (writeRun.running) return false; writeRun.exec(["timedatectl", "set-ntp", enabled ? "true" : "false"]); return true; } }