236 lines
9.1 KiB
QML
236 lines
9.1 KiB
QML
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;
|
|
}
|
|
}
|