Add startup application picker

This commit is contained in:
Gabriel Brown
2026-08-18 12:50:42 -04:00
parent ccf46c40ef
commit 1afa41526a
7 changed files with 203 additions and 15 deletions
@@ -12,6 +12,7 @@ SettingsPage {
lede: "Choose what opens your files and links, and what starts with your session." lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: "" property string expandedRole: ""
property bool addingAutostart: false
readonly property var applications: DesktopEntries.applications.values readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [ readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
@@ -178,7 +179,28 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "User autostart" 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 { TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0 visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
@@ -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
}
}
@@ -34,6 +34,7 @@ DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml AccessibilityPage 1.0 AccessibilityPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml WallpaperPicker 1.0 WallpaperPicker.qml
ApplicationsPage 1.0 ApplicationsPage.qml ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsEditor 1.0 DockPinsEditor.qml DockPinsEditor 1.0 DockPinsEditor.qml
DockAppPicker 1.0 DockAppPicker.qml DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml ShortcutCapture 1.0 ShortcutCapture.qml
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)] return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]: def discovered_desktop_files() -> dict[str, Path]:
desktop_ids: set[str] = set() desktop_files: dict[str, Path] = {}
for root in xdg_data_roots(): for root in xdg_data_roots():
applications = root / "applications" applications = root / "applications"
if not applications.is_dir(): if not applications.is_dir():
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
if not path.is_file(): if not path.is_file():
continue continue
relative = path.relative_to(applications) relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts)) desktop_files.setdefault("-".join(relative.parts), path)
return desktop_ids 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: 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) run(command)
def update_hidden(path: Path, *, hidden: bool) -> None: def with_hidden(original: str, *, hidden: bool) -> str:
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() lines = original.splitlines()
output: list[str] = [] output: list[str] = []
section = "" section = ""
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
raise BoundaryError("That autostart entry is not a desktop file.") raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden: if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}") 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: try:
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary: ) as temporary:
temporary.write("\n".join(output) + "\n") temporary.write(text)
temporary.flush() temporary.flush()
os.fsync(temporary.fileno()) os.fsync(temporary.fileno())
temporary_path = Path(temporary.name) temporary_path = Path(temporary.name)
temporary_path.chmod(mode) temporary_path.chmod(mode)
os.replace(temporary_path, path) os.replace(temporary_path, path)
except OSError as error: except OSError as error:
if "temporary_path" in locals(): if temporary_path is not None:
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error 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: def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}: if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or 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]) set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart": elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2]) set_autostart(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "add-autostart":
add_autostart(arguments[1])
else: else:
raise BoundaryError( raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | " "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: except BoundaryError as error:
print(str(error), file=sys.stderr) print(str(error), file=sys.stderr)
@@ -98,5 +98,16 @@ Singleton {
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]); 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() Component.onCompleted: root.refresh()
} }
@@ -33,6 +33,9 @@ done
assert_contains 'title: "Default applications"' assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"' assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"' assert_contains 'title: "Compositor autostart"'
assert_contains 'AutostartAppPicker {'
assert_contains 'DefaultApps.addAutostart('
assert_contains 'label: "Add an application"'
assert_contains 'categories' assert_contains 'categories'
assert_contains 'genericName' assert_contains 'genericName'
assert_contains '.sort(' assert_contains '.sort('
@@ -123,4 +126,14 @@ fi
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \ [[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable' || 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' printf 'applications settings contract: PASS\n'
+24
View File
@@ -29,6 +29,7 @@ assert_service_contains 'property string lastError'
assert_service_contains 'function refresh(): void' assert_service_contains 'function refresh(): void'
assert_service_contains 'function setDefault(role: string, desktopId: string): 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 setAutostart(desktopId: string, enabled: bool): void'
assert_service_contains 'function addAutostart(desktopId: string): void'
assert_service_contains 'DesktopEntries.applications.values' assert_service_contains 'DesktopEntries.applications.values'
if rg --quiet 'command\s*:\s*"' "$service"; then if rg --quiet 'command\s*:\s*"' "$service"; then
fail 'Process command must be an argument array' 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' fail 'read-only compositor entry was accepted for mutation'
fi 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' printf 'default apps contract: PASS\n'