Files

703 lines
31 KiB
QML

// 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: {
if (!AppLibrary.flatpaksLoaded)
AppLibrary.refresh();
if (!AppLibrary.catalogLoaded)
AppLibrary.refreshCatalog();
}
ErrorRow {
label: "Application settings need attention"
message: DefaultApps.lastError
}
ErrorRow {
label: "The application list needs attention"
message: String(AppLibrary.lastError ?? "")
}
// ── 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();
}
}
}
}