diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index 06c4a6b..903dd9e 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -12,6 +12,7 @@ SettingsPage { lede: "Choose what opens your files and links, and what starts with your session." property string expandedRole: "" + property bool addingAutostart: false readonly property var applications: DesktopEntries.applications.values readonly property var roles: [ { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, @@ -178,7 +179,28 @@ SettingsPage { SettingsCard { title: "User autostart" - subtitle: "These desktop entries live in your user configuration. Select a row to toggle it." + subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor." + + ActionRow { + label: "Add an application" + detail: root.addingAutostart + ? "Search the applications installed on this machine" + : "Start another installed application when you sign in" + action: root.addingAutostart ? "Close" : "Choose" + divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0 + enabled: !DefaultApps.busy + onTriggered: root.addingAutostart = !root.addingAutostart + } + + AutostartAppPicker { + visible: root.addingAutostart + width: parent.width + existing: DefaultApps.autostartEntries.map(entry => entry.id) + onPicked: id => { + DefaultApps.addAutostart(id); + root.addingAutostart = false; + } + } TextRow { visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0 diff --git a/config/dot/quickshell/modules/settings/AutostartAppPicker.qml b/config/dot/quickshell/modules/settings/AutostartAppPicker.qml new file mode 100644 index 0000000..62a302c --- /dev/null +++ b/config/dot/quickshell/modules/settings/AutostartAppPicker.qml @@ -0,0 +1,77 @@ +// Adds an installed application to the user's freedesktop autostart directory. + +import QtQuick +import Quickshell +import qs.config +import qs.modules.clipboard + +Column { + id: root + + required property var existing + signal picked(string id) + + spacing: 0 + + function desktopId(entry: var): string { + const id = String(entry?.id ?? ""); + return id.endsWith(".desktop") ? id : id + ".desktop"; + } + + readonly property var matches: { + const needle = search.text.trim().toLowerCase(); + if (needle === "") + return []; + const out = []; + for (const entry of DesktopEntries.applications.values) { + const desktopId = root.desktopId(entry); + if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0) + continue; + const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase(); + if (haystack.indexOf(needle) >= 0) + out.push(entry); + if (out.length >= 8) + break; + } + return out; + } + + SearchField { + id: search + width: parent.width + placeholder: "Search installed applications" + } + + Repeater { + model: root.matches + + SettingRow { + id: candidate + + required property var modelData + required property int index + + label: String(candidate.modelData.name || root.desktopId(candidate.modelData)) + detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData)) + divider: candidate.index < root.matches.length - 1 + controlWidth: 86 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Add" + onClicked: { + root.picked(root.desktopId(candidate.modelData)); + search.text = ""; + } + } + } + } + + SettingRow { + visible: search.text.trim() !== "" && root.matches.length === 0 + label: "No matching applications" + detail: "Only installed desktop applications can start with the session" + divider: false + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index bb1d693..afcc332 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -34,6 +34,7 @@ DateTimePage 1.0 DateTimePage.qml AccessibilityPage 1.0 AccessibilityPage.qml WallpaperPicker 1.0 WallpaperPicker.qml ApplicationsPage 1.0 ApplicationsPage.qml +AutostartAppPicker 1.0 AutostartAppPicker.qml DockPinsEditor 1.0 DockPinsEditor.qml DockAppPicker 1.0 DockAppPicker.qml ShortcutCapture 1.0 ShortcutCapture.qml diff --git a/config/dot/quickshell/scripts/panama-default-apps b/config/dot/quickshell/scripts/panama-default-apps index 4da6e10..c060a20 100755 --- a/config/dot/quickshell/scripts/panama-default-apps +++ b/config/dot/quickshell/scripts/panama-default-apps @@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]: return [data_home, *(Path(item) for item in data_dirs.split(":") if item)] -def discovered_desktop_ids() -> set[str]: - desktop_ids: set[str] = set() +def discovered_desktop_files() -> dict[str, Path]: + desktop_files: dict[str, Path] = {} for root in xdg_data_roots(): applications = root / "applications" if not applications.is_dir(): @@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]: if not path.is_file(): continue relative = path.relative_to(applications) - desktop_ids.add("-".join(relative.parts)) - return desktop_ids + desktop_files.setdefault("-".join(relative.parts), path) + return desktop_files + + +def discovered_desktop_ids() -> set[str]: + return set(discovered_desktop_files()) def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None: @@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None: 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 - +def with_hidden(original: str, *, hidden: bool) -> str: lines = original.splitlines() output: list[str] = [] section = "" @@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None: raise BoundaryError("That autostart entry is not a desktop file.") if not wrote_hidden: output.append(f"Hidden={'true' if hidden else 'false'}") + return "\n".join(output) + "\n" - mode = path.stat().st_mode + +def write_atomic(path: Path, text: str, *, mode: int) -> None: + temporary_path: Path | None = None 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.write(text) 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(): + if temporary_path is not None: temporary_path.unlink(missing_ok=True) raise BoundaryError("That autostart entry could not be updated.") from error +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 + + mode = path.stat().st_mode + write_atomic(path, with_hidden(original, hidden=hidden), mode=mode) + + +def add_autostart(desktop_id: str) -> None: + desktop_files = discovered_desktop_files() + require_desktop_id(desktop_id, discovered=set(desktop_files)) + source = desktop_files[desktop_id] + directory = autostart_directory() + try: + directory.mkdir(parents=True, exist_ok=True) + except OSError as error: + raise BoundaryError("The user autostart directory could not be created.") from error + + target = directory / desktop_id + if target.is_symlink(): + raise BoundaryError("That autostart entry is not available.") + if target.exists(): + if not target.is_file(): + raise BoundaryError("That autostart entry is not available.") + update_hidden(target, hidden=False) + return + + try: + original = source.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise BoundaryError("That application could not be read.") from error + write_atomic(target, with_hidden(original, hidden=False), mode=0o644) + + 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.") @@ -260,10 +298,12 @@ def main(arguments: list[str]) -> int: set_default(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-autostart": set_autostart(arguments[1], arguments[2]) + elif len(arguments) == 2 and arguments[0] == "add-autostart": + add_autostart(arguments[1]) else: raise BoundaryError( "Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | " - "set-autostart DESKTOP_ID true|false" + "set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID" ) except BoundaryError as error: print(str(error), file=sys.stderr) diff --git a/config/dot/quickshell/services/DefaultApps.qml b/config/dot/quickshell/services/DefaultApps.qml index da66ff5..02ddc55 100644 --- a/config/dot/quickshell/services/DefaultApps.qml +++ b/config/dot/quickshell/services/DefaultApps.qml @@ -98,5 +98,16 @@ Singleton { mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]); } + function addAutostart(desktopId: string): void { + if (root.busy) + return; + if (!root.knownDesktopId(desktopId)) { + root.lastError = "Choose an installed application."; + return; + } + root.lastError = ""; + mutationProcess.exec([root.helper, "add-autostart", desktopId]); + } + Component.onCompleted: root.refresh() } diff --git a/tests/quickshell/applications-settings-contract.sh b/tests/quickshell/applications-settings-contract.sh index d3b5ef6..735fd22 100755 --- a/tests/quickshell/applications-settings-contract.sh +++ b/tests/quickshell/applications-settings-contract.sh @@ -33,6 +33,9 @@ done assert_contains 'title: "Default applications"' assert_contains 'title: "User autostart"' assert_contains 'title: "Compositor autostart"' +assert_contains 'AutostartAppPicker {' +assert_contains 'DefaultApps.addAutostart(' +assert_contains 'label: "Add an application"' assert_contains 'categories' assert_contains 'genericName' assert_contains '.sort(' @@ -123,4 +126,14 @@ fi [[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \ || fail 'default and autostart rows are not both whole-row activatable' +picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml" +qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir" +[[ -f "$picker" ]] || fail 'autostart application picker is missing' +rg -Fq 'required property var existing' "$picker" \ + || fail 'autostart picker cannot exclude existing entries' +rg -Fq 'signal picked(string id)' "$picker" \ + || fail 'autostart picker does not emit a validated desktop id' +rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \ + || fail 'autostart picker is not registered in the Settings module' + printf 'applications settings contract: PASS\n' diff --git a/tests/quickshell/default-apps-contract.sh b/tests/quickshell/default-apps-contract.sh index 1008969..0657b42 100755 --- a/tests/quickshell/default-apps-contract.sh +++ b/tests/quickshell/default-apps-contract.sh @@ -29,6 +29,7 @@ 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 'function addAutostart(desktopId: string): void' assert_service_contains 'DesktopEntries.applications.values' if rg --quiet 'command\s*:\s*"' "$service"; then fail 'Process command must be an argument array' @@ -227,4 +228,27 @@ if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then fail 'read-only compositor entry was accepted for mutation' fi +$helper add-autostart org.mozilla.firefox.desktop +firefox_autostart="$config_home/autostart/org.mozilla.firefox.desktop" +[[ -f "$firefox_autostart" && ! -L "$firefox_autostart" ]] \ + || fail 'adding an installed application did not create a regular user autostart entry' +rg --quiet '^Name=Firefox$' "$firefox_autostart" \ + || fail 'adding an application did not preserve its desktop entry' +rg --quiet '^Hidden=false$' "$firefox_autostart" \ + || fail 'a newly added application was not enabled' +[[ "$(rg --count '^Hidden=' "$firefox_autostart")" == "1" ]] \ + || fail 'adding an application wrote more than one Hidden key' + +$helper set-autostart org.mozilla.firefox.desktop false +$helper add-autostart org.mozilla.firefox.desktop +rg --quiet '^Hidden=false$' "$firefox_autostart" \ + || fail 'adding an existing disabled application did not re-enable it' + +if $helper add-autostart org.example.Missing.desktop >/dev/null 2>&1; then + fail 'an undiscovered application was accepted for autostart' +fi +if $helper add-autostart ../escape.desktop >/dev/null 2>&1; then + fail 'an unsafe desktop id was accepted for autostart' +fi + printf 'default apps contract: PASS\n'