Add application and autostart settings
This commit is contained in:
@@ -1,19 +1,207 @@
|
|||||||
// Applications — PLACEHOLDER.
|
// Applications and session startup.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.services
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
objectName: "applications"
|
||||||
title: "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 {
|
SettingsCard {
|
||||||
title: "Being built"
|
title: "Default applications"
|
||||||
subtitle: "Default browser, mail, files, and terminal, plus the autostart list, are on their way."
|
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
@@ -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
@@ -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'
|
||||||
Executable
+230
@@ -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'
|
||||||
Reference in New Issue
Block a user