Add application and autostart settings

This commit is contained in:
Gabriel Brown
2026-08-18 01:28:29 -04:00
parent ce95b34d19
commit 375ecfcd95
5 changed files with 857 additions and 10 deletions
@@ -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()
}
}
}
+275
View File
@@ -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:]))
@@ -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()
}
+52
View File
@@ -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'
+230
View File
@@ -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" <<EOF
[Desktop Entry]
Type=Application
Name=$name
GenericName=$generic_name
Categories=$categories
Exec=/usr/bin/true
EOF
}
write_application org.mozilla.firefox.desktop Firefox 'Web Browser' 'Network;WebBrowser;'
write_application org.gnome.Geary.desktop Geary 'Mail Client' 'Network;Email;'
write_application org.gnome.Nautilus.desktop Files 'File Manager' 'System;FileManager;'
write_application org.gnome.Ptyxis.desktop Ptyxis Terminal 'System;TerminalEmulator;'
write_application org.gnome.Rhythmbox3.desktop Rhythmbox 'Music Player' 'AudioVideo;Audio;Player;'
write_application org.gnome.Loupe.desktop Loupe 'Image Viewer' 'Graphics;Viewer;'
write_application org.gnome.Totem.desktop Videos 'Video Player' 'AudioVideo;Video;Player;'
cat >"$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'