// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. pragma Singleton import Quickshell import Quickshell.Io import QtQuick // The hyprsunset side: profiles in ~/.config/hypr/hyprsunset.conf, the // location in hyprsunset-qt's own config, and the daemon. The file format is // byte-for-byte what ~/Programming/GIT/sunset-qt writes, so both tools edit // the same file. Singleton { id: root readonly property string home: Quickshell.env("HOME") readonly property string confPath: `${home}/.config/hypr/hyprsunset.conf` readonly property string appConfPath: `${home}/.config/hyprsunset-qt/config` property var profiles: [] property string lat: "" property string lon: "" property bool autoDetect: true property string daemonCommand: "hyprsunset" property string cachePathRaw: "~/.config/hyprsunset-qt/sun.json" property string cachePath: `${home}/.config/hyprsunset-qt/sun.json` property string notice: "" property string sunSummary: "" property bool busy: false property bool confLoaded: false property bool appLoaded: false readonly property string header: "# Managed by hyprsunset-qt. Edits here are overwritten on save.\n" // QML has no String.matchAll; everything here is exec loops. function getField(body, key) { const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); return m ? m[1] : null; } function parseProfiles(text) { const list = []; const re = /profile\s*\{([\s\S]*?)\}/g; let m; while ((m = re.exec(text)) !== null) { const body = m[1]; const t = root.getField(body, "temperature"); const g = root.getField(body, "gamma"); list.push({ time: root.getField(body, "time") ?? "", identity: (root.getField(body, "identity") ?? "").toLowerCase() === "true", temperature: t === null ? null : parseInt(t, 10), gamma: g === null ? null : parseFloat(g), }); } return list; } // Byte-for-byte the same construction as sunset-qt's config.serialize(): // a list of parts joined with newlines, so a no-op save writes the file // back exactly as it was. function serializeProfiles(list) { const di = root.dayIndex(list); const ni = root.nightIndex(list); const parts = [root.header]; for (let i = 0; i < list.length; i++) { const p = list[i]; if (i === di) parts.push("\n# day profile -- sunrise"); else if (i === ni) parts.push("\n# night profile -- sunset"); else parts.push("\n# profile"); const lines = ["profile {", ` time = ${p.time}`]; if (p.identity) lines.push(" identity = true"); if (p.temperature !== null && p.temperature !== undefined) lines.push(` temperature = ${p.temperature}`); if (p.gamma !== null && p.gamma !== undefined) lines.push(` gamma = ${p.gamma}`); lines.push("}"); parts.push(lines.join("\n")); } return parts.join("\n") + "\n"; } function dayIndex(list) { for (let i = 0; i < list.length; i++) if (list[i].identity) return i; return null; } function nightIndex(list) { for (let i = 0; i < list.length; i++) if (list[i].temperature !== null && list[i].temperature !== undefined) return i; return null; } function validTime(s) { return /^([01]?\d|2[0-3]):([0-5]\d)$/.test(s); } function validTemperature(t) { return t >= 1000 && t <= 20000; } function validGamma(g) { return g >= 0.0 && g <= 2.0; } function localHM(iso) { const d = new Date(iso); if (isNaN(d.getTime())) return ""; return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); } function selftest(): string { // A file exactly as hyprsunset-qt writes it. String.raw keeps the // backslashes, none here, but keeps this fixture readable. // Two blank lines after the header: sunset-qt's serializer joins parts // with a newline and each part starts with one, so the file really has // them. const fixture = String.raw`# Managed by hyprsunset-qt. Edits here are overwritten on save. # day profile -- sunrise profile { time = 05:42 identity = true } # night profile -- sunset profile { time = 21:02 temperature = 5500 gamma = 0.8 } `; const parsed = root.parseProfiles(fixture); if (parsed.length !== 2) return `SELFTEST Hyprsunset FAIL: parsed ${parsed.length} profiles`; if (parsed[0].time !== "05:42" || parsed[0].identity !== true) return "SELFTEST Hyprsunset FAIL: day profile wrong"; if (parsed[1].temperature !== 5500 || parsed[1].gamma !== 0.8) return "SELFTEST Hyprsunset FAIL: night profile wrong"; if (root.dayIndex(parsed) !== 0 || root.nightIndex(parsed) !== 1) return "SELFTEST Hyprsunset FAIL: day/night index wrong"; if (root.serializeProfiles(parsed) !== fixture) return "SELFTEST Hyprsunset FAIL: round-trip not byte-identical"; if (!root.validTime("05:42") || root.validTime("24:00") || root.validTime("5:6")) return "SELFTEST Hyprsunset FAIL: validTime"; if (!root.validTemperature(1000) || root.validTemperature(20001)) return "SELFTEST Hyprsunset FAIL: validTemperature"; if (!root.validGamma(0.8) || root.validGamma(2.1)) return "SELFTEST Hyprsunset FAIL: validGamma"; return "SELFTEST Hyprsunset PASS"; } function refresh() { confProc.running = false; confProc.running = true; appProc.running = false; appProc.running = true; } Process { id: confProc command: ["cat", root.confPath] stdout: StdioCollector { onStreamFinished: { root.profiles = root.parseProfiles(text); root.confLoaded = true; } } } // hyprsunset-qt's own settings: location + daemon command. function parseAppConf(text) { const out = {}; let section = ""; for (const raw of text.split("\n")) { const line = raw.trim(); if (!line || line.startsWith("#") || line.startsWith(";")) continue; const sec = line.match(/^\[(.+)\]$/); if (sec) { section = sec[1]; continue; } const kv = line.match(/^([^=]+)=\s*(.*)$/); if (kv) out[`${section}.${kv[1].trim()}`] = kv[2].trim(); } return out; } function expandTilde(p) { return p.startsWith("~/") ? root.home + p.slice(1) : p; } Process { id: appProc command: ["cat", root.appConfPath] stdout: StdioCollector { onStreamFinished: { const c = root.parseAppConf(text); root.lat = c["location.lat"] ?? ""; root.lon = c["location.lon"] ?? ""; root.autoDetect = (c["location.auto_detect"] ?? "true") === "true"; root.cachePathRaw = c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json"; root.cachePath = root.expandTilde(root.cachePathRaw); root.daemonCommand = c["daemon.command"] ?? "hyprsunset"; root.appLoaded = true; } } } FileView { id: confFile; path: root.confPath } FileView { id: appFile; path: root.appConfPath } FileView { id: cacheFile; path: root.cachePath } function appConfText() { return `[location]\nlat = ${root.lat}\nlon = ${root.lon}\n` + `auto_detect = ${root.autoDetect}\n\n` + `[cache]\npath = ${root.cachePathRaw}\n\n` + `[daemon]\ncommand = ${root.daemonCommand}\n\n`; } function saveSettings() { appFile.setText(root.appConfText()); } // hyprsunset holds Hyprland's CTM manager exclusively, and pkill only // sends SIGTERM, so the replacement must wait for the old process to // actually exit or it dies with "A CTM manager is already running". function restart() { Quickshell.execDetached(["sh", "-c", `pkill -x hyprsunset; ` + `for i in $(seq 50); do ` + `pgrep -x hyprsunset >/dev/null 2>&1 || break; sleep 0.1; done; ` + `setsid -f ${root.daemonCommand}`]); } function save() { if (!root.confLoaded || !root.appLoaded) return; for (const p of root.profiles) { if (!root.validTime(p.time)) { root.notice = `invalid time: ${p.time}`; return; } if (p.temperature !== null && p.temperature !== undefined && !root.validTemperature(p.temperature)) { root.notice = `invalid temperature: ${p.temperature}`; return; } if (p.gamma !== null && p.gamma !== undefined && !root.validGamma(p.gamma)) { root.notice = `invalid gamma: ${p.gamma}`; return; } } confFile.setText(root.serializeProfiles(root.profiles)); root.saveSettings(); root.restart(); root.notice = "saved + restarted"; } // Live preview via the daemon's IPC. Identity wins, then temperature, then // gamma, matching hyprsunset-qt. Nothing is written. function preview() { const i = root.nightIndex(root.profiles); const target = i !== null ? root.profiles[i] : (root.profiles.length ? root.profiles[0] : null); if (!target) return; if (target.identity) { previewProc.command = ["hyprctl", "hyprsunset", "identity"]; } else { const parts = []; if (target.temperature !== null && target.temperature !== undefined) parts.push(`hyprctl hyprsunset temperature ${target.temperature}`); if (target.gamma !== null && target.gamma !== undefined) parts.push(`hyprctl hyprsunset gamma ${Math.round(target.gamma * 100)}`); if (!parts.length) return; previewProc.command = ["sh", "-c", parts.join("; ")]; } previewProc.running = false; previewProc.running = true; } Process { id: previewProc } function detect() { detectProc.running = false; detectProc.running = true; } Process { id: detectProc command: ["curl", "-fsS", "http://ip-api.com/json"] stdout: StdioCollector { onStreamFinished: { try { const d = JSON.parse(text); root.lat = String(d.lat); root.lon = String(d.lon); root.saveSettings(); root.notice = `located ${root.lat}, ${root.lon}`; } catch (e) { root.notice = `detect failed: ${e}`; } } } } // sunrise-sunset.org is behind Cloudflare and 403s curl's default UA. function fetchSun() { const url = "https://api.sunrise-sunset.org/json?lat=" + encodeURIComponent(root.lat) + "&lng=" + encodeURIComponent(root.lon) + "&formatted=0"; fetchProc.command = ["curl", "-fsS", "-A", "Mozilla/5.0 (X11; Linux x86_64) hyprsunset-qt", url]; fetchProc.running = false; fetchProc.running = true; root.busy = true; } Process { id: fetchProc stdout: StdioCollector { onStreamFinished: { root.busy = false; let data; try { data = JSON.parse(text); } catch (e) { root.notice = `fetch failed: ${e}`; return; } cacheFile.setText(JSON.stringify(data, null, 2)); const r = data.results ?? {}; const rise = r.sunrise ? root.localHM(r.sunrise) : ""; const set = r.sunset ? root.localHM(r.sunset) : ""; root.sunSummary = `sunrise ${rise || "—"} sunset ${set || "—"}`; const di = root.dayIndex(root.profiles); const ni = root.nightIndex(root.profiles); const next = root.profiles.slice(); if (di !== null && rise) next[di] = Object.assign({}, next[di], { time: rise }); if (ni !== null && set) next[ni] = Object.assign({}, next[ni], { time: set }); root.profiles = next; } } } }