Make Applications a real app manager, and clean up storage without the racket
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,22 +1,56 @@
|
||||
// Applications and session startup.
|
||||
// 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: "Choose what opens your files and links, and what starts with your session."
|
||||
lede: "What is installed, what opens your files, and what starts with your session."
|
||||
|
||||
property string expandedRole: ""
|
||||
property bool addingAutostart: false
|
||||
// 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
|
||||
@@ -90,6 +124,154 @@ SettingsPage {
|
||||
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"
|
||||
@@ -98,143 +280,248 @@ SettingsPage {
|
||||
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: "Open a row to choose from applications that advertise the matching role."
|
||||
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: Column {
|
||||
id: roleBlock
|
||||
delegate: OptionPickerRow {
|
||||
id: roleRow
|
||||
|
||||
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)
|
||||
|
||||
readonly property var choices: root.choicesForRole(roleRow.modelData)
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
id: roleRow
|
||||
|
||||
readonly property bool open: root.expandedRole === roleBlock.modelData.key
|
||||
|
||||
label: roleBlock.modelData.label
|
||||
detail: roleBlock.modelData.detail
|
||||
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
|
||||
controlWidth: 210
|
||||
|
||||
// Drawn rather than left to SettingRow's plain value text, so
|
||||
// the row carries the same chevron a PickerRow does. These
|
||||
// open a chooser but looked completely inert without it.
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: DefaultApps.busy ? "Loading…" : (
|
||||
roleBlock.selectedEntry
|
||||
? root.displayName(roleBlock.selectedEntry)
|
||||
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
|
||||
)
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: roleBlock.choices.length > 0
|
||||
text: roleRow.open ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
|
||||
value: "Super+Space"
|
||||
}
|
||||
|
||||
// The escape hatch from the paragraph above: one type, on its own.
|
||||
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")
|
||||
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: "User autostart"
|
||||
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
|
||||
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"
|
||||
@@ -262,7 +549,6 @@ SettingsPage {
|
||||
label: "No user autostart entries"
|
||||
detail: "Applications can add entries to ~/.config/autostart."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
@@ -281,7 +567,6 @@ SettingsPage {
|
||||
detail: autostartRow.confirming
|
||||
? "Removing deletes this entry. Turning it off instead is reversible."
|
||||
: autostartRow.modelData.id
|
||||
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
|
||||
controlWidth: 210
|
||||
|
||||
// A switch, not the words "Enabled"/"Disabled". The row always
|
||||
@@ -320,11 +605,19 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Compositor autostart"
|
||||
subtitle: "These are started from the Hyprland configuration. They are read-only here."
|
||||
// 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
|
||||
@@ -351,16 +644,63 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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, and user autostart files"
|
||||
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
|
||||
enabled: !DefaultApps.busy
|
||||
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()
|
||||
onTriggered: {
|
||||
DefaultApps.refresh();
|
||||
AppLibrary.refresh();
|
||||
AppLibrary.refreshCatalog();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Overriding one file type, when the role above it covers too much.
|
||||
//
|
||||
// The role rows set a whole family together on purpose -- a file manager must
|
||||
// not open one image in a viewer and its neighbour in an editor. That is right
|
||||
// almost always and wrong exactly once: the one type somebody wants somewhere
|
||||
// else. This is that escape hatch, and it is deliberately a search rather than
|
||||
// a list, because the MIME database has thousands of entries and none of them
|
||||
// is worth scrolling past.
|
||||
//
|
||||
// Nothing is written here. The page owns the service call, so a type set from
|
||||
// this row goes down the same validated path as a role does.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// [{ mime, label, extensions, handler, handlerName, candidates: [{ id, name }] }]
|
||||
property var matches: []
|
||||
property bool busy: false
|
||||
property bool truncated: false
|
||||
|
||||
// The helper answers nothing shorter than this, so the row says so rather
|
||||
// than looking broken while somebody types the first letter.
|
||||
readonly property int minimumQuery: 2
|
||||
|
||||
// Emitted after the typing settles, so a query is one search rather than
|
||||
// one per keystroke.
|
||||
signal queried(string query)
|
||||
signal chosen(string mime, string desktopId)
|
||||
|
||||
readonly property string query: search.text.trim()
|
||||
|
||||
function typeLabel(match: var): string {
|
||||
const label = String(match?.label ?? "").trim();
|
||||
return label !== "" ? label : String(match?.mime ?? "");
|
||||
}
|
||||
|
||||
function typeDetail(match: var): string {
|
||||
const parts = [String(match?.mime ?? "")];
|
||||
const extensions = match?.extensions ?? [];
|
||||
if (extensions.length > 0)
|
||||
parts.push(extensions.join(" "));
|
||||
const handler = String(match?.handlerName ?? match?.handler ?? "").trim();
|
||||
parts.push(handler === "" ? "nothing opens it yet" : "opens with " + handler);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function optionsFor(match: var): var {
|
||||
return (match?.candidates ?? []).map(candidate => ({
|
||||
value: String(candidate.id ?? ""),
|
||||
label: String(candidate.name ?? candidate.id ?? ""),
|
||||
detail: String(candidate.id ?? "")
|
||||
}));
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
SearchField {
|
||||
id: search
|
||||
|
||||
width: parent.width
|
||||
placeholder: "png, pdf, video/… — the type or its extension"
|
||||
onTextChanged: settle.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: settle
|
||||
interval: 250
|
||||
onTriggered: root.queried(search.text.trim())
|
||||
}
|
||||
|
||||
Item { width: 1; height: 8 }
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: root.query.length < root.minimumQuery
|
||||
label: "Type what you are looking for"
|
||||
detail: "The type and its file extensions are both searched, so \"svg\" and"
|
||||
+ " \"image/svg+xml\" land on the same row. Two letters is the shortest"
|
||||
+ " search the type database answers."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: root.busy && root.query.length >= root.minimumQuery
|
||||
label: "Searching the type database…"
|
||||
detail: "Reading which applications advertise each type"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.busy ? [] : root.matches
|
||||
|
||||
delegate: OptionPickerRow {
|
||||
id: typeRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: root.typeLabel(typeRow.modelData)
|
||||
detail: root.typeDetail(typeRow.modelData)
|
||||
options: root.optionsFor(typeRow.modelData)
|
||||
current: String(typeRow.modelData.handler ?? "")
|
||||
enabled: typeRow.options.length > 0
|
||||
divider: typeRow.index < root.matches.length - 1
|
||||
value: typeRow.currentOption
|
||||
? String(typeRow.currentOption.label)
|
||||
: (typeRow.options.length === 0 ? "Nothing offers it" : "Not set")
|
||||
onPicked: value => root.chosen(String(typeRow.modelData.mime ?? ""), String(value))
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !root.busy && root.truncated
|
||||
label: "Only the closest matches are shown"
|
||||
detail: "Narrow the search to reach the rest."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !root.busy && root.query.length >= root.minimumQuery
|
||||
&& root.matches.length === 0
|
||||
label: "No file type matches that"
|
||||
detail: "Only types this system knows about can be given a handler."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
// One installed application: a line you can read, unfolding in place into
|
||||
// everything this desktop can honestly say about it.
|
||||
//
|
||||
// Built like NotificationAppRow rather than out of SettingRow, for the same
|
||||
// reason -- the head carries the application's own icon and a badge saying
|
||||
// where it came from, and neither fits a row whose trailing slot is one fixed
|
||||
// width. What is different here is that the body's contents depend on where the
|
||||
// application came from: a Flatpak can be inspected and removed, a system
|
||||
// package can only be described.
|
||||
//
|
||||
// Nothing in here writes. Every control leaves through a signal so the page
|
||||
// owns the service calls, which is what keeps the uninstall confirmation a
|
||||
// single piece of page state rather than something each row remembers for
|
||||
// itself.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// { entryId, name, icon, kind: "flatpak"|"system", flatpakId, size }
|
||||
required property var app
|
||||
|
||||
property bool expanded: false
|
||||
property bool divider: true
|
||||
property bool busy: false
|
||||
|
||||
// Whether ~/.config/autostart currently starts this application.
|
||||
property bool autostart: false
|
||||
|
||||
// The helper's curated permission summary, as a list of human strings.
|
||||
// Empty while it is still being read, which the row says rather than
|
||||
// showing a blank line.
|
||||
property var permissionSummary: []
|
||||
property bool permissionsKnown: false
|
||||
property bool flatsealAvailable: false
|
||||
|
||||
// Jump chips appear only where a rule for this application actually
|
||||
// exists somewhere else. A chip that lands on a page with nothing about
|
||||
// this application on it is worse than no chip.
|
||||
property bool hasNotificationRule: false
|
||||
property bool hasSoundRule: false
|
||||
property bool hasPrivacyRule: false
|
||||
|
||||
// Set by the page while this row's uninstall is one press from happening.
|
||||
property bool confirmingUninstall: false
|
||||
|
||||
signal activated
|
||||
signal autostartToggled(bool value)
|
||||
signal jumped(string page)
|
||||
signal flatsealRequested
|
||||
signal uninstallArmed
|
||||
signal uninstallCancelled
|
||||
signal uninstallConfirmed
|
||||
|
||||
readonly property string appName: String(root.app?.name ?? "")
|
||||
readonly property string entryId: String(root.app?.entryId ?? "")
|
||||
readonly property string flatpakId: String(root.app?.flatpakId ?? "")
|
||||
readonly property bool flatpak: String(root.app?.kind ?? "") === "flatpak"
|
||||
|
||||
// The dnf package name, for the refusal row's exact command. The desktop
|
||||
// entry id is the closest thing to it this desktop can know without asking
|
||||
// rpm about every application on the machine, so the row says "the package
|
||||
// that provides" rather than claiming the name is the package.
|
||||
readonly property string systemName: {
|
||||
const id = root.entryId.replace(/\.desktop$/, "");
|
||||
const short = id.split(".").pop();
|
||||
return short !== "" ? short : id;
|
||||
}
|
||||
|
||||
// `sizeBytes` when the helper could turn flatpak's answer into a number and
|
||||
// `size` -- flatpak's own "412.5 MB" -- when it could not. Whichever
|
||||
// arrived is shown as it arrived, rather than one being coerced into the
|
||||
// other and rounded to nothing.
|
||||
readonly property string subtitle: {
|
||||
if (!root.flatpak)
|
||||
return "Installed by the system package manager";
|
||||
const bytes = Number(root.app?.sizeBytes ?? 0);
|
||||
if (Number.isFinite(bytes) && bytes > 0)
|
||||
return root.flatpakId + " · " + root.formatBytes(bytes);
|
||||
const text = String(root.app?.size ?? "").trim();
|
||||
return text === "" || text === "0" ? root.flatpakId : root.flatpakId + " · " + text;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: real): string {
|
||||
if (!(bytes > 0))
|
||||
return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let index = 0;
|
||||
while (value >= 1000 && index < units.length - 1) {
|
||||
value /= 1000;
|
||||
index += 1;
|
||||
}
|
||||
return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
|
||||
}
|
||||
|
||||
readonly property string iconSource: {
|
||||
const name = String(root.app?.icon ?? "");
|
||||
return name === "" ? "" : Quickshell.iconPath(name, true);
|
||||
}
|
||||
|
||||
// A stable colour per application, so the tile keeps its identity as the
|
||||
// list re-sorts or the search narrows it.
|
||||
readonly property color tileColor: {
|
||||
const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan,
|
||||
Theme.green, Theme.orange, Theme.pink];
|
||||
let hash = 0;
|
||||
for (let index = 0; index < root.entryId.length; index++)
|
||||
hash = (hash * 31 + root.entryId.charCodeAt(index)) % 9973;
|
||||
return palette[hash % palette.length];
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
id: head
|
||||
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(56, copy.implicitHeight + 20)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.bottomMargin: 1
|
||||
radius: 9
|
||||
z: -1
|
||||
visible: headHover.hovered
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 30
|
||||
height: 30
|
||||
radius: 8
|
||||
color: root.iconSource === "" ? root.tileColor : "transparent"
|
||||
border.width: 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.iconSource === ""
|
||||
text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : "?"
|
||||
color: Theme.bgDark
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
implicitSize: 24
|
||||
asynchronous: true
|
||||
visible: root.iconSource !== ""
|
||||
source: root.iconSource
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
anchors.left: tile.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: chevron.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.min(implicitWidth, Math.max(0, parent.width - badge.width - 8))
|
||||
text: root.appName
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Where it came from, said once and in the same place on every
|
||||
// row -- the difference decides what the body can offer.
|
||||
Rectangle {
|
||||
id: badge
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: badgeText.implicitWidth + 14
|
||||
height: 17
|
||||
radius: Theme.pillRadius
|
||||
color: root.flatpak
|
||||
? Theme.alpha(Theme.cyan, 0.09)
|
||||
: Theme.alpha(Theme.fgMuted, 0.12)
|
||||
border.width: 1
|
||||
border.color: root.flatpak
|
||||
? Theme.alpha(Theme.cyan, 0.28)
|
||||
: Theme.alpha(Theme.fgMuted, 0.3)
|
||||
|
||||
Text {
|
||||
id: badgeText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: root.flatpak ? "FLATPAK" : "SYSTEM"
|
||||
color: root.flatpak ? Theme.cyan : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.subtitle
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: chevron
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: copy.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider && !root.expanded
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: headHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.activated()
|
||||
}
|
||||
}
|
||||
|
||||
// Indented under the head, so what is inside reads as belonging to the
|
||||
// application above it rather than to the card.
|
||||
Column {
|
||||
id: body
|
||||
|
||||
x: 42
|
||||
width: Math.max(0, parent.width - 42)
|
||||
visible: root.expanded
|
||||
|
||||
// ── What it can reach ────────────────────────────────────────────────
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.flatpak
|
||||
label: "Permissions"
|
||||
detail: !root.permissionsKnown
|
||||
? "Reading what this application is allowed to reach…"
|
||||
: (root.permissionSummary.length > 0
|
||||
? root.permissionSummary.join(" · ")
|
||||
: "Nothing beyond its own sandbox")
|
||||
controlWidth: root.flatsealAvailable ? 130 : 0
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.flatsealAvailable
|
||||
text: "Open Flatseal"
|
||||
onClicked: root.flatsealRequested()
|
||||
}
|
||||
}
|
||||
|
||||
// The honest refusal, with the command that does the thing this page
|
||||
// will not. Removing a system package can take the desktop with it, so
|
||||
// it happens where the consequences are visible.
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !root.flatpak
|
||||
label: "Managed by dnf"
|
||||
detail: "Settings does not remove system packages — removing one can take the desktop"
|
||||
+ " with it. To remove the package that provides this: sudo dnf remove "
|
||||
+ root.systemName
|
||||
value: ""
|
||||
}
|
||||
|
||||
// ── What it does at sign-in ──────────────────────────────────────────
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: "Start with the session"
|
||||
detail: root.autostart
|
||||
? "An entry in ~/.config/autostart starts this when you sign in"
|
||||
: "Nothing starts this automatically"
|
||||
controlWidth: 48
|
||||
divider: root.flatpak || root.hasNotificationRule
|
||||
|| root.hasSoundRule || root.hasPrivacyRule
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.autostart
|
||||
onToggled: value => root.autostartToggled(value)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Where its other settings live ────────────────────────────────────
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.hasNotificationRule || root.hasSoundRule || root.hasPrivacyRule
|
||||
label: "Elsewhere in Settings"
|
||||
detail: "This application already has a rule on these pages"
|
||||
controlWidth: 270
|
||||
divider: root.flatpak
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
SettingsChip {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.hasNotificationRule
|
||||
text: "Notifications"
|
||||
onClicked: root.jumped("notifications")
|
||||
}
|
||||
|
||||
SettingsChip {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.hasSoundRule
|
||||
text: "Sound"
|
||||
onClicked: root.jumped("sound")
|
||||
}
|
||||
|
||||
SettingsChip {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.hasPrivacyRule
|
||||
text: "Privacy"
|
||||
onClicked: root.jumped("privacy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Removing it ──────────────────────────────────────────────────────
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.flatpak
|
||||
label: "Uninstall"
|
||||
detail: root.confirmingUninstall
|
||||
? "This removes the application. Data it kept under ~/.var/app stays behind."
|
||||
: "Removes the application, and its runtime if nothing else needs it"
|
||||
controlWidth: 210
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.confirmingUninstall
|
||||
text: "Uninstall it"
|
||||
tone: "danger"
|
||||
enabled: !root.busy
|
||||
onClicked: root.uninstallConfirmed()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.confirmingUninstall ? "Keep" : "Uninstall…"
|
||||
enabled: !root.busy
|
||||
onClicked: root.confirmingUninstall
|
||||
? root.uninstallCancelled()
|
||||
: root.uninstallArmed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// A pill-shaped button, for the places a row's trailing slot needs several
|
||||
// small targets rather than one.
|
||||
//
|
||||
// Two of them exist on the Applications page and they want the same shape: the
|
||||
// catalog's category filter, where one chip is active and the rest are not, and
|
||||
// the per-application "elsewhere in Settings" links, where none is. `active`
|
||||
// covers the first case; leaving it false gives the second.
|
||||
//
|
||||
// SettingsButton stays the control for a row's primary action -- it is taller,
|
||||
// squarer, and reads as a button. A chip reads as a tag you can press.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property bool active: false
|
||||
|
||||
signal clicked
|
||||
|
||||
implicitWidth: caption.implicitWidth + 22
|
||||
implicitHeight: 26
|
||||
radius: Theme.pillRadius
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
color: root.active
|
||||
? Theme.alpha(Theme.accent, 0.14)
|
||||
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.12 : 0.055)
|
||||
border.width: root.activeFocus || root.active ? 2 : 1
|
||||
border.color: root.activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (root.active ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.1))
|
||||
activeFocusOnTab: root.enabled
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.text
|
||||
Accessible.focusable: root.enabled
|
||||
Accessible.focused: root.activeFocus
|
||||
Accessible.onPressAction: root.clicked()
|
||||
|
||||
Keys.onReturnPressed: root.clicked()
|
||||
Keys.onSpacePressed: root.clicked()
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.active ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: chipHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.clicked()
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,16 @@
|
||||
// Inside a volume, the timeline is the Time Machine view: points in time,
|
||||
// newest first, each one openable as a folder tree you can take a file out of.
|
||||
//
|
||||
// Rollback is deliberately absent. snapper's rollback changes the btrfs default
|
||||
// subvolume, and this system's fstab pins subvol= explicitly, which overrides
|
||||
// it -- so it would report success and change nothing after a reboot.
|
||||
// The browser is its own card, not a section inside a volume card. It used to
|
||||
// live inside, behind `volumeCard.open` -- so opening a point in time from a
|
||||
// collapsed card set the browsing state, drew nothing, and hid the timeline as
|
||||
// well. Whether a volume's older points are showing has nothing to do with
|
||||
// whether you are looking inside one of them.
|
||||
//
|
||||
// Going back to a previous state of the whole machine is deliberately absent.
|
||||
// snapper's version of it changes the btrfs default subvolume, and this
|
||||
// system's fstab pins subvol= explicitly, which overrides it -- so it would
|
||||
// report success and change nothing after a reboot.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
@@ -34,7 +41,38 @@ SettingsPage {
|
||||
property string confirmingDelete: ""
|
||||
property string confirmingRestore: ""
|
||||
|
||||
readonly property bool browsingOpen: Snapshots.browsingConfig !== ""
|
||||
// The service's own answer, kept under the page's name so every `visible:`
|
||||
// below reads as "because something is being browsed" and never as
|
||||
// "because a volume card happens to be expanded".
|
||||
readonly property bool browsingOpen: Snapshots.browserOpen
|
||||
|
||||
readonly property var browsingConfigRecord: Snapshots.configs.find(
|
||||
config => String(config.name ?? "") === Snapshots.browsingConfig) ?? null
|
||||
|
||||
// How many of each horizon the timeline keeps. A subset of the 0-50 the
|
||||
// service accepts, because a dropdown of fifty-one numbers is a list, not a
|
||||
// choice -- and whatever the configuration is actually set to is added to
|
||||
// it, so a snapper default of 24 hourly is a row you can see rather than a
|
||||
// value the picker cannot display.
|
||||
readonly property var retentionCounts: [0, 1, 2, 3, 5, 7, 10, 14, 21, 30, 50]
|
||||
|
||||
function retentionOptions(unit: string, current: int): var {
|
||||
const counts = root.retentionCounts.slice();
|
||||
if (counts.indexOf(current) < 0 && current >= 0 && current <= Snapshots.retentionMax)
|
||||
counts.push(current);
|
||||
counts.sort((left, right) => left - right);
|
||||
return counts.map(count => ({
|
||||
value: count,
|
||||
label: count === 0 ? "None kept" : count + " " + unit,
|
||||
detail: count === 0
|
||||
? "Nothing is kept on this horizon"
|
||||
: ""
|
||||
}));
|
||||
}
|
||||
|
||||
function retentionLimit(config: var, horizon: string): int {
|
||||
return Number((config?.limits ?? ({}))[horizon] ?? 0);
|
||||
}
|
||||
|
||||
Component.onCompleted: Snapshots.refresh()
|
||||
|
||||
@@ -96,6 +134,8 @@ SettingsPage {
|
||||
readonly property string configName: String(volumeCard.modelData.name ?? "")
|
||||
readonly property var snapshots: volumeCard.modelData.snapshots ?? []
|
||||
readonly property bool open: root.openConfig === volumeCard.configName
|
||||
readonly property bool editable: !Snapshots.busy
|
||||
&& volumeCard.modelData.readable === true
|
||||
readonly property int shownCount: volumeCard.open
|
||||
? volumeCard.snapshots.length
|
||||
: Math.min(root.previewCount, volumeCard.snapshots.length)
|
||||
@@ -109,21 +149,75 @@ SettingsPage {
|
||||
? Snapshots.describe(volumeCard.modelData)
|
||||
: "Nothing is being taken for this volume"
|
||||
checked: volumeCard.modelData.timelineEnabled === true
|
||||
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
|
||||
enabled: volumeCard.editable
|
||||
onToggled: value => Snapshots.setTimeline(volumeCard.configName, value)
|
||||
}
|
||||
|
||||
// The three horizons, editable. This was a read-only summary of
|
||||
// numbers only snapper's config file could change, sitting one
|
||||
// line above a service function nothing ever called.
|
||||
TextRow {
|
||||
label: "Keep"
|
||||
detail: "Older points are removed automatically once these counts are exceeded"
|
||||
value: Snapshots.retentionSummary(volumeCard.modelData)
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
id: hourlyRow
|
||||
|
||||
width: parent.width
|
||||
label: "Hourly"
|
||||
detail: "Points taken on the hour, and the first to be removed"
|
||||
options: root.retentionOptions(
|
||||
"hourly", root.retentionLimit(volumeCard.modelData, "hourly"))
|
||||
current: root.retentionLimit(volumeCard.modelData, "hourly")
|
||||
enabled: volumeCard.editable
|
||||
onPicked: value => Snapshots.setRetention(
|
||||
volumeCard.configName,
|
||||
Number(value),
|
||||
root.retentionLimit(volumeCard.modelData, "daily"),
|
||||
root.retentionLimit(volumeCard.modelData, "weekly"))
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
id: dailyRow
|
||||
|
||||
width: parent.width
|
||||
label: "Daily"
|
||||
detail: "One point kept per day, for this many days"
|
||||
options: root.retentionOptions(
|
||||
"daily", root.retentionLimit(volumeCard.modelData, "daily"))
|
||||
current: root.retentionLimit(volumeCard.modelData, "daily")
|
||||
enabled: volumeCard.editable
|
||||
onPicked: value => Snapshots.setRetention(
|
||||
volumeCard.configName,
|
||||
root.retentionLimit(volumeCard.modelData, "hourly"),
|
||||
Number(value),
|
||||
root.retentionLimit(volumeCard.modelData, "weekly"))
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
id: weeklyRow
|
||||
|
||||
width: parent.width
|
||||
label: "Weekly"
|
||||
detail: "One point kept per week, for this many weeks — the longest reach back"
|
||||
options: root.retentionOptions(
|
||||
"weekly", root.retentionLimit(volumeCard.modelData, "weekly"))
|
||||
current: root.retentionLimit(volumeCard.modelData, "weekly")
|
||||
enabled: volumeCard.editable
|
||||
onPicked: value => Snapshots.setRetention(
|
||||
volumeCard.configName,
|
||||
root.retentionLimit(volumeCard.modelData, "hourly"),
|
||||
root.retentionLimit(volumeCard.modelData, "daily"),
|
||||
Number(value))
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Take one now"
|
||||
detail: "Kept until you remove it, unlike the automatic ones"
|
||||
action: "Take snapshot"
|
||||
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
|
||||
enabled: volumeCard.editable
|
||||
onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings")
|
||||
}
|
||||
|
||||
@@ -139,7 +233,6 @@ SettingsPage {
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: !root.browsingOpen
|
||||
|
||||
Repeater {
|
||||
model: volumeCard.open
|
||||
@@ -154,6 +247,9 @@ SettingsPage {
|
||||
|
||||
readonly property string token: volumeCard.configName + ":" + pointRow.modelData.number
|
||||
readonly property bool confirming: root.confirmingDelete === pointRow.token
|
||||
readonly property bool browsingThis: root.browsingOpen
|
||||
&& Snapshots.browsingConfig === volumeCard.configName
|
||||
&& Snapshots.browsingSnapshot === Number(pointRow.modelData.number)
|
||||
|
||||
width: parent.width
|
||||
// A kept snapshot is one the timeline will not remove,
|
||||
@@ -164,6 +260,7 @@ SettingsPage {
|
||||
detail: String(pointRow.modelData.description ?? "")
|
||||
+ " · #" + pointRow.modelData.number
|
||||
+ (pointRow.modelData.kept ? " · kept" : "")
|
||||
+ (pointRow.browsingThis ? " · open below" : "")
|
||||
controlWidth: 250
|
||||
divider: pointRow.index < volumeCard.shownCount - 1
|
||||
|
||||
@@ -173,7 +270,7 @@ SettingsPage {
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Open"
|
||||
text: pointRow.browsingThis ? "Open again" : "Open"
|
||||
enabled: !Snapshots.browsing
|
||||
onClicked: Snapshots.browse(volumeCard.configName,
|
||||
Number(pointRow.modelData.number), "")
|
||||
@@ -224,119 +321,128 @@ SettingsPage {
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: volumeCard.open ? "Show fewer \u25B4" : "Show all \u25BE"
|
||||
text: volumeCard.open ? "Show fewer ▴" : "Show all ▾"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside one point in time ─────────────────────────────────────
|
||||
// ── Inside one point in time ─────────────────────────────────────────────
|
||||
//
|
||||
// Its own card, rendered on the browsing state alone. Nothing about which
|
||||
// volume card happens to be expanded can hide it.
|
||||
|
||||
SettingsCard {
|
||||
visible: root.browsingOpen
|
||||
title: "Browsing " + (root.browsingConfigRecord
|
||||
? Snapshots.labelFor(root.browsingConfigRecord)
|
||||
: Snapshots.browsingConfig)
|
||||
+ " · #" + Snapshots.browsingSnapshot
|
||||
subtitle: Snapshots.browsingPath === ""
|
||||
? "The top of that point in time."
|
||||
: "Inside " + Snapshots.browsingPath + "."
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: Snapshots.browsingPath === ""
|
||||
? "Close this point in time"
|
||||
: "…/" + Snapshots.browsingPath
|
||||
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
|
||||
action: Snapshots.browsingPath === "" ? "Close" : "Back"
|
||||
enabled: !Snapshots.browsing
|
||||
onTriggered: Snapshots.browseUp()
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Snapshots.browsing
|
||||
label: "Reading the snapshot…"
|
||||
detail: "Listing a folder from a point in time"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Snapshots.browsing ? [] : Snapshots.browseEntries
|
||||
|
||||
delegate: SettingRow {
|
||||
id: entryRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string entryPath: Snapshots.browsingPath === ""
|
||||
? String(entryRow.modelData.name)
|
||||
: Snapshots.browsingPath + "/" + String(entryRow.modelData.name)
|
||||
readonly property bool confirming: root.confirmingRestore === entryRow.entryPath
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: volumeCard.open && root.browsingOpen
|
||||
&& Snapshots.browsingConfig === volumeCard.configName
|
||||
icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}"
|
||||
label: String(entryRow.modelData.name ?? "")
|
||||
detail: entryRow.modelData.directory
|
||||
? "Folder"
|
||||
: Snapshots.formatBytes(entryRow.modelData.bytes ?? 0)
|
||||
controlWidth: 230
|
||||
divider: entryRow.index < Snapshots.browseEntries.length - 1
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: Snapshots.browsingPath === ""
|
||||
? "Snapshot #" + Snapshots.browsingSnapshot
|
||||
: "…/" + Snapshots.browsingPath
|
||||
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
|
||||
action: "Back"
|
||||
enabled: !Snapshots.browsing
|
||||
onTriggered: Snapshots.browseUp()
|
||||
}
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Snapshots.browsing
|
||||
label: "Reading the snapshot…"
|
||||
detail: "Listing a folder from a point in time"
|
||||
value: ""
|
||||
}
|
||||
SettingsButton {
|
||||
visible: entryRow.modelData.directory === true
|
||||
text: "Open"
|
||||
enabled: !Snapshots.browsing
|
||||
onClicked: Snapshots.browse(Snapshots.browsingConfig,
|
||||
Snapshots.browsingSnapshot,
|
||||
entryRow.entryPath)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Snapshots.browsing ? [] : Snapshots.browseEntries
|
||||
SettingsButton {
|
||||
text: entryRow.confirming ? "Cancel" : "Restore"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: root.confirmingRestore =
|
||||
entryRow.confirming ? "" : entryRow.entryPath
|
||||
}
|
||||
|
||||
delegate: SettingRow {
|
||||
id: entryRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string entryPath: Snapshots.browsingPath === ""
|
||||
? String(entryRow.modelData.name)
|
||||
: Snapshots.browsingPath + "/" + String(entryRow.modelData.name)
|
||||
readonly property bool confirming: root.confirmingRestore === entryRow.entryPath
|
||||
|
||||
width: parent.width
|
||||
icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}"
|
||||
label: String(entryRow.modelData.name ?? "")
|
||||
detail: entryRow.modelData.directory
|
||||
? "Folder"
|
||||
: Snapshots.formatBytes(entryRow.modelData.bytes ?? 0)
|
||||
controlWidth: 230
|
||||
divider: entryRow.index < Snapshots.browseEntries.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
visible: entryRow.modelData.directory === true
|
||||
text: "Open"
|
||||
enabled: !Snapshots.browsing
|
||||
onClicked: Snapshots.browse(Snapshots.browsingConfig,
|
||||
Snapshots.browsingSnapshot,
|
||||
entryRow.entryPath)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: entryRow.confirming ? "Cancel" : "Restore"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: root.confirmingRestore =
|
||||
entryRow.confirming ? "" : entryRow.entryPath
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: entryRow.confirming
|
||||
text: "Put it back"
|
||||
tone: "danger"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: {
|
||||
root.confirmingRestore = "";
|
||||
Snapshots.restore(Snapshots.browsingConfig,
|
||||
Snapshots.browsingSnapshot,
|
||||
entryRow.entryPath);
|
||||
}
|
||||
}
|
||||
SettingsButton {
|
||||
visible: entryRow.confirming
|
||||
text: "Put it back"
|
||||
tone: "danger"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: {
|
||||
root.confirmingRestore = "";
|
||||
Snapshots.restore(Snapshots.browsingConfig,
|
||||
Snapshots.browsingSnapshot,
|
||||
entryRow.entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !Snapshots.browsing && Snapshots.browseTruncated
|
||||
label: "Only the first entries are shown"
|
||||
detail: "This folder holds more than this list can usefully show"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0
|
||||
label: "Nothing here"
|
||||
detail: "This folder was empty at that point in time"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !Snapshots.browsing && Snapshots.browseTruncated
|
||||
label: "Only the first entries are shown"
|
||||
detail: "This folder holds more than this list can usefully show"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0
|
||||
label: "Nothing here"
|
||||
detail: "This folder was empty at that point in time"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── What it costs ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// What is on the disk, drawn as the disk.
|
||||
//
|
||||
// One bar whose segments add up to the whole filesystem, because the question
|
||||
// people bring to this page is "what is taking my space" and the only answer
|
||||
// that cannot mislead is one where the parts sum to the total. Four of the five
|
||||
// segments are measured; the fifth is what is left over, and it is labelled as
|
||||
// exactly that rather than being called "System" and quietly absorbing every
|
||||
// measurement error in the other four.
|
||||
//
|
||||
// The legend carries the numbers. A bar this wide can hold a 2% segment but it
|
||||
// cannot label one, and a segment nobody can name is decoration.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
readonly property var breakdown: Disks.breakdown ?? null
|
||||
|
||||
readonly property var segments: {
|
||||
const source = root.breakdown?.segments ?? null;
|
||||
if (!source)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
key: "home",
|
||||
label: "Home",
|
||||
detail: "Your files, not counting caches",
|
||||
bytes: Number(source.home ?? 0),
|
||||
color: Theme.accent
|
||||
},
|
||||
{
|
||||
key: "applications",
|
||||
label: "Applications",
|
||||
detail: "Flatpak applications and the runtimes they share",
|
||||
bytes: Number(source.applications ?? 0),
|
||||
color: Theme.teal
|
||||
},
|
||||
{
|
||||
key: "caches",
|
||||
label: "Caches",
|
||||
detail: "~/.cache, rebuilt as applications run",
|
||||
bytes: Number(source.caches ?? 0),
|
||||
color: Theme.warn
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
label: "System & everything else",
|
||||
detail: "Whatever the measured segments do not account for",
|
||||
bytes: Number(source.system ?? 0),
|
||||
color: Theme.magenta
|
||||
},
|
||||
{
|
||||
key: "free",
|
||||
label: "Free",
|
||||
detail: "Reported by the filesystem",
|
||||
bytes: Number(source.free ?? 0),
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
readonly property real total: root.segments.reduce(
|
||||
(sum, segment) => sum + Math.max(0, segment.bytes), 0)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.total <= 0
|
||||
text: Disks.measuringBreakdown
|
||||
? "Measuring what is on this disk…"
|
||||
: (Disks.lastError !== "" ? Disks.lastError : "Not measured yet.")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
visible: root.total > 0
|
||||
height: 26
|
||||
radius: 8
|
||||
clip: true
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.segments
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
|
||||
// A measured segment always gets a sliver, so "1 GB of
|
||||
// caches" is visible as a fact rather than rounded away.
|
||||
width: root.total > 0 && modelData.bytes > 0
|
||||
? Math.max(parent.width * (modelData.bytes / root.total), 2)
|
||||
: 0
|
||||
height: parent.height
|
||||
color: modelData.color
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
visible: root.total > 0
|
||||
spacing: 14
|
||||
|
||||
Repeater {
|
||||
model: root.segments
|
||||
|
||||
delegate: Row {
|
||||
required property var modelData
|
||||
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 9
|
||||
height: 9
|
||||
radius: 3
|
||||
color: modelData.key === "free"
|
||||
? Theme.alpha(Theme.fg, 0.25)
|
||||
: modelData.color
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.label + " " + Disks.formatBytes(modelData.bytes)
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,6 +9,13 @@
|
||||
// be a terabyte -- so it happens on request rather than on open. The page says
|
||||
// so plainly instead of showing an empty list that reads as "nothing here".
|
||||
//
|
||||
// The cleanup card has rules, and they are the whole reason it can exist at
|
||||
// all. Every row names what it is and what is lost by clearing it. Every row
|
||||
// carries its size. Nothing is selected in advance, nothing is recommended,
|
||||
// nothing counts down, and a row with nothing in it says so and offers no
|
||||
// button. A "clean up your PC" panel that nags is a racket; this one is a list
|
||||
// of facts with a button next to each.
|
||||
//
|
||||
// Partitioning and formatting are deliberately absent; GNOME Disks is one row
|
||||
// away for that.
|
||||
|
||||
@@ -22,19 +29,25 @@ SettingsPage {
|
||||
|
||||
objectName: "storage"
|
||||
title: "Storage"
|
||||
lede: "How much space is left, what is using it, and whether the drive is healthy."
|
||||
lede: root.rootFs
|
||||
? Disks.formatBytes(root.rootFs.usedBytes) + " used of "
|
||||
+ Disks.formatBytes(root.rootFs.sizeBytes) + " — "
|
||||
+ Disks.formatBytes(root.rootFs.availBytes) + " free."
|
||||
: "How much space is left, what is using it, and whether the drive is healthy."
|
||||
|
||||
readonly property var rootFs: Disks.rootFilesystem
|
||||
readonly property var drive: Disks.primaryDrive
|
||||
|
||||
// The measured folders, as a share of the largest one, so the bars compare
|
||||
// against each other rather than against a total they do not sum to.
|
||||
readonly property real largestFolder: {
|
||||
let largest = 0;
|
||||
for (const folder of Disks.folders)
|
||||
largest = Math.max(largest, Number(folder.bytes ?? 0));
|
||||
return largest;
|
||||
}
|
||||
// Clearing something deletes it, so it never happens on a first press.
|
||||
// Holds the id of the cleanable that is one press away from running.
|
||||
property string pendingClean: ""
|
||||
|
||||
// Folder bars are drawn against the DISK, not against the largest folder.
|
||||
// Against the largest, the top row is always full and the bar says nothing
|
||||
// except which row is biggest -- which the sizes beside them already say.
|
||||
// Against the disk, the bar means the same thing as the free-space bar
|
||||
// above it, and a 212 GB folder on a 2 TB disk looks like what it is.
|
||||
readonly property real diskBytes: Number(root.rootFs?.sizeBytes ?? 0)
|
||||
|
||||
readonly property var removableDrives: Disks.drives.filter(entry => entry.removable)
|
||||
|
||||
@@ -51,12 +64,74 @@ SettingsPage {
|
||||
// ── Space ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Free space"
|
||||
title: "What is on this disk"
|
||||
subtitle: root.rootFs && (root.rootFs.mountpoints ?? []).length > 1
|
||||
? "One filesystem is mounted at " + Disks.mountLabel(root.rootFs)
|
||||
+ ", so they share the same space."
|
||||
: "The filesystem this session runs from."
|
||||
|
||||
// Measuring the segments walks the same folders the Folders card walks,
|
||||
// so it costs the same and is asked for the same way.
|
||||
ActionRow {
|
||||
visible: !Disks.breakdownMeasured || Disks.measuringBreakdown
|
||||
label: Disks.measuringBreakdown ? "Measuring…" : "Measure what is on this disk"
|
||||
detail: Disks.measuringBreakdown
|
||||
? "Walking your home folder, the flatpak installations, and the cache."
|
||||
: "Three of the four segments are measured by walking folders, which takes a minute."
|
||||
action: "Measure"
|
||||
enabled: !Disks.measuringBreakdown
|
||||
divider: false
|
||||
onTriggered: Disks.measureBreakdown()
|
||||
}
|
||||
|
||||
StorageBreakdownBar {
|
||||
visible: Disks.breakdownMeasured
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
// The caption belongs to the bar's arithmetic, so it lives with the
|
||||
// card rather than inside the component: home, applications and caches
|
||||
// are measured, and the fourth segment is what is left of the used
|
||||
// space once those three are subtracted. Calling that "System" would
|
||||
// blame the desktop for the user's own unscanned files.
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: Disks.breakdownMeasured
|
||||
topPadding: 12
|
||||
text: "Home, applications and caches are measured. \"System & everything else\" is"
|
||||
+ " the remainder — the packages this machine runs on, plus anything those"
|
||||
+ " three measurements did not reach."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// What the walk could not finish, said rather than absorbed into the
|
||||
// remainder without comment.
|
||||
TextRow {
|
||||
visible: Disks.breakdownMeasured && Disks.breakdown
|
||||
&& Disks.breakdown.complete === false
|
||||
label: "The walk ran out of time"
|
||||
detail: "Home, applications and caches are floors rather than totals, so the remainder is larger than it should be."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Disks.breakdownMeasured && Disks.breakdown
|
||||
&& Disks.breakdown.exceedsUsed === true
|
||||
label: "The measured parts overlap"
|
||||
detail: "They add up to more than the used space, which means something was counted twice. The remainder is shown as zero rather than as a negative number."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Free space"
|
||||
subtitle: "The number the rest of this page is about."
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
@@ -118,9 +193,9 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "What is using it"
|
||||
title: "Folders"
|
||||
subtitle: Disks.foldersMeasured
|
||||
? "Measured by walking each folder."
|
||||
? "Measured by walking each folder. Bars are drawn against the whole disk, so they mean the same thing as the free-space bar above."
|
||||
: "Measuring means walking every file, so it is not done automatically."
|
||||
|
||||
ActionRow {
|
||||
@@ -160,12 +235,9 @@ SettingsPage {
|
||||
radius: 2
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// Relative to the LARGEST folder, not to the drive. At this
|
||||
// scale one folder holds almost everything, so bars drawn
|
||||
// against the total would leave every other row invisible.
|
||||
Rectangle {
|
||||
width: root.largestFolder > 0
|
||||
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.largestFolder), 2)
|
||||
width: root.diskBytes > 0
|
||||
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.diskBytes), 2)
|
||||
: 0
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
@@ -182,26 +254,98 @@ SettingsPage {
|
||||
label: "Some folders were not measured"
|
||||
detail: "The walk ran out of time before reaching them, so this list is incomplete."
|
||||
value: ""
|
||||
divider: Disks.containers !== null
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reclaiming space, without the racket ─────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Clean up, honestly"
|
||||
subtitle: "Each row says what it is and what clearing it costs. Nothing here is chosen for you, and nothing on this page needs doing."
|
||||
|
||||
ActionRow {
|
||||
visible: Disks.containers !== null
|
||||
&& Number(Disks.containers?.reclaimableBytes ?? 0) > 0
|
||||
label: "Unused container images"
|
||||
detail: Disks.containers
|
||||
? Disks.formatBytes(Disks.containers.reclaimableBytes)
|
||||
+ " of " + Disks.formatBytes(Disks.containers.totalBytes)
|
||||
+ " is not used by any container"
|
||||
: ""
|
||||
action: "Show"
|
||||
visible: !Disks.cleanablesMeasured || Disks.measuringCleanables
|
||||
label: Disks.measuringCleanables ? "Measuring…" : "Measure what could be cleared"
|
||||
detail: "Measuring only counts bytes. Every row below is cleared by its own button, one at a time."
|
||||
action: "Measure"
|
||||
enabled: !Disks.measuringCleanables
|
||||
divider: Disks.cleanablesMeasured
|
||||
onTriggered: Disks.measureCleanables()
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Disks.cleanables ?? []
|
||||
|
||||
delegate: SettingRow {
|
||||
id: cleanRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string cleanId: String(cleanRow.modelData.id ?? "")
|
||||
readonly property real bytes: Number(cleanRow.modelData.bytes ?? 0)
|
||||
readonly property bool empty: cleanRow.bytes <= 0
|
||||
readonly property bool privileged: cleanRow.modelData.privileged === true
|
||||
readonly property bool confirming: root.pendingClean !== ""
|
||||
&& root.pendingClean === cleanRow.cleanId
|
||||
|
||||
width: parent.width
|
||||
label: String(cleanRow.modelData.label ?? "")
|
||||
detail: cleanRow.empty
|
||||
? String(cleanRow.modelData.detail ?? "") + " · empty, nothing to do"
|
||||
: (cleanRow.confirming
|
||||
? "This deletes it now. Nothing here is recoverable from the trash afterwards."
|
||||
: String(cleanRow.modelData.detail ?? "")
|
||||
+ (cleanRow.privileged ? " · the system will ask for your password" : ""))
|
||||
value: cleanRow.empty ? Disks.formatBytes(0) : ""
|
||||
controlWidth: cleanRow.empty ? 90 : 210
|
||||
divider: cleanRow.index < (Disks.cleanables ?? []).length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: cleanRow.bytes > 0
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Disks.formatBytes(cleanRow.bytes)
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: cleanRow.confirming
|
||||
text: "Clear it"
|
||||
tone: "danger"
|
||||
enabled: Disks.cleaningId === ""
|
||||
onClicked: {
|
||||
root.pendingClean = "";
|
||||
Disks.clean(cleanRow.cleanId);
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: cleanRow.confirming ? "Keep" : "Clear…"
|
||||
enabled: Disks.cleaningId === ""
|
||||
onClicked: root.pendingClean =
|
||||
cleanRow.confirming ? "" : cleanRow.cleanId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Disks.cleanablesMeasured && (Disks.cleanables ?? []).length === 0
|
||||
label: "Nothing itemized"
|
||||
detail: "Caches, the trash, unused Flatpak runtimes and the package download cache were all measured and none of them holds anything."
|
||||
value: ""
|
||||
divider: false
|
||||
// Reclaiming is not offered here on purpose: pruning images can
|
||||
// destroy work that lives outside this desktop, and a settings pane
|
||||
// should not put that one click deep. This opens a terminal showing
|
||||
// what would be reclaimed, and leaves the decision there.
|
||||
onTriggered: Quickshell.execDetached(
|
||||
["kitty", "--hold", "-e", "podman", "system", "df"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +425,11 @@ SettingsPage {
|
||||
// Named here because this is the page somebody opens when space is short,
|
||||
// and container images are usually the largest thing nobody remembers
|
||||
// having. Only shown when there is actually something to reclaim.
|
||||
//
|
||||
// This is the ONLY container affordance on the page. There used to be a
|
||||
// second one above that opened a terminal running `podman system df`, which
|
||||
// meant two rows about the same gigabytes leading two different places --
|
||||
// and the terminal one could not actually reclaim anything.
|
||||
SettingsCard {
|
||||
visible: Containers.available && Containers.reclaimable > 0
|
||||
title: "Containers are holding " + Containers.formatBytes(Containers.reclaimable)
|
||||
|
||||
@@ -63,6 +63,10 @@ WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
WallpaperControls 1.0 WallpaperControls.qml
|
||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||
InstalledAppRow 1.0 InstalledAppRow.qml
|
||||
FileTypePicker 1.0 FileTypePicker.qml
|
||||
SettingsChip 1.0 SettingsChip.qml
|
||||
StorageBreakdownBar 1.0 StorageBreakdownBar.qml
|
||||
DockPinsStrip 1.0 DockPinsStrip.qml
|
||||
DockAppPicker 1.0 DockAppPicker.qml
|
||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||
|
||||
+583
@@ -0,0 +1,583 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""What is installed, what Panama offers to install, and what a flatpak may do.
|
||||
|
||||
Three boundaries, deliberately separate:
|
||||
|
||||
flatpaks / permissions read-only questions about what is already here.
|
||||
catalog the same optional-application catalog `panama apps`
|
||||
and ./install read, parsed by the same rules.
|
||||
install / uninstall mutations, each one validated against the catalog or
|
||||
against what flatpak reports as installed.
|
||||
|
||||
Deliberately absent: removing dnf packages. A settings page that uninstalls
|
||||
system packages is one mis-click away from removing the compositor it is drawn
|
||||
by, and dnf's dependency resolution will happily take half the desktop with it.
|
||||
The Applications page says "installed by the system package manager" and names
|
||||
the command instead. There is no dnf removal path anywhere in this file, and the
|
||||
contract checks for one.
|
||||
|
||||
The catalog is a closed surface: `install` refuses any entry that is not in
|
||||
setup/packages/extras, so this helper can never become a way to install an
|
||||
arbitrary package by passing a different string.
|
||||
|
||||
panama-applications flatpaks
|
||||
panama-applications permissions APP_ID
|
||||
panama-applications uninstall APP_ID
|
||||
panama-applications unused-runtimes
|
||||
panama-applications clean-unused
|
||||
panama-applications catalog
|
||||
panama-applications install CATEGORY ENTRY_ID
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Flathub ids, dnf package names, and category file names. Everything that
|
||||
# reaches a command line is matched against one of these first.
|
||||
APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$")
|
||||
PACKAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$")
|
||||
CATEGORY_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
|
||||
# flatpak prints sizes for people, not for programs: there is no machine-readable
|
||||
# column for the installed size in any released flatpak. The display string is
|
||||
# kept as-is for the page, and the parsed number is only ever used for the
|
||||
# storage breakdown, which says out loud that it is flatpak's own rounded figure.
|
||||
SIZE_UNITS = {
|
||||
"b": 1, "byte": 1, "bytes": 1,
|
||||
"kb": 10**3, "mb": 10**6, "gb": 10**9, "tb": 10**12,
|
||||
"kib": 1024, "mib": 1024**2, "gib": 1024**3, "tib": 1024**4,
|
||||
}
|
||||
SIZE = re.compile(r"^\s*([0-9]+(?:[.,][0-9]+)?)\s*([A-Za-z]+)\s*$")
|
||||
|
||||
# The remote every catalog flatpak comes from. install-packages adds it during
|
||||
# setup; naming it here keeps a machine with several remotes from resolving an
|
||||
# id against whichever one happens to be first.
|
||||
FLATHUB = "flathub"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or command failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise BoundaryError(f"{command[0]} did not answer in time.") from error
|
||||
except OSError as error:
|
||||
raise BoundaryError(f"{command[0]} is not available.") from error
|
||||
|
||||
|
||||
def require_flatpak() -> None:
|
||||
if not shutil.which("flatpak"):
|
||||
raise BoundaryError("Flatpak is not installed on this machine.")
|
||||
|
||||
|
||||
def parse_size(text: str) -> int | None:
|
||||
"""flatpak's own size string as bytes, or nothing when it cannot be read.
|
||||
|
||||
Nothing is not zero. A size this cannot parse is reported as unmeasured so
|
||||
the storage breakdown can say so, rather than quietly shrinking the
|
||||
applications segment and inflating the remainder.
|
||||
"""
|
||||
match = SIZE.match(text or "")
|
||||
if not match:
|
||||
return None
|
||||
number, unit = match.groups()
|
||||
factor = SIZE_UNITS.get(unit.lower())
|
||||
if factor is None:
|
||||
return None
|
||||
try:
|
||||
return int(float(number.replace(",", ".")) * factor)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def flatpak_rows(scope: list[str], columns: list[str]) -> list[list[str]]:
|
||||
"""Tab-separated `flatpak list` output as rows, or nothing when it fails."""
|
||||
result = run(["flatpak", "list", *scope, "--columns=" + ",".join(columns)])
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
rows = []
|
||||
for line in result.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
fields = line.split("\t")
|
||||
# A column flatpak could not fill comes back empty rather than missing,
|
||||
# but a future flatpak that drops one should not throw an IndexError
|
||||
# across the whole page.
|
||||
fields += [""] * (len(columns) - len(fields))
|
||||
rows.append(fields[:len(columns)])
|
||||
return rows
|
||||
|
||||
|
||||
def flatpaks() -> list[dict]:
|
||||
require_flatpak()
|
||||
entries = []
|
||||
for app_id, name, size, origin in flatpak_rows(
|
||||
["--app"], ["application", "name", "size", "origin"]):
|
||||
entries.append({
|
||||
"id": app_id,
|
||||
"name": name or app_id,
|
||||
"size": size,
|
||||
"sizeBytes": parse_size(size),
|
||||
"origin": origin,
|
||||
})
|
||||
entries.sort(key=lambda entry: (entry["name"].casefold(), entry["id"]))
|
||||
return entries
|
||||
|
||||
|
||||
# ── Permissions ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# A flatpak's sandbox is described by a handful of keys whose values are
|
||||
# semicolon-separated tokens. The buckets below are the questions people
|
||||
# actually ask -- can it see my camera, can it read my files -- and everything
|
||||
# that does not land in a bucket still gets a line. A permission summary that
|
||||
# silently omits what it did not recognise is worse than no summary: it reads as
|
||||
# "this app asks for nothing else".
|
||||
|
||||
CONTEXT_KEYS = ("shared", "sockets", "devices", "filesystems", "features", "persistent")
|
||||
HOST_FILESYSTEMS = {"host", "host-os", "host-etc", "/"}
|
||||
HOME_FILESYSTEMS = {"home", "~", "~/"}
|
||||
DEVICE_LABELS = {
|
||||
"all": "every device",
|
||||
"dri": "graphics acceleration",
|
||||
"kvm": "virtual machines",
|
||||
"shm": "shared memory",
|
||||
"input": "input devices",
|
||||
"usb": "USB devices",
|
||||
}
|
||||
SOCKET_LABELS = {
|
||||
"wayland": "Wayland",
|
||||
"x11": "X11",
|
||||
"fallback-x11": "X11 when Wayland is unavailable",
|
||||
"session-bus": "the whole session bus",
|
||||
"system-bus": "the whole system bus",
|
||||
"ssh-auth": "your SSH agent",
|
||||
"gpg-agent": "your GPG agent",
|
||||
"cups": "printing",
|
||||
"pcsc": "smart cards",
|
||||
"inherit-wayland-socket": "an inherited Wayland socket",
|
||||
}
|
||||
|
||||
|
||||
def permission_sections(text: str) -> dict[str, dict[str, str]]:
|
||||
parser = configparser.RawConfigParser(strict=False)
|
||||
# Keys are lower-case already in the [Context] section, but bus names are
|
||||
# not, and lower-casing org.gnome.Software would make the raw payload lie.
|
||||
parser.optionxform = str
|
||||
try:
|
||||
parser.read_string(text)
|
||||
except configparser.Error as error:
|
||||
raise BoundaryError("That application's permissions could not be read.") from error
|
||||
return {section: dict(parser.items(section)) for section in parser.sections()}
|
||||
|
||||
|
||||
def tokens(value: str) -> list[str]:
|
||||
return [item.strip() for item in (value or "").split(";") if item.strip()]
|
||||
|
||||
|
||||
def base_path(entry: str) -> str:
|
||||
"""`xdg-download:create` names the path `xdg-download`."""
|
||||
return entry.split(":", 1)[0]
|
||||
|
||||
|
||||
def join_some(items: list[str], limit: int = 4) -> str:
|
||||
if len(items) <= limit:
|
||||
return ", ".join(items)
|
||||
return ", ".join(items[:limit]) + f" and {len(items) - limit} more"
|
||||
|
||||
|
||||
def permission_summary(sections: dict[str, dict[str, str]]) -> list[str]:
|
||||
context = sections.get("Context", {})
|
||||
shared = tokens(context.get("shared", ""))
|
||||
sockets = tokens(context.get("sockets", ""))
|
||||
devices = tokens(context.get("devices", ""))
|
||||
filesystems = tokens(context.get("filesystems", ""))
|
||||
|
||||
summary: list[str] = []
|
||||
host = [item for item in filesystems if base_path(item) in HOST_FILESYSTEMS]
|
||||
home = [item for item in filesystems if base_path(item) in HOME_FILESYSTEMS]
|
||||
|
||||
# Camera. Flatpak has no camera token: a webcam is reached through
|
||||
# `devices=all`, so that is what this reports, and it says why.
|
||||
if "all" in devices:
|
||||
summary.append("Camera — full device access reaches webcams")
|
||||
# Microphone. Likewise, audio is one permission in both directions.
|
||||
if "pulseaudio" in sockets:
|
||||
summary.append("Microphone — audio access records as well as plays")
|
||||
if host:
|
||||
summary.append("Full file system access")
|
||||
if home:
|
||||
summary.append("Home folder")
|
||||
if "network" in shared:
|
||||
summary.append("Network")
|
||||
if devices:
|
||||
summary.append("Devices: " + join_some(
|
||||
[DEVICE_LABELS.get(item, item) for item in devices]))
|
||||
|
||||
# Everything the buckets did not claim, said plainly rather than dropped.
|
||||
rest_shared = [item for item in shared if item != "network"]
|
||||
if rest_shared:
|
||||
summary.append("Also shares: " + join_some(rest_shared))
|
||||
rest_sockets = [item for item in sockets if item != "pulseaudio"]
|
||||
if rest_sockets:
|
||||
# Sorted by the words shown rather than by flatpak's order, so
|
||||
# "X11 when Wayland is unavailable" follows "X11" instead of leading it.
|
||||
summary.append("Talks to " + join_some(
|
||||
sorted(SOCKET_LABELS.get(item, item) for item in rest_sockets)))
|
||||
rest_files = [item for item in filesystems if item not in host and item not in home]
|
||||
if rest_files:
|
||||
summary.append("Other locations: " + join_some(rest_files))
|
||||
features = tokens(context.get("features", ""))
|
||||
if features:
|
||||
summary.append("Sandbox features: " + join_some(features))
|
||||
persistent = tokens(context.get("persistent", ""))
|
||||
if persistent:
|
||||
summary.append("Keeps files in " + join_some(persistent))
|
||||
|
||||
for key, value in context.items():
|
||||
if key in CONTEXT_KEYS:
|
||||
continue
|
||||
items = tokens(value)
|
||||
summary.append(f"Also requests {key}: " + (join_some(items) if items else str(value)))
|
||||
|
||||
for name, entries in sections.items():
|
||||
if name == "Context" or not entries:
|
||||
continue
|
||||
count = len(entries)
|
||||
thing = "setting" if count == 1 else "settings"
|
||||
summary.append(f"{name}: {count} {thing}")
|
||||
|
||||
if not summary:
|
||||
summary.append("Nothing beyond the sandbox defaults")
|
||||
return summary
|
||||
|
||||
|
||||
def permissions(app_id: str) -> dict:
|
||||
require_flatpak()
|
||||
if not APP_ID.fullmatch(app_id or ""):
|
||||
raise BoundaryError("That is not an application id.")
|
||||
result = run(["flatpak", "info", "--show-permissions", app_id])
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError("That application is not installed.")
|
||||
sections = permission_sections(result.stdout)
|
||||
return {"id": app_id, "summary": permission_summary(sections), "raw": sections}
|
||||
|
||||
|
||||
# ── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def installed_app_ids() -> set[str]:
|
||||
return {row[0] for row in flatpak_rows(["--app"], ["application"]) if row[0]}
|
||||
|
||||
|
||||
def uninstall(app_id: str) -> list[dict]:
|
||||
require_flatpak()
|
||||
if not APP_ID.fullmatch(app_id or ""):
|
||||
raise BoundaryError("That is not an application id.")
|
||||
# --app so a mistyped id can never resolve to a runtime, and the id is
|
||||
# checked against what is actually installed rather than trusting the page.
|
||||
if app_id not in installed_app_ids():
|
||||
raise BoundaryError("That application is not installed.")
|
||||
result = run(["flatpak", "uninstall", "--app", "--noninteractive", app_id], timeout=300)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "That application could not be removed."))
|
||||
return flatpaks()
|
||||
|
||||
|
||||
def unused_runtimes() -> list[dict]:
|
||||
"""Runtimes and extensions no installed application asks for.
|
||||
|
||||
This is an estimate, and the page says so: the removal itself is
|
||||
`flatpak uninstall --unused`, which recomputes the list with flatpak's own
|
||||
dependency graph. Showing a size before asking needs a number now, and
|
||||
flatpak offers no way to ask what --unused would do without doing it.
|
||||
"""
|
||||
require_flatpak()
|
||||
apps = flatpak_rows(["--app"], ["application", "runtime"])
|
||||
runtimes = flatpak_rows(["--runtime"], ["application", "ref", "branch", "size", "runtime"])
|
||||
|
||||
# Seeded with the apps, so an application's own extensions -- OBS's plugins
|
||||
# are installed as runtimes named com.obsproject.Studio.Plugin.* -- count as
|
||||
# used rather than as sixteen orphans.
|
||||
used: set[str] = {row[0] for row in apps if row[0]}
|
||||
used |= {row[1].split("/")[0] for row in apps if row[1]}
|
||||
used_refs: set[str] = {row[1] for row in apps if row[1]}
|
||||
|
||||
# An extension of something used is used, and a runtime's own runtime is
|
||||
# used. Repeated until nothing new appears, because the chain can be two or
|
||||
# three long (app -> Platform -> Platform.Locale).
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for app_id, ref, _branch, _size, runtime in runtimes:
|
||||
if not app_id or app_id in used:
|
||||
continue
|
||||
parent = next((name for name in used
|
||||
if app_id.startswith(name + ".")), None)
|
||||
if parent is not None or ref in used_refs:
|
||||
used.add(app_id)
|
||||
if runtime:
|
||||
used.add(runtime.split("/")[0])
|
||||
used_refs.add(runtime)
|
||||
changed = True
|
||||
|
||||
entries = []
|
||||
for app_id, ref, branch, size, _runtime in runtimes:
|
||||
if not app_id or app_id in used:
|
||||
continue
|
||||
entries.append({
|
||||
"id": app_id,
|
||||
"ref": ref,
|
||||
"branch": branch,
|
||||
"size": size,
|
||||
"sizeBytes": parse_size(size),
|
||||
})
|
||||
entries.sort(key=lambda entry: entry["sizeBytes"] or 0, reverse=True)
|
||||
return entries
|
||||
|
||||
|
||||
def clean_unused() -> list[dict]:
|
||||
require_flatpak()
|
||||
result = run(["flatpak", "uninstall", "--unused", "--noninteractive"], timeout=600)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The unused runtimes could not be removed."))
|
||||
return flatpaks()
|
||||
|
||||
|
||||
# ── The catalog ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The rules below are setup/lib/extras-catalog's, restated in Python because a
|
||||
# QML page cannot source a bash library. They are pinned by a contract that
|
||||
# reads both, so a change to one that is not made to the other fails loudly
|
||||
# rather than producing a menu that installs something else.
|
||||
#
|
||||
# * everything from the first `#` onward is stripped, inline comments included
|
||||
# * blank and whitespace-only lines are skipped, after stripping
|
||||
# * a line beginning with whitespace continues the entry above it and is
|
||||
# installed with it, never listed on its own
|
||||
# * `target | label`, split on the first `|`, both sides trimmed
|
||||
# * with no label, a `flatpak:` id becomes its last dotted component and a dnf
|
||||
# package keeps its own name
|
||||
# * installed means `flatpak info <id>` for a flatpak and `rpm -q <name>`
|
||||
# otherwise -- here, membership in one listing of each rather than a process
|
||||
# per entry, which is the same question asked cheaply
|
||||
|
||||
|
||||
def extras_directory() -> Path:
|
||||
override = os.environ.get("PANAMA_EXTRAS_DIR")
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path(__file__).resolve().parents[4] / "setup" / "packages" / "extras"
|
||||
|
||||
|
||||
def category_files() -> list[Path]:
|
||||
directory = extras_directory()
|
||||
if not directory.is_dir():
|
||||
raise BoundaryError("The application catalog is not available.")
|
||||
return sorted(path for path in directory.iterdir()
|
||||
if path.is_file() and CATEGORY_NAME.fullmatch(path.name))
|
||||
|
||||
|
||||
def category_file(name: str) -> Path:
|
||||
if not CATEGORY_NAME.fullmatch(name or ""):
|
||||
raise BoundaryError("That is not an application category.")
|
||||
for path in category_files():
|
||||
if path.name == name:
|
||||
return path
|
||||
raise BoundaryError("That is not an application category.")
|
||||
|
||||
|
||||
def catalog_lines(path: Path) -> list[str]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as error:
|
||||
raise BoundaryError("The application catalog could not be read.") from error
|
||||
return [line.split("#", 1)[0] for line in text.splitlines()]
|
||||
|
||||
|
||||
def split_entry(line: str) -> tuple[str, str]:
|
||||
target, _, label = line.partition("|")
|
||||
target = target.strip()
|
||||
label = label.strip()
|
||||
if not label:
|
||||
label = target[len("flatpak:"):] if target.startswith("flatpak:") else target
|
||||
if target.startswith("flatpak:"):
|
||||
label = label.rsplit(".", 1)[-1]
|
||||
return target, label
|
||||
|
||||
|
||||
def catalog_entries(path: Path) -> list[tuple[str, str]]:
|
||||
entries = []
|
||||
for line in catalog_lines(path):
|
||||
if not line.strip() or line[:1].isspace():
|
||||
continue
|
||||
target, label = split_entry(line)
|
||||
if target:
|
||||
entries.append((target, label))
|
||||
return entries
|
||||
|
||||
|
||||
def catalog_targets(path: Path, wanted: str) -> list[str]:
|
||||
"""The entry itself, then the indented lines beneath it."""
|
||||
targets: list[str] = []
|
||||
seen = False
|
||||
for line in catalog_lines(path):
|
||||
if not line.strip():
|
||||
continue
|
||||
if line[:1].isspace():
|
||||
if seen:
|
||||
targets.append(line.split("|", 1)[0].strip())
|
||||
continue
|
||||
target, _ = split_entry(line)
|
||||
if target == wanted:
|
||||
seen = True
|
||||
targets.append(target)
|
||||
elif seen:
|
||||
break
|
||||
return [target for target in targets if target]
|
||||
|
||||
|
||||
def installed_sets() -> tuple[set[str], set[str]]:
|
||||
"""Every installed flatpak ref name, and every installed rpm name.
|
||||
|
||||
One listing each rather than `flatpak info` and `rpm -q` per entry: the
|
||||
catalog runs to a hundred entries and the page opens with it.
|
||||
"""
|
||||
flatpak_ids: set[str] = set()
|
||||
if shutil.which("flatpak"):
|
||||
flatpak_ids = {row[0] for row in flatpak_rows([], ["application"]) if row[0]}
|
||||
packages: set[str] = set()
|
||||
if shutil.which("rpm"):
|
||||
result = run(["rpm", "-qa", "--qf", "%{NAME}\\n"], timeout=60)
|
||||
if result.returncode == 0:
|
||||
packages = {line.strip() for line in result.stdout.splitlines() if line.strip()}
|
||||
return flatpak_ids, packages
|
||||
|
||||
|
||||
def entry_payload(target: str, label: str,
|
||||
flatpak_ids: set[str], packages: set[str]) -> dict:
|
||||
is_flatpak = target.startswith("flatpak:")
|
||||
ref = target[len("flatpak:"):] if is_flatpak else target
|
||||
return {
|
||||
# The id is the catalog line's target, verbatim, because that is what
|
||||
# `install` matches and what extras-catalog's own lookup takes.
|
||||
"id": target,
|
||||
"ref": ref,
|
||||
"label": label,
|
||||
"kind": "flatpak" if is_flatpak else "dnf",
|
||||
"installed": ref in (flatpak_ids if is_flatpak else packages),
|
||||
}
|
||||
|
||||
|
||||
def catalog() -> dict:
|
||||
flatpak_ids, packages = installed_sets()
|
||||
categories = []
|
||||
for path in category_files():
|
||||
entries = [entry_payload(target, label, flatpak_ids, packages)
|
||||
for target, label in catalog_entries(path)]
|
||||
categories.append({
|
||||
"name": path.name,
|
||||
# "gpu-compute" is a file name; "GPU compute" is a heading. The
|
||||
# page needs the second and the helper needs the first.
|
||||
"label": path.name.replace("-", " ").capitalize(),
|
||||
"entries": entries,
|
||||
})
|
||||
return {"categories": categories}
|
||||
|
||||
|
||||
def install(category: str, entry_id: str) -> dict:
|
||||
"""Install one catalog entry, and nothing that is not a catalog entry."""
|
||||
path = category_file(category)
|
||||
known = {target for target, _ in catalog_entries(path)}
|
||||
if entry_id not in known:
|
||||
raise BoundaryError("That application is not in the catalog.")
|
||||
|
||||
flatpak_targets: list[str] = []
|
||||
dnf_targets: list[str] = []
|
||||
for target in catalog_targets(path, entry_id):
|
||||
if target.startswith("flatpak:"):
|
||||
identifier = target[len("flatpak:"):]
|
||||
if not APP_ID.fullmatch(identifier):
|
||||
raise BoundaryError("That catalog entry names something unusable.")
|
||||
flatpak_targets.append(identifier)
|
||||
else:
|
||||
if not PACKAGE_NAME.fullmatch(target):
|
||||
raise BoundaryError("That catalog entry names something unusable.")
|
||||
dnf_targets.append(target)
|
||||
|
||||
if not flatpak_targets and not dnf_targets:
|
||||
raise BoundaryError("That catalog entry installs nothing.")
|
||||
|
||||
if flatpak_targets:
|
||||
require_flatpak()
|
||||
result = run(["flatpak", "install", "--noninteractive", FLATHUB, *flatpak_targets],
|
||||
timeout=1800)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "That application could not be installed."))
|
||||
if dnf_targets:
|
||||
if not shutil.which("dnf"):
|
||||
raise BoundaryError("dnf is not available on this machine.")
|
||||
# pkexec rather than sudo: the desktop already runs a polkit agent, and
|
||||
# a settings page has no terminal to type a password into.
|
||||
result = run(["pkexec", "dnf", "install", "-y", *dnf_targets], timeout=1800)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "That package could not be installed."))
|
||||
return catalog()
|
||||
|
||||
|
||||
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
|
||||
text = (result.stderr or result.stdout or "").strip().splitlines()
|
||||
if not text:
|
||||
return fallback
|
||||
last = text[-1].strip()
|
||||
lowered = last.lower()
|
||||
if "not authorized" in lowered or "dismissed" in lowered:
|
||||
return "That change was not authorized."
|
||||
return last[:200] or fallback
|
||||
|
||||
|
||||
def emit(payload) -> None:
|
||||
print(json.dumps(payload, separators=(",", ":")))
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["flatpaks"]:
|
||||
emit(flatpaks())
|
||||
elif arguments == ["catalog"]:
|
||||
emit(catalog())
|
||||
elif arguments == ["unused-runtimes"]:
|
||||
emit(unused_runtimes())
|
||||
elif arguments == ["clean-unused"]:
|
||||
emit(clean_unused())
|
||||
elif len(arguments) == 2 and arguments[0] == "permissions":
|
||||
emit(permissions(arguments[1]))
|
||||
elif len(arguments) == 2 and arguments[0] == "uninstall":
|
||||
emit(uninstall(arguments[1]))
|
||||
elif len(arguments) == 3 and arguments[0] == "install":
|
||||
emit(install(arguments[1], arguments[2]))
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-applications flatpaks | permissions APP_ID | "
|
||||
"uninstall APP_ID | unused-runtimes | clean-unused | catalog | "
|
||||
"install CATEGORY ENTRY_ID")
|
||||
except BoundaryError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -57,6 +57,15 @@ ROLE_TARGETS = {
|
||||
}
|
||||
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
|
||||
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
|
||||
MIME_TYPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}"
|
||||
r"/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$")
|
||||
|
||||
# A single-type override is a scalpel, not a browser: someone looking for "the
|
||||
# thing that opens .heic" wants a short answer, and a list of six hundred types
|
||||
# is not one. Anything past this is reported as truncated so the page can say
|
||||
# "narrow the search" rather than pretending these are all of them.
|
||||
TYPE_SEARCH_LIMIT = 20
|
||||
TYPE_CANDIDATE_LIMIT = 12
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
@@ -224,6 +233,182 @@ def set_default(role: str, desktop_id: str) -> None:
|
||||
run(["xdg-mime", "default", desktop_id, setting])
|
||||
|
||||
|
||||
# ── One file type at a time ──────────────────────────────────────────────────
|
||||
#
|
||||
# The roles above govern families, which is right nearly always and wrong
|
||||
# exactly when a family is too broad: SVG belongs in an editor while the rest of
|
||||
# the images belong in a viewer, and setting the whole "Images" role to the
|
||||
# editor is not what anyone wanted. These two verbs are the escape hatch.
|
||||
#
|
||||
# Searching is over the type name and its file extensions, and says so on the
|
||||
# page. Matching human descriptions would mean reading the whole shared-mime-info
|
||||
# database -- some thousands of small XML files -- to answer a keystroke.
|
||||
|
||||
|
||||
def mime_globs() -> dict[str, list[str]]:
|
||||
"""Extension patterns per type, from shared-mime-info's globs2.
|
||||
|
||||
Absent on a machine without shared-mime-info, which is not fatal: the search
|
||||
falls back to matching the type name, and every type still resolves.
|
||||
"""
|
||||
globs: dict[str, list[str]] = {}
|
||||
for root in xdg_data_roots():
|
||||
path = root / "mime" / "globs2"
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
continue
|
||||
for line in lines:
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(":")
|
||||
# weight:type:glob, with optional trailing flags.
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
mime, pattern = parts[1], parts[2]
|
||||
if not MIME_TYPE.fullmatch(mime):
|
||||
continue
|
||||
patterns = globs.setdefault(mime, [])
|
||||
if pattern not in patterns:
|
||||
patterns.append(pattern)
|
||||
return globs
|
||||
|
||||
|
||||
def mime_registrations() -> dict[str, list[str]]:
|
||||
"""Which installed applications declare which types.
|
||||
|
||||
Read from the desktop files themselves rather than mimeinfo.cache, because
|
||||
the cache is regenerated by update-desktop-database and is stale on exactly
|
||||
the machine where an application was just installed.
|
||||
"""
|
||||
registrations: dict[str, list[str]] = {}
|
||||
for desktop_id, path in discovered_desktop_files().items():
|
||||
try:
|
||||
values = parse_desktop_entry(path)
|
||||
except BoundaryError:
|
||||
continue
|
||||
if values.get("NoDisplay", "false").lower() == "true":
|
||||
continue
|
||||
for mime in values.get("MimeType", "").split(";"):
|
||||
mime = mime.strip()
|
||||
if not MIME_TYPE.fullmatch(mime):
|
||||
continue
|
||||
registered = registrations.setdefault(mime, [])
|
||||
if desktop_id not in registered:
|
||||
registered.append(desktop_id)
|
||||
return registrations
|
||||
|
||||
|
||||
def mime_label(mime: str) -> str:
|
||||
"""shared-mime-info's own description, or "" when it has none.
|
||||
|
||||
Read only for the handful of types a search actually returns.
|
||||
"""
|
||||
media, _, subtype = mime.partition("/")
|
||||
if not media or not subtype:
|
||||
return ""
|
||||
for root in xdg_data_roots():
|
||||
path = root / "mime" / media / f"{subtype}.xml"
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
# The untranslated <comment> comes first; the xml:lang ones follow.
|
||||
found = re.search(r"<comment>([^<]*)</comment>", text)
|
||||
if found:
|
||||
return found.group(1).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def application_names() -> dict[str, str]:
|
||||
names: dict[str, str] = {}
|
||||
for desktop_id, path in discovered_desktop_files().items():
|
||||
try:
|
||||
values = parse_desktop_entry(path)
|
||||
except BoundaryError:
|
||||
continue
|
||||
names[desktop_id] = values.get("Name", desktop_id[:-len(".desktop")])
|
||||
return names
|
||||
|
||||
|
||||
def current_handler(mime: str) -> str:
|
||||
completed = subprocess.run(["xdg-mime", "query", "default", mime],
|
||||
check=False, capture_output=True, text=True)
|
||||
if completed.returncode != 0:
|
||||
return ""
|
||||
output = completed.stdout.strip().splitlines()
|
||||
return output[0] if output else ""
|
||||
|
||||
|
||||
def search_types(query: str) -> dict[str, object]:
|
||||
needle = (query or "").strip().lower().lstrip(".")
|
||||
if len(needle) < 2:
|
||||
return {"query": query, "types": [], "truncated": False}
|
||||
|
||||
globs = mime_globs()
|
||||
registrations = mime_registrations()
|
||||
names = application_names()
|
||||
known = sorted(set(globs) | set(registrations))
|
||||
|
||||
def score(mime: str) -> tuple[int, str]:
|
||||
patterns = globs.get(mime, [])
|
||||
extensions = [pattern[2:].lower() for pattern in patterns
|
||||
if pattern.startswith("*.")]
|
||||
if needle in extensions:
|
||||
return (0, mime)
|
||||
if mime.lower() == needle or mime.lower().split("/")[-1] == needle:
|
||||
return (1, mime)
|
||||
return (2, mime)
|
||||
|
||||
matched = []
|
||||
for mime in known:
|
||||
patterns = globs.get(mime, [])
|
||||
haystack = " ".join([mime.lower(),
|
||||
*(pattern.lower() for pattern in patterns)])
|
||||
if needle in haystack:
|
||||
matched.append(mime)
|
||||
matched.sort(key=score)
|
||||
|
||||
truncated = len(matched) > TYPE_SEARCH_LIMIT
|
||||
types = []
|
||||
for mime in matched[:TYPE_SEARCH_LIMIT]:
|
||||
handler = current_handler(mime)
|
||||
candidates = list(registrations.get(mime, []))
|
||||
# The current handler belongs in the list even when it never declared
|
||||
# the type -- an override put it there, and a picker that cannot show
|
||||
# the answer it is displaying is a picker nobody trusts.
|
||||
if handler and handler not in candidates:
|
||||
candidates.insert(0, handler)
|
||||
candidates = candidates[:TYPE_CANDIDATE_LIMIT]
|
||||
types.append({
|
||||
"mime": mime,
|
||||
"label": mime_label(mime),
|
||||
"extensions": [pattern[1:] for pattern in globs.get(mime, [])
|
||||
if pattern.startswith("*.")][:6],
|
||||
"handler": handler,
|
||||
"handlerName": names.get(handler, ""),
|
||||
"candidates": [{"id": desktop_id, "name": names.get(desktop_id, desktop_id)}
|
||||
for desktop_id in candidates],
|
||||
})
|
||||
return {"query": query, "types": types, "truncated": truncated}
|
||||
|
||||
|
||||
def set_type(mime: str, desktop_id: str) -> None:
|
||||
"""Point one type at one application, leaving its family alone."""
|
||||
if not MIME_TYPE.fullmatch(mime or ""):
|
||||
raise BoundaryError("That is not a file type.")
|
||||
globs = mime_globs()
|
||||
registrations = mime_registrations()
|
||||
if mime not in globs and mime not in registrations:
|
||||
raise BoundaryError("This system does not know that file type.")
|
||||
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
|
||||
run(["xdg-mime", "default", desktop_id, mime])
|
||||
|
||||
|
||||
# What this desktop opens a file with, when nobody has said otherwise.
|
||||
#
|
||||
# Applications register themselves for every type they can technically read, so
|
||||
@@ -442,10 +627,15 @@ def main(arguments: list[str]) -> int:
|
||||
remove_autostart(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
||||
add_autostart(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "search-types":
|
||||
print(json.dumps(search_types(arguments[1]), separators=(",", ":")))
|
||||
elif len(arguments) == 3 and arguments[0] == "set-type":
|
||||
set_type(arguments[1], arguments[2])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | "
|
||||
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
|
||||
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID | "
|
||||
"remove-autostart DESKTOP_ID | search-types QUERY | set-type MIME DESKTOP_ID"
|
||||
)
|
||||
except BoundaryError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
|
||||
@@ -10,11 +10,22 @@ Two boundaries, deliberately separate:
|
||||
means walking it, and this machine has a 1.2 TiB Steam library.
|
||||
The page asks for this on demand and remembers the answer.
|
||||
|
||||
breakdown what the used space is made of, as four segments and a remainder.
|
||||
Same walk as `scan`, so it costs the same and is asked for on
|
||||
demand.
|
||||
cleanables what could be freed, itemized and sized. Reading only.
|
||||
clean ID frees exactly one of them, named explicitly.
|
||||
|
||||
unmount PATH / eject PATH removable media only, by explicit request.
|
||||
|
||||
Deliberately absent: partitioning and formatting. A settings pane is the wrong
|
||||
place to hand someone a way to erase a disk in two clicks; GNOME Disks is one
|
||||
button away on the page for that.
|
||||
|
||||
Also deliberately absent: anything that runs on its own. Nothing here is
|
||||
pre-selected, nothing is measured in order to nag about it, and `clean` refuses
|
||||
every id it was not handed. A storage page that cleans things you did not ask it
|
||||
to clean is a cleaner, and cleaners are how people lose files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -47,6 +58,22 @@ SCAN_TARGETS = [
|
||||
("Trash", "~/.local/share/Trash"),
|
||||
]
|
||||
|
||||
# The cache folder is its own segment in the breakdown and its own cleanable, so
|
||||
# it is named once here rather than spelled out in three places.
|
||||
CACHE_TARGET = "~/.cache"
|
||||
TRASH_TARGET = "~/.local/share/Trash"
|
||||
|
||||
# Where flatpak keeps what it installed. Measured rather than summed from
|
||||
# `flatpak list --columns=size`: those are per-ref installed sizes, and ostree
|
||||
# hard-links every object shared between refs, so adding them up on this machine
|
||||
# reports about twice what the drive actually holds.
|
||||
FLATPAK_ROOTS = ["/var/lib/flatpak", "~/.local/share/flatpak"]
|
||||
|
||||
# dnf keeps downloaded rpms under <repo>/packages and its metadata beside them.
|
||||
# Only the packages are offered: dropping the metadata costs a re-download on
|
||||
# the next install and frees comparatively little.
|
||||
DNF_CACHE_ROOTS = ["/var/cache/libdnf5", "/var/cache/dnf"]
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or command failure."""
|
||||
@@ -329,6 +356,295 @@ def scan() -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── What the used space is made of ───────────────────────────────────────────
|
||||
#
|
||||
# Four measured segments and one remainder. The remainder is what is left of the
|
||||
# filesystem's used bytes after the four are subtracted, and it is labelled
|
||||
# "System & everything else" on the page for exactly that reason: it is not a
|
||||
# measurement of the system, it is everything this did not measure.
|
||||
#
|
||||
# The arithmetic rule, which a contract pins: the measured segments never sum to
|
||||
# more than the filesystem reports as used, and nothing is scaled to make a bar
|
||||
# look tidy. When a measurement does overshoot -- possible when /home lives on a
|
||||
# different filesystem from the flatpak installation -- the remainder is zero and
|
||||
# `exceedsUsed` says so rather than inventing a number.
|
||||
|
||||
|
||||
def measure_all(paths: list[str], budget: float) -> tuple[int, float, bool]:
|
||||
"""Bytes across several paths, and whether every one of them was measured."""
|
||||
total = 0
|
||||
complete = True
|
||||
for target in paths:
|
||||
if budget <= 1.0:
|
||||
return total, budget, False
|
||||
size, budget = measure(Path(os.path.expanduser(target)), budget)
|
||||
if size is None:
|
||||
# A path that does not exist contributes nothing and is not a gap;
|
||||
# one that timed out is, and the budget is gone either way.
|
||||
if Path(os.path.expanduser(target)).is_dir():
|
||||
complete = False
|
||||
continue
|
||||
total += size
|
||||
return total, budget, complete
|
||||
|
||||
|
||||
def backing_device(path: str) -> str:
|
||||
"""The block device behind a path, with any btrfs subvolume stripped.
|
||||
|
||||
st_dev is not the question being asked. btrfs hands every subvolume its own
|
||||
device number, so / and /home compare as different filesystems by that test
|
||||
even though they are one pool with one free-space total -- which is the
|
||||
exact confusion the filesystems list upstairs already exists to avoid. The
|
||||
first version of the breakdown left the system-wide flatpak installation out
|
||||
of the applications segment for that reason, and reported 4 kB of apps on a
|
||||
machine with twenty gigabytes of them.
|
||||
"""
|
||||
if not shutil.which("findmnt"):
|
||||
try:
|
||||
return str(os.stat(path).st_dev)
|
||||
except OSError:
|
||||
return ""
|
||||
try:
|
||||
source = run(["findmnt", "-n", "-o", "SOURCE", "--target", path], timeout=10.0)
|
||||
except BoundaryError:
|
||||
return ""
|
||||
return source.strip().split("[", 1)[0]
|
||||
|
||||
|
||||
def same_filesystem(first: str, second: str) -> bool:
|
||||
left = backing_device(first)
|
||||
return left != "" and left == backing_device(second)
|
||||
|
||||
|
||||
def breakdown() -> dict:
|
||||
home = os.path.expanduser("~")
|
||||
try:
|
||||
usage = shutil.disk_usage(home)
|
||||
except OSError as error:
|
||||
raise BoundaryError("The filesystem holding your home folder could not be read.") from error
|
||||
|
||||
remaining = float(SCAN_TIMEOUT_SECONDS)
|
||||
caches, remaining, caches_complete = measure_all([CACHE_TARGET], remaining)
|
||||
|
||||
home_targets = [target for _, target in SCAN_TARGETS if target != CACHE_TARGET]
|
||||
home_bytes, remaining, home_complete = measure_all(home_targets, remaining)
|
||||
|
||||
# Only the installations that live on the same filesystem as home, because
|
||||
# adding bytes from another drive into this drive's bar is a lie about this
|
||||
# drive.
|
||||
flatpak_paths = [target for target in FLATPAK_ROOTS
|
||||
if Path(os.path.expanduser(target)).is_dir()
|
||||
and same_filesystem(os.path.expanduser(target), home)]
|
||||
applications, remaining, applications_complete = measure_all(flatpak_paths, remaining)
|
||||
|
||||
used = int(usage.used)
|
||||
accounted = home_bytes + applications + caches
|
||||
system = max(0, used - accounted)
|
||||
|
||||
return {
|
||||
"segments": {
|
||||
"home": home_bytes,
|
||||
"applications": applications,
|
||||
"caches": caches,
|
||||
"system": system,
|
||||
"free": int(usage.free),
|
||||
},
|
||||
"totalBytes": int(usage.total),
|
||||
"usedBytes": used,
|
||||
"freeBytes": int(usage.free),
|
||||
# The measured segments are floors when this is false: something took
|
||||
# longer than the budget and was left out rather than guessed at.
|
||||
"complete": home_complete and caches_complete and applications_complete,
|
||||
"exceedsUsed": accounted > used,
|
||||
"path": home,
|
||||
}
|
||||
|
||||
|
||||
# ── Cleaning up, honestly ────────────────────────────────────────────────────
|
||||
#
|
||||
# Every row is itemized, sized in real bytes, and inert until its own id is
|
||||
# passed to `clean`. There is no "clean everything" verb and there is no
|
||||
# recommendation: the page shows what each one costs you -- caches are rebuilt,
|
||||
# first launches get slower -- and lets it be somebody's decision.
|
||||
|
||||
|
||||
def applications_helper() -> str:
|
||||
return os.environ.get("PANAMA_APPLICATIONS_HELPER") or str(
|
||||
Path(__file__).resolve().parent / "panama-applications")
|
||||
|
||||
|
||||
def unused_runtime_bytes() -> int:
|
||||
"""What the flatpak helper reports as unused, in bytes.
|
||||
|
||||
Asked of the applications helper rather than reimplemented, so the number
|
||||
shown here and the thing `clean` removes can never come from two different
|
||||
ideas of "unused".
|
||||
"""
|
||||
if not shutil.which("flatpak"):
|
||||
return 0
|
||||
try:
|
||||
raw = run([applications_helper(), "unused-runtimes"], timeout=60.0)
|
||||
entries = json.loads(raw)
|
||||
except (BoundaryError, json.JSONDecodeError):
|
||||
return 0
|
||||
return sum(int(entry.get("sizeBytes") or 0)
|
||||
for entry in entries if isinstance(entry, dict))
|
||||
|
||||
|
||||
def dnf_package_cache_paths() -> list[str]:
|
||||
paths = []
|
||||
for root in DNF_CACHE_ROOTS:
|
||||
directory = Path(root)
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
try:
|
||||
paths.extend(str(child / "packages") for child in directory.iterdir()
|
||||
if (child / "packages").is_dir())
|
||||
except OSError:
|
||||
continue
|
||||
return paths
|
||||
|
||||
|
||||
def cleanables() -> list[dict]:
|
||||
remaining = float(SCAN_TIMEOUT_SECONDS)
|
||||
cache_bytes, remaining, _ = measure_all([CACHE_TARGET], remaining)
|
||||
trash_bytes, remaining, _ = measure_all([TRASH_TARGET], remaining)
|
||||
dnf_bytes, remaining, _ = measure_all(dnf_package_cache_paths(), remaining)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": "cache",
|
||||
"label": "Application caches",
|
||||
"detail": "~/.cache · rebuilt as apps run · first launches get slower once",
|
||||
"bytes": cache_bytes,
|
||||
"privileged": False,
|
||||
},
|
||||
{
|
||||
"id": "trash",
|
||||
"label": "Trash",
|
||||
"detail": "Files you deleted · emptying is permanent",
|
||||
"bytes": trash_bytes,
|
||||
"privileged": False,
|
||||
},
|
||||
{
|
||||
"id": "flatpak-unused",
|
||||
"label": "Unused Flatpak runtimes",
|
||||
"detail": "Runtimes no installed app asks for · flatpak decides the final list",
|
||||
"bytes": unused_runtime_bytes(),
|
||||
"privileged": False,
|
||||
},
|
||||
{
|
||||
"id": "dnf-cache",
|
||||
"label": "Package download cache",
|
||||
"detail": "Downloaded packages · the system will ask for your password",
|
||||
"bytes": dnf_bytes,
|
||||
"privileged": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def guarded_cache_directory() -> Path:
|
||||
"""~/.cache, or a refusal.
|
||||
|
||||
This function is the whole reason the cache row is safe to press. It refuses
|
||||
a symlinked ~/.cache and refuses anything that resolves outside the home
|
||||
directory, so XDG_CACHE_HOME pointing somewhere alarming, or a ~/.cache
|
||||
someone linked to /, cannot turn one click into a deleted system.
|
||||
"""
|
||||
home = Path(os.path.expanduser("~")).resolve(strict=False)
|
||||
target = Path(os.path.expanduser(CACHE_TARGET))
|
||||
if target.is_symlink():
|
||||
raise BoundaryError("The cache folder is a link, so it will not be emptied.")
|
||||
if not target.is_dir():
|
||||
raise BoundaryError("There is no cache folder to empty.")
|
||||
resolved = target.resolve(strict=True)
|
||||
if resolved == home or home not in resolved.parents:
|
||||
raise BoundaryError("The cache folder is not inside your home folder.")
|
||||
return resolved
|
||||
|
||||
|
||||
def empty_cache() -> None:
|
||||
"""Delete what is inside ~/.cache, never following a link out of it.
|
||||
|
||||
A cache file an application still has open cannot be removed, and that is
|
||||
the normal case rather than a failure -- so a partial pass succeeds, and the
|
||||
freshly measured size the caller gets back is what says how much is left.
|
||||
Only a pass that removed nothing at all is reported as a failure.
|
||||
"""
|
||||
directory = guarded_cache_directory()
|
||||
removed = 0
|
||||
failures = 0
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
# is_symlink first: a symlinked directory must be unlinked, not
|
||||
# walked, or this deletes whatever it points at.
|
||||
if entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
|
||||
os.unlink(entry.path)
|
||||
else:
|
||||
# rmtree lstats as it goes and refuses to descend a symlink.
|
||||
shutil.rmtree(entry.path, ignore_errors=False)
|
||||
removed += 1
|
||||
except OSError:
|
||||
failures += 1
|
||||
if failures and not removed:
|
||||
raise BoundaryError("The cache is in use and nothing could be removed.")
|
||||
|
||||
|
||||
def empty_trash() -> None:
|
||||
if not shutil.which("gio"):
|
||||
raise BoundaryError("gio is not available, so the trash cannot be emptied.")
|
||||
# gio rather than removing ~/.local/share/Trash by hand: the trash is a
|
||||
# freedesktop structure with per-file metadata and mount-point trash
|
||||
# directories elsewhere, and gio empties all of it correctly.
|
||||
run(["gio", "trash", "--empty"], timeout=300.0)
|
||||
|
||||
|
||||
def clean_flatpak_unused() -> None:
|
||||
if not shutil.which("flatpak"):
|
||||
raise BoundaryError("Flatpak is not installed on this machine.")
|
||||
run([applications_helper(), "clean-unused"], timeout=600.0)
|
||||
|
||||
|
||||
def clean_dnf_cache() -> None:
|
||||
if not shutil.which("dnf"):
|
||||
raise BoundaryError("dnf is not available on this machine.")
|
||||
# `clean packages` and never `clean all`: this drops the downloaded rpms,
|
||||
# which is what was measured and what takes the space. Dropping the metadata
|
||||
# as well would free little and make the next install slow for no reason.
|
||||
#
|
||||
# This is the only dnf invocation in Panama's settings surface, and it
|
||||
# removes downloads. Nothing here removes an installed package.
|
||||
try:
|
||||
run(["pkexec", "dnf", "clean", "packages"], timeout=300.0)
|
||||
except BoundaryError as error:
|
||||
detail = str(error).lower()
|
||||
if "dismissed" in detail or "not authorized" in detail:
|
||||
raise BoundaryError("That change was not authorized.") from error
|
||||
raise
|
||||
|
||||
|
||||
CLEANERS = {
|
||||
"cache": empty_cache,
|
||||
"trash": empty_trash,
|
||||
"flatpak-unused": clean_flatpak_unused,
|
||||
"dnf-cache": clean_dnf_cache,
|
||||
}
|
||||
|
||||
|
||||
def clean(identifier: str) -> list[dict]:
|
||||
"""Free exactly one named thing, and refuse everything else.
|
||||
|
||||
One id per call, no list, no "all". The caller has to name what it wants
|
||||
removed, which is what keeps a mis-wired button from emptying four things.
|
||||
"""
|
||||
cleaner = CLEANERS.get(identifier or "")
|
||||
if cleaner is None:
|
||||
raise BoundaryError("There is nothing by that name to clean up.")
|
||||
cleaner()
|
||||
return cleanables()
|
||||
|
||||
|
||||
def removable_device(path: str) -> dict:
|
||||
"""Resolve a device path, refusing anything that is not removable.
|
||||
|
||||
@@ -354,6 +670,12 @@ def main(arguments: list[str]) -> int:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
elif arguments == ["scan"]:
|
||||
print(json.dumps(scan(), separators=(",", ":")))
|
||||
elif arguments == ["breakdown"]:
|
||||
print(json.dumps(breakdown(), separators=(",", ":")))
|
||||
elif arguments == ["cleanables"]:
|
||||
print(json.dumps(cleanables(), separators=(",", ":")))
|
||||
elif len(arguments) == 2 and arguments[0] == "clean":
|
||||
print(json.dumps(clean(arguments[1]), separators=(",", ":")))
|
||||
elif len(arguments) == 2 and arguments[0] in ("unmount", "eject"):
|
||||
removable_device(arguments[1])
|
||||
action = "unmount" if arguments[0] == "unmount" else "power-off"
|
||||
@@ -361,7 +683,8 @@ def main(arguments: list[str]) -> int:
|
||||
run(["udisksctl", action, flag, arguments[1]], timeout=30.0)
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-disks snapshot | scan | unmount DEVICE | eject DEVICE")
|
||||
"Usage: panama-disks snapshot | scan | breakdown | cleanables | "
|
||||
"clean ID | unmount DEVICE | eject DEVICE")
|
||||
except BoundaryError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
@@ -308,11 +308,21 @@ def delete(config: str, number: str) -> None:
|
||||
raise BoundaryError(_refusal(result, "That snapshot could not be removed."))
|
||||
|
||||
|
||||
# The three horizons the page can edit, and the largest number it will accept
|
||||
# for any of them. 999 hourly snapshots is not a retention policy, it is a typo
|
||||
# that fills a drive; the page offers a dropdown and this is its ceiling. The
|
||||
# monthly and yearly limits are left exactly as snapper has them -- nothing here
|
||||
# writes them, so a config with longer horizons keeps them.
|
||||
RETENTION_LIMIT = 50
|
||||
|
||||
|
||||
def set_retention(config: str, hourly: str, daily: str, weekly: str) -> None:
|
||||
values = []
|
||||
for label, value in (("HOURLY", hourly), ("DAILY", daily), ("WEEKLY", weekly)):
|
||||
if not str(value).isdigit() or int(value) > 999:
|
||||
if not str(value).isdigit():
|
||||
raise BoundaryError("Keep counts must be whole numbers.")
|
||||
if int(value) > RETENTION_LIMIT:
|
||||
raise BoundaryError(f"Keep counts go up to {RETENTION_LIMIT}.")
|
||||
values.append(f"TIMELINE_LIMIT_{label}={int(value)}")
|
||||
result = run(["snapper", "-c", require_config(config), "set-config", *values])
|
||||
if result.returncode != 0:
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
pragma Singleton
|
||||
|
||||
// What is installed, what Panama offers to install, and what a flatpak may do.
|
||||
//
|
||||
// DesktopEntries knows every application that put a launcher on this machine
|
||||
// and nothing about where it came from. flatpak knows its own applications and
|
||||
// nothing about the rest. Neither answers "what is installed", which is the
|
||||
// question the Applications page asks, so this joins them: a desktop entry
|
||||
// whose id matches an installed flatpak id is that flatpak, because flatpak
|
||||
// exports its launcher as <app-id>.desktop and always has.
|
||||
//
|
||||
// Everything else is a system package. That is stated rather than acted on --
|
||||
// there is no dnf removal here or in the helper. A settings page that
|
||||
// uninstalls system packages is one mis-click from removing the compositor it
|
||||
// is drawn by, and dnf will take half the desktop with it. The page names the
|
||||
// command instead.
|
||||
//
|
||||
// Permissions are cached per application id. `flatpak info --show-permissions`
|
||||
// is a process launch, the page shows them per expanded row, and re-reading
|
||||
// them on every repaint would launch one per frame.
|
||||
//
|
||||
// Nothing here loads on its own. `flatpak list` and the catalog cost a process
|
||||
// each and only the Applications page wants them, so the page calls refresh()
|
||||
// and refreshCatalog() when it opens rather than every shell start paying for a
|
||||
// page nobody opened.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// The seam: contracts point this at a stub that answers from fixtures, so
|
||||
// "install refuses an id that is not in the catalog" can be proven without
|
||||
// installing anything.
|
||||
readonly property string helperPath: Quickshell.env("PANAMA_APPLICATIONS_HELPER")
|
||||
|| Quickshell.shellDir + "/scripts/panama-applications"
|
||||
|
||||
// [{ id, name, size, sizeBytes, origin }]
|
||||
property var flatpaks: []
|
||||
// [{ name, label, entries: [{ id, ref, label, kind, installed }] }]
|
||||
property var categories: []
|
||||
property var permissionCache: ({})
|
||||
|
||||
property bool flatpaksLoaded: false
|
||||
property bool catalogLoaded: false
|
||||
property string lastError: ""
|
||||
// Which row is mid-change, so the page can disable exactly that one rather
|
||||
// than greying out the whole card.
|
||||
property string busyEntryId: ""
|
||||
|
||||
// Guards read the Process objects directly rather than this binding; a
|
||||
// binding is stale inside the handler that changes it. See DefaultApps.qml.
|
||||
readonly property bool busy: flatpakQuery.running || catalogQuery.running || mutation.running
|
||||
|
||||
readonly property var flatpakIndex: {
|
||||
const index = {};
|
||||
for (const entry of root.flatpaks)
|
||||
index[String(entry.id ?? "").toLowerCase()] = entry;
|
||||
return index;
|
||||
}
|
||||
|
||||
// [{ entryId, name, icon, kind, flatpakId, size, sizeBytes, entry }]
|
||||
//
|
||||
// `entry` is the DesktopEntry itself when there is one, so the page can
|
||||
// launch it, read its categories, or match it against a notification rule
|
||||
// without looking it up a second time.
|
||||
readonly property var apps: {
|
||||
const out = [];
|
||||
const claimed = {};
|
||||
for (const entry of DesktopEntries.applications.values) {
|
||||
if (entry.noDisplay)
|
||||
continue;
|
||||
const entryId = root.desktopId(entry);
|
||||
const key = entryId.replace(/\.desktop$/, "").toLowerCase();
|
||||
const flatpak = root.flatpakIndex[key] ?? null;
|
||||
if (flatpak)
|
||||
claimed[key] = true;
|
||||
out.push({
|
||||
entryId: entryId,
|
||||
name: String(entry.name || entry.genericName || entryId),
|
||||
icon: String(entry.icon ?? ""),
|
||||
kind: flatpak ? "flatpak" : "system",
|
||||
flatpakId: flatpak ? String(flatpak.id) : "",
|
||||
size: flatpak ? String(flatpak.size ?? "") : "",
|
||||
sizeBytes: flatpak ? Number(flatpak.sizeBytes ?? 0) : 0,
|
||||
entry: entry
|
||||
});
|
||||
}
|
||||
// A flatpak with no launcher is still installed and still takes space.
|
||||
// Dropping it would make the list disagree with `flatpak list`, and the
|
||||
// uninstall row is the only place someone can get rid of it.
|
||||
for (const flatpak of root.flatpaks) {
|
||||
const key = String(flatpak.id ?? "").toLowerCase();
|
||||
if (claimed[key])
|
||||
continue;
|
||||
out.push({
|
||||
entryId: String(flatpak.id) + ".desktop",
|
||||
name: String(flatpak.name ?? flatpak.id),
|
||||
icon: "",
|
||||
kind: "flatpak",
|
||||
flatpakId: String(flatpak.id),
|
||||
size: String(flatpak.size ?? ""),
|
||||
sizeBytes: Number(flatpak.sizeBytes ?? 0),
|
||||
entry: null
|
||||
});
|
||||
}
|
||||
out.sort((left, right) => left.name.localeCompare(right.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
readonly property int flatpakCount: root.flatpaks.length
|
||||
|
||||
function desktopId(entry: var): string {
|
||||
const entryId = String(entry?.id ?? "");
|
||||
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
|
||||
}
|
||||
|
||||
// Name and id both, because someone searching for "obs" and someone
|
||||
// searching for "com.obsproject" are looking for the same row.
|
||||
function matches(app: var, query: string): bool {
|
||||
const needle = String(query ?? "").trim().toLowerCase();
|
||||
if (needle === "")
|
||||
return true;
|
||||
return (String(app.name) + " " + String(app.entryId) + " " + String(app.flatpakId))
|
||||
.toLowerCase().indexOf(needle) >= 0;
|
||||
}
|
||||
|
||||
function entriesFor(category: string): var {
|
||||
for (const entry of root.categories) {
|
||||
if (String(entry.name) === String(category))
|
||||
return entry.entries ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function categoryLabel(category: string): string {
|
||||
for (const entry of root.categories) {
|
||||
if (String(entry.name) === String(category))
|
||||
return String(entry.label ?? entry.name);
|
||||
}
|
||||
return String(category);
|
||||
}
|
||||
|
||||
// Cached, and null while the answer is on its way. Reading this inside a
|
||||
// binding is safe: the read of permissionCache is what makes the binding
|
||||
// re-evaluate when the answer lands.
|
||||
function permissionsFor(flatpakId: string): var {
|
||||
const key = String(flatpakId ?? "");
|
||||
if (key === "")
|
||||
return null;
|
||||
const cached = root.permissionCache[key];
|
||||
if (cached !== undefined)
|
||||
return cached;
|
||||
root.requestPermissions(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestPermissions(flatpakId: string): void {
|
||||
if (permissionQuery.pending.indexOf(flatpakId) >= 0)
|
||||
return;
|
||||
permissionQuery.pending = permissionQuery.pending.concat([flatpakId]);
|
||||
permissionQuery.pump();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (flatpakQuery.running)
|
||||
return;
|
||||
flatpakQuery.command = [root.helperPath, "flatpaks"];
|
||||
flatpakQuery.running = true;
|
||||
}
|
||||
|
||||
function refreshCatalog(): void {
|
||||
if (catalogQuery.running)
|
||||
return;
|
||||
catalogQuery.command = [root.helperPath, "catalog"];
|
||||
catalogQuery.running = true;
|
||||
}
|
||||
|
||||
// Only ids the catalog already listed. The helper refuses off-catalog ids
|
||||
// too -- this is the near guard, not the only one, because the page is a
|
||||
// caller that can be wrong.
|
||||
function install(category: string, entryId: string): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
const known = root.entriesFor(category).some(entry => String(entry.id) === String(entryId));
|
||||
if (!known) {
|
||||
root.lastError = "That application is not in the catalog.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.busyEntryId = String(entryId);
|
||||
mutation.mode = "catalog";
|
||||
mutation.command = [root.helperPath, "install", String(category), String(entryId)];
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
function uninstall(flatpakId: string): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
const known = root.flatpaks.some(entry => String(entry.id) === String(flatpakId));
|
||||
if (!known) {
|
||||
root.lastError = "That application is not installed.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.busyEntryId = String(flatpakId);
|
||||
mutation.mode = "flatpaks";
|
||||
mutation.command = [root.helperPath, "uninstall", String(flatpakId)];
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
function absorbFlatpaks(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.flatpaks = Array.isArray(parsed) ? parsed : [];
|
||||
root.flatpaksLoaded = true;
|
||||
} catch (error) {
|
||||
root.lastError = "The list of installed applications could not be read.";
|
||||
console.warn("AppLibrary: could not parse flatpaks output:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function absorbCatalog(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.categories = Array.isArray(parsed.categories) ? parsed.categories : [];
|
||||
root.catalogLoaded = true;
|
||||
} catch (error) {
|
||||
root.lastError = "The application catalog could not be read.";
|
||||
console.warn("AppLibrary: could not parse catalog output:", error);
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: flatpakQuery
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbFlatpaks(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: catalogQuery
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbCatalog(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mutation
|
||||
|
||||
// Which fresh state the helper answers with, so the reply lands in the
|
||||
// right property instead of being parsed twice and guessed at.
|
||||
property string mode: "flatpaks"
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (mutation.mode === "catalog")
|
||||
root.absorbCatalog(this.text);
|
||||
else
|
||||
root.absorbFlatpaks(this.text);
|
||||
}
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: {
|
||||
root.busyEntryId = "";
|
||||
// Uninstalling changes what the catalog says is installed, and
|
||||
// installing changes what is installed. Both sides are re-read
|
||||
// rather than assumed.
|
||||
if (mutation.mode === "catalog")
|
||||
root.refresh();
|
||||
else
|
||||
root.refreshCatalog();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: permissionQuery
|
||||
|
||||
property var pending: []
|
||||
property string current: ""
|
||||
|
||||
function pump(): void {
|
||||
if (permissionQuery.running || permissionQuery.pending.length === 0)
|
||||
return;
|
||||
permissionQuery.current = String(permissionQuery.pending[0]);
|
||||
permissionQuery.command = [root.helperPath, "permissions", permissionQuery.current];
|
||||
permissionQuery.running = true;
|
||||
}
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const key = permissionQuery.current;
|
||||
let value = { summary: [], raw: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
value = {
|
||||
summary: Array.isArray(parsed.summary) ? parsed.summary : [],
|
||||
raw: parsed.raw ?? ({})
|
||||
};
|
||||
} catch (error) {
|
||||
// Cached as an honest empty answer rather than left absent,
|
||||
// or every repaint asks again for something that failed.
|
||||
value = { summary: ["Permissions could not be read"], raw: {} };
|
||||
}
|
||||
const next = Object.assign({}, root.permissionCache);
|
||||
next[key] = value;
|
||||
root.permissionCache = next;
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0 && root.permissionCache[permissionQuery.current] === undefined) {
|
||||
const next = Object.assign({}, root.permissionCache);
|
||||
next[permissionQuery.current] = { summary: ["Permissions could not be read"], raw: {} };
|
||||
root.permissionCache = next;
|
||||
}
|
||||
permissionQuery.pending = permissionQuery.pending.filter(
|
||||
id => String(id) !== permissionQuery.current);
|
||||
permissionQuery.current = "";
|
||||
permissionQuery.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,16 @@ Singleton {
|
||||
property var luaAutostartEntries: []
|
||||
property string lastError: ""
|
||||
|
||||
// One file type at a time: the escape hatch for when a role's family is too
|
||||
// broad, which is a real case -- SVG belongs in an editor while every other
|
||||
// image belongs in a viewer. Kept separate from the role state because it is
|
||||
// a search, not a setting: it is whatever was last asked for and nothing is
|
||||
// remembered between visits.
|
||||
property var typeMatches: []
|
||||
property string typeQuery: ""
|
||||
property bool typeSearchTruncated: false
|
||||
readonly property bool searchingTypes: typeSearch.running
|
||||
|
||||
// For the UI, which wants one answer to "is anything happening".
|
||||
//
|
||||
// Guards inside this file do NOT use it. `busy` is a binding, and a binding
|
||||
@@ -56,15 +66,48 @@ Singleton {
|
||||
Process {
|
||||
id: mutationProcess
|
||||
|
||||
// A set-type write changes the answer the open search is showing, so
|
||||
// the search is re-run rather than left displaying the old handler.
|
||||
property string repeatQuery: ""
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
mutationProcess.repeatQuery = ""
|
||||
root.lastError = "That application setting could not be changed."
|
||||
return;
|
||||
}
|
||||
if (mutationProcess.repeatQuery !== "") {
|
||||
const query = mutationProcess.repeatQuery;
|
||||
mutationProcess.repeatQuery = "";
|
||||
root.searchTypes(query);
|
||||
}
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: typeSearch
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const payload = JSON.parse(this.text);
|
||||
root.typeMatches = Array.isArray(payload.types) ? payload.types : [];
|
||||
root.typeSearchTruncated = payload.truncated === true;
|
||||
} catch (error) {
|
||||
root.typeMatches = [];
|
||||
root.lastError = "File types returned an unreadable response."
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.typeMatches = [];
|
||||
root.lastError = "File types could not be searched."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapshot(text: string): void {
|
||||
try {
|
||||
const payload = JSON.parse(text);
|
||||
@@ -135,6 +178,47 @@ Singleton {
|
||||
mutationProcess.exec([root.helper, "remove-autostart", desktopId]);
|
||||
}
|
||||
|
||||
// Searching is over the type name and its file extensions -- "svg", "heic",
|
||||
// ".mkv". Matching the human descriptions would mean reading the whole
|
||||
// shared-mime-info database, some thousands of small files, per keystroke.
|
||||
function searchTypes(query: string): void {
|
||||
const trimmed = String(query ?? "").trim();
|
||||
root.typeQuery = trimmed;
|
||||
if (trimmed.length < 2) {
|
||||
root.typeMatches = [];
|
||||
root.typeSearchTruncated = false;
|
||||
return;
|
||||
}
|
||||
if (typeSearch.running)
|
||||
return;
|
||||
typeSearch.exec([root.helper, "search-types", trimmed]);
|
||||
}
|
||||
|
||||
function clearTypeSearch(): void {
|
||||
root.typeQuery = "";
|
||||
root.typeMatches = [];
|
||||
root.typeSearchTruncated = false;
|
||||
}
|
||||
|
||||
// One type, one application: the override that leaves the rest of the
|
||||
// family where it is. The helper validates the type against what this
|
||||
// system knows and the application against what is installed.
|
||||
function setType(mime: string, desktopId: string): void {
|
||||
if (mutationProcess.running)
|
||||
return;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test(mime)) {
|
||||
root.lastError = "Choose a file type from the list."
|
||||
return;
|
||||
}
|
||||
if (!root.knownDesktopId(desktopId)) {
|
||||
root.lastError = "Choose an application from the available list."
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
mutationProcess.repeatQuery = root.typeQuery;
|
||||
mutationProcess.exec([root.helper, "set-type", mime, desktopId]);
|
||||
}
|
||||
|
||||
function addAutostart(desktopId: string): void {
|
||||
if (mutationProcess.running)
|
||||
return;
|
||||
|
||||
@@ -40,6 +40,32 @@ Singleton {
|
||||
// are different answers.
|
||||
property bool foldersMeasured: false
|
||||
|
||||
// What the used space is made of, and what could be freed.
|
||||
//
|
||||
// { segments: { home, applications, caches, system, free }, usedBytes,
|
||||
// totalBytes, freeBytes, complete, exceedsUsed }
|
||||
//
|
||||
// `system` is the remainder -- used bytes minus the three measured segments
|
||||
// -- and the page calls it "System & everything else" for that reason. It
|
||||
// is not a measurement of the system; it is everything the walk did not
|
||||
// reach. The segments never sum past used, and when a measurement would
|
||||
// overshoot, `exceedsUsed` says so instead of a number being scaled to make
|
||||
// the bar look tidy.
|
||||
property var breakdown: null
|
||||
property bool breakdownMeasured: false
|
||||
property bool measuringBreakdown: false
|
||||
|
||||
// [{ id, label, detail, bytes, privileged }]
|
||||
//
|
||||
// Nothing here is selected, ordered by urgency, or acted on. Each row is
|
||||
// freed only by its own id being passed to clean(), which is what keeps a
|
||||
// mis-wired button from emptying four things at once.
|
||||
property var cleanables: []
|
||||
property bool cleanablesMeasured: false
|
||||
property bool measuringCleanables: false
|
||||
// Which row is mid-clean, so the page can disable that one row.
|
||||
property string cleaningId: ""
|
||||
|
||||
readonly property var primaryDrive: root.drives.length > 0 ? root.drives[0] : null
|
||||
|
||||
// The filesystem the user means when they ask how full the machine is.
|
||||
@@ -110,6 +136,49 @@ Singleton {
|
||||
folderScan.running = true;
|
||||
}
|
||||
|
||||
// The same walk `scan` does, so it costs the same and is asked for on
|
||||
// demand rather than when the page opens.
|
||||
function measureBreakdown(): void {
|
||||
if (root.measuringBreakdown)
|
||||
return;
|
||||
root.measuringBreakdown = true;
|
||||
breakdownScan.running = true;
|
||||
}
|
||||
|
||||
function measureCleanables(): void {
|
||||
if (root.measuringCleanables)
|
||||
return;
|
||||
root.measuringCleanables = true;
|
||||
cleanableScan.running = true;
|
||||
}
|
||||
|
||||
// Exactly one, named. An id this service has not been told about is
|
||||
// refused here and refused again by the helper.
|
||||
function clean(identifier: string): void {
|
||||
if (cleaner.running || root.measuringCleanables)
|
||||
return;
|
||||
const known = root.cleanables.some(item => String(item.id) === String(identifier));
|
||||
if (!known) {
|
||||
root.lastError = "There is nothing by that name to clean up.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.cleaningId = String(identifier);
|
||||
cleaner.command = [root.helperPath, "clean", String(identifier)];
|
||||
cleaner.running = true;
|
||||
}
|
||||
|
||||
function absorbCleanables(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.cleanables = Array.isArray(parsed) ? parsed : [];
|
||||
root.cleanablesMeasured = true;
|
||||
} catch (error) {
|
||||
root.lastError = "Could not measure what could be cleaned up.";
|
||||
console.warn("Disks: could not parse cleanables output:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function unmount(devicePath: string): void {
|
||||
root.runMedia(["unmount", devicePath]);
|
||||
}
|
||||
@@ -171,6 +240,54 @@ Singleton {
|
||||
onExited: root.scanning = false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: breakdownScan
|
||||
command: [root.helperPath, "breakdown"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
root.breakdown = JSON.parse(this.text);
|
||||
root.breakdownMeasured = true;
|
||||
} catch (error) {
|
||||
root.lastError = "Could not measure what is using the drive.";
|
||||
console.warn("Disks: could not parse breakdown output:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: root.measuringBreakdown = false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: cleanableScan
|
||||
command: [root.helperPath, "cleanables"]
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: root.measuringCleanables = false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: cleaner
|
||||
// The helper answers with the fresh list, so the sizes on screen are
|
||||
// what is there now rather than what was there before the clean.
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: {
|
||||
root.cleaningId = "";
|
||||
// Freeing space changes the drive's usage and its breakdown, and
|
||||
// both are on screen while this happens.
|
||||
root.refresh();
|
||||
if (root.breakdownMeasured)
|
||||
root.measureBreakdown();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: media
|
||||
stderr: StdioCollector {
|
||||
|
||||
@@ -92,6 +92,16 @@ Singleton {
|
||||
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
|
||||
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
|
||||
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
|
||||
// The Applications tab manages applications now, rather than only
|
||||
// pointing file types at them, so the things people come looking for —
|
||||
// what is installed, removing one, what it is allowed to reach — are
|
||||
// findable by their own names.
|
||||
{ label: "Installed applications", detail: "Everything installed here, where it came from, and how much room it takes", page: "applications" },
|
||||
{ label: "Uninstall an application", detail: "Remove a Flatpak application, or see the command for a system package", page: "applications" },
|
||||
{ label: "Application permissions", detail: "What a Flatpak application may reach: files, camera, microphone, network", page: "applications" },
|
||||
{ label: "Install applications", detail: "Browse the catalog Panama curates, by category", page: "applications" },
|
||||
{ label: "Autostart", detail: "Which applications start with your session", page: "applications" },
|
||||
{ label: "File associations", detail: "Which application opens each kind of file", page: "applications" },
|
||||
{ label: "User account", detail: "Your name, picture, and password", page: "users" },
|
||||
{ label: "Profile picture", detail: "The avatar shown on the lock screen and in the Control Center", page: "users" },
|
||||
{ label: "Change password", detail: "Set a new password for signing in", page: "users" },
|
||||
@@ -133,11 +143,14 @@ Singleton {
|
||||
{ label: "Backups", detail: "Automatic snapshots of the system and your home folder", page: "snapshots" },
|
||||
{ label: "File history", detail: "Earlier versions of your files", page: "snapshots" },
|
||||
{ label: "Undo a change", detail: "Put back a file as it was at an earlier point", page: "snapshots" },
|
||||
{ label: "Snapshot retention", detail: "How many hourly, daily, and weekly snapshots to keep", page: "snapshots" },
|
||||
{ label: "Free space", detail: "How full each drive and filesystem is", page: "storage" },
|
||||
{ label: "Disk usage", detail: "What is using the space on this machine", page: "storage" },
|
||||
{ label: "Drive health", detail: "Temperature, hours powered on, and reported warnings", page: "storage" },
|
||||
{ label: "Removable drives", detail: "Unmount a USB drive or memory card safely", page: "storage" },
|
||||
{ label: "Encryption", detail: "Whether the filesystem is encrypted", page: "storage" },
|
||||
{ label: "Clean up storage", detail: "Caches, trash, and unused runtimes, each itemized and sized before you remove it", page: "storage" },
|
||||
{ label: "Application caches", detail: "What applications have left in your cache folder, and clearing it", page: "storage" },
|
||||
{ label: "Output volume", detail: "Choose the output device and its level", page: "sound" },
|
||||
{ label: "Input volume", detail: "Choose the microphone and its level", page: "sound" },
|
||||
{ label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" },
|
||||
|
||||
@@ -44,6 +44,26 @@ Singleton {
|
||||
// inside the handler that changes it. See DefaultApps.qml.
|
||||
readonly property bool busy: query.running || mutation.running
|
||||
|
||||
// How many snapshots a horizon may keep. The page offers a dropdown rather
|
||||
// than a number field, and this is what fills it; the helper refuses
|
||||
// anything above it too. 999 hourly snapshots is not a retention policy,
|
||||
// it is a typo that fills a drive.
|
||||
//
|
||||
// The monthly and yearly limits are not editable here and are never
|
||||
// written, so a configuration with longer horizons keeps them.
|
||||
readonly property int retentionMax: 50
|
||||
readonly property var retentionChoices: {
|
||||
const values = [];
|
||||
for (let index = 0; index <= root.retentionMax; index += 1)
|
||||
values.push(index);
|
||||
return values;
|
||||
}
|
||||
|
||||
// The card the browser lives in is not the card it was opened from: the
|
||||
// browse state below is independent of any config row's expanded state, so
|
||||
// opening a snapshot from a collapsed row still shows the files.
|
||||
readonly property bool browserOpen: root.browsingConfig !== ""
|
||||
|
||||
function labelFor(config: var): string {
|
||||
const subvolume = String(config?.subvolume ?? "");
|
||||
if (subvolume === "/")
|
||||
@@ -128,8 +148,21 @@ Singleton {
|
||||
root.run(["delete", config, String(number)]);
|
||||
}
|
||||
|
||||
// Three horizons, each a whole number the dropdown offered. Checked here as
|
||||
// well as in the helper because the caller is a page that can be wrong, and
|
||||
// a keep count that arrives as "5.5" or "-1" would be written straight into
|
||||
// snapper's config file.
|
||||
function setRetention(config: string, hourly: int, daily: int, weekly: int): void {
|
||||
root.run(["set-retention", config, String(hourly), String(daily), String(weekly)]);
|
||||
const values = [hourly, daily, weekly];
|
||||
for (const value of values) {
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < 0 || number > root.retentionMax) {
|
||||
root.lastError = "Keep counts go from 0 to " + root.retentionMax + ".";
|
||||
return;
|
||||
}
|
||||
}
|
||||
root.run(["set-retention", config,
|
||||
String(values[0]), String(values[1]), String(values[2])]);
|
||||
}
|
||||
|
||||
function setTimeline(config: string, enabled: bool): void {
|
||||
|
||||
Reference in New Issue
Block a user