// Applications: what is installed, what opens your files, and what starts with // your session. // // This page used to answer only the middle question. That made it the one place // in Settings you could not do the obvious thing -- see what is on the machine // and remove something -- so the list came first and everything else arranged // itself around it. // // Two rules the page keeps to: // // It never pretends to own a system package. A Flatpak can be inspected and // removed here because that is a per-user operation with per-user blast // radius. `dnf remove` is not: removing the wrong package takes the desktop // with it, and a settings pane should not put that one press away. The row // says so and prints the command, which is more useful than a button that // refuses. // // It never links somewhere with nothing on it. The "Elsewhere in Settings" // chips appear per application, and only where that application already has a // rule on the page being linked to. import Quickshell import QtQuick import qs.config import qs.services import qs.modules.clipboard SettingsPage { id: root objectName: "applications" title: "Applications" lede: "What is installed, what opens your files, and what starts with your session." // Which installed application is unfolded. One at a time: the body is tall // enough that two open rows would push the third off the screen. property string expandedApp: "" // Uninstalling deletes an application, so it never happens on a first // press. Holds the Flatpak id that is one press away from being removed. property string pendingUninstall: "" // Which entry has been asked to be removed. Removal deletes a file, so // it never happens on a first press. property string confirmingAutostartRemoval: "" property bool addingAutostart: false property bool showingFileTypes: false // Empty means "whichever category the catalog lists first", so the card is // never blank before anything has been chosen. property string catalogCategory: "" readonly property var applications: DesktopEntries.applications.values // Each role governs a whole family of types, not one representative: setting // "Images" writes PNG, JPEG, WebP and the rest together, so a file manager // can never open one image in a viewer and its neighbour in an editor. // The detail line names the family the way someone would describe it. readonly property var roles: [ { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, { key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] }, { key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] }, { key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] }, { key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] }, { key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] }, { key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] }, { key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] }, { key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] }, { key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] } ] 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 { // DesktopEntries hands back a QML list, not a JavaScript array, so // Array.isArray is false for it. The old code took that as "this is a // string", stringified the list into "Network,WebBrowser" and then split // on ";" only -- producing the single token "network,webbrowser", which // matches no category at all. // // Nothing failed loudly. Browsers still appeared because their generic // name contains "web browser", so the terms fallback carried the role // by itself. Archives matched NOTHING, which meant that row could only // ever offer the application it already had. // // Joining first and splitting on both separators handles the list form // and a plain string equally. const raw = entry.categories; const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? ""); const categories = []; for (const value of joined.split(/[;,]/)) { const category = value.trim().toLowerCase(); if (category !== "") categories.push(category); } const metadata = [entry.name, entry.genericName] .map(value => String(value ?? "").toLowerCase()) .join(" "); return role.categorySets.some(set => set.every(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))); } // ── The installed list ─────────────────────────────────────────────────── // How many rows the card draws before it stops and says how many are left. // A machine with 243 applications is a scroll, not a list; the search field // is what actually finds one. readonly property int installedLimit: 10 readonly property var installedApps: (AppLibrary.apps ?? []).filter( app => AppLibrary.matches(app, installedSearch.text)) readonly property var expandedAppRecord: (AppLibrary.apps ?? []).find(app => String(app.entryId ?? "") === root.expandedApp) ?? null // Read for the open row only. The cache is read directly so this binding // has a dependency on it -- that is what turns "Reading…" into the summary // when the answer lands -- and permissionsFor() is what asks for an answer // that is not there yet. readonly property var expandedPermissions: { const cache = AppLibrary.permissionCache; const app = root.expandedAppRecord; if (!app || String(app.kind ?? "") !== "flatpak") return null; const key = String(app.flatpakId ?? ""); const info = cache[key] ?? AppLibrary.permissionsFor(key); return info && Array.isArray(info.summary) ? info : null; } // Flatseal is the tool that edits these; offering it when it is not // installed would be a button that does nothing. readonly property bool flatsealAvailable: root.applications.some( entry => String(entry.id ?? "").replace(/\.desktop$/, "") === "com.github.tchx84.Flatseal") function autostartId(app: var): string { const id = String(app?.entryId ?? ""); return id.endsWith(".desktop") ? id : id + ".desktop"; } function autostartEntry(desktopId: string): var { return DefaultApps.autostartEntries.find(entry => String(entry.id) === desktopId) ?? null; } function autostartOn(app: var): bool { const entry = root.autostartEntry(root.autostartId(app)); return entry !== null && entry.enabled !== false; } // Turning it on for an application that has never had an entry writes one; // there is no separate "add" step from here, because from the row's point // of view there is one switch and it means one thing. function setAppAutostart(app: var, value: bool): void { const desktopId = root.autostartId(app); if (root.autostartEntry(desktopId) === null) { if (value) DefaultApps.addAutostart(desktopId); return; } DefaultApps.setAutostart(desktopId, value); } // ── Where else this application already has a rule ─────────────────────── function appIdentifiers(app: var): var { const out = []; const entryId = String(app?.entryId ?? "").replace(/\.desktop$/, ""); const flatpakId = String(app?.flatpakId ?? ""); const name = String(app?.name ?? ""); for (const value of [entryId, flatpakId, name]) { if (value !== "") out.push(value.toLowerCase()); } return out; } function hasNotificationRule(app: var): bool { const identifiers = root.appIdentifiers(app); return (Notifs.applications ?? []).some( known => identifiers.indexOf(String(known.id ?? "").toLowerCase()) >= 0); } // A stream, not a stored rule: the Sound page's per-application rows come // from what is playing right now, so the chip is honest only while there is // something there to change. function hasSoundRule(app: var): bool { const identifiers = root.appIdentifiers(app); return (AudioDevices.applications ?? []).some(stream => identifiers.indexOf(String(stream.key ?? "").toLowerCase()) >= 0 || identifiers.indexOf(String(stream.label ?? "").toLowerCase()) >= 0); } // The portal's permission store records answers under the application's own // id, which is the same id this list carries -- so the match is exact and // costs nothing. function hasPrivacyRule(app: var): bool { const identifiers = root.appIdentifiers(app); return (Permissions.devices ?? []).some(device => (device.applications ?? []).some( recorded => identifiers.indexOf(String(recorded.app ?? "").toLowerCase()) >= 0)); } // ── The catalog ────────────────────────────────────────────────────────── readonly property var catalogCategories: AppLibrary.categories ?? [] // A category arrives either as a plain name or as an object carrying its // entries with it, so both are read rather than one being assumed. function categoryId(category: var): string { return String(category?.id ?? category?.name ?? category ?? ""); } function categoryLabel(category: var): string { const label = String(category?.label ?? category?.name ?? category?.id ?? category ?? ""); const count = Number(category?.count ?? (category?.entries ?? []).length ?? 0); return count > 0 ? label + " · " + count : label; } readonly property string activeCategory: { if (root.catalogCategory !== "") return root.catalogCategory; const categories = root.catalogCategories; return categories.length > 0 ? root.categoryId(categories[0]) : ""; } readonly property var catalogEntries: { // Reading the category list is what makes this re-run once the catalog // has been parsed; entriesFor() alone is a function call and would not. const categories = root.catalogCategories; if (categories.length === 0 || root.activeCategory === "") return []; return AppLibrary.entriesFor(root.activeCategory) ?? []; } // ── The launcher's own chord, read from the keymap ─────────────────────── // Hardcoding "Super+Space" survived two rebinds of the launcher and told // the wrong story both times. The keymap is already loaded; this reads it. readonly property var launcherChords: (Keybinds.binds ?? []) .filter(bind => String(bind.description ?? "") === "Launcher") .map(bind => String(bind.chord ?? "")) .filter(chord => chord !== "") // AppLibrary loads nothing on its own: `flatpak list` and the catalog cost // a process each and only this page wants them, so a shell start where // nobody opens Applications pays for neither. Component.onCompleted: { AppLibrary.refresh(); AppLibrary.refreshCatalog(); } TextRow { visible: DefaultApps.lastError !== "" label: "Application settings need attention" detail: DefaultApps.lastError value: "" divider: false } TextRow { visible: String(AppLibrary.lastError ?? "") !== "" label: "The application list needs attention" detail: String(AppLibrary.lastError ?? "") value: "" divider: false } // ── What is installed ──────────────────────────────────────────────────── SettingsCard { title: "Installed applications" subtitle: !AppLibrary.flatpaksLoaded ? "Reading the applications installed on this machine…" : (AppLibrary.apps ?? []).length + " installed · " + AppLibrary.flatpakCount + " through Flatpak" SearchField { id: installedSearch width: parent.width placeholder: "Search installed applications" } Item { width: 1; height: 8 } Repeater { model: root.installedApps.slice(0, root.installedLimit) delegate: InstalledAppRow { id: installedRow required property var modelData required property int index readonly property bool open: root.expandedApp === String(installedRow.modelData.entryId ?? "") width: parent.width app: installedRow.modelData expanded: installedRow.open // A write in flight, not a read: the catalog refresh this page // starts on open must not grey out every row for a second. busy: AppLibrary.busyEntryId !== "" || DefaultApps.busy divider: installedRow.index < Math.min(root.installedApps.length, root.installedLimit) - 1 autostart: root.autostartOn(installedRow.modelData) flatsealAvailable: root.flatsealAvailable permissionsKnown: installedRow.open && root.expandedPermissions !== null permissionSummary: installedRow.open && root.expandedPermissions ? root.expandedPermissions.summary : [] hasNotificationRule: root.hasNotificationRule(installedRow.modelData) hasSoundRule: root.hasSoundRule(installedRow.modelData) hasPrivacyRule: root.hasPrivacyRule(installedRow.modelData) confirmingUninstall: root.pendingUninstall !== "" && root.pendingUninstall === String(installedRow.modelData.flatpakId ?? "") onActivated: { root.pendingUninstall = ""; root.expandedApp = installedRow.open ? "" : String(installedRow.modelData.entryId ?? ""); } onAutostartToggled: value => root.setAppAutostart(installedRow.modelData, value) onJumped: page => ShellState.openSettings(page) onFlatsealRequested: Quickshell.execDetached( ["flatpak", "run", "com.github.tchx84.Flatseal"]) onUninstallArmed: root.pendingUninstall = String(installedRow.modelData.flatpakId ?? "") onUninstallCancelled: root.pendingUninstall = "" onUninstallConfirmed: { const flatpakId = String(installedRow.modelData.flatpakId ?? ""); root.pendingUninstall = ""; root.expandedApp = ""; AppLibrary.uninstall(flatpakId); } } } TextRow { visible: root.installedApps.length > root.installedLimit label: (root.installedApps.length - root.installedLimit) + " more" detail: "Search finds any of them by name or by application id." value: "" divider: false } TextRow { visible: (AppLibrary.apps ?? []).length > 0 && root.installedApps.length === 0 label: "Nothing matches that" detail: "Both the application's name and its id are searched." value: "" divider: false } } // ── What could be installed ────────────────────────────────────────────── SettingsCard { title: "Browse the catalog" subtitle: "Panama's curated applications — the same list `panama apps` offers, installed the same way." Flow { width: parent.width spacing: 7 Repeater { model: root.catalogCategories delegate: SettingsChip { required property var modelData text: root.categoryLabel(modelData) active: root.categoryId(modelData) === root.activeCategory onClicked: root.catalogCategory = root.categoryId(modelData) } } } Item { width: 1; height: 10 } Repeater { model: root.catalogEntries delegate: SettingRow { id: catalogRow required property var modelData required property int index readonly property bool installed: catalogRow.modelData.installed === true readonly property bool flatpak: String(catalogRow.modelData.kind ?? "") === "flatpak" // `id` is the catalog line verbatim, "flatpak:" prefix and all, // because that is what install matches. `ref` is the same thing // with the prefix taken off, which is what a person reads. readonly property string ref: String(catalogRow.modelData.ref ?? catalogRow.modelData.id ?? "") width: parent.width label: String(catalogRow.modelData.label ?? "") !== "" ? String(catalogRow.modelData.label) : catalogRow.ref detail: catalogRow.flatpak ? catalogRow.ref + " · Flatpak, from Flathub" : catalogRow.ref + " · system package, so installing asks for your password" value: catalogRow.installed ? "Installed" : "" controlWidth: 110 divider: catalogRow.index < root.catalogEntries.length - 1 SettingsButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: !catalogRow.installed // Only the row being installed says so, rather than the // whole card greying out around one press. text: AppLibrary.busyEntryId === String(catalogRow.modelData.id ?? "") ? "Installing…" : "Install" tone: "accent" enabled: !AppLibrary.busy onClicked: AppLibrary.install(root.activeCategory, String(catalogRow.modelData.id ?? "")) } } } TextRow { visible: root.catalogCategories.length === 0 label: AppLibrary.catalogLoaded ? "The catalog is empty" : "Reading the catalog…" detail: "It is parsed from setup/packages/extras, the same files the command line uses." value: "" divider: false } } // ── What opens what ────────────────────────────────────────────────────── SettingsCard { title: "Default applications" subtitle: "Each role sets its whole family of types together, so one picture never opens somewhere different from the next." Repeater { model: root.roles delegate: OptionPickerRow { id: roleRow required property var modelData required property int index readonly property var choices: root.choicesForRole(roleRow.modelData) width: parent.width label: roleRow.modelData.label detail: roleRow.modelData.detail options: roleRow.choices.map(entry => ({ value: root.desktopId(entry), label: root.displayName(entry), detail: String(entry.genericName || entry.comment || root.desktopId(entry)) })) current: root.currentHandler(roleRow.modelData.key) enabled: roleRow.choices.length > 0 && !DefaultApps.busy value: DefaultApps.busy ? "Loading…" : ( roleRow.currentOption ? String(roleRow.currentOption.label) : (root.currentHandler(roleRow.modelData.key) || "Not set") ) onPicked: value => DefaultApps.setDefault(roleRow.modelData.key, String(value)) } } // The escape hatch from the paragraph above: one type, on its own. ActionRow { label: "One file type" detail: root.showingFileTypes ? "Search the type database and give a single type its own application" : "Override a single type when a role's family is too broad" action: root.showingFileTypes ? "Close" : "Choose" divider: root.showingFileTypes onTriggered: { root.showingFileTypes = !root.showingFileTypes; if (!root.showingFileTypes) DefaultApps.clearTypeSearch(); } } FileTypePicker { visible: root.showingFileTypes width: parent.width matches: DefaultApps.typeMatches ?? [] busy: DefaultApps.searchingTypes truncated: DefaultApps.typeSearchTruncated onQueried: query => DefaultApps.searchTypes(query) onChosen: (mime, desktopId) => DefaultApps.setType(mime, desktopId) } } // ── What starts with the session ───────────────────────────────────────── SettingsCard { title: "Autostart" subtitle: "Entries live in your user configuration. The switch in an application's own row above writes the same files." 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 label: "No user autostart entries" detail: "Applications can add entries to ~/.config/autostart." value: "" } Repeater { model: DefaultApps.autostartEntries delegate: SettingRow { id: autostartRow required property var modelData required property int index readonly property bool confirming: root.confirmingAutostartRemoval === String(autostartRow.modelData.id) label: autostartRow.modelData.name detail: autostartRow.confirming ? "Removing deletes this entry. Turning it off instead is reversible." : autostartRow.modelData.id controlWidth: 210 // A switch, not the words "Enabled"/"Disabled". The row always // toggled on click, but read as static text, so a control that // worked looked like a status nobody could change. Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 9 SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: autostartRow.confirming text: "Remove it" tone: "danger" enabled: !DefaultApps.busy onClicked: { root.confirmingAutostartRemoval = ""; DefaultApps.removeAutostart(String(autostartRow.modelData.id)); } } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: autostartRow.confirming ? "Keep" : "Remove…" enabled: !DefaultApps.busy onClicked: root.confirmingAutostartRemoval = autostartRow.confirming ? "" : String(autostartRow.modelData.id) } SettingsToggle { anchors.verticalCenter: parent.verticalCenter checked: autostartRow.modelData.enabled onToggled: value => DefaultApps.setAutostart(autostartRow.modelData.id, value) } } } } // The compositor's own autostart, described rather than offered. These // are started from config/dot/hypr/autostart.lua, which is where they // are changed -- a switch here would edit a file the compositor reads // once at launch and give no sign that nothing had happened. TextRow { label: "Compositor autostart" detail: "Started from the Hyprland configuration, and read-only here." value: DefaultApps.luaAutostartEntries.length > 0 ? DefaultApps.luaAutostartEntries.length + (DefaultApps.luaAutostartEntries.length === 1 ? " entry" : " entries") : "" } TextRow { visible: !DefaultApps.busy && 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 } } } // ── Searching from the launcher ────────────────────────────────────────── // GNOME's Search panel, answered honestly. // // It configures which applications provide results in gnome-shell's // overview and which folders are indexed. gnome-shell does not run here, // so those settings would do nothing. Under Panama searching is the // launcher's job, and Vicinae carries its own preferences -- reimplementing // them here would give two places to change one thing. SettingsCard { title: "Search" subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher." TextEntryRow { setting: "webSearchUrl"; placeholder: "https://duckduckgo.com/?q=" } TextRow { label: "Launcher" detail: SystemSettings.vicinaeActive ? "Running as a user service" : "Not running — Super+Shift+R opens the fallback launcher" value: "Vicinae" } TextRow { label: "Open search" detail: root.launcherChords.length > 1 ? root.launcherChords.join(", ") + " all open it, because Super+A and Super+R" + " were GNOME's app grid and run dialog" : "Rebindable from the keyboard page, like every other launcher chord" // The keymap is the source of truth; the literal is what shows // while it is still being read. value: root.launcherChords.length > 0 ? root.launcherChords[0] : "Super + Space" } ActionRow { label: "Change these shortcuts" detail: "Every launcher chord is rebindable, including clipboard history and emoji" action: "Open keyboard" divider: false onTriggered: ShellState.openSettings("shortcuts") } } SettingsCard { title: "Refresh" ActionRow { label: "Reload application settings" detail: "Re-read desktop entries, defaults, user autostart files, and the installed list" action: DefaultApps.busy || AppLibrary.busy ? "Refreshing…" : "Refresh" enabled: !DefaultApps.busy && !AppLibrary.busy divider: false onTriggered: { DefaultApps.refresh(); AppLibrary.refresh(); AppLibrary.refreshCatalog(); } } } }