diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index d20d694..864da62 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -1,19 +1,207 @@ -// Applications — PLACEHOLDER. -// -// Owned by the codex agent, which is building default-application handling and -// the autostart list. This stub exists only so the page id can be routed, -// registered, and searchable before that work lands; it is expected to be -// replaced wholesale rather than edited. +// Applications and session startup. +import Quickshell import QtQuick -import qs.config +import qs.services SettingsPage { + id: root + + objectName: "applications" title: "Applications" - lede: "Default applications and what starts with your session." + lede: "Choose what opens your files and links, and what starts with your session." + + property string expandedRole: "" + readonly property var applications: DesktopEntries.applications.values + readonly property var roles: [ + { key: "browser", label: "Browser", detail: "Web links and HTML pages", categories: ["webbrowser"], terms: ["browser", "web"] }, + { key: "mail", label: "Mail", detail: "Email links", categories: ["email"], terms: ["mail", "email"] }, + { key: "files", label: "Files", detail: "Folders and file locations", categories: ["filemanager"], terms: ["file manager", "files"] }, + { key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categories: ["terminalemulator"], terms: ["terminal", "console"] }, + { key: "music", label: "Music", detail: "MP3 audio", categories: ["audio", "player"], terms: ["music", "audio player"] }, + { key: "images", label: "Images", detail: "PNG images", categories: ["graphics", "viewer"], terms: ["image", "photo", "picture"] }, + { key: "video", label: "Video", detail: "MP4 video", categories: ["video", "player"], terms: ["video", "media player"] } + ] + + function desktopId(entry: var): string { + const entryId = String(entry?.id ?? ""); + return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop"; + } + + function displayName(entry: var): string { + return String(entry?.name || entry?.genericName || root.desktopId(entry)); + } + + function currentHandler(role: string): string { + return String(DefaultApps.handlers[role] ?? ""); + } + + function currentEntry(role: string): var { + const handler = root.currentHandler(role); + return root.applications.find(entry => root.desktopId(entry) === handler) ?? null; + } + + function matchesRole(entry: var, role: var): bool { + const categories = Array.isArray(entry.categories) + ? entry.categories.join(" ").toLowerCase() + : String(entry.categories ?? "").toLowerCase(); + const metadata = [entry.name, entry.genericName, entry.comment] + .map(value => String(value ?? "").toLowerCase()) + .join(" "); + return role.categories.some(category => categories.includes(category)) + || role.terms.some(term => metadata.includes(term)); + } + + function choicesForRole(role: var): var { + const choices = root.applications.filter(entry => root.matchesRole(entry, role)); + const currentEntry = root.currentEntry(role.key); + if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry))) + choices.push(currentEntry); + return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right))); + } + + TextRow { + visible: DefaultApps.lastError !== "" + label: "Could not apply the change" + detail: DefaultApps.lastError + value: "" + divider: false + } SettingsCard { - title: "Being built" - subtitle: "Default browser, mail, files, and terminal, plus the autostart list, are on their way." + title: "Default applications" + subtitle: "Open a row to choose from applications that advertise the matching role." + + Repeater { + model: root.roles + + delegate: Column { + id: roleBlock + + required property var modelData + required property int index + readonly property var choices: root.choicesForRole(roleBlock.modelData) + readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key) + + width: parent.width + + SettingRow { + label: roleBlock.modelData.label + detail: roleBlock.modelData.detail + value: roleBlock.selectedEntry + ? root.displayName(roleBlock.selectedEntry) + : (root.currentHandler(roleBlock.modelData.key) || "Not set") + activatable: roleBlock.choices.length > 0 && !DefaultApps.busy + divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1 + onActivated: { + root.expandedRole = root.expandedRole === roleBlock.modelData.key + ? "" + : roleBlock.modelData.key; + } + } + + Column { + width: parent.width + visible: root.expandedRole === roleBlock.modelData.key + + Repeater { + model: roleBlock.choices + + delegate: SettingRow { + id: candidateRow + + required property var modelData + required property int index + + readonly property string candidateId: root.desktopId(candidateRow.modelData) + readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key) + + label: root.displayName(candidateRow.modelData) + detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId) + value: candidateRow.selected ? "Current" : "" + activatable: !candidateRow.selected && !DefaultApps.busy + divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1 + onActivated: { + DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId); + root.expandedRole = ""; + } + } + } + } + } + } + } + + SettingsCard { + title: "User autostart" + subtitle: "These desktop entries live in your user configuration. Select a row to toggle it." + + TextRow { + visible: DefaultApps.autostartEntries.length === 0 + label: "No user autostart entries" + detail: "Applications can add entries to ~/.config/autostart." + value: "" + divider: false + } + + Repeater { + model: DefaultApps.autostartEntries + + delegate: SettingRow { + id: autostartRow + + required property var modelData + required property int index + + label: autostartRow.modelData.name + detail: autostartRow.modelData.id + value: autostartRow.modelData.enabled ? "Enabled" : "Disabled" + activatable: !DefaultApps.busy + divider: autostartRow.index < DefaultApps.autostartEntries.length - 1 + onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled) + } + } + } + + SettingsCard { + title: "Compositor autostart" + subtitle: "Panama starts these from Hyprland configuration. They are read-only here." + + TextRow { + visible: DefaultApps.luaAutostartEntries.length === 0 + label: "No compositor entries found" + detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua." + value: "" + divider: false + } + + Repeater { + model: DefaultApps.luaAutostartEntries + + delegate: TextRow { + id: luaRow + + required property var modelData + required property int index + + label: luaRow.modelData.name + detail: luaRow.modelData.command + value: "Hyprland" + divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1 + } + } + } + + SettingsCard { + title: "Refresh" + + ActionRow { + label: "Reload application settings" + detail: "Re-read desktop entries, defaults, and user autostart files" + action: DefaultApps.busy ? "Refreshing…" : "Refresh" + enabled: !DefaultApps.busy + divider: false + onTriggered: DefaultApps.refresh() + } } } diff --git a/config/dot/quickshell/scripts/panama-default-apps b/config/dot/quickshell/scripts/panama-default-apps new file mode 100755 index 0000000..4da6e10 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-default-apps @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 + +"""Read and update freedesktop defaults for Panama's settings page.""" + +from __future__ import annotations + +import ast +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + + +ROLE_TARGETS = { + "browser": ("settings", "default-web-browser"), + "mail": ("mime", "x-scheme-handler/mailto"), + "files": ("mime", "inode/directory"), + "terminal": ("mime", "x-scheme-handler/terminal"), + "music": ("mime", "audio/mpeg"), + "images": ("mime", "image/png"), + "video": ("mime", "video/mp4"), +} +DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$") +EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)") + + +class BoundaryError(RuntimeError): + """A user-visible validation or command failure.""" + + +def xdg_data_roots() -> list[Path]: + data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) + data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share") + return [data_home, *(Path(item) for item in data_dirs.split(":") if item)] + + +def discovered_desktop_ids() -> set[str]: + desktop_ids: set[str] = set() + for root in xdg_data_roots(): + applications = root / "applications" + if not applications.is_dir(): + continue + for path in applications.rglob("*.desktop"): + if not path.is_file(): + continue + relative = path.relative_to(applications) + desktop_ids.add("-".join(relative.parts)) + return desktop_ids + + +def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None: + if not DESKTOP_ID.fullmatch(desktop_id) or desktop_id not in discovered: + raise BoundaryError("That application is not available.") + + +def run(command: list[str]) -> str: + completed = subprocess.run(command, check=False, capture_output=True, text=True) + if completed.returncode != 0: + detail = completed.stderr.strip() + raise BoundaryError(detail or "The system default could not be updated.") + return completed.stdout.strip() + + +def query_handlers() -> dict[str, str]: + handlers: dict[str, str] = {} + for role, (kind, target) in ROLE_TARGETS.items(): + command = ( + ["xdg-settings", "get", target] + if kind == "settings" + else ["xdg-mime", "query", "default", target] + ) + output = run(command) + handlers[role] = output.splitlines()[0] if output else "" + return handlers + + +def parse_desktop_entry(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + section = "" + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as error: + raise BoundaryError(f"Could not read {path.name}.") from error + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + section = stripped[1:-1] + continue + if section != "Desktop Entry" or "=" not in line or stripped.startswith("#"): + continue + key, value = line.split("=", 1) + values.setdefault(key.strip(), value.strip()) + return values + + +def autostart_directory() -> Path: + config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return config_home / "autostart" + + +def user_autostart_entries() -> list[dict[str, object]]: + directory = autostart_directory() + if not directory.is_dir(): + return [] + entries: list[dict[str, object]] = [] + for path in directory.glob("*.desktop"): + if path.is_symlink() or not path.is_file(): + continue + values = parse_desktop_entry(path) + entries.append( + { + "id": path.name, + "name": values.get("Name", path.stem), + "enabled": values.get("Hidden", "false").lower() != "true", + } + ) + return sorted(entries, key=lambda entry: (str(entry["name"]).casefold(), str(entry["id"]))) + + +def hypr_autostart_path() -> Path: + override = os.environ.get("PANAMA_HYPR_AUTOSTART") + if override: + return Path(override) + return Path(__file__).resolve().parents[2] / "hypr" / "autostart.lua" + + +def lua_autostart_entries() -> list[dict[str, object]]: + path = hypr_autostart_path() + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError): + return [] + + commands: list[str] = [] + in_start_handler = False + for line in lines: + if not in_start_handler: + in_start_handler = bool(re.search(r'hl\.on\(\s*"hyprland\.start"', line)) + continue + if line.strip() == "end)": + break + match = EXEC_CMD.search(line) + if match: + try: + commands.append(ast.literal_eval(match.group(1))) + except (SyntaxError, ValueError): + continue + + return [ + { + "id": f"hyprland:{index}", + "name": command.split()[0].rsplit("/", 1)[-1], + "command": command, + "enabled": True, + "readOnly": True, + "source": "config/dot/hypr/autostart.lua", + } + for index, command in enumerate(commands, start=1) + ] + + +def snapshot() -> dict[str, object]: + return { + "handlers": query_handlers(), + "autostartEntries": user_autostart_entries(), + "luaAutostartEntries": lua_autostart_entries(), + } + + +def set_default(role: str, desktop_id: str) -> None: + target = ROLE_TARGETS.get(role) + if target is None: + raise BoundaryError("That default application role is not supported.") + require_desktop_id(desktop_id, discovered=discovered_desktop_ids()) + kind, setting = target + command = ( + ["xdg-settings", "set", setting, desktop_id] + if kind == "settings" + else ["xdg-mime", "default", desktop_id, setting] + ) + run(command) + + +def update_hidden(path: Path, *, hidden: bool) -> None: + try: + original = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise BoundaryError("That autostart entry could not be read.") from error + + lines = original.splitlines() + output: list[str] = [] + section = "" + found_section = False + wrote_hidden = False + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + if section == "Desktop Entry" and not wrote_hidden: + output.append(f"Hidden={'true' if hidden else 'false'}") + wrote_hidden = True + section = stripped[1:-1] + found_section = found_section or section == "Desktop Entry" + output.append(line) + continue + if section == "Desktop Entry" and line.split("=", 1)[0].strip() == "Hidden": + if not wrote_hidden: + output.append(f"Hidden={'true' if hidden else 'false'}") + wrote_hidden = True + continue + output.append(line) + + if not found_section: + raise BoundaryError("That autostart entry is not a desktop file.") + if not wrote_hidden: + output.append(f"Hidden={'true' if hidden else 'false'}") + + mode = path.stat().st_mode + try: + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False + ) as temporary: + temporary.write("\n".join(output) + "\n") + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + temporary_path.chmod(mode) + os.replace(temporary_path, path) + except OSError as error: + if "temporary_path" in locals(): + temporary_path.unlink(missing_ok=True) + raise BoundaryError("That autostart entry could not be updated.") from error + + +def set_autostart(desktop_id: str, enabled_text: str) -> None: + if enabled_text not in {"true", "false"}: + raise BoundaryError("Autostart state must be true or false.") + if not DESKTOP_ID.fullmatch(desktop_id): + raise BoundaryError("That autostart entry is not available.") + + directory = autostart_directory() + path = directory / desktop_id + try: + resolved_directory = directory.resolve(strict=True) + resolved_path = path.resolve(strict=True) + except OSError as error: + raise BoundaryError("That autostart entry is not available.") from error + if path.is_symlink() or resolved_path.parent != resolved_directory or not resolved_path.is_file(): + raise BoundaryError("That autostart entry is not available.") + update_hidden(resolved_path, hidden=enabled_text == "false") + + +def main(arguments: list[str]) -> int: + try: + if arguments == ["snapshot"]: + print(json.dumps(snapshot(), separators=(",", ":"))) + elif len(arguments) == 3 and arguments[0] == "set-default": + set_default(arguments[1], arguments[2]) + elif len(arguments) == 3 and arguments[0] == "set-autostart": + set_autostart(arguments[1], arguments[2]) + else: + raise BoundaryError( + "Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | " + "set-autostart DESKTOP_ID true|false" + ) + except BoundaryError as error: + print(str(error), file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/services/DefaultApps.qml b/config/dot/quickshell/services/DefaultApps.qml new file mode 100644 index 0000000..da66ff5 --- /dev/null +++ b/config/dot/quickshell/services/DefaultApps.qml @@ -0,0 +1,102 @@ +pragma Singleton + +// Freedesktop default handlers and session autostart entries. +// +// The helper owns parsing and atomic desktop-file writes. This singleton keeps +// the QML side typed and reactive, and every external command crosses Process +// as an argument array. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property var handlers: ({}) + property var autostartEntries: [] + property var luaAutostartEntries: [] + property string lastError: "" + + readonly property bool busy: snapshotProcess.running || mutationProcess.running + readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps" + readonly property var supportedRoles: ["browser", "mail", "files", "terminal", "music", "images", "video"] + + Process { + id: snapshotProcess + + stdout: StdioCollector { + onStreamFinished: root.applySnapshot(this.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + root.lastError = "Default applications could not be read. Try refreshing." + } + } + + Process { + id: mutationProcess + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.lastError = "That application setting could not be changed." + return; + } + root.refresh(); + } + } + + function applySnapshot(text: string): void { + try { + const payload = JSON.parse(text); + root.handlers = payload.handlers ?? ({}); + root.autostartEntries = payload.autostartEntries ?? []; + root.luaAutostartEntries = payload.luaAutostartEntries ?? []; + root.lastError = ""; + } catch (error) { + root.lastError = "Default applications returned an unreadable response." + } + } + + function refresh(): void { + if (root.busy) + return; + root.lastError = ""; + snapshotProcess.exec([root.helper, "snapshot"]); + } + + function knownDesktopId(desktopId: string): bool { + if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$/.test(desktopId)) + return false; + const entries = DesktopEntries.applications.values; + return entries.some(entry => { + const entryId = String(entry.id ?? ""); + return entryId === desktopId || entryId + ".desktop" === desktopId; + }); + } + + function setDefault(role: string, desktopId: string): void { + if (root.busy) + return; + if (!root.supportedRoles.includes(role) || !root.knownDesktopId(desktopId)) { + root.lastError = "Choose an application from the available list." + return; + } + root.lastError = ""; + mutationProcess.exec([root.helper, "set-default", role, desktopId]); + } + + function setAutostart(desktopId: string, enabled: bool): void { + if (root.busy) + return; + const known = root.autostartEntries.some(entry => entry.id === desktopId); + if (!known) { + root.lastError = "That user autostart entry is no longer available." + return; + } + root.lastError = ""; + mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]); + } + + Component.onCompleted: root.refresh() +} diff --git a/tests/quickshell/applications-settings-contract.sh b/tests/quickshell/applications-settings-contract.sh new file mode 100755 index 0000000..a351adf --- /dev/null +++ b/tests/quickshell/applications-settings-contract.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'applications settings contract: %s\n' "$1" >&2 + exit 1 +} + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +page="$project_root/config/dot/quickshell/modules/settings/ApplicationsPage.qml" + +[[ -f "$page" ]] || fail 'Applications page is missing' + +assert_contains() { + rg -F --quiet "$1" "$page" || fail "page is missing: $1" +} + +assert_contains 'SettingsPage {' +assert_contains 'objectName: "applications"' +assert_contains 'DesktopEntries.applications.values' +assert_contains 'DefaultApps' +assert_contains 'SettingsCard {' +assert_contains 'SettingRow {' +assert_contains 'activatable:' +assert_contains 'ActionRow {' +assert_contains 'TextRow {' + +for label in Browser Mail Files Terminal Music Images Video; do + assert_contains "label: \"$label\"" +done + +assert_contains 'title: "Default applications"' +assert_contains 'title: "User autostart"' +assert_contains 'title: "Compositor autostart"' +assert_contains 'categories' +assert_contains 'genericName' +assert_contains '.sort(' +assert_contains 'currentEntry' +assert_contains 'read-only' + +if rg --quiet 'Component\.onCompleted|DesktopEntries\.(byId|heuristicLookup)' "$page"; then + fail 'page snapshots or performs a one-time desktop-entry lookup' +fi +if rg --quiet '#[0-9A-Fa-f]{3,8}' "$page"; then + fail 'page introduces a color literal instead of the shared visual system' +fi + +[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \ + || fail 'default and autostart rows are not both whole-row activatable' + +printf 'applications settings contract: PASS\n' diff --git a/tests/quickshell/default-apps-contract.sh b/tests/quickshell/default-apps-contract.sh new file mode 100755 index 0000000..1008969 --- /dev/null +++ b/tests/quickshell/default-apps-contract.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'default apps contract: %s\n' "$1" >&2 + exit 1 +} + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$project_root/config/dot/quickshell/scripts/panama-default-apps" +service="$project_root/config/dot/quickshell/services/DefaultApps.qml" +test_root="$(mktemp -d /tmp/panama-default-apps.XXXXXX)" +trap 'rm -rf "$test_root"' EXIT + +[[ -x "$helper" ]] || fail 'helper is missing or not executable' +[[ -f "$service" ]] || fail 'DefaultApps service is missing' + +assert_service_contains() { + rg -F --quiet "$1" "$service" || fail "service is missing: $1" +} + +assert_service_contains 'pragma Singleton' +assert_service_contains 'property var handlers' +assert_service_contains 'property var autostartEntries' +assert_service_contains 'property var luaAutostartEntries' +assert_service_contains 'readonly property bool busy' +assert_service_contains 'property string lastError' +assert_service_contains 'function refresh(): void' +assert_service_contains 'function setDefault(role: string, desktopId: string): void' +assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void' +assert_service_contains 'DesktopEntries.applications.values' +if rg --quiet 'command\s*:\s*"' "$service"; then + fail 'Process command must be an argument array' +fi + +config_home="$test_root/config" +data_home="$test_root/data" +data_dirs="$test_root/data-dirs" +fake_bin="$test_root/bin" +call_log="$test_root/calls" +lua_fixture="$test_root/autostart.lua" +mkdir -p "$config_home/autostart" "$data_home/applications" "$data_dirs" "$fake_bin" + +write_application() { + local desktop_id="$1" + local name="$2" + local generic_name="$3" + local categories="$4" + cat >"$data_home/applications/$desktop_id" <"$config_home/autostart/nextcloud.desktop" <<'EOF' +[Desktop Entry] +Type=Application +Name=Nextcloud +Exec=nextcloud --background +Hidden=true +EOF + +cat >"$lua_fixture" <<'EOF' +hl.on("hyprland.start", function() + hl.exec_cmd("quickshell --daemonize") + hl.exec_cmd("nextcloud --background") +end) + +hl.on("hyprland.shutdown", function() + hl.exec_cmd("systemctl --user stop hyprland-session.target") +end) +EOF + +cat >"$fake_bin/xdg-settings" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >>"$PANAMA_CALL_LOG" +if [[ "$1" == "get" && "$2" == "default-web-browser" ]]; then + printf '%s\n' 'org.mozilla.firefox.desktop' + exit 0 +fi +EOF +chmod +x "$fake_bin/xdg-settings" + +cat >"$fake_bin/xdg-mime" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >>"$PANAMA_CALL_LOG" +if [[ "$1" == "query" && "$2" == "default" ]]; then + case "$3" in + x-scheme-handler/mailto) printf '%s\n' 'org.gnome.Geary.desktop' ;; + inode/directory) printf '%s\n' 'org.gnome.Nautilus.desktop' ;; + x-scheme-handler/terminal) printf '%s\n' 'org.gnome.Ptyxis.desktop' ;; + audio/mpeg) printf '%s\n' 'org.gnome.Rhythmbox3.desktop' ;; + image/png) printf '%s\n' 'org.gnome.Loupe.desktop' ;; + video/mp4) printf '%s\n' 'org.gnome.Totem.desktop' ;; + *) exit 91 ;; + esac + exit 0 +fi +EOF +chmod +x "$fake_bin/xdg-mime" + +export XDG_CONFIG_HOME="$config_home" +export XDG_DATA_HOME="$data_home" +export XDG_DATA_DIRS="$data_dirs" +export PANAMA_HYPR_AUTOSTART="$lua_fixture" +export PANAMA_CALL_LOG="$call_log" +export PATH="$fake_bin:$PATH" + +snapshot="$($helper snapshot)" || fail 'snapshot command failed' +[[ "$(rg --count '^get$' "$call_log")" == "1" ]] \ + || fail 'browser handler was queried more than once' +[[ "$(rg --count '^query$' "$call_log")" == "6" ]] \ + || fail 'MIME handlers were queried more than once' +jq -e ' + .handlers == { + browser: "org.mozilla.firefox.desktop", + mail: "org.gnome.Geary.desktop", + files: "org.gnome.Nautilus.desktop", + terminal: "org.gnome.Ptyxis.desktop", + music: "org.gnome.Rhythmbox3.desktop", + images: "org.gnome.Loupe.desktop", + video: "org.gnome.Totem.desktop" + } and + .autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: false}] and + (.luaAutostartEntries | length == 2) and + ([.luaAutostartEntries[] | + .enabled == true and .readOnly == true and + .source == "config/dot/hypr/autostart.lua" and + (.id | startswith("hyprland:")) and + (.name | length > 0) and (.command | length > 0) + ] | all) and + ([.luaAutostartEntries[].command] | + index("systemctl --user stop hyprland-session.target") == null) +' <<<"$snapshot" >/dev/null || fail 'snapshot shape, handlers, or autostart parsing is incorrect' + +assert_call() { + local expected="$1" + local actual + actual="$(cat "$call_log")" + [[ "$actual" == "$expected" ]] || { + printf 'expected argv:\n%s\nactual argv:\n%s\n' "$expected" "$actual" >&2 + fail 'setter did not pass separate arguments' + } +} + +: >"$call_log" +$helper set-default browser org.mozilla.firefox.desktop +assert_call $'set\ndefault-web-browser\norg.mozilla.firefox.desktop' + +roles=(mail files terminal music images video) +desktop_ids=( + org.gnome.Geary.desktop + org.gnome.Nautilus.desktop + org.gnome.Ptyxis.desktop + org.gnome.Rhythmbox3.desktop + org.gnome.Loupe.desktop + org.gnome.Totem.desktop +) +mime_types=( + x-scheme-handler/mailto + inode/directory + x-scheme-handler/terminal + audio/mpeg + image/png + video/mp4 +) +for index in "${!roles[@]}"; do + : >"$call_log" + $helper set-default "${roles[$index]}" "${desktop_ids[$index]}" + assert_call $'default\n'"${desktop_ids[$index]}"$'\n'"${mime_types[$index]}" +done + +: >"$call_log" +if $helper set-default unknown org.mozilla.firefox.desktop >/dev/null 2>&1; then + fail 'unknown role was accepted' +fi +[[ ! -s "$call_log" ]] || fail 'unknown role reached an xdg command' + +if $helper set-default browser org.example.Missing.desktop >/dev/null 2>&1; then + fail 'undiscovered desktop id was accepted' +fi +if $helper set-default browser ../escape.desktop >/dev/null 2>&1; then + fail 'unsafe desktop id was accepted' +fi + +$helper set-autostart nextcloud.desktop true +rg --quiet '^Hidden=false$' "$config_home/autostart/nextcloud.desktop" \ + || fail 'enabling autostart did not set Hidden=false' +[[ "$(rg --count '^Hidden=' "$config_home/autostart/nextcloud.desktop")" == "1" ]] \ + || fail 'enabling autostart duplicated Hidden' +rg --quiet '^Exec=nextcloud --background$' "$config_home/autostart/nextcloud.desktop" \ + || fail 'autostart update damaged another desktop key' +jq -e '.autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: true}]' \ + <<<"$($helper snapshot)" >/dev/null || fail 'enabled state did not round-trip' + +$helper set-autostart nextcloud.desktop false +rg --quiet '^Hidden=true$' "$config_home/autostart/nextcloud.desktop" \ + || fail 'disabling autostart did not set Hidden=true' + +outside_entry="$test_root/outside.desktop" +cp "$config_home/autostart/nextcloud.desktop" "$outside_entry" +ln -s "$outside_entry" "$config_home/autostart/linked.desktop" +if $helper set-autostart linked.desktop true >/dev/null 2>&1; then + fail 'autostart symlink escaping XDG config was accepted' +fi +rg --quiet '^Hidden=true$' "$outside_entry" || fail 'outside autostart file was modified' + +if $helper set-autostart missing.desktop true >/dev/null 2>&1; then + fail 'unknown autostart desktop id was accepted' +fi +if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then + fail 'read-only compositor entry was accepted for mutation' +fi + +printf 'default apps contract: PASS\n'