A Process's exited and streamFinished signals aren't guaranteed to fire in order, and several services decided an outcome on whichever fired first: KdeConnect could report a successful file transfer as failed if exited landed before the real stdout payload; Clipboard could present a failed history query as an empty-but-healthy one; Brightness could strand the last queued write of a drag; SoundFeedback and SystemLocale could drop or misapply a rapid second toggle/click because re-arming an already-running Process is a no-op. All five now wait for both signals and let the authoritative one decide, matching the pattern HomeAssistantConfig.qml already used correctly. Health's "copy report" never enabled stdin, so it copied nothing while claiming success. Capture announced every recording as saved regardless of the recorder's actual exit code. Connectivity never restarted Bluetooth discovery when the adapter was enabled from an already-open page. CalendarAgenda left the UI in "loading" forever if its helper died at startup, and the helper itself could crash unguarded instead of reporting unavailable. Geocoding silently dropped a query typed while the previous one was still in flight. Notifs leaked tracked-but-undisplayed notifications under Do Not Disturb, and dismissAll() skipped them. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
136 lines
4.7 KiB
QML
136 lines
4.7 KiB
QML
pragma Singleton
|
|
|
|
// Turning a place name into coordinates.
|
|
//
|
|
// The weather card needs latitude and longitude, but nobody knows their own
|
|
// coordinates, and a settings page that demands them is a settings page nobody
|
|
// changes. Open-Meteo publishes a geocoding endpoint that needs no API key and
|
|
// no account, which is the same reason the forecast itself uses them.
|
|
//
|
|
// Fetched with curl rather than XMLHttpRequest for the same reason as
|
|
// services/Weather.qml: curl is guaranteed present, and a search that fails
|
|
// must leave the page usable rather than producing an error popup.
|
|
//
|
|
// Only the query is sent. The stored location label never leaves the machine.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
import qs.config
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
// [{ name, admin, country, latitude, longitude, label }]
|
|
property var results: []
|
|
property bool searching: false
|
|
property string lastError: ""
|
|
property string lastQuery: ""
|
|
|
|
readonly property string endpoint: "https://geocoding-api.open-meteo.com/v1/search"
|
|
|
|
Process {
|
|
id: fetch
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.parse(this.text)
|
|
}
|
|
|
|
onExited: (exitCode, exitStatus) => {
|
|
root.searching = false;
|
|
if (exitCode !== 0)
|
|
root.lastError = "Could not reach the location service.";
|
|
|
|
// Typing kept going while this fetch was in flight -- rather than
|
|
// leaving the newer query stranded until another keystroke, go
|
|
// fetch it now. run() no-ops if pending is now too short.
|
|
if (root.pending !== root.lastQuery)
|
|
root.run();
|
|
}
|
|
}
|
|
|
|
// Debounced: typing "Denver" should not fire six searches.
|
|
Timer {
|
|
id: debounce
|
|
interval: 350
|
|
onTriggered: root.run()
|
|
}
|
|
|
|
property string pending: ""
|
|
|
|
function search(query: string): void {
|
|
const trimmed = String(query).trim();
|
|
root.pending = trimmed;
|
|
if (trimmed.length < 2) {
|
|
root.results = [];
|
|
root.lastError = "";
|
|
debounce.stop();
|
|
return;
|
|
}
|
|
debounce.restart();
|
|
}
|
|
|
|
function run(): void {
|
|
if (fetch.running || root.pending.length < 2)
|
|
return;
|
|
root.searching = true;
|
|
root.lastError = "";
|
|
root.lastQuery = root.pending;
|
|
|
|
// --get with --data-urlencode makes curl do the escaping, so a place
|
|
// name with spaces or an ampersand cannot alter the request.
|
|
fetch.exec(["curl", "-s", "--max-time", "10", "--get",
|
|
"--data-urlencode", `name=${root.pending}`,
|
|
"--data-urlencode", "count=8",
|
|
"--data-urlencode", "format=json",
|
|
root.endpoint]);
|
|
}
|
|
|
|
function parse(text: string): void {
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
const out = [];
|
|
for (const item of (parsed.results ?? [])) {
|
|
if (typeof item.latitude !== "number" || typeof item.longitude !== "number")
|
|
continue;
|
|
const admin = item.admin1 ?? "";
|
|
const country = item.country ?? "";
|
|
out.push({
|
|
name: item.name ?? "",
|
|
admin: admin,
|
|
country: country,
|
|
latitude: item.latitude,
|
|
longitude: item.longitude,
|
|
// What the user will see stored as their location label.
|
|
label: [item.name, admin, country].filter(part => !!part).join(", ")
|
|
});
|
|
}
|
|
root.results = out;
|
|
root.lastError = out.length === 0 ? "No places match that name." : "";
|
|
} catch (error) {
|
|
root.results = [];
|
|
root.lastError = "The location service returned something unreadable.";
|
|
}
|
|
}
|
|
|
|
// Stores a chosen place. Coordinates are rounded to four decimals -- roughly
|
|
// ten metres, far finer than a weather reading resolves, and it keeps a
|
|
// precise home location out of the settings file.
|
|
function choose(place: var): bool {
|
|
const latitude = Math.round(place.latitude * 10000) / 10000;
|
|
const longitude = Math.round(place.longitude * 10000) / 10000;
|
|
const label = String(place.label).slice(0, 64);
|
|
|
|
const ok = DesktopPreferences.set("weatherLatitude", latitude)
|
|
&& DesktopPreferences.set("weatherLongitude", longitude)
|
|
&& DesktopPreferences.set("weatherLocation", label);
|
|
if (!ok) {
|
|
root.lastError = "That location could not be saved.";
|
|
return false;
|
|
}
|
|
root.results = [];
|
|
root.lastError = "";
|
|
return true;
|
|
}
|
|
}
|