1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-02 10:32:07 -04:00

Compare commits

..

90 Commits

Author SHA1 Message Date
bbedward
2f3b6ca814 optimizations for animation behaviors 2026-05-01 22:23:06 -04:00
purian23
68d68e21b1 fix(Frame): Update all remaining known issues & align state modes 2026-05-01 21:17:22 -04:00
purian23
5e02f37587 fix(DankLauncher): Update click area input on first launch & adjust footer 2026-05-01 19:57:09 -04:00
purian23
7de70b3172 arcExtender(Launcher): Extend launcher by default 2026-05-01 19:18:10 -04:00
purian23
06674c9dac feat(ArcExtender): New center modal option in ConnectedMode 2026-05-01 18:40:39 -04:00
bbedward
442188ba70 optimize some animations 2026-05-01 18:37:17 -04:00
bbedward
856a3d9f50 revert animators 2026-05-01 18:09:12 -04:00
bbedward
a04478227f stash 2026-05-01 17:06:27 -04:00
purian23
339481893b frame(Connected): Fix connected regression & Ran Performance pass 2026-05-01 16:31:47 -04:00
bbedward
b10023f18e general fixes and audit 2026-05-01 16:31:47 -04:00
bbedward
eff39e248f numerous animation improvements, convert a bunch of stuff to use
Animator, etc.
2026-05-01 16:31:47 -04:00
bbedward
f6ddb4e691 re-organize settings 2026-05-01 16:31:47 -04:00
bbedward
fce1cdfafb fix 2026-05-01 16:31:47 -04:00
purian23
c3bca485e6 frame(Motion): Restore fluid motion & Update component connections 2026-05-01 16:31:47 -04:00
purian23
444411c24c Sync up Frame w/Master branch updates 2026-05-01 16:31:47 -04:00
bbedward
ae0ee157c6 restore niri overview connected mode 2026-05-01 16:31:47 -04:00
bbedward
44ec4ae010 some more simplifications and bug fixes 2026-05-01 16:31:47 -04:00
bbedward
396926f522 de-dupe and cleanup 2026-05-01 16:31:47 -04:00
bbedward
ee975b0bfa restore CC and notification standalone behavior 2026-05-01 16:31:47 -04:00
bbedward
3e1be94ca9 refactor connected/standalone architecture 2026-05-01 16:31:47 -04:00
purian23
7633c36260 (frameMode): New Modal & Launcher connections 2026-05-01 16:31:47 -04:00
purian23
5eddfcdfae (Notifications): Update body card expansions 2026-05-01 16:31:47 -04:00
purian23
78d3e675f2 (frame): QOL Control Center & Notification updates 2026-05-01 16:31:47 -04:00
purian23
328da9f155 feat(Frame): Close the gaps 2026-05-01 16:31:47 -04:00
purian23
25c85c865f frame(Notifications): Update Arc path & Motion 2026-05-01 16:31:47 -04:00
purian23
cea3b12739 (frame): Update animation sync w/Dank Popouts 2026-05-01 16:31:47 -04:00
purian23
a9c1fa2aa8 (frame): Performance round 2026-05-01 16:31:47 -04:00
purian23
daf4816ae1 (frame): Update Connected blur Arcs & Enable shadow modes 2026-05-01 16:31:47 -04:00
purian23
79c8cfef43 frame(ConnectedMode): Wire up Notifications 2026-05-01 16:31:46 -04:00
purian23
08a5c0f3f1 (frame): Update connected mode animation & motion logic 2026-05-01 16:31:46 -04:00
purian23
e7ede6874d (frame): implement ConnectedModeState to better handle component sync 2026-05-01 16:31:46 -04:00
purian23
31223e1ee8 (frameMode): Restore user settings when exiting frame mode
- Align blur settings in non-FrameMode motion settings
2026-05-01 16:31:46 -04:00
purian23
a148d4463b (frame): Update connected mode with blur 2026-05-01 16:31:46 -04:00
purian23
4a6214b1eb (frame): Update connected mode & opacity connection settings 2026-05-01 16:31:46 -04:00
purian23
77514b225b (frameInMotion): Initial Unified Frame Connected Mode 2026-05-01 16:31:46 -04:00
purian23
9f26aaff01 Add Directional Motion options 2026-05-01 16:31:46 -04:00
purian23
b7f7964e38 Initial staging for Animation & Motion effects 2026-05-01 16:31:46 -04:00
purian23
ea34ad2cdf (frame): Add blur support & cleanup 2026-05-01 16:31:46 -04:00
purian23
6f8abe8527 (frame): Multi-monitor support 2026-05-01 16:31:46 -04:00
purian23
9150080403 Connected frames & defaults 2026-05-01 16:31:46 -04:00
purian23
26b9f9f7b4 Continue frame implementation 2026-05-01 16:31:46 -04:00
purian23
1b1f186fc3 Initial framework 2026-05-01 16:31:46 -04:00
bbedward
c81645bacb add pre-commit hook for console.log 2026-04-30 16:59:26 -04:00
Archit Arora
cdc4ca7e1f matugen: generate theme for Vencord (#2320) 2026-04-30 16:16:55 -04:00
gibbert
7d92842ff2 matugen: fix emacs template constant line number size (#2317)
Made it so line numbers don't stay a constant size when changing buffer
text scale.

See this thread:
<https://emacs.stackexchange.com/questions/74507/constant-font-size-in-display-line-numbers-mode-when-zooming-in-and-out>
2026-04-30 11:47:24 -04:00
Body
d8bf3bdfe8 processes: fix list gaps and overlap when searching (#2315) 2026-04-30 11:45:46 -04:00
David Mireles
23ed795e85 Fix VPN UI for active transient entries (#2312)
Co-authored-by: louzt <18044171+louzt@users.noreply.github.com>
2026-04-30 11:41:41 -04:00
bbedward
2877c63c97 system update: make refresh synchronous 2026-04-30 11:41:07 -04:00
bbedward
86096db26b system update: general fixes to flatpak parsing 2026-04-29 16:14:19 -04:00
bbedward
f76724f7cd logger: add a dedicated QML logging Singleton
- adds log.info/error/debug/warn/fatal
- adds ability to write logs to any file
- add CLI options in addition to env to set log levels
2026-04-29 15:42:30 -04:00
bbedward
3b96c6ab22 Revert "system updater: make all distros use terminal"
This reverts commit 1467f5dba9.
2026-04-29 14:56:54 -04:00
bbedward
1467f5dba9 system updater: make all distros use terminal 2026-04-29 14:41:24 -04:00
dms-ci[bot]
baaa30c94e nix: update vendorHash for go.mod changes 2026-04-29 16:42:28 +00:00
bbedward
24a3cd5a3d core: update go dependencies 2026-04-29 12:40:24 -04:00
bbedward
65151dbfd7 i18n: term sync 2026-04-29 12:39:32 -04:00
bbedward
7bd9574868 system updater: complete overhaul
Move system update flow to GO, with a CLI (convenient AIO tool) and
server integration. All lifecycle, scheduling, execution occurs on
backend side.

Run some backends via pkexec, some via terminal like paru/yay.

Incorporate flatpak as an option to update.

Add terminal override setting in GUI, in addition to $TERMINAL env
variable.

fixes #2307
fixes #822
fixes #1102
fixes #1812
fixes #1087
fixes #1743
2026-04-29 12:33:57 -04:00
purian23
a4cfdf4a59 (dms): Add input group to dms setup
- Suppress fix/warnings
2026-04-28 14:03:37 -04:00
bbedward
fd651dc943 niri overlay: fix state binding
fixes #2301
2026-04-28 13:19:34 -04:00
Kangheng Liu
919b09fc96 feat(desktop): expose screen var to desktop plugins (#2300) 2026-04-28 11:45:34 -04:00
bbedward
aeb3fdd637 osd(media): workaround for firefox reporting youtube thumbnails as
players
fixes #2298
2026-04-28 11:27:16 -04:00
Amaan Qureshi
dc5636bed5 flake: let module callers supply pkgs so overlays reach the build (#2244)
The nixosModule/homeModule path previously called `buildDmsPkgs pkgs` but
internally referenced `self.packages.${system}.default`, which was
instantiated via `nixpkgs.legacyPackages`, an unoverlayed pkgs. That
meant downstream flakes couldn't reach through their own overlays to
the dms-shell build (e.g. to swap `kdePackages.sonnet` or trim perl
out of the aspell closure).

Extract the derivation as `mkDmsShell = pkgs: ...` at the top-level
`let`, and call it from both `packages.${system}.dms-shell` (for
direct consumers of the flake) and `buildDmsPkgs pkgs` (for module
consumers, which now pass in the system's overlayed pkgs).

Also re-checks overrideAttrs / .override still work: `mkDmsShell pkgs`
is the same `pkgs.lib.makeOverridable` wrapper as before, just
parameterized on the caller's pkgs instance.

Co-authored-by: Lucas <43530291+LuckShiba@users.noreply.github.com>
2026-04-27 16:14:25 -03:00
bbedward
36a7692da7 dock: add trash CLI, refine implementation 2026-04-27 11:14:57 -04:00
Kangheng Liu
c9b38023d5 feat(desktop): expose accept keyboard focus to desktop widgets (#2285)
Opt in by setting acceptsKeyboardFocus: true
2026-04-27 10:23:49 -04:00
Kangheng Liu
536e654b5e dock: add trash bin button (#2277)
* dock: add trash bin button

- icon reflects content- filled/empty
- multiple file manager support with nautilus as default, builtin as
fallback
- settingsspec at dock tab
- context menu

* fix: remove support for builtin filebrowser

needs specific adaptors at FB adhering the trash freedesktop spec

* fix: suppress auto-hide dock with trash context menu open

* feat: allow for custom file manager command

* feat: switch runner to proc.runcommand with toasts on command failures
2026-04-27 09:55:00 -04:00
Nic Ficca
e805f6b5ac Fix: close notification center after clicking action buttons (#2276)
* Close notification center after clicking action buttons

When clicking action buttons (e.g., "View", "Activate") in the
notification center, the action fires but the popout stays open. Since
the center is a layer-shell surface, it blocks focus changes on Wayland
compositors like niri, making the action appear to do nothing.

The keyboard navigation path already closes the center after invoking
actions; this brings the mouse click path in line.

Also fix closeNotificationCenter() in PopoutService to set
notificationHistoryVisible = false (matching PopoutManager._closePopout)
instead of calling close() directly, which left the visibility property
stale and caused the bell toggle to require two presses to reopen.

Fixes #2178

* Sync notificationHistoryVisible with shouldBeVisible

NotificationCenterPopout has its own notificationHistoryVisible property
that drives open/close, but the PopoutService public API (open, close,
toggle) calls DankPopout methods directly, bypassing that property. This
leaves notificationHistoryVisible stale, causing the bell toggle to
require two presses to reopen after a programmatic close.

Sync the property from onShouldBeVisibleChanged so any caller going
through open()/close() gets the state corrected automatically.
2026-04-27 09:48:36 -04:00
Lucas
94f4b6d4a9 nix: add VM tests for flake modules (#2281)
* nix: add VM tests for flake modules

* ci: add NixOS tests
2026-04-27 09:37:28 -04:00
purian23
28f68ac702 refactor: update PPA upload script to handle series selection 2026-04-25 19:56:58 -04:00
purian23
441ec42ee0 (ubuntu): Update Workflow handling 2026-04-25 19:33:35 -04:00
Kangheng Liu
5415444e15 keybinds: add move workspace to monitor keybinds (#2268)
and distinguish with move columns
2026-04-25 12:07:18 -04:00
Archit Arora
bd5276b40d feat(system-tray): add icon tinting (#2266) 2026-04-25 12:06:56 -04:00
Kangheng Liu
dd3f17f51e clipboard: add keybind to switch tabs and toggle pinned (#2262)
* clipboard: add keybind to switch tabs

* clipboard: add bind to toggle pinned
2026-04-25 12:06:33 -04:00
purian23
a459b7d1b4 (dbar): Settings reorg 2026-04-25 00:40:33 -04:00
purian23
0f71c29776 dms(blur): Dank all the things 2026-04-24 22:52:14 -04:00
Lucas
4a32739d3f nix: update quickshell version (#2263)
Updated the quickshell revision to 783c95, matching the "stable" package in other DMS distributions.
2026-04-24 17:12:44 -04:00
bbedward
1abb221024 blur: revise general blur styling and refine it 2026-04-24 12:07:23 -04:00
Walid Salah
b2668a2ffc Fix focused app when switching to empty workspace (#2259)
* Fix multiple screens on niri, when switching to an empty wokspace the other screen focused app widget would get confused

* Blank workspace fix
2026-04-24 10:48:24 -04:00
bbedward
f4c11bc2ff clipboard: decode metadata only 2026-04-23 09:28:26 -04:00
bbedward
97fa86d8f0 loginctl: simplify event handling 2026-04-22 10:32:05 -04:00
Kristijan Ribarić
b87c36d29e fix(quickshell): restore night mode and OSD surfaces after resume (#2254) 2026-04-22 10:08:50 -04:00
bbedward
c6ed64b24e launcher: add elide helpers for RichText 2026-04-21 15:18:41 -04:00
bbedward
cf382c0322 launcher: add indicators for flatpak/snap/appimage/nix
fixes #2251
2026-04-21 14:03:47 -04:00
bbedward
9139fd2fb1 doctor: add Miracle WM to checks 2026-04-20 09:27:59 -04:00
bbedward
da3df9bb77 systray: fix missing import 2026-04-20 09:24:13 -04:00
Jos Dehaes
e7834c981a Labwc service (#2248)
* services: add LabwcService with quit

labwc has a minimal IPC surface (no socket, no queries) but it does
expose `labwc --exit` as a clean shutdown path. Wrap that in a small
Singleton service following the same shape as DwlService/NiriService
so the compositor-specific dispatch in callers can stay uniform.

* session: dispatch labwc logout via LabwcService

CompositorService.isLabwc was detected but never dispatched in
_logout(); labwc sessions therefore fell through to the Hyprland
exit call, which silently no-ops under labwc. Users had to set
customPowerActionLogout to 'labwc --exit' as a workaround.

Add a labwc branch alongside the existing niri/dwl/sway branches
so the power menu logout works out of the box.
2026-04-20 09:22:20 -04:00
supposede
316428b14a Update color variables in zen-userchrome.css because it got broken again (#2246) 2026-04-20 09:16:04 -04:00
Walid Salah
6a9de8b423 Fix: Expand tilde from config paths (#2242)
* Expand tilde to the home directory for paths from config

* Remove extra line
2026-04-20 09:15:29 -04:00
Roni Laukkarinen
f1e3452307 feat(system-tray): add optional monochrome icons setting (#2241)
Adds a 'Monochrome Icons' toggle to the system tray widget context menu.
When enabled, all system tray icons are desaturated using MultiEffect,
giving a cleaner monochrome bar aesthetic that matches minimal themes.

The setting is per-user (settings.json), defaults to false to preserve
existing behavior.
2026-04-20 09:15:02 -04:00
Sunny
4c2c193766 added non-flake nix compatibility with flake-compat (#2009)
* added non-flake nix compatibility with flake-compat

* nix: move flake-compat files to distro/nix

---------

Co-authored-by: LuckShiba <luckshiba@protonmail.com>
2026-04-17 22:42:19 -03:00
Lucas
112f2165f3 doctor: add blur support (#2236) 2026-04-17 18:57:13 -03:00
Lucas
40e3a22b99 nix: update flake.lock (#2237) 2026-04-17 18:57:02 -03:00
291 changed files with 21601 additions and 8502 deletions

View File

@@ -1,4 +1,4 @@
name: Check nix flake
name: Nix flake and NixOS tests
on:
pull_request:
@@ -9,6 +9,7 @@ on:
jobs:
check-flake:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Checkout
@@ -18,6 +19,25 @@ jobs:
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
enable_kvm: true
extra_nix_config: |
system-features = nixos-test benchmark big-parallel kvm
- name: Check the flake
run: nix flake check
run: nix flake check -L
- name: Run NixOS module test
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
- name: Run NixOS service start test
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
- name: Run greeter niri test
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
- name: Run home-manager module test
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
- name: Run niri home-manager module test
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L

View File

@@ -243,7 +243,7 @@ jobs:
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ppa-upload.sh uploads to questing + resolute when series is omitted
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" "" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
echo "::error::Upload failed for $PKG"
exit 1
fi

View File

@@ -20,3 +20,11 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
pass_filenames: false
- repo: local
hooks:
- id: no-console-in-qml
name: no console.* in QML (use Log service)
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
language: system
files: ^quickshell/.*\.qml$
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)

View File

@@ -26,6 +26,17 @@ var runCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon")
session, _ := cmd.Flags().GetBool("session")
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
}
}
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
}
}
log.ApplyEnvOverrides()
if daemon {
runShellDaemon(session)
} else {
@@ -526,5 +537,7 @@ func getCommonCommands() []*cobra.Command {
dlCmd,
randrCmd,
blurCmd,
trashCmd,
systemCmd,
}
}

View File

@@ -11,6 +11,7 @@ import (
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
@@ -90,6 +91,7 @@ var (
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
)
var doctorCmd = &cobra.Command{
@@ -468,6 +470,7 @@ func checkWindowManagers() []checkResult {
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
}
var results []checkResult
@@ -500,7 +503,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, or Wayfire",
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
doctorDocsURL + "#compositor-checks",
})
}
@@ -509,9 +512,24 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
}
results = append(results, checkCompositorBlurSupport())
return results
}
func checkCompositorBlurSupport() checkResult {
supported, err := blur.ProbeSupport()
if err != nil {
return checkResult{catCompositor, "Background Blur", statusInfo, "Unable to verify", err.Error(), doctorDocsURL + "#compositor-checks"}
}
if supported {
return checkResult{catCompositor, "Background Blur", statusOK, "Supported", "Compositor supports ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 {
@@ -535,6 +553,8 @@ func detectRunningWM() string {
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
return "niri"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP")
}
@@ -553,6 +573,7 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
qmlContent := `
import QtQuick
import Quickshell
import Quickshell.Wayland
ShellRoot {
id: root
@@ -561,6 +582,7 @@ ShellRoot {
property bool idleMonitorAvailable: false
property bool idleInhibitorAvailable: false
property bool shortcutInhibitorAvailable: false
property bool backgroundBlurAvailable: false
Timer {
interval: 50
@@ -578,16 +600,18 @@ ShellRoot {
try {
var testItem = Qt.createQmlObject(
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
'import Quickshell; import Quickshell.Wayland; import QtQuick; QtObject { ' +
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined"; ' +
'readonly property bool hasBackgroundBlur: typeof BackgroundEffect !== "undefined" ' +
'}',
root
)
root.idleMonitorAvailable = testItem.hasIdleMonitor
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
testItem.destroy()
} catch (e) {}
@@ -596,6 +620,8 @@ ShellRoot {
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
console.warn(root.backgroundBlurAvailable ? "FEATURE:BackgroundBlur:OK" : "FEATURE:BackgroundBlur:UNAVAILABLE")
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
}
}
@@ -616,6 +642,7 @@ ShellRoot {
{"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
}
var results []checkResult

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -11,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -267,6 +269,8 @@ func runSetupDmsConfig(name string) error {
func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===")
ensureInputGroup()
wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd()
@@ -340,6 +344,37 @@ func runSetup() error {
return nil
}
// Add user to the input group for the evdev manager for inut state tracking.
// Caps Lock OSD and the Caps Lock bar indicator.
func ensureInputGroup() {
if !utils.HasGroup("input") {
return
}
currentUser := os.Getenv("USER")
if currentUser == "" {
currentUser = os.Getenv("LOGNAME")
}
if currentUser == "" {
return
}
out, err := execGroups(currentUser)
if err == nil && strings.Contains(out, "input") {
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
return
}
fmt.Println("Adding user to input group for Caps Lock OSD support...")
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
} else {
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
}
}
func execGroups(user string) (string, error) {
out, err := exec.Command("groups", user).Output()
return string(out), err
}
func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")

View File

@@ -0,0 +1,277 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/spf13/cobra"
)
var systemCmd = &cobra.Command{
Use: "system",
Short: "System operations",
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
}
var systemUpdateCmd = &cobra.Command{
Use: "update",
Short: "Apply or list system updates",
Long: `Apply or list system updates across detected package managers.
Default behavior is to apply available updates after prompting for confirmation.
Use --check to list updates without applying.
Examples:
dms system update --check # list available updates
dms system update # apply updates (interactive prompt)
dms system update --noconfirm # apply updates without prompting
dms system update --dry # simulate without changing anything
dms system update --no-flatpak --noconfirm # apply system updates only
dms system update --interval 3600 # set the server poll interval to 1h`,
Run: runSystemUpdate,
}
var (
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
)
func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
systemCmd.AddCommand(systemUpdateCmd)
}
func runSystemUpdate(cmd *cobra.Command, args []string) {
switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS)
case sysUpdateCheck:
runSystemUpdateCheck()
default:
runSystemUpdateApply()
}
}
func selectBackends(ctx context.Context) []sysupdate.Backend {
sel := sysupdate.Select(ctx)
backends := sel.All()
if !sysUpdateNoFlatpak {
return backends
}
out := backends[:0]
for _, b := range backends {
if b.Repo() == sysupdate.RepoFlatpak {
continue
}
out = append(out, b)
}
return out
}
func runSystemUpdateCheck() {
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer cancel()
backends := selectBackends(ctx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
type backendResult struct {
ID string `json:"id"`
Display string `json:"displayName"`
Packages []sysupdate.Package `json:"packages"`
}
var results []backendResult
var allPkgs []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs})
allPkgs = append(allPkgs, pkgs...)
}
if sysUpdateJSON {
out, _ := json.MarshalIndent(map[string]any{
"backends": results,
"packages": allPkgs,
"error": errOrEmpty(firstErr),
"count": len(allPkgs),
}, "", " ")
fmt.Println(string(out))
return
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(allPkgs))
if firstErr != nil {
fmt.Printf("Error: %v\n", firstErr)
}
if len(allPkgs) == 0 {
return
}
fmt.Println()
for _, p := range allPkgs {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
}
}
func runSystemUpdateApply() {
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer checkCancel()
backends := selectBackends(checkCtx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
pkgs, firstErr := collectUpdates(checkCtx, backends)
if firstErr != nil {
fmt.Printf("Warning: %v\n\n", firstErr)
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(pkgs))
if len(pkgs) == 0 {
fmt.Println("Nothing to upgrade.")
return
}
fmt.Println()
for _, p := range pkgs {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
}
fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [y/N]: ") {
fmt.Println("Aborted.")
return
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
opts := sysupdate.UpgradeOptions{
IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR,
DryRun: sysUpdateDry,
}
onLine := func(line string) { fmt.Println(line) }
for _, b := range backends {
fmt.Printf("\n== %s ==\n", b.DisplayName())
if err := b.Upgrade(ctx, opts, onLine); err != nil {
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
}
}
if sysUpdateDry {
fmt.Println("\nDry run complete (no changes applied).")
return
}
fmt.Println("\nUpgrade complete.")
}
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
var all []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
all = append(all, pkgs...)
}
return all, firstErr
}
func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "sysupdate.setInterval",
Params: map[string]any{"seconds": float64(seconds)},
})
if err != nil {
log.Fatalf("Failed: %v (is dms server running?)", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
fmt.Printf("Interval set to %d seconds.\n", seconds)
}
func promptYesNo(prompt string) bool {
if !stdinIsTTY() {
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
}
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
func printBackends(backends []sysupdate.Backend) {
if len(backends) == 0 {
return
}
names := make([]string, 0, len(backends))
for _, b := range backends {
names = append(names, b.DisplayName())
}
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
}
func stdinIsTTY() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
func errOrEmpty(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func defaultIfEmpty(s, def string) string {
if s == "" {
return def
}
return s
}

View File

@@ -0,0 +1,122 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra"
)
var trashCmd = &cobra.Command{
Use: "trash",
Short: "Manage the user's trash (XDG Trash spec 1.0)",
}
var trashPutCmd = &cobra.Command{
Use: "put <path...>",
Short: "Move files or directories into the trash",
Args: cobra.MinimumNArgs(1),
Run: runTrashPut,
}
var trashListCmd = &cobra.Command{
Use: "list",
Short: "List trashed items across all known trash directories",
Run: runTrashList,
}
var trashCountCmd = &cobra.Command{
Use: "count",
Short: "Print the total number of trashed items",
Run: runTrashCount,
}
var trashEmptyCmd = &cobra.Command{
Use: "empty",
Short: "Permanently delete every trashed item",
Run: runTrashEmpty,
}
var trashRestoreCmd = &cobra.Command{
Use: "restore <name>",
Short: "Restore a trashed item to its original location",
Args: cobra.ExactArgs(1),
Run: runTrashRestore,
}
var (
trashJSONOutput bool
trashRestoreDir string
)
func init() {
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
}
func runTrashPut(cmd *cobra.Command, args []string) {
var failed int
for _, p := range args {
if _, err := trash.Put(p); err != nil {
log.Errorf("trash %s: %v", p, err)
failed++
continue
}
fmt.Println(p)
}
if failed > 0 {
os.Exit(1)
}
}
func runTrashList(cmd *cobra.Command, args []string) {
entries, err := trash.List()
if err != nil {
log.Fatalf("list trash: %v", err)
}
if trashJSONOutput {
if entries == nil {
entries = []trash.Entry{}
}
out, _ := json.MarshalIndent(entries, "", " ")
fmt.Println(string(out))
return
}
if len(entries) == 0 {
fmt.Println("Trash is empty")
return
}
for _, e := range entries {
marker := "F"
if e.IsDir {
marker = "D"
}
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
}
}
func runTrashCount(cmd *cobra.Command, args []string) {
n, err := trash.Count()
if err != nil {
log.Fatalf("count trash: %v", err)
}
fmt.Println(n)
}
func runTrashEmpty(cmd *cobra.Command, args []string) {
if err := trash.Empty(); err != nil {
log.Fatalf("empty trash: %v", err)
}
}
func runTrashRestore(cmd *cobra.Command, args []string) {
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
log.Fatalf("restore: %v", err)
}
}

View File

@@ -15,6 +15,8 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)

View File

@@ -15,6 +15,8 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)

View File

@@ -80,6 +80,16 @@ func getRuntimeDir() string {
return os.TempDir()
}
func appendLogEnv(env []string) []string {
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
env = append(env, "DMS_LOG_LEVEL="+v)
}
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
env = append(env, "DMS_LOG_FILE="+v)
}
return env
}
func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run")
return err == nil
@@ -216,6 +226,8 @@ func runShellInteractive(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -459,6 +471,8 @@ func runShellDaemon(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil {
log.Fatalf("Error opening /dev/null: %v", err)

View File

@@ -6,11 +6,11 @@ toolchain go1.26.1
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/alecthomas/chroma/v2 v2.24.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/charmbracelet/log v1.0.0
github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
@@ -20,28 +20,27 @@ require (
github.com/stretchr/testify v1.11.1
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
golang.org/x/image v0.36.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
@@ -49,36 +48,37 @@ require (
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
github.com/go-git/go-git/v6 v6.0.0-alpha.2
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-isatty v0.0.22
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/muesli/termenv v0.16.0
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -1,14 +1,14 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998EGi6Llk=
github.com/alecthomas/chroma/v2 v2.24.0/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
@@ -24,22 +24,22 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
@@ -52,8 +52,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -66,12 +66,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 h1:QRpwB1ans3fB3Cmeuog1ATzvXg/xhqubqiQi97xNO6E=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -79,8 +79,6 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -95,20 +93,20 @@ github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7Dmvb
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -125,8 +123,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
@@ -155,35 +153,33 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,12 +1,16 @@
package log
import (
"io"
"os"
"regexp"
"strings"
"sync"
"github.com/charmbracelet/lipgloss"
cblog "github.com/charmbracelet/log"
"github.com/mattn/go-isatty"
"github.com/muesli/termenv"
)
// Logger embeds the Charm Logger and adds Printf/Fatalf
@@ -21,8 +25,26 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
var (
logger *Logger
initLogger sync.Once
logMu sync.Mutex
logFile *os.File
logStderr io.Writer = os.Stderr
ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
)
// ansiStripWriter strips ANSI escape sequences before forwarding to w. Used
// for the file sink so colored stderr stays colored while the file stays plain.
type ansiStripWriter struct{ w io.Writer }
func (a *ansiStripWriter) Write(p []byte) (int, error) {
stripped := ansiRe.ReplaceAll(p, nil)
if _, err := a.w.Write(stripped); err != nil {
return 0, err
}
return len(p), nil
}
func parseLogLevel(level string) cblog.Level {
switch strings.ToLower(level) {
case "debug":
@@ -86,7 +108,7 @@ func GetLogger() *Logger {
SetString(" DEBUG").
Foreground(lipgloss.Color("4"))
base := cblog.New(os.Stderr)
base := cblog.New(logStderr)
base.SetStyles(styles)
base.SetReportTimestamp(false)
@@ -98,10 +120,85 @@ func GetLogger() *Logger {
base.SetPrefix(" go")
logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path)
}
})
return logger
}
// SetLevel updates the active log level. Accepts the same strings as
// DMS_LOG_LEVEL. Unknown values default to info.
func SetLevel(level string) {
GetLogger().SetLevel(parseLogLevel(level))
}
// SetLogFile makes the logger append to path in addition to stderr. Passing an
// empty string detaches the file sink. Atomic per-line writes (≤PIPE_BUF) on
// O_APPEND keep concurrent Go and QML writers from corrupting each other.
//
// Color handling: charmbracelet/log auto-detects color support from its
// io.Writer, and io.MultiWriter doesn't pass that through, so we force the ANSI
// profile when stderr is a TTY and route the file through ansiStripWriter so
// the file stays plain while stderr keeps its colors.
func SetLogFile(path string) error {
logMu.Lock()
defer logMu.Unlock()
if logFile != nil {
logFile.Close()
logFile = nil
}
l := GetLogger()
if path == "" {
l.SetOutput(logStderr)
applyColorProfile(l, logStderr)
return nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
if err != nil {
return err
}
logFile = f
out := io.MultiWriter(logStderr, &ansiStripWriter{w: f})
l.SetOutput(out)
applyColorProfile(l, logStderr)
return nil
}
// applyColorProfile forces the renderer's color profile to match what stderr
// would produce on its own, undoing the auto-downgrade triggered by wrapping
// stderr in a non-TTY writer (e.g. io.MultiWriter).
func applyColorProfile(l *Logger, stderr io.Writer) {
f, ok := stderr.(*os.File)
if !ok {
l.SetColorProfile(termenv.Ascii)
return
}
if isatty.IsTerminal(f.Fd()) {
l.SetColorProfile(termenv.ANSI)
return
}
l.SetColorProfile(termenv.Ascii)
}
// ApplyEnvOverrides re-reads DMS_LOG_LEVEL and DMS_LOG_FILE and reconfigures
// the singleton. Safe to call after CLI flags have rewritten the environment.
func ApplyEnvOverrides() {
GetLogger()
if level := os.Getenv("DMS_LOG_LEVEL"); level != "" {
SetLevel(level)
}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
if err := SetLogFile(path); err != nil {
Warnf("Failed to open log file %q: %v", path, err)
}
}
}
// * Convenience wrappers
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }

View File

@@ -60,6 +60,7 @@ var templateRegistry = []TemplateDef{
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},

View File

@@ -212,9 +212,10 @@ func (m *Manager) setupDataDeviceSync() {
}
var offer any
if e.Id != nil {
switch {
case e.Id != nil:
offer = e.Id
} else if e.OfferId != 0 {
case e.OfferId != 0:
m.offerMutex.RLock()
offer = m.offerRegistry[e.OfferId]
m.offerMutex.RUnlock()
@@ -224,10 +225,6 @@ func (m *Manager) setupDataDeviceSync() {
wasOwner := m.isOwner
m.ownerLock.Unlock()
if offer == nil {
return
}
if wasOwner {
return
}
@@ -236,9 +233,11 @@ func (m *Manager) setupDataDeviceSync() {
m.currentOffer = offer
if prevOffer != nil && prevOffer != offer {
m.offerMutex.Lock()
delete(m.offerMimeTypes, prevOffer)
m.offerMutex.Unlock()
m.releaseOffer(prevOffer)
}
if offer == nil {
return
}
m.offerMutex.RLock()
@@ -292,6 +291,33 @@ func (m *Manager) setupDataDeviceSync() {
log.Info("Data device setup complete")
}
func (m *Manager) releaseOffer(offer any) {
if offer == nil {
return
}
typedOffer, ok := offer.(*ext_data_control.ExtDataControlOfferV1)
if !ok {
return
}
m.offerMutex.Lock()
delete(m.offerMimeTypes, offer)
delete(m.offerRegistry, typedOffer.ID())
m.offerMutex.Unlock()
typedOffer.Destroy()
}
func (m *Manager) releaseCurrentSource() {
if m.currentSource == nil {
return
}
source, ok := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
m.currentSource = nil
if !ok {
return
}
source.Destroy()
}
func (m *Manager) readAndStore(r *os.File, mimeType string) {
defer r.Close()
@@ -395,7 +421,7 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
if extractHash(v) != hash {
continue
}
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned {
continue
}
@@ -413,7 +439,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
c := b.Cursor()
var count int
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned {
continue
}
@@ -456,6 +482,14 @@ func encodeEntry(e Entry) ([]byte, error) {
}
func decodeEntry(data []byte) (Entry, error) {
return decodeEntryFields(data, true)
}
func decodeEntryMeta(data []byte) (Entry, error) {
return decodeEntryFields(data, false)
}
func decodeEntryFields(data []byte, withData bool) (Entry, error) {
buf := bytes.NewReader(data)
var e Entry
@@ -463,8 +497,15 @@ func decodeEntry(data []byte) (Entry, error) {
var dataLen uint32
binary.Read(buf, binary.BigEndian, &dataLen)
e.Data = make([]byte, dataLen)
buf.Read(e.Data)
switch {
case withData:
e.Data = make([]byte, dataLen)
buf.Read(e.Data)
default:
if _, err := buf.Seek(int64(dataLen), io.SeekCurrent); err != nil {
return e, err
}
}
var mimeLen uint32
binary.Read(buf, binary.BigEndian, &mimeLen)
@@ -668,14 +709,9 @@ func sizeStr(size int) string {
func (m *Manager) updateState() {
history := m.GetHistory()
for i := range history {
history[i].Data = nil
}
var current *Entry
if len(history) > 0 {
c := history[0]
c.Data = nil
current = &c
}
@@ -750,7 +786,7 @@ func (m *Manager) GetHistory() []Entry {
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err != nil {
continue
}
@@ -935,7 +971,7 @@ func (m *Manager) ClearHistory() {
var toDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err != nil || !entry.Pinned {
toDelete = append(toDelete, k)
}
@@ -958,7 +994,7 @@ func (m *Manager) ClearHistory() {
if b != nil {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, _ := decodeEntry(v)
entry, _ := decodeEntryMeta(v)
if entry.Pinned {
pinnedCount++
}
@@ -1066,6 +1102,7 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
m.ownerLock.Unlock()
})
m.releaseCurrentSource()
m.currentSource = source
m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType}
@@ -1145,9 +1182,11 @@ func (m *Manager) Close() {
m.subscribers = make(map[string]chan State)
m.subMutex.Unlock()
if m.currentSource != nil {
source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
source.Destroy()
m.releaseCurrentSource()
if m.currentOffer != nil {
m.releaseOffer(m.currentOffer)
m.currentOffer = nil
}
if m.dataDevice != nil {
@@ -1191,11 +1230,10 @@ func (m *Manager) clearOldEntries(days int) error {
var toDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err != nil {
continue
}
// Skip pinned entries
if entry.Pinned {
continue
}
@@ -1310,7 +1348,7 @@ func (m *Manager) Search(params SearchParams) SearchResult {
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err != nil {
continue
}
@@ -1335,7 +1373,6 @@ func (m *Manager) Search(params SearchParams) SearchResult {
continue
}
entry.Data = nil
all = append(all, entry)
}
return nil
@@ -1510,7 +1547,7 @@ func (m *Manager) PinEntry(id uint64) error {
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err != nil || !entry.Pinned {
continue
}
@@ -1528,7 +1565,6 @@ func (m *Manager) PinEntry(id uint64) error {
return nil
}
// Check pinned count
cfg := m.getConfig()
pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error {
@@ -1538,7 +1574,7 @@ func (m *Manager) PinEntry(id uint64) error {
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned {
pinnedCount++
}
@@ -1629,12 +1665,11 @@ func (m *Manager) GetPinnedEntries() []Entry {
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err != nil {
continue
}
if entry.Pinned {
entry.Data = nil
pinned = append(pinned, entry)
}
}
@@ -1660,7 +1695,7 @@ func (m *Manager) GetPinnedCount() int {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned {
count++
}
@@ -1779,6 +1814,7 @@ func (m *Manager) CopyFile(filePath string) error {
m.ownerLock.Unlock()
})
m.releaseCurrentSource()
m.currentSource = source
m.ownerLock.Lock()

View File

@@ -391,7 +391,7 @@ func (m *Manager) Close() {
func InitializeManager() (*Manager, error) {
if os.Getuid() != 0 && !hasInputGroupAccess() {
return nil, fmt.Errorf("insufficient permissions to access input devices")
return nil, fmt.Errorf("insufficient permissions to access input devices. Add your user to the 'input' group: `sudo usermod -a -G input $USER` or run `dms setup`")
}
return NewManager()

View File

@@ -104,7 +104,7 @@ func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface
return false
}
if reply != dbus.RequestNameReplyPrimaryOwner {
log.Warnf("Screensaver name %s already owned by another process", name)
log.Infof("Screensaver name %s already owned by another process (e.g. hypridle/swayidle)", name)
return false
}
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {

View File

@@ -35,12 +35,7 @@ type SessionState struct {
type EventType string
const (
EventStateChanged EventType = "state_changed"
EventLock EventType = "lock"
EventUnlock EventType = "unlock"
EventPrepareForSleep EventType = "prepare_for_sleep"
EventIdleHintChanged EventType = "idle_hint_changed"
EventLockedHintChanged EventType = "locked_hint_changed"
EventStateChanged EventType = "state_changed"
)
type SessionEvent struct {

View File

@@ -8,11 +8,6 @@ import (
func TestEventType_Constants(t *testing.T) {
assert.Equal(t, EventType("state_changed"), EventStateChanged)
assert.Equal(t, EventType("lock"), EventLock)
assert.Equal(t, EventType("unlock"), EventUnlock)
assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep)
assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged)
assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged)
}
func TestSessionState_Struct(t *testing.T) {
@@ -40,11 +35,11 @@ func TestSessionEvent_Struct(t *testing.T) {
}
event := SessionEvent{
Type: EventLock,
Type: EventStateChanged,
Data: state,
}
assert.Equal(t, EventLock, event.Type)
assert.Equal(t, EventStateChanged, event.Type)
assert.Equal(t, "1", event.Data.SessionID)
assert.True(t, event.Data.Locked)
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -202,6 +203,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "sysupdate.") {
if sysUpdateManager == nil {
models.RespondError(conn, req.ID, "sysupdate manager not initialized")
return
}
sysupdate.HandleRequest(conn, req, sysUpdateManager)
return
}
switch req.Method {
case "ping":
models.Respond(conn, req.ID, "pong")

View File

@@ -30,6 +30,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -75,6 +76,7 @@ var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager
var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager
var sysUpdateManager *sysupdate.Manager
var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client"
@@ -421,6 +423,19 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
return nil
}
func InitializeSysUpdateManager() error {
manager, err := sysupdate.NewManager()
if err != nil {
log.Warnf("Failed to initialize sysupdate manager: %v", err)
return err
}
sysUpdateManager = manager
log.Info("Sysupdate manager initialized")
return nil
}
func handleConnection(conn net.Conn) {
defer conn.Close()
@@ -506,6 +521,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "dbus")
}
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return Capabilities{Capabilities: caps}
}
@@ -576,6 +595,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "dbus")
}
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return ServerInfo{
APIVersion: APIVersion,
CLIVersion: CLIVersion,
@@ -1243,6 +1266,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}()
}
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
wg.Add(1)
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
go func() {
defer wg.Done()
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
initialState := sysUpdateManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-sysupdateChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("dbus") && dbusManager != nil {
wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
@@ -1348,6 +1403,9 @@ func cleanupManagers() {
if locationManager != nil {
locationManager.Close()
}
if sysUpdateManager != nil {
sysUpdateManager.Close()
}
if geoClientInstance != nil {
geoClientInstance.Close()
}
@@ -1733,6 +1791,10 @@ func Start(printDocs bool) error {
}
}()
if err := InitializeSysUpdateManager(); err != nil {
log.Warnf("Sysupdate manager unavailable: %v", err)
}
log.Info("")
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)

View File

@@ -0,0 +1,96 @@
package sysupdate
import (
"context"
"os/exec"
"sync"
)
type Backend interface {
ID() string
DisplayName() string
Repo() RepoKind
IsAvailable(ctx context.Context) bool
NeedsAuth() bool
RunsInTerminal() bool
CheckUpdates(ctx context.Context) ([]Package, error)
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
}
type Selection struct {
System Backend
Overlay []Backend
}
func (s Selection) All() []Backend {
if s.System == nil {
return s.Overlay
}
out := make([]Backend, 0, 1+len(s.Overlay))
out = append(out, s.System)
out = append(out, s.Overlay...)
return out
}
func (s Selection) Info() []BackendInfo {
all := s.All()
out := make([]BackendInfo, 0, len(all))
for _, b := range all {
out = append(out, BackendInfo{
ID: b.ID(),
DisplayName: b.DisplayName(),
Repo: b.Repo(),
NeedsAuth: b.NeedsAuth(),
RunsInTerminal: b.RunsInTerminal(),
})
}
return out
}
var (
registryMu sync.RWMutex
systemCandidates []func() Backend
overlayCandidate []func() Backend
)
func RegisterSystemBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
systemCandidates = append(systemCandidates, factory)
}
func RegisterOverlayBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
overlayCandidate = append(overlayCandidate, factory)
}
func Select(ctx context.Context) Selection {
registryMu.RLock()
sys := append([]func() Backend(nil), systemCandidates...)
ov := append([]func() Backend(nil), overlayCandidate...)
registryMu.RUnlock()
var sel Selection
for _, factory := range sys {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.System = b
break
}
for _, factory := range ov {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.Overlay = append(sel.Overlay, b)
}
return sel
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -0,0 +1,79 @@
package sysupdate
import (
"context"
"os/exec"
"regexp"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &aptBackend{} })
}
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
type aptBackend struct{}
func (aptBackend) ID() string { return "apt" }
func (aptBackend) DisplayName() string { return "APT" }
func (aptBackend) Repo() RepoKind { return RepoSystem }
func (aptBackend) NeedsAuth() bool { return true }
func (aptBackend) RunsInTerminal() bool { return false }
func (aptBackend) IsAvailable(_ context.Context) bool {
return commandExists("apt") || commandExists("apt-get")
}
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
out, err := cmd.Output()
if err != nil {
return nil, err
}
return parseAptUpgradable(string(out)), nil
}
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
bin := "apt-get"
if !commandExists(bin) {
bin = "apt"
}
if opts.DryRun {
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
OnLine: onLine,
})
}
names := pickTargetNames(opts.Targets, "apt", true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "install", "-y", "--only-upgrade"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func parseAptUpgradable(text string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := aptUpgradableLine.FindStringSubmatch(line)
if m == nil {
continue
}
pkgs = append(pkgs, Package{
Name: m[1],
Repo: RepoSystem,
Backend: "apt",
FromVersion: m[3],
ToVersion: m[2],
})
}
return pkgs
}

View File

@@ -0,0 +1,72 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseAptUpgradable(t *testing.T) {
tests := []struct {
name string
input string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "header line only",
input: `Listing... Done
`,
want: nil,
},
{
name: "single upgradable",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
{
name: "multiple architectures and suites",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "package name with hyphens, dots, plus signs",
input: `Listing... Done
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
want: []Package{
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
},
},
{
name: "non-matching lines ignored",
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseAptUpgradable(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,125 @@
package sysupdate
import (
"context"
"errors"
"os/exec"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
}
type dnfBackend struct {
bin string
}
func (b dnfBackend) ID() string { return b.bin }
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
func (b dnfBackend) NeedsAuth() bool { return true }
func (b dnfBackend) RunsInTerminal() bool { return false }
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
if !commandExists(b.bin) {
return false
}
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
return false
}
return true
}
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := dnfListUpgrades(ctx, b.bin)
if err != nil {
return nil, err
}
installed := rpmInstalledVersions(ctx)
return parseDnfList(out, b.bin, installed), nil
}
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.bin, true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", b.bin, "upgrade", "-y"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--quiet")
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 {
return "", nil
}
return "", err
}
func rpmInstalledVersions(ctx context.Context) map[string]string {
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
if err != nil {
return nil
}
m := make(map[string]string)
for line := range strings.SplitSeq(string(out), "\n") {
name, ver, ok := strings.Cut(line, "\t")
if !ok {
continue
}
m[name] = ver
}
return m
}
func parseDnfList(text, backendID string, installed map[string]string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
nameArch := fields[0]
version := fields[1]
dot := strings.LastIndex(nameArch, ".")
if dot <= 0 {
continue
}
if !looksLikeRpmVersion(version) {
continue
}
name := nameArch[:dot]
pkgs = append(pkgs, Package{
Name: nameArch,
Repo: RepoSystem,
Backend: backendID,
FromVersion: installed[name],
ToVersion: version,
})
}
return pkgs
}
func looksLikeRpmVersion(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r >= '0' && r <= '9' {
return true
}
}
return false
}

View File

@@ -0,0 +1,80 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseDnfList(t *testing.T) {
tests := []struct {
name string
input string
backendID string
installed map[string]string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "single package with installed cross-ref",
input: "bash.x86_64 5.2.40-1.fc41 updates",
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "noarch package and missing installed entry",
input: `bash.x86_64 5.2.40-1.fc41 updates
fonts-misc.noarch 1.0.5-2.fc41 updates`,
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
},
},
{
name: "skips header rows",
input: `Available
Upgrades
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "skips lines with too few fields",
input: "incomplete",
backendID: "dnf",
want: nil,
},
{
name: "skips dnf5 banner / column header lines",
input: `Updates available
Last metadata expiration check: 0:01:23 ago on Tue Apr 29 14:00:00 2026.
Package Version Repository Size
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDnfList(tt.input, tt.backendID, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,169 @@
package sysupdate
import (
"context"
"os/exec"
"strings"
)
func init() {
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
}
type flatpakBackend struct{}
func (flatpakBackend) ID() string { return "flatpak" }
func (flatpakBackend) DisplayName() string { return "Flatpak" }
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
func (flatpakBackend) NeedsAuth() bool { return false }
func (flatpakBackend) RunsInTerminal() bool { return false }
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name")
out, err := cmd.Output()
if err != nil {
return nil, err
}
installed := flatpakInstalled(ctx)
return parseFlatpakUpdates(string(out), installed), nil
}
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").Output()
if err != nil {
return nil
}
m := make(map[string]flatpakInstalledEntry)
for line := range strings.SplitSeq(string(out), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
entry := flatpakInstalledEntry{}
if len(fields) > 1 {
entry.version = fields[1]
}
if len(fields) > 2 {
entry.branch = fields[2]
}
if len(fields) > 3 {
entry.commit = fields[3]
}
key := appID
if entry.branch != "" {
key = appID + "//" + entry.branch
}
m[key] = entry
}
return m
}
type flatpakInstalledEntry struct {
version string
branch string
commit string
}
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine})
}
refs := flatpakTargetRefs(opts.Targets)
if len(refs) == 0 {
return nil
}
argv := append([]string{"flatpak", "update", "-y", "--noninteractive"}, refs...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func flatpakTargetRefs(targets []Package) []string {
out := make([]string, 0, len(targets))
for _, p := range targets {
if p.Backend != "flatpak" {
continue
}
ref := p.Ref
if ref == "" {
ref = p.Name
}
out = append(out, ref)
}
return out
}
func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
version, branch, commit := "", "", ""
if len(fields) > 1 {
version = fields[1]
}
if len(fields) > 2 {
branch = fields[2]
}
if len(fields) > 3 {
commit = fields[3]
}
display := appID
if len(fields) > 4 && fields[4] != "" {
display = fields[4]
}
key := appID
if branch != "" {
key = appID + "//" + branch
}
inst := installed[key]
if inst.commit != "" && commit != "" && strings.HasPrefix(commit, inst.commit) {
continue
}
from, to := flatpakVersionPair(inst.version, inst.commit, version, commit)
ref := appID
if branch != "" {
ref = appID + "//" + branch
}
pkgs = append(pkgs, Package{
Name: display,
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: from,
ToVersion: to,
Ref: ref,
})
}
return pkgs
}
func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) {
if remoteVer != "" {
return installedVer, remoteVer
}
return shortCommit(installedCommit), shortCommit(remoteCommit)
}
func shortCommit(c string) string {
if len(c) > 8 {
return c[:8]
}
return c
}

View File

@@ -0,0 +1,150 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseFlatpakUpdates(t *testing.T) {
tests := []struct {
name string
input string
installed map[string]flatpakInstalledEntry
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "real flathub-style row with empty version, falls back to commit",
// columns: application,version,branch,commit,name
input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord",
installed: map[string]flatpakInstalledEntry{
"com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"},
},
want: []Package{
{
Name: "Discord",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "8b16fa1a",
ToVersion: "43a1e5d2",
Ref: "com.discordapp.Discord//stable",
},
},
},
{
name: "remote provides version, installed version known",
input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App",
installed: map[string]flatpakInstalledEntry{
"com.example.App//stable": {version: "1.4.2"},
},
want: []Package{
{
Name: "Example App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "1.4.2",
ToVersion: "1.5.0",
Ref: "com.example.App//stable",
},
},
},
{
name: "no installed entry, remote has no version, falls back to commit on both sides",
input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform",
installed: nil,
want: []Package{
{
Name: "gnome platform",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "badcd4af",
Ref: "org.gnome.Platform//49",
},
},
},
{
name: "missing display name falls back to application id",
input: "com.example.NoName\t2.0\tstable\tabcdef123456\t",
want: []Package{
{
Name: "com.example.NoName",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "2.0",
Ref: "com.example.NoName//stable",
},
},
},
{
name: "skips blank lines and rows with empty application id",
input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App",
want: []Package{
{
Name: "Real App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "1.0",
Ref: "org.real.App//stable",
},
},
},
{
name: "skips phantom updates where remote commit matches installed",
input: "com.phantom.App\t\tstable\tabc12345deadbeef\tPhantom",
installed: map[string]flatpakInstalledEntry{
"com.phantom.App//stable": {commit: "abc12345"},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFlatpakUpdates(tt.input, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}
func TestFlatpakVersionPair(t *testing.T) {
tests := []struct {
name string
installedVer, installedCommit, remoteVer, remoteCommit string
wantFrom, wantTo string
}{
{
name: "remote has version - prefer versions",
installedVer: "1.0.0", remoteVer: "1.1.0",
wantFrom: "1.0.0", wantTo: "1.1.0",
},
{
name: "remote has no version - both sides fall to short commit",
installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855",
remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586",
wantFrom: "8b16fa1a", wantTo: "43a1e5d2",
},
{
name: "short commits left as-is",
installedCommit: "abc123", remoteCommit: "def456",
wantFrom: "abc123", wantTo: "def456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit)
if from != tt.wantFrom || to != tt.wantTo {
t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo)
}
})
}
}

View File

@@ -0,0 +1,258 @@
package sysupdate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func init() {
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
}
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
type pacmanBackend struct{}
func (pacmanBackend) ID() string { return "pacman" }
func (pacmanBackend) DisplayName() string { return "Pacman" }
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
func (pacmanBackend) NeedsAuth() bool { return true }
func (pacmanBackend) RunsInTerminal() bool { return false }
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
return parseArchUpdates(out, b.ID(), RepoSystem), nil
}
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.ID(), opts.IncludeAUR)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "pacman", "-Sy", "--noconfirm", "--needed"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
type archHelperBackend struct {
id string
}
func (b archHelperBackend) ID() string { return b.id }
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
func (b archHelperBackend) NeedsAuth() bool { return true }
func (b archHelperBackend) RunsInTerminal() bool {
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
}
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
func (b archHelperBackend) DisplayName() string {
switch b.id {
case "paru":
return "Paru (AUR)"
case "yay":
return "Yay (AUR)"
default:
return b.id
}
}
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
repoOut, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
if err != nil {
return nil, err
}
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
return pkgs, nil
}
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.id, opts.IncludeAUR)
if len(names) == 0 {
return nil
}
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
argv := append([]string{"pkexec", b.id, "-Sy", "--noconfirm", "--needed"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
cmd := fmt.Sprintf("%s -Sy --noconfirm --needed %s", b.id, strings.Join(names, " "))
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
}
func pickTargetNames(targets []Package, backendID string, includeAUR bool) []string {
out := make([]string, 0, len(targets))
for _, p := range targets {
if p.Backend != backendID {
continue
}
if !includeAUR && p.Repo == RepoAUR {
continue
}
out = append(out, p.Name)
}
return out
}
func pacmanRepoUpdates(ctx context.Context) (string, error) {
if commandExists("checkupdates") {
return capturePermissive(ctx, "checkupdates")
}
if commandExists("fakeroot") {
out, err := pacmanCheckViaFakeroot(ctx)
if err == nil {
return out, nil
}
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
}
return capturePermissive(ctx, "pacman", "-Qu")
}
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
dir, err := pacmanPrivateDB()
if err != nil {
return "", err
}
if err := seedPacmanDB(dir); err != nil {
return "", fmt.Errorf("seed sync db: %w", err)
}
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
if out, err := refresh.CombinedOutput(); err != nil {
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
}
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
}
func seedPacmanDB(dir string) error {
syncDir := filepath.Join(dir, "sync")
if err := os.MkdirAll(syncDir, 0o755); err != nil {
return err
}
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
if err != nil {
return err
}
for _, src := range dbs {
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
return err
}
}
localLink := filepath.Join(dir, "local")
if fi, err := os.Lstat(localLink); err == nil {
if fi.Mode()&os.ModeSymlink == 0 {
if err := os.RemoveAll(localLink); err != nil {
return err
}
} else {
return nil
}
}
return os.Symlink("/var/lib/pacman/local", localLink)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func pacmanPrivateDB() (string, error) {
tmp := os.Getenv("TMPDIR")
if tmp == "" {
tmp = "/tmp"
}
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 1, 2:
return string(out), nil
}
}
return "", err
}
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := archUpdateLine.FindStringSubmatch(line)
if m == nil {
continue
}
p := Package{
Name: m[1],
Repo: repo,
Backend: backendID,
FromVersion: m[2],
ToVersion: m[3],
}
if repo == RepoAUR {
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}

View File

@@ -0,0 +1,114 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseArchUpdates(t *testing.T) {
tests := []struct {
name string
input string
backendID string
repo RepoKind
want []Package
}{
{
name: "empty",
input: "",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "whitespace only",
input: " \n\n \n",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "single repo update",
input: "bat 0.26.0-1 -> 0.26.1-2",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "multiple updates with epoch versions",
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
linux 6.18.0-1 -> 6.18.1-1
mesa 26.4.0-1 -> 26.4.1-1`,
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
},
},
{
name: "AUR update with changelog url",
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
backendID: "paru",
repo: RepoAUR,
want: []Package{
{
Name: "google-chrome",
Repo: RepoAUR,
Backend: "paru",
FromVersion: "147.0.7727.116-1",
ToVersion: "147.0.7727.137-1",
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
},
},
},
{
name: "git package latest-commit marker",
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
backendID: "yay",
repo: RepoAUR,
want: []Package{
{
Name: "niri-git",
Repo: RepoAUR,
Backend: "yay",
FromVersion: "26.04.r5.ga85b922-1",
ToVersion: "latest-commit",
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
},
},
},
{
name: "skips lines that don't match arrow format",
input: `bat 0.26.0-1 -> 0.26.1-2
this is not an update line
foo`,
backendID: "pacman",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "extra whitespace tolerated",
input: " bat 0.26.0-1 -> 0.26.1-2 ",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,125 @@
package sysupdate
import (
"context"
"encoding/json"
"errors"
"os/exec"
)
const ostreeExitUpdateAvailable = 77
func init() {
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
}
type rpmOstreeBackend struct{}
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
func (rpmOstreeBackend) NeedsAuth() bool { return true }
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
if !commandExists("rpm-ostree") {
return false
}
return ostreeBooted(ctx)
}
type ostreeStatus struct {
Deployments []ostreeDeployment `json:"deployments"`
CachedUpdate *ostreeCached `json:"cached-update"`
}
type ostreeDeployment struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Booted bool `json:"booted"`
}
type ostreeCached struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Checksum string `json:"checksum"`
}
func ostreeBooted(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
out, err := cmd.Output()
if err != nil {
return false
}
var s ostreeStatus
if err := json.Unmarshal(out, &s); err != nil {
return false
}
return len(s.Deployments) > 0
}
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
if err := cmd.Run(); err != nil {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
return nil, err
}
}
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
if err != nil {
return nil, err
}
return parseRpmOstreeStatus(statusOut)
}
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
var s ostreeStatus
if err := json.Unmarshal(statusOut, &s); err != nil {
return nil, err
}
if s.CachedUpdate == nil {
return nil, nil
}
booted := bootedDeployment(s.Deployments)
from := ""
if booted != nil {
from = booted.Version
}
if from == s.CachedUpdate.Version {
return nil, nil
}
name := s.CachedUpdate.Origin
if name == "" {
name = "system"
}
return []Package{{
Name: name,
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: from,
ToVersion: s.CachedUpdate.Version,
}}, nil
}
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
for i := range deps {
if deps[i].Booted {
return &deps[i]
}
}
return nil
}
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
argv := []string{"rpm-ostree", "upgrade"}
if opts.DryRun {
argv = append(argv, "--check")
}
return Run(ctx, argv, RunOptions{OnLine: onLine})
}

View File

@@ -0,0 +1,104 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseRpmOstreeStatus(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "no cached update",
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
want: nil,
},
{
name: "cached update available, booted version differs",
input: `{
"deployments": [
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
],
"cached-update": {
"origin": "fedora:fedora/x86_64/silverblue",
"version": "39.20240115.0",
"checksum": "abc123"
}
}`,
want: []Package{
{
Name: "fedora:fedora/x86_64/silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "39.20240101.0",
ToVersion: "39.20240115.0",
},
},
},
{
name: "cached update equals booted version (no real update)",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": true}],
"cached-update": {"origin": "x", "version": "39.20240101.0"}
}`,
want: nil,
},
{
name: "no booted deployment falls back to empty from",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": false}],
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
}`,
want: []Package{
{
Name: "fedora:silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "",
ToVersion: "39.20240115.0",
},
},
},
{
name: "missing origin defaults to system",
input: `{
"deployments": [{"version": "1.0", "booted": true}],
"cached-update": {"version": "1.1"}
}`,
want: []Package{
{
Name: "system",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "1.0",
ToVersion: "1.1",
},
},
},
{
name: "malformed JSON",
input: `{not json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseRpmOstreeStatus([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,83 @@
package sysupdate
import (
"context"
"encoding/xml"
"errors"
"os/exec"
)
func init() {
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
}
type zypperBackend struct{}
func (zypperBackend) ID() string { return "zypper" }
func (zypperBackend) DisplayName() string { return "Zypper" }
func (zypperBackend) Repo() RepoKind { return RepoSystem }
func (zypperBackend) NeedsAuth() bool { return true }
func (zypperBackend) RunsInTerminal() bool { return false }
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
type zypperUpdateList struct {
XMLName xml.Name `xml:"stream"`
Updates []zypperUpdate `xml:"update-list>update"`
}
type zypperUpdate struct {
Name string `xml:"name,attr"`
Edition string `xml:"edition,attr"`
EditionOld string `xml:"edition-old,attr"`
Kind string `xml:"kind,attr"`
}
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
out, err := cmd.Output()
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 100, 101, 102, 103:
err = nil
}
}
if err != nil {
return nil, err
}
}
return parseZypperXML(out)
}
func parseZypperXML(out []byte) ([]Package, error) {
var list zypperUpdateList
if err := xml.Unmarshal(out, &list); err != nil {
return nil, err
}
pkgs := make([]Package, 0, len(list.Updates))
for _, u := range list.Updates {
if u.Kind != "" && u.Kind != "package" {
continue
}
pkgs = append(pkgs, Package{
Name: u.Name,
Repo: RepoSystem,
Backend: "zypper",
FromVersion: u.EditionOld,
ToVersion: u.Edition,
})
}
return pkgs, nil
}
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, "zypper", true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "zypper", "--non-interactive", "update"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}

View File

@@ -0,0 +1,80 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseZypperXML(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "empty stream",
input: `<?xml version="1.0"?><stream><update-list></update-list></stream>`,
want: []Package{},
},
{
name: "single package update",
input: `<?xml version="1.0"?>
<stream>
<update-list>
<update name="zsh" edition="5.9-6" edition-old="5.9-5" kind="package" arch="x86_64">
<source url="https://download.opensuse.org/" alias="repo-oss"/>
</update>
</update-list>
</stream>`,
want: []Package{
{Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "skips non-package kinds",
input: `<?xml version="1.0"?>
<stream>
<update-list>
<update name="foo" edition="2.0" edition-old="1.0" kind="package"/>
<update name="security-patch" edition="1" edition-old="0" kind="patch"/>
<update name="bar" edition="3.0" edition-old="2.0" kind="package"/>
</update-list>
</stream>`,
want: []Package{
{Name: "foo", Repo: RepoSystem, Backend: "zypper", FromVersion: "1.0", ToVersion: "2.0"},
{Name: "bar", Repo: RepoSystem, Backend: "zypper", FromVersion: "2.0", ToVersion: "3.0"},
},
},
{
name: "treats missing kind as package",
input: `<?xml version="1.0"?>
<stream><update-list>
<update name="kernel" edition="6.18.1-1" edition-old="6.18.0-1"/>
</update-list></stream>`,
want: []Package{
{Name: "kernel", Repo: RepoSystem, Backend: "zypper", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
},
},
{
name: "malformed XML returns error",
input: `not xml at all`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseZypperXML([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseZypperXML() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseZypperXML() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,125 @@
package sysupdate
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"sync"
"syscall"
)
type RunOptions struct {
Env []string
OnLine func(string)
}
func Run(ctx context.Context, argv []string, opts RunOptions) error {
if len(argv) == 0 {
return fmt.Errorf("sysupdate.Run: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
if len(opts.Env) > 0 {
cmd.Env = append(cmd.Environ(), opts.Env...)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
var wg sync.WaitGroup
wg.Add(2)
go pump(stdout, opts.OnLine, &wg)
go pump(stderr, opts.OnLine, &wg)
wg.Wait()
return cmd.Wait()
}
func pump(r io.Reader, onLine func(string), wg *sync.WaitGroup) {
defer wg.Done()
if onLine == nil {
_, _ = io.Copy(io.Discard, r)
return
}
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
onLine(scanner.Text())
}
}
func Capture(ctx context.Context, argv []string) (string, error) {
if len(argv) == 0 {
return "", fmt.Errorf("sysupdate.Capture: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
return string(out), err
}
func findTerminal(override string) string {
if override != "" && commandExists(override) {
return override
}
if t := os.Getenv("TERMINAL"); t != "" && commandExists(t) {
return t
}
for _, t := range []string{"ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"} {
if commandExists(t) {
return t
}
}
return ""
}
func wrapInTerminal(term, title, shellCmd string) []string {
const appID = "dms-sysupdate"
banner := fmt.Sprintf(
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
title, shellCmd,
)
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer
switch term {
case "kitty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "alacritty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "foot":
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "ghostty":
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "xterm":
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
case "konsole":
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
case "gnome-terminal":
return []string{term, "--title=" + title, "--", "sh", "-c", full}
default:
return []string{term, "-e", "sh", "-c", full}
}
}

View File

@@ -0,0 +1,55 @@
package sysupdate
import (
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
switch req.Method {
case "sysupdate.getState":
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.refresh":
force := params.BoolOpt(req.Params, "force", false)
m.Refresh(RefreshOptions{Force: force})
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.upgrade":
handleUpgrade(conn, req, m)
case "sysupdate.cancel":
m.Cancel()
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.acquire":
m.Acquire()
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
case "sysupdate.release":
m.Release()
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
case "sysupdate.setInterval":
seconds, err := params.Int(req.Params, "seconds")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
m.SetInterval(seconds)
models.Respond(conn, req.ID, m.GetState())
default:
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
}
}
func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
opts := UpgradeOptions{
IncludeFlatpak: params.BoolOpt(req.Params, "includeFlatpak", true),
IncludeAUR: params.BoolOpt(req.Params, "includeAUR", true),
DryRun: params.BoolOpt(req.Params, "dry", false),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""),
}
if err := m.Upgrade(opts); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, m.GetState())
}

View File

@@ -0,0 +1,506 @@
package sysupdate
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
const (
defaultIntervalSeconds = 30 * 60
minIntervalSeconds = 5 * 60
recentLogCapacity = 200
checkTimeout = 5 * time.Minute
upgradeTimeout = 30 * time.Minute
)
type Manager struct {
mu sync.RWMutex
state State
subscribers syncmap.Map[string, chan State]
selection Selection
notifyDirty chan struct{}
stopChan chan struct{}
notifierWG sync.WaitGroup
schedulerWG sync.WaitGroup
acquireCount int32
wakeSched chan struct{}
refreshSerial sync.Mutex
opMu sync.Mutex
opCtx context.Context
opCancel context.CancelFunc
}
func NewManager() (*Manager, error) {
m := &Manager{
notifyDirty: make(chan struct{}, 1),
stopChan: make(chan struct{}),
wakeSched: make(chan struct{}, 1),
}
m.state = State{
Phase: PhaseIdle,
IntervalSeconds: defaultIntervalSeconds,
Backends: []BackendInfo{},
Packages: []Package{},
}
id, pretty := readOSRelease()
m.state.Distro = id
m.state.DistroPretty = pretty
m.selection = Select(context.Background())
m.state.Backends = m.selection.Info()
if len(m.state.Backends) == 0 {
m.state.Error = &ErrorInfo{
Code: ErrCodeNoBackend,
Message: "no supported package manager found",
Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak",
}
}
m.notifierWG.Add(1)
go m.notifier()
m.schedulerWG.Add(1)
go m.scheduler()
go m.runRefresh(context.Background())
return m, nil
}
func (m *Manager) GetState() State {
m.mu.RLock()
defer m.mu.RUnlock()
return cloneState(m.state)
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 16)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) Close() {
select {
case <-m.stopChan:
return
default:
close(m.stopChan)
}
m.opMu.Lock()
if m.opCancel != nil {
m.opCancel()
}
m.opMu.Unlock()
select {
case m.wakeSched <- struct{}{}:
default:
}
m.schedulerWG.Wait()
m.notifierWG.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
func (m *Manager) SetInterval(seconds int) {
if seconds < minIntervalSeconds {
seconds = minIntervalSeconds
}
m.mu.Lock()
m.state.IntervalSeconds = seconds
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) Refresh(opts RefreshOptions) {
m.mu.RLock()
phase := m.state.Phase
m.mu.RUnlock()
switch {
case phase == PhaseUpgrading:
return
case phase == PhaseRefreshing && !opts.Force:
m.refreshSerial.Lock()
m.refreshSerial.Unlock()
return
}
m.runRefresh(context.Background())
}
func (m *Manager) Upgrade(opts UpgradeOptions) error {
if len(m.selection.All()) == 0 {
return errors.New("no backend available")
}
m.opMu.Lock()
if m.opCancel != nil {
m.opMu.Unlock()
return errors.New("operation already running")
}
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
m.opCtx = ctx
m.opCancel = cancel
m.opMu.Unlock()
go m.runUpgrade(ctx, opts)
return nil
}
func (m *Manager) Cancel() {
m.opMu.Lock()
cancel := m.opCancel
m.opMu.Unlock()
if cancel == nil {
return
}
cancel()
}
func (m *Manager) Acquire() {
first := atomic.AddInt32(&m.acquireCount, 1) == 1
select {
case m.wakeSched <- struct{}{}:
default:
}
if first {
go m.runRefresh(context.Background())
}
}
func (m *Manager) Release() {
if atomic.AddInt32(&m.acquireCount, -1) < 0 {
atomic.StoreInt32(&m.acquireCount, 0)
}
}
func (m *Manager) scheduler() {
defer m.schedulerWG.Done()
for {
if atomic.LoadInt32(&m.acquireCount) == 0 {
select {
case <-m.stopChan:
return
case <-m.wakeSched:
}
continue
}
m.mu.RLock()
interval := m.state.IntervalSeconds
m.mu.RUnlock()
if interval < minIntervalSeconds {
interval = minIntervalSeconds
}
t := time.NewTimer(time.Duration(interval) * time.Second)
select {
case <-m.stopChan:
t.Stop()
return
case <-m.wakeSched:
t.Stop()
case <-t.C:
m.runRefresh(context.Background())
}
}
}
func (m *Manager) runRefresh(parent context.Context) {
m.refreshSerial.Lock()
defer m.refreshSerial.Unlock()
if len(m.selection.All()) == 0 {
return
}
ctx, cancel := context.WithTimeout(parent, checkTimeout)
defer cancel()
m.mu.Lock()
if m.state.Phase == PhaseUpgrading {
m.mu.Unlock()
return
}
m.state.Phase = PhaseRefreshing
m.state.Error = nil
m.state.RecentLog = nil
m.mu.Unlock()
m.markDirty()
type backendResult struct {
pkgs []Package
err error
}
backends := m.selection.All()
results := make([]backendResult, len(backends))
var wg sync.WaitGroup
for i, b := range backends {
wg.Add(1)
go func(i int, b Backend) {
defer wg.Done()
pkgs, err := b.CheckUpdates(ctx)
results[i] = backendResult{pkgs: pkgs, err: err}
}(i, b)
}
wg.Wait()
now := time.Now().Unix()
m.mu.Lock()
m.state.LastCheckUnix = now
m.state.Packages = m.state.Packages[:0]
var firstErr error
for i, r := range results {
if r.err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
}
continue
}
m.state.Packages = append(m.state.Packages, r.pkgs...)
}
m.state.Count = len(m.state.Packages)
if firstErr != nil {
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
} else {
m.state.Phase = PhaseIdle
m.state.LastSuccessUnix = now
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
defer func() {
m.opMu.Lock()
if m.opCancel != nil {
m.opCancel = nil
m.opCtx = nil
}
m.opMu.Unlock()
}()
if opts.CustomCommand != "" {
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
return
}
backends := upgradeBackends(m.selection, opts)
if len(backends) == 0 {
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
return
}
if len(opts.Targets) == 0 {
m.mu.RLock()
opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock()
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
m.state.OperationID = opID
m.state.OperationStarted = time.Now().Unix()
m.state.RecentLog = m.state.RecentLog[:0]
m.state.Error = nil
m.mu.Unlock()
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
for _, b := range backends {
m.appendLog(fmt.Sprintf("== %s ==", b.DisplayName()))
if err := b.Upgrade(ctx, opts, onLine); err != nil {
code := ErrCodeBackendFailed
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
code = ErrCodeTimeout
} else if errors.Is(ctx.Err(), context.Canceled) {
code = ErrCodeCancelled
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: fmt.Sprintf("%s: %v", b.ID(), err)}
m.mu.Unlock()
m.markDirty()
return
}
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
}
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
term := findTerminal(terminalOverride)
if term == "" {
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
return
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
m.state.OperationID = opID
m.state.OperationStarted = time.Now().Unix()
m.state.RecentLog = m.state.RecentLog[:0]
m.state.Error = nil
m.mu.Unlock()
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
code = ErrCodeTimeout
case errors.Is(ctx.Err(), context.Canceled):
code = ErrCodeCancelled
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
return
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
}
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
var out []Backend
if sel.System != nil {
out = append(out, sel.System)
}
for _, b := range sel.Overlay {
switch {
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
continue
}
out = append(out, b)
}
return out
}
func (m *Manager) appendLog(line string) {
m.mu.Lock()
if cap(m.state.RecentLog) == 0 {
m.state.RecentLog = make([]string, 0, recentLogCapacity)
}
if len(m.state.RecentLog) >= recentLogCapacity {
copy(m.state.RecentLog, m.state.RecentLog[1:])
m.state.RecentLog = m.state.RecentLog[:recentLogCapacity-1]
}
m.state.RecentLog = append(m.state.RecentLog, line)
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) setError(code ErrorCode, msg string) {
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: msg}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) markDirty() {
select {
case m.notifyDirty <- struct{}{}:
default:
}
}
func (m *Manager) notifier() {
defer m.notifierWG.Done()
for {
select {
case <-m.stopChan:
return
case <-m.notifyDirty:
snap := m.GetState()
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- snap:
default:
}
return true
})
}
}
}
func cloneState(s State) State {
out := s
out.Backends = append([]BackendInfo(nil), s.Backends...)
out.Packages = append([]Package(nil), s.Packages...)
out.RecentLog = append([]string(nil), s.RecentLog...)
if s.Error != nil {
errCopy := *s.Error
out.Error = &errCopy
}
return out
}
func readOSRelease() (id, pretty string) {
f, err := os.Open("/etc/os-release")
if err != nil {
return "", ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
k, v, ok := strings.Cut(scanner.Text(), "=")
if !ok {
continue
}
v = strings.Trim(v, "\"")
switch k {
case "ID":
id = v
case "PRETTY_NAME":
pretty = v
}
}
if err := scanner.Err(); err != nil {
log.Debugf("[sysupdate] read os-release: %v", err)
}
return id, pretty
}

View File

@@ -0,0 +1,86 @@
package sysupdate
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseRefreshing Phase = "refreshing"
PhaseUpgrading Phase = "upgrading"
PhaseError Phase = "error"
)
type RepoKind string
const (
RepoSystem RepoKind = "system"
RepoAUR RepoKind = "aur"
RepoFlatpak RepoKind = "flatpak"
RepoOSTree RepoKind = "ostree"
)
type ErrorCode string
const (
ErrCodeNone ErrorCode = ""
ErrCodeNoBackend ErrorCode = "no-backend"
ErrCodeBusy ErrorCode = "busy"
ErrCodeBackendFailed ErrorCode = "backend-failed"
ErrCodeTimeout ErrorCode = "timeout"
ErrCodeCancelled ErrorCode = "cancelled"
ErrCodeInvalidRequest ErrorCode = "invalid-request"
)
type Package struct {
Name string `json:"name"`
Repo RepoKind `json:"repo"`
Backend string `json:"backend"`
FromVersion string `json:"fromVersion,omitempty"`
ToVersion string `json:"toVersion,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
ChangelogURL string `json:"changelogUrl,omitempty"`
Ref string `json:"-"`
}
type BackendInfo struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Repo RepoKind `json:"repo"`
NeedsAuth bool `json:"needsAuth"`
RunsInTerminal bool `json:"runsInTerminal"`
}
type ErrorInfo struct {
Code ErrorCode `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Hint string `json:"hint,omitempty"`
}
type State struct {
Phase Phase `json:"phase"`
Distro string `json:"distro,omitempty"`
DistroPretty string `json:"distroPretty,omitempty"`
Backends []BackendInfo `json:"backends"`
Packages []Package `json:"packages"`
Count int `json:"count"`
IntervalSeconds int `json:"intervalSeconds"`
LastCheckUnix int64 `json:"lastCheckUnix,omitempty"`
LastSuccessUnix int64 `json:"lastSuccessUnix,omitempty"`
NextCheckUnix int64 `json:"nextCheckUnix,omitempty"`
OperationID string `json:"operationId,omitempty"`
OperationStarted int64 `json:"operationStartedUnix,omitempty"`
RecentLog []string `json:"recentLog,omitempty"`
Error *ErrorInfo `json:"error,omitempty"`
}
type UpgradeOptions struct {
IncludeFlatpak bool
IncludeAUR bool
DryRun bool
CustomCommand string
Terminal string
Targets []Package
}
type RefreshOptions struct {
Force bool
}

View File

@@ -0,0 +1,455 @@
// Package trash implements the FreeDesktop.org Trash specification 1.0.
// See: https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
package trash
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
const trashInfoExt = ".trashinfo"
type Entry struct {
Name string `json:"name"`
OriginalPath string `json:"originalPath"`
DeletionDate string `json:"deletionDate"`
TrashDir string `json:"trashDir"`
FilesPath string `json:"filesPath"`
InfoPath string `json:"infoPath"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
}
func homeTrashDir() (string, error) {
xdg := os.Getenv("XDG_DATA_HOME")
if xdg == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
xdg = filepath.Join(home, ".local", "share")
}
return filepath.Join(xdg, "Trash"), nil
}
func ensureTrashDirs(trashDir string) error {
if err := os.MkdirAll(filepath.Join(trashDir, "files"), 0o700); err != nil {
return err
}
return os.MkdirAll(filepath.Join(trashDir, "info"), 0o700)
}
func fsDevice(path string) (uint64, error) {
var st syscall.Stat_t
if err := syscall.Lstat(path, &st); err != nil {
return 0, err
}
return uint64(st.Dev), nil
}
func fsDeviceWalkUp(start string) (uint64, error) {
cur := start
for {
if dev, err := fsDevice(cur); err == nil {
return dev, nil
}
parent := filepath.Dir(cur)
if parent == cur {
return 0, fmt.Errorf("no existing ancestor for %s", start)
}
cur = parent
}
}
func findTopDir(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
dev, err := fsDevice(abs)
if err != nil {
return "", err
}
cur := abs
for {
parent := filepath.Dir(cur)
if parent == cur {
return cur, nil
}
pdev, err := fsDevice(parent)
if err != nil {
return cur, nil
}
if pdev != dev {
return cur, nil
}
cur = parent
}
}
// isValidSharedTrash enforces the spec's checks on $topdir/.Trash:
// must exist, must be a directory, must not be a symlink, must have sticky bit.
func isValidSharedTrash(p string) bool {
info, err := os.Lstat(p)
if err != nil {
return false
}
if info.Mode()&os.ModeSymlink != 0 {
return false
}
if !info.IsDir() {
return false
}
return info.Mode()&os.ModeSticky != 0
}
// trashDirForPath chooses the correct trash dir per spec and returns the value
// to store in the .trashinfo Path field (absolute for home, relative-to-topdir
// for per-mountpoint trash).
func trashDirForPath(absPath string) (trashDir string, storedPath string, err error) {
home, err := homeTrashDir()
if err != nil {
return "", "", err
}
pathDev, err := fsDevice(absPath)
if err != nil {
return "", "", err
}
homeDev, err := fsDeviceWalkUp(home)
if err != nil {
return "", "", err
}
if pathDev == homeDev {
return home, absPath, nil
}
topDir, err := findTopDir(absPath)
if err != nil {
return "", "", err
}
uid := strconv.Itoa(os.Getuid())
stored, rerr := filepath.Rel(topDir, absPath)
if rerr != nil || strings.HasPrefix(stored, "..") {
stored = absPath
}
shared := filepath.Join(topDir, ".Trash")
if isValidSharedTrash(shared) {
return filepath.Join(shared, uid), stored, nil
}
return filepath.Join(topDir, ".Trash-"+uid), stored, nil
}
// uniqueName returns a basename in trashDir that does not collide with an
// existing entry in either files/ or info/.
func uniqueName(trashDir, basename string) (string, error) {
filesDir := filepath.Join(trashDir, "files")
infoDir := filepath.Join(trashDir, "info")
if !exists(filepath.Join(filesDir, basename)) && !exists(filepath.Join(infoDir, basename+trashInfoExt)) {
return basename, nil
}
ext := filepath.Ext(basename)
stem := strings.TrimSuffix(basename, ext)
for i := 2; i < 100000; i++ {
candidate := fmt.Sprintf("%s.%d%s", stem, i, ext)
if !exists(filepath.Join(filesDir, candidate)) && !exists(filepath.Join(infoDir, candidate+trashInfoExt)) {
return candidate, nil
}
}
return "", errors.New("could not find unique trash name")
}
func exists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
// pathEncode percent-escapes a POSIX path per RFC 2396, preserving "/".
func pathEncode(p string) string {
parts := strings.Split(p, "/")
for i, seg := range parts {
parts[i] = url.PathEscape(seg)
}
return strings.Join(parts, "/")
}
func pathDecode(p string) string {
if d, err := url.PathUnescape(p); err == nil {
return d
}
return p
}
func writeTrashInfo(infoPath, storedPath string, when time.Time) error {
body := "[Trash Info]\nPath=" + pathEncode(storedPath) +
"\nDeletionDate=" + when.Format("2006-01-02T15:04:05") + "\n"
f, err := os.OpenFile(infoPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(body)
return err
}
// Put trashes a single file or directory.
func Put(path string) (Entry, error) {
abs, err := filepath.Abs(path)
if err != nil {
return Entry{}, err
}
info, err := os.Lstat(abs)
if err != nil {
return Entry{}, err
}
trashDir, storedPath, err := trashDirForPath(abs)
if err != nil {
return Entry{}, err
}
if err := ensureTrashDirs(trashDir); err != nil {
return Entry{}, fmt.Errorf("create trash dir %s: %w", trashDir, err)
}
name, err := uniqueName(trashDir, filepath.Base(abs))
if err != nil {
return Entry{}, err
}
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
when := time.Now()
if err := writeTrashInfo(infoPath, storedPath, when); err != nil {
return Entry{}, err
}
target := filepath.Join(trashDir, "files", name)
if err := os.Rename(abs, target); err != nil {
os.Remove(infoPath)
return Entry{}, err
}
return Entry{
Name: name,
OriginalPath: storedPath,
DeletionDate: when.Format("2006-01-02T15:04:05"),
TrashDir: trashDir,
FilesPath: target,
InfoPath: infoPath,
Size: info.Size(),
IsDir: info.IsDir(),
}, nil
}
// allTrashDirs returns the home trash plus every per-mountpoint trash dir
// that exists (and passes the spec's safety checks for $topdir/.Trash).
func allTrashDirs() []string {
var dirs []string
if h, err := homeTrashDir(); err == nil {
dirs = append(dirs, h)
}
uid := strconv.Itoa(os.Getuid())
for _, mount := range readMountPoints() {
shared := filepath.Join(mount, ".Trash")
if isValidSharedTrash(shared) {
candidate := filepath.Join(shared, uid)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
dirs = append(dirs, candidate)
}
}
candidate := filepath.Join(mount, ".Trash-"+uid)
if info, err := os.Lstat(candidate); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
dirs = append(dirs, candidate)
}
}
return dirs
}
// readMountPoints returns user-visible mount points from /proc/self/mountinfo,
// skipping pseudo and system filesystems.
func readMountPoints() []string {
data, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return nil
}
skipPrefixes := []string{"/proc", "/sys", "/dev"}
var out []string
seen := map[string]bool{}
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
mp := fields[4]
if mp == "/" {
continue
}
skip := false
for _, p := range skipPrefixes {
if mp == p || strings.HasPrefix(mp, p+"/") {
skip = true
break
}
}
if skip || seen[mp] {
continue
}
seen[mp] = true
out = append(out, mp)
}
return out
}
func List() ([]Entry, error) {
var entries []Entry
for _, d := range allTrashDirs() {
es, _ := listOne(d)
entries = append(entries, es...)
}
return entries, nil
}
func listOne(trashDir string) ([]Entry, error) {
infoDir := filepath.Join(trashDir, "info")
filesDir := filepath.Join(trashDir, "files")
dir, err := os.ReadDir(infoDir)
if err != nil {
return nil, err
}
var entries []Entry
for _, ent := range dir {
if !strings.HasSuffix(ent.Name(), trashInfoExt) {
continue
}
name := strings.TrimSuffix(ent.Name(), trashInfoExt)
infoPath := filepath.Join(infoDir, ent.Name())
filesPath := filepath.Join(filesDir, name)
body, err := os.ReadFile(infoPath)
if err != nil {
continue
}
e := Entry{Name: name, TrashDir: trashDir, InfoPath: infoPath, FilesPath: filesPath}
for line := range strings.SplitSeq(string(body), "\n") {
if v, ok := strings.CutPrefix(line, "Path="); ok {
e.OriginalPath = pathDecode(v)
continue
}
if v, ok := strings.CutPrefix(line, "DeletionDate="); ok {
e.DeletionDate = v
}
}
if info, err := os.Lstat(filesPath); err == nil {
e.Size = info.Size()
e.IsDir = info.IsDir()
}
entries = append(entries, e)
}
return entries, nil
}
func Count() (int, error) {
n := 0
for _, d := range allTrashDirs() {
ents, err := os.ReadDir(filepath.Join(d, "info"))
if err != nil {
continue
}
for _, e := range ents {
if strings.HasSuffix(e.Name(), trashInfoExt) {
n++
}
}
}
return n, nil
}
func Empty() error {
var firstErr error
for _, d := range allTrashDirs() {
if err := emptyOne(d); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func emptyOne(trashDir string) error {
var firstErr error
for _, sub := range []string{"files", "info"} {
path := filepath.Join(trashDir, sub)
ents, err := os.ReadDir(path)
if err != nil {
continue
}
for _, e := range ents {
if err := os.RemoveAll(filepath.Join(path, e.Name())); err != nil && firstErr == nil {
firstErr = err
}
}
}
os.Remove(filepath.Join(trashDir, "directorysizes"))
return firstErr
}
// Restore returns a trashed item to its original location.
func Restore(name, trashDir string) error {
if trashDir == "" {
h, err := homeTrashDir()
if err != nil {
return err
}
trashDir = h
}
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
filesPath := filepath.Join(trashDir, "files", name)
body, err := os.ReadFile(infoPath)
if err != nil {
return err
}
var stored string
for line := range strings.SplitSeq(string(body), "\n") {
if v, ok := strings.CutPrefix(line, "Path="); ok {
stored = pathDecode(v)
break
}
}
if stored == "" {
return errors.New("invalid .trashinfo: missing Path")
}
target := stored
if !filepath.IsAbs(stored) {
topDir := filepath.Dir(trashDir)
if filepath.Base(topDir) == ".Trash" {
topDir = filepath.Dir(topDir)
}
target = filepath.Join(topDir, stored)
}
if exists(target) {
return fmt.Errorf("restore target already exists: %s", target)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := os.Rename(filesPath, target); err != nil {
return err
}
os.Remove(infoPath)
return nil
}

View File

@@ -0,0 +1,315 @@
package trash
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func setupHomeTrash(t *testing.T) (homeRoot string, trashDir string) {
t.Helper()
homeRoot = t.TempDir()
xdg := filepath.Join(homeRoot, ".local", "share")
if err := os.MkdirAll(xdg, 0o700); err != nil {
t.Fatalf("mkdir xdg: %v", err)
}
t.Setenv("XDG_DATA_HOME", xdg)
t.Setenv("HOME", homeRoot)
trashDir = filepath.Join(xdg, "Trash")
return homeRoot, trashDir
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestPutHomeTrashAbsolutePath(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
src := filepath.Join(homeRoot, "doc.txt")
writeFile(t, src, "hi")
entry, err := Put(src)
if err != nil {
t.Fatalf("Put: %v", err)
}
if entry.Name != "doc.txt" {
t.Errorf("name = %q, want doc.txt", entry.Name)
}
if entry.OriginalPath != src {
t.Errorf("originalPath = %q, want %q", entry.OriginalPath, src)
}
if entry.TrashDir != trashDir {
t.Errorf("trashDir = %q, want %q", entry.TrashDir, trashDir)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Errorf("source still exists: %v", err)
}
body, err := os.ReadFile(filepath.Join(trashDir, "info", "doc.txt.trashinfo"))
if err != nil {
t.Fatalf("read trashinfo: %v", err)
}
if !strings.HasPrefix(string(body), "[Trash Info]\n") {
t.Errorf("trashinfo missing header: %q", body)
}
if !strings.Contains(string(body), "Path="+src+"\n") {
t.Errorf("Path key missing or wrong: %q", body)
}
if !strings.Contains(string(body), "DeletionDate=") {
t.Errorf("DeletionDate missing: %q", body)
}
}
func TestPutPercentEncodesPath(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
name := "spaces & %.txt"
src := filepath.Join(homeRoot, name)
writeFile(t, src, "x")
if _, err := Put(src); err != nil {
t.Fatalf("Put: %v", err)
}
body, err := os.ReadFile(filepath.Join(trashDir, "info", name+".trashinfo"))
if err != nil {
t.Fatalf("read: %v", err)
}
want := "Path=" + filepath.Dir(src) + "/spaces%20&%20%25.txt"
if !strings.Contains(string(body), want) {
t.Errorf("expected %q in %q", want, body)
}
}
func TestPutCollisionGetsUniqueName(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
for i := range 3 {
src := filepath.Join(homeRoot, "dup.txt")
writeFile(t, src, "x")
if _, err := Put(src); err != nil {
t.Fatalf("Put #%d: %v", i, err)
}
}
want := []string{"dup.txt", "dup.2.txt", "dup.3.txt"}
for _, n := range want {
if _, err := os.Stat(filepath.Join(trashDir, "files", n)); err != nil {
t.Errorf("expected %s in trash: %v", n, err)
}
if _, err := os.Stat(filepath.Join(trashDir, "info", n+".trashinfo")); err != nil {
t.Errorf("expected %s.trashinfo: %v", n, err)
}
}
}
func TestListAndCount(t *testing.T) {
homeRoot, _ := setupHomeTrash(t)
if n, _ := Count(); n != 0 {
t.Errorf("initial count = %d, want 0", n)
}
entries, _ := List()
if len(entries) != 0 {
t.Errorf("initial list len = %d, want 0", len(entries))
}
for _, n := range []string{"a.txt", "b.txt", "c.log"} {
src := filepath.Join(homeRoot, n)
writeFile(t, src, n)
if _, err := Put(src); err != nil {
t.Fatalf("Put %s: %v", n, err)
}
}
got, _ := Count()
if got != 3 {
t.Errorf("count = %d, want 3", got)
}
entries, _ = List()
if len(entries) != 3 {
t.Errorf("list len = %d, want 3", len(entries))
}
for _, e := range entries {
if e.OriginalPath == "" {
t.Errorf("entry %s: empty OriginalPath", e.Name)
}
if _, err := time.Parse("2006-01-02T15:04:05", e.DeletionDate); err != nil {
t.Errorf("entry %s: bad DeletionDate %q: %v", e.Name, e.DeletionDate, err)
}
}
}
func TestEmptyClearsAll(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
for _, n := range []string{"x", "y", "z"} {
src := filepath.Join(homeRoot, n)
writeFile(t, src, n)
if _, err := Put(src); err != nil {
t.Fatalf("Put: %v", err)
}
}
if n, _ := Count(); n != 3 {
t.Fatalf("pre-empty count = %d", n)
}
if err := Empty(); err != nil {
t.Fatalf("Empty: %v", err)
}
if n, _ := Count(); n != 0 {
t.Errorf("post-empty count = %d, want 0", n)
}
for _, sub := range []string{"files", "info"} {
ents, err := os.ReadDir(filepath.Join(trashDir, sub))
if err != nil {
t.Fatalf("readdir %s: %v", sub, err)
}
if len(ents) != 0 {
t.Errorf("%s/ has %d entries, want 0", sub, len(ents))
}
}
}
func TestRestoreToOriginalPath(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
src := filepath.Join(homeRoot, "sub", "dir", "thing.txt")
writeFile(t, src, "payload")
entry, err := Put(src)
if err != nil {
t.Fatalf("Put: %v", err)
}
os.RemoveAll(filepath.Join(homeRoot, "sub"))
if err := Restore(entry.Name, trashDir); err != nil {
t.Fatalf("Restore: %v", err)
}
body, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read restored: %v", err)
}
if string(body) != "payload" {
t.Errorf("restored content = %q, want %q", body, "payload")
}
if _, err := os.Stat(entry.InfoPath); !os.IsNotExist(err) {
t.Errorf("info file still present: %v", err)
}
if _, err := os.Stat(entry.FilesPath); !os.IsNotExist(err) {
t.Errorf("files entry still present: %v", err)
}
}
func TestRestoreRefusesToOverwrite(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
src := filepath.Join(homeRoot, "keep.txt")
writeFile(t, src, "v1")
entry, err := Put(src)
if err != nil {
t.Fatalf("Put: %v", err)
}
writeFile(t, src, "v2-blocking")
err = Restore(entry.Name, trashDir)
if err == nil {
t.Fatalf("expected error on conflicting restore, got nil")
}
if !strings.Contains(err.Error(), "exists") {
t.Errorf("error %q does not mention conflict", err)
}
body, _ := os.ReadFile(src)
if string(body) != "v2-blocking" {
t.Errorf("blocking file altered: %q", body)
}
}
func TestPutDirectory(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
dir := filepath.Join(homeRoot, "myfolder")
writeFile(t, filepath.Join(dir, "child.txt"), "inside")
entry, err := Put(dir)
if err != nil {
t.Fatalf("Put dir: %v", err)
}
if !entry.IsDir {
t.Errorf("IsDir = false, want true")
}
moved := filepath.Join(trashDir, "files", "myfolder", "child.txt")
body, err := os.ReadFile(moved)
if err != nil {
t.Fatalf("read moved child: %v", err)
}
if string(body) != "inside" {
t.Errorf("child content = %q", body)
}
}
func TestIsValidSharedTrashRejectsSymlink(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "real")
if err := os.MkdirAll(target, os.ModeSticky|0o777); err != nil {
t.Fatalf("mkdir target: %v", err)
}
link := filepath.Join(tmp, ".Trash")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if isValidSharedTrash(link) {
t.Errorf("symlinked .Trash accepted; spec requires rejection")
}
}
func TestIsValidSharedTrashRequiresStickyBit(t *testing.T) {
tmp := t.TempDir()
dir := filepath.Join(tmp, ".Trash")
if err := os.MkdirAll(dir, 0o777); err != nil {
t.Fatalf("mkdir: %v", err)
}
if isValidSharedTrash(dir) {
t.Errorf(".Trash without sticky bit accepted; spec requires rejection")
}
if err := os.Chmod(dir, os.ModeSticky|0o777); err != nil {
t.Fatalf("chmod: %v", err)
}
if !isValidSharedTrash(dir) {
t.Errorf(".Trash with sticky bit rejected; spec accepts it")
}
}
func TestPathEncodeRoundTrip(t *testing.T) {
cases := []string{
"/home/u/file.txt",
"/path with spaces/and-symbols & %.txt",
"relative/path/é unicode.md",
}
for _, in := range cases {
got := pathDecode(pathEncode(in))
if got != in {
t.Errorf("round-trip %q -> %q", in, got)
}
}
}

View File

@@ -2,12 +2,10 @@ package version
import (
"os"
"os/exec"
"path/filepath"
"testing"
mocks_version "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/version"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func TestCompareVersions(t *testing.T) {
@@ -150,76 +148,6 @@ func TestGetCurrentDMSVersion_NotInstalled(t *testing.T) {
}
}
func TestGetCurrentDMSVersion_GitTag(t *testing.T) {
if !utils.CommandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
exec.Command("git", "init", dmsPath).Run()
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0o644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
exec.Command("git", "-C", dmsPath, "tag", "v0.1.0").Run()
version, err := GetCurrentDMSVersion()
if err != nil {
t.Fatalf("GetCurrentDMSVersion() failed: %v", err)
}
if version != "v0.1.0" {
t.Errorf("Expected version v0.1.0, got %s", version)
}
}
func TestGetCurrentDMSVersion_GitBranch(t *testing.T) {
if !utils.CommandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
exec.Command("git", "init", dmsPath).Run()
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run()
testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0o644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
version, err := GetCurrentDMSVersion()
if err != nil {
t.Fatalf("GetCurrentDMSVersion() failed: %v", err)
}
if version == "" {
t.Error("Expected non-empty version")
}
if len(version) < 7 {
t.Errorf("Expected version with branch@commit format, got %s", version)
}
}
func TestVersionInfo_IsGit(t *testing.T) {
tests := []struct {
current string

11
distro/nix/default.nix Normal file
View File

@@ -0,0 +1,11 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ../../flake.lock);
in
fetchTarball {
url =
lock.nodes.flake-compat.locked.url
or "https://github.com/NixOS/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
) { src = ../..; }).defaultNix

11
distro/nix/shell.nix Normal file
View File

@@ -0,0 +1,11 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ../../flake.lock);
in
fetchTarball {
url =
lock.nodes.flake-compat.locked.url
or "https://github.com/NixOS/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
) { src = ../..; }).shellNix

View File

@@ -0,0 +1,52 @@
{
self,
pkgs,
...
}:
rec {
all = pkgs.symlinkJoin {
name = "dms-nixos-tests";
paths = [
nixos-module
nixos-service-start-module
greeter-niri-module
niri-home-module
home-manager-module
];
};
nixos-module = import ./nixos-module.nix {
inherit
self
pkgs
;
};
nixos-service-start-module = import ./nixos-service-start-module.nix {
inherit
self
pkgs
;
};
greeter-niri-module = import ./greeter-niri-module.nix {
inherit
self
pkgs
;
};
niri-home-module = import ./niri-home-module.nix {
inherit
self
pkgs
;
};
home-manager-module = import ./home-manager-module.nix {
inherit
self
pkgs
;
};
}

View File

@@ -0,0 +1,60 @@
{
self,
pkgs,
...
}:
pkgs.testers.runNixOSTest {
name = "dms-greeter-niri-module";
nodes.machine = {
imports = [
self.nixosModules.greeter
];
users.groups.greeter = { };
users.users.greeter = {
isSystemUser = true;
group = "greeter";
};
services.greetd.settings.default_session.user = "greeter";
programs.niri.enable = true;
programs.dank-material-shell.greeter = {
enable = true;
compositor.name = "niri";
};
system.stateVersion = "25.11";
};
testScript = ''
import re
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("greetd.service")
machine.succeed("systemctl is-enabled greetd.service")
machine.succeed("systemctl is-active greetd.service")
greetd_unit = machine.succeed("cat /etc/systemd/system/greetd.service")
config_match = re.search(r'--config (/nix/store[^ ]+-greetd.toml)', greetd_unit)
if config_match is None:
raise AssertionError(greetd_unit)
greetd_config_path = config_match.group(1)
greetd_config = machine.succeed(f"cat {greetd_config_path}")
t.assertIn("dms-greeter", greetd_config)
script_match = re.search(r'command\s*=\s*"([^"]+/bin/dms-greeter)"', greetd_config)
if script_match is None:
raise AssertionError(greetd_config)
script_path = script_match.group(1)
script = machine.succeed(f"cat {script_path}")
t.assertIn("--command", script)
t.assertIn("niri", script)
t.assertIn("/share/quickshell/dms", script)
'';
}

View File

@@ -0,0 +1,107 @@
{
self,
pkgs,
...
}:
let
homeManagerNixosModule =
(fetchTarball {
url = "https://github.com/nix-community/home-manager/archive/e82d4a4ecd18363aa2054cbaa3e32e4134c3dbf4.tar.gz";
sha256 = "sha256-ZTYDofOM3/PJhRF1EuBh6uibm+DmkhU7Wor6mMN7YTc=";
})
+ "/nixos";
in
pkgs.testers.runNixOSTest {
name = "dms-home-manager-module";
nodes.machine = {
...
}: {
imports = [
homeManagerNixosModule
];
users.users.danklinux = {
isNormalUser = true;
createHome = true;
home = "/home/danklinux";
extraGroups = [ "wheel" ];
};
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.users.danklinux = {
pkgs,
...
}: {
imports = [
self.homeModules.dank-material-shell
];
home.username = "danklinux";
home.homeDirectory = "/home/danklinux";
home.stateVersion = "25.11";
programs.dank-material-shell = {
enable = true;
systemd = {
enable = true;
target = "default.target";
};
settings = {
theme = "integration-test";
};
clipboardSettings = {
maxItems = 10;
};
session = {
startedFrom = "nixos-test";
};
plugins.TestPlugin = {
enable = true;
src = pkgs.runCommand "dms-test-plugin" { } ''
mkdir -p "$out"
echo plugin > "$out/plugin.txt"
'';
settings = {
enabled = true;
source = "test";
};
};
};
};
system.stateVersion = "25.11";
};
testScript = ''
import json
machine.wait_for_unit("multi-user.target")
machine.succeed("su -- danklinux -c 'command -v dms'")
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/settings.json'")
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/clsettings.json'")
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/plugin_settings.json'")
machine.succeed("su -- danklinux -c 'test -e ~/.config/DankMaterialShell/plugins/TestPlugin'")
machine.succeed("su -- danklinux -c 'test -f ~/.local/state/DankMaterialShell/session.json'")
settings = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/settings.json'"))
clipboard = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/clsettings.json'"))
session = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.local/state/DankMaterialShell/session.json'"))
plugins = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/plugin_settings.json'"))
doctor = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
t.assertEqual(settings["theme"], "integration-test")
t.assertEqual(clipboard["maxItems"], 10)
t.assertEqual(session["startedFrom"], "nixos-test")
t.assertTrue(plugins["TestPlugin"]["enabled"])
t.assertEqual(plugins["TestPlugin"]["source"], "test")
t.assertIsInstance(doctor.get("results"), list)
'';
}

View File

@@ -0,0 +1,84 @@
{
self,
pkgs,
...
}:
let
homeManagerNixosModule =
(fetchTarball {
url = "https://github.com/nix-community/home-manager/archive/e82d4a4ecd18363aa2054cbaa3e32e4134c3dbf4.tar.gz";
sha256 = "sha256-ZTYDofOM3/PJhRF1EuBh6uibm+DmkhU7Wor6mMN7YTc=";
})
+ "/nixos";
niriFlake = builtins.getFlake "github:sodiboo/niri-flake/2bb22af2985e5f3cfd051b3d977ebfbf81126280?narHash=sha256-ooPmu%2B8tqOGh4kozPW4rJC7Y7WM/FHtEY3OK1PoNW7g%3D";
fakeNiri = (pkgs.writeScriptBin "niri" "") // {
cargoBuildNoDefaultFeatures = false;
};
in
pkgs.testers.runNixOSTest {
name = "dms-niri-home-module";
nodes.machine = {
...
}: {
imports = [
homeManagerNixosModule
];
users.users.danklinux = {
isNormalUser = true;
createHome = true;
home = "/home/danklinux";
extraGroups = [ "wheel" ];
};
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
environment.pathsToLink = [
"/share/applications"
"/share/xdg-desktop-portal"
];
home-manager.users.danklinux = {
...
}: {
imports = [
self.homeModules.dank-material-shell
niriFlake.homeModules.niri
self.homeModules.niri
];
home.username = "danklinux";
home.homeDirectory = "/home/danklinux";
home.stateVersion = "25.11";
programs.niri = {
enable = true;
package = fakeNiri; # avoids niri from being compiled in the CI
};
programs.dank-material-shell = {
enable = true;
niri = {
enableKeybinds = false;
enableSpawn = true;
};
};
};
system.stateVersion = "25.11";
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.succeed("su -- danklinux -c 'test -f ~/.config/niri/config.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"include \\\"dms/binds.kdl\\\"\" ~/.config/niri/config.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"include \\\"hm.kdl\\\"\" ~/.config/niri/config.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"spawn-at-startup\" ~/.config/niri/hm.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"\\\"dms\\\" \\\"run\\\"\" ~/.config/niri/hm.kdl'")
'';
}

View File

@@ -0,0 +1,47 @@
{
self,
pkgs,
...
}:
pkgs.testers.runNixOSTest {
name = "dms-nixos-module";
nodes.machine = {
imports = [
self.nixosModules.dank-material-shell
];
users.users.danklinux = {
isNormalUser = true;
extraGroups = [ "wheel" ];
};
programs.dank-material-shell = {
enable = true;
systemd.enable = true;
plugins = {
TestPlugin = {
src = pkgs.emptyDirectory;
};
};
};
system.stateVersion = "25.11";
};
testScript = ''
import json
machine.wait_for_unit("multi-user.target")
machine.succeed("command -v dms")
machine.succeed("command -v quickshell")
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
t.assertIn("summary", payload)
t.assertIsInstance(payload.get("results"), list)
'';
}

View File

@@ -0,0 +1,48 @@
{
self,
pkgs,
...
}:
let
fakeDms = pkgs.writeShellScriptBin "dms" ''
printf '%s\n' "$@" > /tmp/dms-service-args
exec ${pkgs.coreutils}/bin/sleep 300
'';
in
pkgs.testers.runNixOSTest {
name = "dms-nixos-service-start-module";
nodes.machine = {
imports = [
self.nixosModules.dank-material-shell
];
users.users.danklinux = {
isNormalUser = true;
linger = true;
extraGroups = [ "wheel" ];
};
programs.dank-material-shell = {
enable = true;
package = fakeDms;
systemd = {
enable = true;
target = "default.target";
};
};
system.stateVersion = "25.11";
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("user@1000.service")
machine.succeed("systemctl --machine=danklinux@ --user start dms.service")
machine.wait_until_succeeds("systemctl --machine=danklinux@ --user is-active dms.service")
machine.wait_until_succeeds("test -f /tmp/dms-service-args")
machine.succeed("grep -Fx run /tmp/dms-service-args")
machine.succeed("grep -Fx -- --session /tmp/dms-service-args")
'';
}

33
flake.lock generated
View File

@@ -1,12 +1,28 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1771369470,
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
"lastModified": 1776169885,
"narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "0182a361324364ae3f436a63005877674cf45efb",
"rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
"type": "github"
},
"original": {
@@ -23,22 +39,23 @@
]
},
"locked": {
"lastModified": 1766725085,
"narHash": "sha256-O2aMFdDUYJazFrlwL7aSIHbUSEm3ADVZjmf41uBJfHs=",
"lastModified": 1776854048,
"narHash": "sha256-lLbV66V3RMNp1l8/UelmR4YzoJ5ONtgvEtiUMJATH/o=",
"ref": "refs/heads/master",
"rev": "41828c4180fb921df7992a5405f5ff05d2ac2fff",
"revCount": 715,
"rev": "783c953987dc56ff0601abe6845ed96f1d00495a",
"revCount": 806,
"type": "git",
"url": "https://git.outfoxxed.me/quickshell/quickshell"
},
"original": {
"rev": "41828c4180fb921df7992a5405f5ff05d2ac2fff",
"rev": "783c953987dc56ff0601abe6845ed96f1d00495a",
"type": "git",
"url": "https://git.outfoxxed.me/quickshell/quickshell"
}
},
"root": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": "nixpkgs",
"quickshell": "quickshell"
}

176
flake.nix
View File

@@ -4,9 +4,13 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
quickshell = {
url = "git+https://git.outfoxxed.me/quickshell/quickshell?rev=41828c4180fb921df7992a5405f5ff05d2ac2fff";
url = "git+https://git.outfoxxed.me/quickshell/quickshell?rev=783c953987dc56ff0601abe6845ed96f1d00495a";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-compat = {
url = "github:NixOS/flake-compat";
flake = false;
};
};
outputs =
@@ -41,10 +45,12 @@
nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ] (
system: fn system nixpkgs.legacyPackages.${system}
);
buildDmsPkgs = pkgs: {
dms-shell = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
forEachLinuxSystem =
fn:
nixpkgs.lib.genAttrs [ "aarch64-linux" "x86_64-linux" ] (
system: fn system nixpkgs.legacyPackages.${system}
);
mkModuleWithDmsPkgs =
modulePath:
args@{ pkgs, ... }:
@@ -53,6 +59,7 @@
(import modulePath (args // { dmsPkgs = buildDmsPkgs pkgs; }))
];
};
mkQmlImportPath =
pkgs: qmlPkgs:
pkgs.lib.concatStringsSep ":" (map (o: "${o}/${pkgs.qt6.qtbase.qtQmlPrefix}") qmlPkgs);
@@ -69,10 +76,11 @@
qtimageformats
kimageformats
];
in
{
packages = forEachSystem (
system: pkgs:
# Allows downstream modules to provide their own 'pkgs' (with overlays)
# instead of being forced to use the flake's locked nixpkgs.
mkDmsShell =
pkgs:
let
mkDate =
longDate:
@@ -90,89 +98,96 @@
in
"${cleanVersion}${dateSuffix}${revSuffix}";
in
{
dms-shell = pkgs.lib.makeOverridable (
pkgs.lib.makeOverridable (
{
extraQtPackages ? [ ],
}:
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
let
rootSrc = ./.;
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
in
{
extraQtPackages ? [ ],
}:
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
let
rootSrc = ./.;
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
in
{
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-dEk7IOd6aQwaxZruxQclN7TGMyb8EJOl6NBWRsoZ9HQ=";
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-kPu3MLqhLaCaBpCwIP8JXep0J/Z45kxDFOEY8JvcWdU=";
subPackages = [ "cmd/dms" ];
subPackages = [ "cmd/dms" ];
ldflags = [
"-s"
"-w"
"-X 'main.Version=${version}'"
];
ldflags = [
"-s"
"-w"
"-X 'main.Version=${version}'"
];
nativeBuildInputs = with pkgs; [
installShellFiles
makeWrapper
];
nativeBuildInputs = with pkgs; [
installShellFiles
makeWrapper
];
postInstall = ''
mkdir -p $out/share/quickshell/dms
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
postInstall = ''
mkdir -p $out/share/quickshell/dms
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
chmod u+w $out/share/quickshell/dms/VERSION
echo "${version}" > $out/share/quickshell/dms/VERSION
chmod u+w $out/share/quickshell/dms/VERSION
echo "${version}" > $out/share/quickshell/dms/VERSION
# Install desktop file and icon
install -D ${rootSrc}/assets/dms-open.desktop \
$out/share/applications/dms-open.desktop
install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg
# Install desktop file and icon
install -D ${rootSrc}/assets/dms-open.desktop \
$out/share/applications/dms-open.desktop
install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg
wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
$out/lib/systemd/user/dms.service
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
$out/lib/systemd/user/dms.service
substituteInPlace $out/lib/systemd/user/dms.service \
--replace-fail /usr/bin/dms $out/bin/dms \
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
substituteInPlace $out/lib/systemd/user/dms.service \
--replace-fail /usr/bin/dms $out/bin/dms \
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
installShellCompletion --cmd dms \
--bash <($out/bin/dms completion bash) \
--fish <($out/bin/dms completion fish) \
--zsh <($out/bin/dms completion zsh)
'';
installShellCompletion --cmd dms \
--bash <($out/bin/dms completion bash) \
--fish <($out/bin/dms completion fish) \
--zsh <($out/bin/dms completion zsh)
'';
meta = {
description = "Desktop shell for wayland compositors built with Quickshell & GO";
homepage = "https://danklinux.com";
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
license = pkgs.lib.licenses.mit;
mainProgram = "dms";
platforms = pkgs.lib.platforms.linux;
};
}
)
) { };
meta = {
description = "Desktop shell for wayland compositors built with Quickshell & GO";
homepage = "https://danklinux.com";
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
license = pkgs.lib.licenses.mit;
mainProgram = "dms";
platforms = pkgs.lib.platforms.linux;
};
}
)
) { };
buildDmsPkgs = pkgs: {
dms-shell = mkDmsShell pkgs;
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
in
{
packages = forEachSystem (
system: pkgs: {
dms-shell = mkDmsShell pkgs;
quickshell = quickshell.packages.${system}.default;
default = self.packages.${system}.dms-shell;
}
);
@@ -236,5 +251,16 @@
};
}
);
nixosTests = forEachLinuxSystem (
system: pkgs:
import ./distro/nix/tests {
inherit
self
pkgs
;
lib = pkgs.lib;
}
);
};
}

View File

@@ -55,7 +55,7 @@ Singleton {
readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1
readonly property bool isDepthEffect: _effect === 2
readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.frameEnabled && _effect === 1 && SettingsData.directionalAnimationMode === 3
readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.connectedFrameModeActive
readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96
readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16

View File

@@ -5,9 +5,11 @@ import QtCore
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("CacheData")
readonly property int cacheConfigVersion: 1
@@ -131,7 +133,7 @@ Singleton {
}
}
} catch (e) {
console.warn("CacheData: Failed to parse cache:", e.message);
log.warn("Failed to parse cache:", e.message);
} finally {
_loading = false;
}
@@ -149,7 +151,7 @@ Singleton {
}
function migrateFromUndefinedToV1(cache) {
console.info("CacheData: Migrating configuration from undefined to version 1");
log.info("Migrating configuration from undefined to version 1");
}
function cleanupUnusedKeys() {
@@ -164,7 +166,7 @@ Singleton {
for (const key in cache) {
if (!validKeys.includes(key)) {
console.log("CacheData: Removing unused key:", key);
log.debug("Removing unused key:", key);
delete cache[key];
needsSave = true;
}
@@ -174,7 +176,7 @@ Singleton {
cacheFile.setText(JSON.stringify(cache, null, 2));
}
} catch (e) {
console.warn("CacheData: Failed to cleanup unused keys:", e.message);
log.warn("Failed to cleanup unused keys:", e.message);
}
}
@@ -184,7 +186,7 @@ Singleton {
if (content && content.trim())
return JSON.parse(content);
} catch (e) {
console.warn("CacheData: Failed to parse launcher cache:", e.message);
log.warn("Failed to parse launcher cache:", e.message);
}
return null;
}
@@ -220,7 +222,7 @@ Singleton {
}
onLoadFailed: error => {
if (!isGreeterMode) {
console.info("CacheData: No cache file found, starting fresh");
log.info("No cache file found, starting fresh");
}
}
}

View File

@@ -161,12 +161,22 @@ Singleton {
};
}
function _sameDockState(a, b) {
if (!a || !b)
return false;
return a.reveal === b.reveal && a.barSide === b.barSide && Math.abs(a.bodyX - b.bodyX) < 0.5 && Math.abs(a.bodyY - b.bodyY) < 0.5 && Math.abs(a.bodyW - b.bodyW) < 0.5 && Math.abs(a.bodyH - b.bodyH) < 0.5 && Math.abs(a.slideX - b.slideX) < 0.5 && Math.abs(a.slideY - b.slideY) < 0.5;
}
function setDockState(screenName, state) {
if (!screenName || !state)
return false;
const normalized = _normalizeDockState(state);
if (_sameDockState(dockStates[screenName], normalized))
return true;
const next = _cloneDict(dockStates);
next[screenName] = _normalizeDockState(state);
next[screenName] = normalized;
dockStates = next;
return true;
}
@@ -191,17 +201,20 @@ Singleton {
function setDockSlide(screenName, x, y) {
if (!screenName)
return false;
const numX = Number(x);
const numY = Number(y);
const cur = dockSlides[screenName];
if (cur && Math.abs(cur.x - numX) < 0.5 && Math.abs(cur.y - numY) < 0.5)
return true;
const next = _cloneDict(dockSlides);
next[screenName] = {
"x": Number(x),
"y": Number(y)
"x": numX,
"y": numY
};
dockSlides = next;
return true;
}
// ─── Notification state (per screen, updated by NotificationSurface) ──────
readonly property var emptyNotificationState: ({
"visible": false,
"barSide": "top",
@@ -369,8 +382,6 @@ Singleton {
return true;
}
// ─── Dock retract coordination ────────────────────────────────
property var dockRetractRequests: ({})
function requestDockRetract(requesterId, screenName, side) {
@@ -407,4 +418,64 @@ Singleton {
}
return false;
}
// Prune state for screens that are no longer connected. Stale entries
// accumulate across hotplug cycles otherwise — Frame's per-screen
// FrameInstance doesn't notice when its peer dicts go orphan.
function _pruneToLiveScreens() {
const live = {};
const screens = Quickshell.screens || [];
for (let i = 0; i < screens.length; i++) {
const s = screens[i];
if (s && s.name)
live[s.name] = true;
}
function pruneKeyed(dict) {
let changed = false;
const next = {};
for (const k in dict) {
if (live[k])
next[k] = dict[k];
else
changed = true;
}
return changed ? next : null;
}
const nextDock = pruneKeyed(dockStates);
if (nextDock !== null)
dockStates = nextDock;
const nextSlides = pruneKeyed(dockSlides);
if (nextSlides !== null)
dockSlides = nextSlides;
const nextNotif = pruneKeyed(notificationStates);
if (nextNotif !== null)
notificationStates = nextNotif;
const nextModal = pruneKeyed(modalStates);
if (nextModal !== null)
modalStates = nextModal;
let retractChanged = false;
const nextRetract = {};
for (const k in dockRetractRequests) {
const r = dockRetractRequests[k];
if (r && live[r.screenName])
nextRetract[k] = r;
else
retractChanged = true;
}
if (retractChanged)
dockRetractRequests = nextRetract;
if (popoutOwnerId && popoutScreen && !live[popoutScreen])
releasePopout(popoutOwnerId);
}
Connections {
target: Quickshell
function onScreensChanged() {
root._pruneToLiveScreens();
}
}
}

View File

@@ -5,9 +5,11 @@ import QtQuick
import Qt.labs.folderlistmodel
import Quickshell
import Quickshell.Io
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("I18n")
property string _resolvedLocale: "en"
@@ -54,15 +56,15 @@ Singleton {
try {
root.translations = JSON.parse(text());
root.translationsLoaded = true;
console.info(`I18n: Loaded translations for '${root._resolvedLocale}' (${Object.keys(root.translations).length} contexts)`);
log.info(`I18n: Loaded translations for '${root._resolvedLocale}' (${Object.keys(root.translations).length} contexts)`);
} catch (e) {
console.warn(`I18n: Error parsing '${root._resolvedLocale}':`, e, "- falling back to English");
log.warn(`I18n: Error parsing '${root._resolvedLocale}':`, e, "- falling back to English");
root._fallbackToEnglish();
}
}
onLoadFailed: error => {
console.warn(`I18n: Failed to load '${root._resolvedLocale}' (${error}), ` + "falling back to English");
log.warn(`I18n: Failed to load '${root._resolvedLocale}' (${error}), ` + "falling back to English");
root._fallbackToEnglish();
}
}
@@ -105,14 +107,14 @@ Singleton {
_selectedPath = fileUrl;
translationsLoaded = false;
translations = ({});
console.info(`I18n: Using locale '${localeTag}' from ${fileUrl}`);
log.info(`I18n: Using locale '${localeTag}' from ${fileUrl}`);
}
function _fallbackToEnglish() {
_selectedPath = "";
translationsLoaded = false;
translations = ({});
console.warn("I18n: Falling back to built-in English strings");
log.warn("Falling back to built-in English strings");
}
function tr(term, context) {

View File

@@ -161,10 +161,16 @@ const NIRI_ACTIONS = {
{ id: "focus-monitor-right", label: "Focus Monitor Right" },
{ id: "focus-monitor-down", label: "Focus Monitor Down" },
{ id: "focus-monitor-up", label: "Focus Monitor Up" },
{ id: "move-column-to-monitor-left", label: "Move to Monitor Left" },
{ id: "move-column-to-monitor-right", label: "Move to Monitor Right" },
{ id: "move-column-to-monitor-down", label: "Move to Monitor Down" },
{ id: "move-column-to-monitor-up", label: "Move to Monitor Up" }
{ id: "move-column-to-monitor-left", label: "Move Column to Monitor Left" },
{ id: "move-column-to-monitor-right", label: "Move Column to Monitor Right" },
{ id: "move-column-to-monitor-down", label: "Move Column to Monitor Down" },
{ id: "move-column-to-monitor-up", label: "Move Column to Monitor Up" },
{ id: "move-workspace-to-monitor-left", label: "Move Workspace to Monitor Left" },
{ id: "move-workspace-to-monitor-right", label: "Move Workspace to Monitor Right" },
{ id: "move-workspace-to-monitor-down", label: "Move Workspace to Monitor Down" },
{ id: "move-workspace-to-monitor-up", label: "Move Workspace to Monitor Up" },
{ id: "move-workspace-to-monitor-next", label: "Move Workspace to Next Monitor" },
{ id: "move-workspace-to-monitor-previous", label: "Move Workspace to Previous Monitor" }
],
"Screenshot": [
{ id: "screenshot", label: "Screenshot (Interactive)" },

View File

@@ -24,7 +24,9 @@ Singleton {
}
function expandTilde(path: string): string {
return strip(path.replace("~", stringify(root.home)));
if (!path.startsWith("~"))
return path;
return strip(root.home) + path.substring(1);
}
function shortenHome(path: string): string {

View File

@@ -3,9 +3,11 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("Proc")
readonly property int noTimeout: -1
property int defaultDebounceMs: 50
@@ -112,7 +114,7 @@ Singleton {
const safeExitCode = exitCodeValue !== null && exitCodeValue !== undefined ? exitCodeValue : -1;
entry.callback(safeOutput, safeExitCode);
} catch (e) {
console.warn("runCommand callback error for command:", entry.command, "Error:", e);
log.warn("runCommand callback error for command:", entry.command, "Error:", e);
}
}
try {

View File

@@ -12,6 +12,7 @@ import "settings/SessionStore.js" as Store
Singleton {
id: root
readonly property var log: Log.scoped("SessionData")
readonly property int sessionConfigVersion: 3
@@ -30,9 +31,36 @@ Singleton {
property bool isLightMode: false
property bool doNotDisturb: false
property real doNotDisturbUntil: 0
property string terminalOverride: ""
property bool isSwitchingMode: false
property bool suppressOSD: true
readonly property var terminalOptions: ["ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"]
property var installedTerminals: []
function resolveTerminal() {
if (terminalOverride && terminalOverride.length > 0) {
return terminalOverride;
}
const env = Quickshell.env("TERMINAL");
if (env && env.length > 0) {
return env;
}
return "";
}
Process {
id: terminalProbe
running: true
command: ["sh", "-c", "for t in ghostty kitty foot alacritty wezterm konsole gnome-terminal xterm; do command -v \"$t\" >/dev/null 2>&1 && echo \"$t\"; done"]
stdout: StdioCollector {
onStreamFinished: {
const found = text.trim().split("\n").filter(line => line.length > 0);
root.installedTerminals = found;
}
}
}
Timer {
id: dndExpireTimer
repeat: false
@@ -230,7 +258,7 @@ Singleton {
} catch (e) {
_parseError = true;
const msg = e.message;
console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
log.error("Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
}
}
@@ -310,7 +338,7 @@ Singleton {
} catch (e) {
_parseError = true;
const msg = e.message;
console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
log.error("Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
}
}
@@ -525,7 +553,7 @@ Singleton {
}
if (!screen) {
console.warn("SessionData: Screen not found");
log.warn("Screen not found");
return;
}
@@ -622,7 +650,7 @@ Singleton {
}
if (!screen) {
console.warn("SessionData: Screen not found");
log.warn("Screen not found");
return;
}
@@ -653,7 +681,7 @@ Singleton {
}
if (!screen) {
console.warn("SessionData: Screen not found");
log.warn("Screen not found");
return;
}
@@ -684,7 +712,7 @@ Singleton {
}
if (!screen) {
console.warn("SessionData: Screen not found");
log.warn("Screen not found");
return;
}
@@ -715,7 +743,7 @@ Singleton {
}
if (!screen) {
console.warn("SessionData: Screen not found");
log.warn("Screen not found");
return;
}

View File

@@ -13,6 +13,7 @@ import "settings/SettingsStore.js" as Store
Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 11
@@ -184,8 +185,6 @@ Singleton {
onAnimationVariantChanged: saveSettings()
property int motionEffect: SettingsData.AnimationEffect.Standard
onMotionEffectChanged: saveSettings()
property int directionalAnimationMode: 0
onDirectionalAnimationModeChanged: saveSettings()
property bool m3ElevationEnabled: true
onM3ElevationEnabledChanged: saveSettings()
property int m3ElevationIntensity: 12
@@ -207,11 +206,15 @@ Singleton {
property bool blurEnabled: false
onBlurEnabledChanged: saveSettings()
property bool blurForegroundLayers: true
onBlurForegroundLayersChanged: saveSettings()
property real blurLayerOutlineOpacity: 0.12
onBlurLayerOutlineOpacityChanged: saveSettings()
property string blurBorderColor: "outline"
onBlurBorderColorChanged: saveSettings()
property string blurBorderCustomColor: "#ffffff"
onBlurBorderCustomColorChanged: saveSettings()
property real blurBorderOpacity: 1.0
property real blurBorderOpacity: 0.35
onBlurBorderOpacityChanged: saveSettings()
property string wallpaperFillMode: "Fill"
property bool blurredWallpaperLayer: false
@@ -239,12 +242,14 @@ Singleton {
onFrameCloseGapsChanged: saveSettings()
property string frameLauncherEmergeSide: "bottom"
onFrameLauncherEmergeSideChanged: saveSettings()
property bool frameLauncherArcExtender: false
onFrameLauncherArcExtenderChanged: saveSettings()
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
property int previousDirectionalMode: 1
onPreviousDirectionalModeChanged: saveSettings()
property string frameMode: "separate"
onFrameModeChanged: saveSettings()
property var connectedFrameBarStyleBackups: ({})
onConnectedFrameBarStyleBackupsChanged: saveSettings()
readonly property bool connectedFrameModeActive: frameEnabled && motionEffect === SettingsData.AnimationEffect.Directional && directionalAnimationMode === 3
readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected"
onConnectedFrameModeActiveChanged: {
if (_loading)
return;
@@ -275,6 +280,9 @@ Singleton {
property int selectedGpuIndex: 0
property var enabledGpuPciIds: []
property bool showSystemTray: true
property string systemTrayIconTintMode: "none"
property int systemTrayIconTintSaturation: 50
property int systemTrayIconTintStrength: 135
property bool showClock: true
property bool showNotificationButton: true
property bool showBattery: true
@@ -550,6 +558,7 @@ Singleton {
property bool matugenTemplatePywalfox: true
property bool matugenTemplateZenBrowser: true
property bool matugenTemplateVesktop: true
property bool matugenTemplateVencord: true
property bool matugenTemplateEquibop: true
property bool matugenTemplateGhostty: true
property bool matugenTemplateKitty: true
@@ -602,6 +611,9 @@ Singleton {
property int dockMaxVisibleApps: 0
property int dockMaxVisibleRunningApps: 0
property bool dockShowOverflowBadge: true
property bool dockShowTrash: false
property string dockTrashFileManager: "default"
property string dockTrashCustomCommand: ""
property bool notificationOverlayEnabled: false
property bool notificationPopupShadowEnabled: true
@@ -694,6 +706,9 @@ Singleton {
property bool updaterUseCustomCommand: false
property string updaterCustomCommand: ""
property string updaterTerminalAdditionalParams: ""
property int updaterIntervalSeconds: 1800
property bool updaterIncludeFlatpak: true
property bool updaterAllowAUR: true
property string displayNameMode: "system"
property var screenPreferences: ({})
@@ -1325,6 +1340,9 @@ Singleton {
Store.parse(root, obj);
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
frameMode = "connected";
if (obj?.weatherLocation !== undefined)
_legacyWeatherLocation = obj.weatherLocation;
if (obj?.weatherCoordinates !== undefined)
@@ -1345,7 +1363,7 @@ Singleton {
} catch (e) {
_parseError = true;
const msg = e.message;
console.error("SettingsData: Failed to parse settings.json - file will not be overwritten. Error:", msg);
log.error("Failed to parse settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
applyStoredTheme();
} finally {
@@ -1367,12 +1385,12 @@ Singleton {
if (_isReadOnly) {
_hasUnsavedChanges = _checkForUnsavedChanges();
if (!wasReadOnly)
console.info("SettingsData: settings.json is now read-only");
log.info("settings.json is now read-only");
} else {
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
_hasUnsavedChanges = false;
if (wasReadOnly)
console.info("SettingsData: settings.json is now writable");
log.info("settings.json is now writable");
if (_pendingMigration)
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
}
@@ -1426,7 +1444,7 @@ Singleton {
} catch (e) {
const msg = e.message || String(e);
if (!_isMissingPluginSettingsError(e))
console.warn("SettingsData: Failed to load plugin_settings.json. Error:", msg);
log.warn("Failed to load plugin_settings.json. Error:", msg);
_resetPluginSettings();
}
}
@@ -1443,7 +1461,7 @@ Singleton {
} catch (e) {
_pluginParseError = true;
const msg = e.message;
console.error("SettingsData: Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
log.error("Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse plugin_settings.json"), msg));
pluginSettings = {};
} finally {
@@ -3081,7 +3099,7 @@ Singleton {
} catch (e) {
_parseError = true;
const msg = e.message;
console.error("SettingsData: Failed to reload settings.json - file will not be overwritten. Error:", msg);
log.error("Failed to reload settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
} finally {
_loading = false;
@@ -3116,7 +3134,7 @@ Singleton {
if (!isGreeterMode) {
const msg = String(error || "");
if (!_isMissingPluginSettingsError(error))
console.warn("SettingsData: Failed to load plugin_settings.json. Error:", msg);
log.warn("Failed to load plugin_settings.json. Error:", msg);
_resetPluginSettings();
}
}

View File

@@ -12,6 +12,7 @@ import "StockThemes.js" as StockThemes
Singleton {
id: root
readonly property var log: Log.scoped("Theme")
readonly property string stateDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericCacheLocation).toString()) + "/DankMaterialShell"
readonly property bool envDisableMatugen: Quickshell.env("DMS_DISABLE_MATUGEN") === "1" || Quickshell.env("DMS_DISABLE_MATUGEN") === "true"
@@ -148,7 +149,7 @@ Singleton {
}
if (colorsFileLoadFailed && currentTheme === dynamic && rawWallpaperPath) {
console.info("Theme: Matugen now available, regenerating colors for dynamic theme");
log.info("Matugen now available, regenerating colors for dynamic theme");
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default";
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
@@ -341,19 +342,6 @@ Singleton {
Connections {
target: DMSService
enabled: typeof DMSService !== "undefined" && typeof SessionData !== "undefined"
function onLoginctlEvent(event) {
if (!SessionData.themeModeAutoEnabled)
return;
if (event.event === "unlock" || event.event === "resume") {
if (!themeAutoBackendAvailable()) {
root.evaluateThemeMode();
return;
}
DMSService.sendRequest("theme.auto.trigger", {});
}
}
function onThemeAutoStateUpdate(data) {
if (!SessionData.themeModeAutoEnabled) {
@@ -389,7 +377,7 @@ Singleton {
"use": true
}, response => {
if (!response.error) {
console.info("Theme automation: IP location enabled after connection");
log.info("Theme automation: IP location enabled after connection");
}
});
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
@@ -402,18 +390,39 @@ Singleton {
"longitude": SessionData.longitude
}, locationResponse => {
if (locationResponse?.error) {
console.warn("Theme automation: Failed to set location", locationResponse.error);
log.warn("Theme automation: Failed to set location", locationResponse.error);
}
});
}
});
} else {
console.warn("Theme automation: No location configured");
log.warn("Theme automation: No location configured");
}
}
}
}
Connections {
target: SessionService
enabled: SessionData.themeModeAutoEnabled
function onSessionUnlocked() {
root.triggerThemeAutomationRefresh();
}
function onSessionResumed() {
root.triggerThemeAutomationRefresh();
}
}
function triggerThemeAutomationRefresh() {
if (!themeAutoBackendAvailable()) {
root.evaluateThemeMode();
return;
}
DMSService.sendRequest("theme.auto.trigger", {});
}
function applyGreeterTheme(themeName) {
switchTheme(themeName, false, false);
if (themeName === dynamic && dynamicColorsFileView.path) {
@@ -541,8 +550,8 @@ Singleton {
property color success: currentThemeData.success || "#4CAF50"
property color primaryHover: Qt.rgba(primary.r, primary.g, primary.b, 0.12)
property color primaryHoverLight: Qt.rgba(primary.r, primary.g, primary.b, 0.08)
property color primaryPressed: Qt.rgba(primary.r, primary.g, primary.b, 0.16)
property color primaryHoverLight: Qt.rgba(primary.r, primary.g, primary.b, transparentBlurLayers ? 0.12 : 0.08)
property color primaryPressed: Qt.rgba(primary.r, primary.g, primary.b, transparentBlurLayers ? 0.24 : 0.16)
property color primarySelected: Qt.rgba(primary.r, primary.g, primary.b, 0.3)
property color primaryBackground: Qt.rgba(primary.r, primary.g, primary.b, 0.04)
@@ -551,17 +560,28 @@ Singleton {
property color surfaceHover: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.08)
property color surfacePressed: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.12)
property color surfaceSelected: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.15)
property color surfaceLight: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.1)
property color surfaceLight: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, transparentBlurLayers ? 0.3 : 0.1)
property color surfaceVariantAlpha: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.2)
readonly property bool blurForegroundLayers: BlurService.enabled && (typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true))
readonly property bool transparentBlurLayers: BlurService.enabled && !blurForegroundLayers
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
readonly property color floatingSurface: transparentBlurLayers ? "transparent" : readableSurface
readonly property color floatingSurfaceHigh: transparentBlurLayers ? "transparent" : readableSurfaceHigh
readonly property color nestedSurface: floatingSurfaceHigh
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
readonly property real layerOutlineOpacity: BlurService.enabled ? blurLayerOutlineOpacity : 0.08
readonly property int layerOutlineWidth: BlurService.enabled && layerOutlineOpacity > 0 ? 1 : 0
property color surfaceTextHover: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.08)
property color surfaceTextAlpha: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.3)
property color surfaceTextLight: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.06)
property color surfaceTextMedium: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.7)
property color outlineButton: Qt.rgba(outline.r, outline.g, outline.b, 0.5)
property color outlineLight: Qt.rgba(outline.r, outline.g, outline.b, 0.05)
property color outlineMedium: Qt.rgba(outline.r, outline.g, outline.b, 0.08)
property color outlineStrong: Qt.rgba(outline.r, outline.g, outline.b, 0.12)
property color outlineLight: Qt.rgba(outline.r, outline.g, outline.b, BlurService.enabled ? Math.min(1, layerOutlineOpacity * 0.625) : 0.05)
property color outlineMedium: Qt.rgba(outline.r, outline.g, outline.b, layerOutlineOpacity)
property color outlineStrong: Qt.rgba(outline.r, outline.g, outline.b, BlurService.enabled ? Math.min(1, layerOutlineOpacity * 1.5) : 0.12)
property color errorHover: Qt.rgba(error.r, error.g, error.b, 0.12)
property color errorPressed: Qt.rgba(error.r, error.g, error.b, 0.16)
@@ -579,6 +599,12 @@ Singleton {
}
}
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (blurForegroundLayers ? withAlpha(surfaceContainerHigh, Math.min(popupTransparency, 0.24)) : withAlpha(surfaceContainer, popupTransparency))
readonly property color ccPillInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.08) : nestedSurface
readonly property color ccPillInactiveHoverBg: transparentBlurLayers ? withAlpha(primary, 0.10) : primaryPressed
readonly property color ccSliderTrackColor: transparentBlurLayers ? surfaceText : surfaceContainerHigh
readonly property real ccSliderTrackOpacity: transparentBlurLayers ? 0.18 : popupTransparency
readonly property color ccTileActiveText: {
switch (SettingsData.controlCenterTileColorMode) {
case "primaryContainer":
@@ -960,7 +986,6 @@ Singleton {
"expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1]
}
// ─── Animation variant proxy ──────────────────────────────────────────────
// Theme is the canonical access point for animation variant state. The
// aliases below forward to AnimVariants.qml so consumers don't need two
// imports. ~200 call sites read through Theme.variantEnterCurve /
@@ -1371,7 +1396,7 @@ Singleton {
}
function loadCustomThemeFromFile(filePath) {
customThemeFileView.path = filePath;
customThemeFileView.path = Paths.expandTilde(filePath);
}
function reloadCustomThemeVariant() {
@@ -1550,12 +1575,12 @@ Singleton {
function setDesiredTheme(kind, value, isLight, iconTheme, matugenType, stockColors) {
if (!matugenAvailable) {
console.warn("Theme: matugen not available or disabled - cannot set system theme");
log.warn("matugen not available or disabled - cannot set system theme");
return;
}
if (workerRunning) {
console.info("Theme: Worker already running, queueing request");
log.info("Worker already running, queueing request");
pendingThemeRequest = {
kind,
value,
@@ -1567,7 +1592,7 @@ Singleton {
return;
}
console.info("Theme: Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
log.info("Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
NiriService.suppressNextToast();
@@ -1582,7 +1607,7 @@ Singleton {
"runUserTemplates": (typeof SettingsData !== "undefined") ? SettingsData.runUserMatugenTemplates : true
};
console.log("Theme: Starting matugen worker");
log.debug("Starting matugen worker");
workerRunning = true;
const args = ["dms", "matugen", "queue", "--state-dir", stateDir, "--shell-dir", shellDir, "--config-dir", configDir, "--kind", desired.kind, "--value", desired.value, "--mode", desired.mode, "--icon-theme", desired.iconTheme, "--matugen-type", desired.matugenType,];
@@ -1606,7 +1631,7 @@ Singleton {
if (typeof SettingsData !== "undefined") {
const skipTemplates = [];
if (!SettingsData.runDmsMatugenTemplates) {
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "vencord", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
} else {
if (!SettingsData.matugenTemplateGtk)
skipTemplates.push("gtk");
@@ -1628,6 +1653,8 @@ Singleton {
skipTemplates.push("zenbrowser");
if (!SettingsData.matugenTemplateVesktop)
skipTemplates.push("vesktop");
if (!SettingsData.matugenTemplateVencord)
skipTemplates.push("vencord");
if (!SettingsData.matugenTemplateEquibop)
skipTemplates.push("equibop");
if (!SettingsData.matugenTemplateGhostty)
@@ -1740,7 +1767,7 @@ Singleton {
}
if (!darkTheme || !darkTheme.primary) {
console.warn("Theme data not available for:", currentTheme);
log.warn("Theme data not available for:", currentTheme);
return;
}
@@ -1984,10 +2011,10 @@ Singleton {
id: systemThemeGenerator
running: false
stdout: SplitParser {
onRead: data => console.info("Theme worker:", data)
onRead: data => log.info("Theme worker:", data)
}
stderr: SplitParser {
onRead: data => console.warn("Theme worker:", data)
onRead: data => log.warn("Theme worker:", data)
}
onExited: exitCode => {
@@ -1996,18 +2023,18 @@ Singleton {
switch (exitCode) {
case 0:
console.info("Theme: Matugen worker completed successfully");
log.info("Matugen worker completed successfully");
root.matugenCompleted(currentMode, "success");
break;
case 2:
console.log("Theme: Matugen worker completed with code 2 (no changes needed)");
log.debug("Matugen worker completed with code 2 (no changes needed)");
root.matugenCompleted(currentMode, "no-changes");
break;
default:
if (typeof ToastService !== "undefined") {
ToastService.showError("Theme worker failed (" + exitCode + ")");
}
console.warn("Theme: Matugen worker failed with exit code:", exitCode);
log.warn("Matugen worker failed with exit code:", exitCode);
root.matugenCompleted(currentMode, "error");
}
@@ -2016,13 +2043,14 @@ Singleton {
const req = pendingThemeRequest;
pendingThemeRequest = null;
console.info("Theme: Processing queued theme request");
log.info("Processing queued theme request");
setDesiredTheme(req.kind, req.value, req.isLight, req.iconTheme, req.matugenType, req.stockColors);
}
}
FileView {
id: customThemeFileView
blockLoading: false
watchChanges: currentTheme === "custom"
function parseAndLoadTheme() {
@@ -2069,7 +2097,7 @@ Singleton {
}
}
} catch (e) {
console.error("Theme: Failed to parse dynamic colors:", e);
log.error("Failed to parse dynamic colors:", e);
if (typeof ToastService !== "undefined") {
ToastService.wallpaperErrorStatus = "error";
ToastService.showError("Dynamic colors parse error: " + e.message);
@@ -2089,11 +2117,11 @@ Singleton {
onLoadFailed: function (error) {
if (currentTheme === dynamic) {
console.warn("Theme: Dynamic colors file load failed, marking for regeneration");
log.warn("Dynamic colors file load failed, marking for regeneration");
colorsFileLoadFailed = true;
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
if (!isGreeterMode && matugenAvailable && rawWallpaperPath) {
console.log("Theme: Matugen available, triggering immediate regeneration");
log.debug("Matugen available, triggering immediate regeneration");
generateSystemThemesFromCurrentTheme();
}
}
@@ -2217,7 +2245,7 @@ Singleton {
"endMinute": endMinute
}, response => {
if (response && response.error) {
console.error("Theme automation: Failed to sync time schedule:", response.error);
log.error("Theme automation: Failed to sync time schedule:", response.error);
}
});
@@ -2310,9 +2338,9 @@ Singleton {
if (root.themeModeAutomationActive) {
if (SessionData.nightModeUseIPLocation) {
console.warn("Theme automation: Waiting for IP location from backend");
log.warn("Theme automation: Waiting for IP location from backend");
} else {
console.warn("Theme automation: Location mode requires coordinates");
log.warn("Theme automation: Location mode requires coordinates");
}
}
}
@@ -2394,7 +2422,7 @@ Singleton {
"use": true
}, response => {
if (response?.error) {
console.warn("Theme automation: Failed to enable IP location", response.error);
log.warn("Theme automation: Failed to enable IP location", response.error);
}
});
return true;
@@ -2408,7 +2436,7 @@ Singleton {
"longitude": SessionData.longitude
}, locResp => {
if (locResp?.error) {
console.warn("Theme automation: Failed to set location", locResp.error);
log.warn("Theme automation: Failed to set location", locResp.error);
}
});
}

View File

@@ -0,0 +1,93 @@
.pragma library
function stripHtmlTags(html) {
if (!html)
return "";
return String(html)
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&#039;/g, "'");
}
function elideRichText(html, visibleBudget) {
if (!html)
return "";
if (visibleBudget <= 0)
return "";
var out = "";
var visible = 0;
var i = 0;
var openTags = [];
var len = html.length;
while (i < len && visible < visibleBudget) {
var ch = html.charAt(i);
if (ch === "<") {
var end = html.indexOf(">", i);
if (end < 0)
break;
var tag = html.substring(i, end + 1);
out += tag;
var isClose = tag.charAt(1) === "/";
var match = tag.match(/^<\/?([a-zA-Z]+)/);
var name = match ? match[1] : "";
if (isClose) {
if (openTags.length > 0 && openTags[openTags.length - 1] === name)
openTags.pop();
} else if (!tag.endsWith("/>") && name) {
openTags.push(name);
}
i = end + 1;
} else if (ch === "&") {
var eend = html.indexOf(";", i);
if (eend < 0 || eend - i > 6) {
out += "&amp;";
visible++;
i++;
} else {
out += html.substring(i, eend + 1);
visible++;
i = eend + 1;
}
} else {
out += ch;
visible++;
i++;
}
}
while (i < len && html.charAt(i) === "<") {
var tend = html.indexOf(">", i);
if (tend < 0)
break;
var ttag = html.substring(i, tend + 1);
out += ttag;
var tisClose = ttag.charAt(1) === "/";
var tmatch = ttag.match(/^<\/?([a-zA-Z]+)/);
var tname = tmatch ? tmatch[1] : "";
if (tisClose) {
if (openTags.length > 0 && openTags[openTags.length - 1] === tname)
openTags.pop();
} else if (!ttag.endsWith("/>") && tname) {
openTags.push(tname);
}
i = tend + 1;
}
if (i < len) {
out = out.replace(/\s+$/, "");
while (openTags.length > 0)
out += "</" + openTags.pop() + ">";
out += "…";
} else {
while (openTags.length > 0)
out += "</" + openTags.pop() + ">";
}
return out;
}

View File

@@ -4,6 +4,7 @@ var SPEC = {
isLightMode: { def: false },
doNotDisturb: { def: false },
doNotDisturbUntil: { def: 0 },
terminalOverride: { def: "" },
wallpaperPath: { def: "" },
perMonitorWallpaper: { def: false },

View File

@@ -51,8 +51,6 @@ var SPEC = {
enableRippleEffects: { def: true },
animationVariant: { def: 0 },
motionEffect: { def: 0 },
directionalAnimationMode: { def: 0 },
previousDirectionalMode: { def: 1 },
m3ElevationEnabled: { def: true },
m3ElevationIntensity: { def: 12 },
m3ElevationOpacity: { def: 30 },
@@ -63,9 +61,11 @@ var SPEC = {
popoutElevationEnabled: { def: true },
barElevationEnabled: { def: true },
blurEnabled: { def: false },
blurForegroundLayers: { def: true },
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
blurBorderColor: { def: "outline" },
blurBorderCustomColor: { def: "#ffffff" },
blurBorderOpacity: { def: 1.0, coerce: percentToUnit },
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
wallpaperFillMode: { def: "Fill" },
blurredWallpaperLayer: { def: false },
blurWallpaperOnOverview: { def: false },
@@ -83,6 +83,9 @@ var SPEC = {
selectedGpuIndex: { def: 0 },
enabledGpuPciIds: { def: [] },
showSystemTray: { def: true },
systemTrayIconTintMode: { def: "none" },
systemTrayIconTintSaturation: { def: 50 },
systemTrayIconTintStrength: { def: 135 },
showClock: { def: true },
showNotificationButton: { def: true },
showBattery: { def: true },
@@ -301,6 +304,7 @@ var SPEC = {
matugenTemplatePywalfox: { def: true },
matugenTemplateZenBrowser: { def: true },
matugenTemplateVesktop: { def: true },
matugenTemplateVencord: { def: true },
matugenTemplateEquibop: { def: true },
matugenTemplateGhostty: { def: true },
matugenTemplateKitty: { def: true },
@@ -349,6 +353,9 @@ var SPEC = {
dockMaxVisibleApps: { def: 0 },
dockMaxVisibleRunningApps: { def: 0 },
dockShowOverflowBadge: { def: true },
dockShowTrash: { def: false },
dockTrashFileManager: { def: "default" },
dockTrashCustomCommand: { def: "" },
notificationOverlayEnabled: { def: false },
notificationPopupShadowEnabled: { def: true },
@@ -424,6 +431,9 @@ var SPEC = {
updaterUseCustomCommand: { def: false },
updaterCustomCommand: { def: "" },
updaterTerminalAdditionalParams: { def: "" },
updaterIntervalSeconds: { def: 1800 },
updaterIncludeFlatpak: { def: true },
updaterAllowAUR: { def: true },
displayNameMode: { def: "system" },
screenPreferences: { def: {} },
@@ -554,7 +564,9 @@ var SPEC = {
frameShowOnOverview: { def: false },
frameBlurEnabled: { def: true },
frameCloseGaps: { def: false },
frameLauncherEmergeSide: { def: "bottom" }
frameLauncherEmergeSide: { def: "bottom" },
frameLauncherArcExtender: { def: false },
frameMode: { def: "separate" }
};
function getValidKeys() {

View File

@@ -4,6 +4,7 @@ import qs.Common
import qs.Modals
import qs.Modals.Changelog
import qs.Modals.Clipboard
import qs.Modals.Common
import qs.Modals.Greeter
import qs.Modals.Settings
import qs.Modals.DankLauncherV2
@@ -27,6 +28,16 @@ import qs.Services
Item {
id: root
readonly property var log: Log.scoped("DMSShell")
property bool osdSurfacesLoaded: true
property int pendingOsdResumeReloads: 0
function recreateOsdSurfaces() {
OSDManager.currentOSDsByScreen = ({});
osdSurfacesLoaded = false;
osdSurfaceReloadTimer.restart();
}
Instantiator {
id: daemonPluginInstantiator
@@ -45,7 +56,7 @@ Item {
item.popoutService = PopoutService;
}
item.pluginId = pluginId;
console.info("Daemon plugin loaded:", pluginId);
log.info("Daemon plugin loaded:", pluginId);
}
}
}
@@ -84,7 +95,7 @@ Item {
}
onFadeCancelled: {
console.log("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
}
}
@@ -124,7 +135,7 @@ Item {
}
onFadeCancelled: {
console.log("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
}
}
@@ -235,6 +246,32 @@ Item {
}
}
Timer {
id: osdResumeRecreateTimer
interval: 400
repeat: false
onTriggered: {
root.recreateOsdSurfaces();
root.pendingOsdResumeReloads--;
if (root.pendingOsdResumeReloads <= 0) {
root.pendingOsdResumeReloads = 0;
interval = 400;
return;
}
interval = 1400;
restart();
}
}
Timer {
id: osdSurfaceReloadTimer
interval: 120
repeat: false
onTriggered: root.osdSurfacesLoaded = true
}
Component.onCompleted: {
dockRecreateDebounce.start();
// Force PolkitService singleton to initialize
@@ -252,11 +289,15 @@ Item {
sourceComponent: Dock {
contextMenu: dockContextMenuLoader.item ? dockContextMenuLoader.item : null
trashContextMenu: dockTrashContextMenuLoader.item ? dockTrashContextMenuLoader.item : null
}
onLoaded: {
if (item) {
dockContextMenuLoader.active = true;
if (SettingsData.dockShowTrash) {
dockTrashContextMenuLoader.active = true;
}
}
}
@@ -293,7 +334,6 @@ Item {
sourceComponent: Component {
DankDashPopout {
id: dankDashPopout
onPopoutClosed: PopoutService.unloadDankDash()
}
}
}
@@ -308,6 +348,43 @@ Item {
}
}
LazyLoader {
id: dockTrashContextMenuLoader
active: false
DockTrashContextMenu {
id: dockTrashContextMenu
}
}
Connections {
target: SettingsData
function onDockShowTrashChanged() {
if (SettingsData.dockShowTrash) {
dockTrashContextMenuLoader.active = true;
}
}
}
ConfirmModal {
id: emptyTrashConfirm
}
Connections {
target: TrashService
function onEmptyTrashConfirmRequested(itemCount) {
emptyTrashConfirm.showWithOptions({
title: I18n.tr("Empty Trash?"),
message: I18n.tr("Permanently delete %1 item(s)? This cannot be undone.").arg(itemCount),
confirmText: I18n.tr("Empty"),
cancelText: I18n.tr("Cancel"),
confirmColor: Theme.error,
onConfirm: () => TrashService.emptyTrash()
});
}
}
LazyLoader {
id: notificationCenterLoader
@@ -699,7 +776,7 @@ Item {
cmd += " " + escapedPath;
}
console.log("FilePicker: Launching", cmd);
log.debug("FilePicker: Launching", cmd);
Quickshell.execDetached({
command: ["sh", "-c", cmd]
@@ -731,10 +808,10 @@ Item {
}
function onAppPickerRequested(data) {
console.log("DMSShell: App picker requested with data:", JSON.stringify(data));
log.debug("App picker requested with data:", JSON.stringify(data));
if (!data || !data.target) {
console.warn("DMSShell: Invalid app picker request data");
log.warn("Invalid app picker request data");
return;
}
@@ -752,6 +829,16 @@ Item {
}
}
Connections {
target: SessionService
function onSessionResumed() {
root.pendingOsdResumeReloads = 2;
osdResumeRecreateTimer.interval = 400;
osdResumeRecreateTimer.restart();
}
}
DankColorPickerModal {
id: colorPickerModal
@@ -811,7 +898,12 @@ Item {
SystemUpdatePopout {
id: systemUpdatePopout
onPopoutClosed: PopoutService.unloadSystemUpdate()
onPopoutClosed: {
if (systemUpdatePopout._reopenAfterUpgrade) {
return;
}
PopoutService.unloadSystemUpdate();
}
Component.onCompleted: {
PopoutService.systemUpdatePopout = systemUpdatePopout;
@@ -926,81 +1018,85 @@ Item {
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: VolumeOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MediaVolumeOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MediaPlaybackOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MicMuteOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: BrightnessOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: IdleInhibitorOSD {
modelData: item
}
}
Loader {
id: powerProfileWatcherLoader
active: SettingsData.osdPowerProfileEnabled
source: "Services/PowerProfileWatcher.qml"
}
id: osdSurfacesLoader
active: root.osdSurfacesLoaded
asynchronous: false
Variants {
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
sourceComponent: Component {
Item {
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: PowerProfileOSD {
modelData: item
}
}
delegate: VolumeOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: CapsLockOSD {
modelData: item
}
}
delegate: MediaVolumeOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: AudioOutputOSD {
modelData: item
delegate: MediaPlaybackOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MicMuteOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: BrightnessOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: IdleInhibitorOSD {
modelData: item
}
}
Variants {
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
delegate: PowerProfileOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: CapsLockOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: AudioOutputOSD {
modelData: item
}
}
}
}
}

View File

@@ -9,6 +9,7 @@ import qs.Modules.Settings.DisplayConfig
Item {
id: root
readonly property var log: Log.scoped("DMSShellIPC")
required property var powerMenuModalLoader
required property var processListModalLoader
@@ -161,37 +162,36 @@ Item {
}
IpcHandler {
function resolveTabIndex(tab: string): int {
switch ((tab || "").toLowerCase()) {
case "media":
return 1;
case "wallpaper":
return 2;
case "weather":
return SettingsData.weatherEnabled ? 3 : 0;
default:
return 0;
}
}
function open(tab: string): string {
const bar = root.getPreferredBar("clockButtonRef");
if (!bar)
return "DASH_OPEN_FAILED";
const tabIndex = resolveTabIndex(tab);
const dash = root.dankDashPopoutLoader.item;
const onSameScreen = dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name;
if (!onSameScreen) {
bar.triggerWallpaperBrowser();
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
dash.currentTabIndex = tabIndex;
if (dash.updateSurfacePosition)
dash.updateSurfacePosition();
return "DASH_OPEN_SUCCESS";
}
if (!root.dankDashPopoutLoader.item)
if (!bar.triggerDashTab(tabIndex))
return "DASH_OPEN_FAILED";
switch (tab.toLowerCase()) {
case "media":
root.dankDashPopoutLoader.item.currentTabIndex = 1;
break;
case "wallpaper":
root.dankDashPopoutLoader.item.currentTabIndex = 2;
break;
case "weather":
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
break;
default:
root.dankDashPopoutLoader.item.currentTabIndex = 0;
break;
}
root.dankDashPopoutLoader.item.dashVisible = true;
return "DASH_OPEN_SUCCESS";
}
@@ -211,23 +211,8 @@ Item {
const bar = root.getPreferredBar("clockButtonRef");
if (bar) {
bar.triggerWallpaperBrowser();
if (root.dankDashPopoutLoader.item) {
switch (tab.toLowerCase()) {
case "media":
root.dankDashPopoutLoader.item.currentTabIndex = 1;
break;
case "wallpaper":
root.dankDashPopoutLoader.item.currentTabIndex = 2;
break;
case "weather":
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
break;
default:
root.dankDashPopoutLoader.item.currentTabIndex = 0;
break;
}
}
if (!bar.triggerDashTab(resolveTabIndex(tab)))
return "DASH_TOGGLE_FAILED";
return "DASH_TOGGLE_SUCCESS";
}
return "DASH_TOGGLE_FAILED";
@@ -861,7 +846,7 @@ Item {
function set(key: string, value: string): string {
if (!(key in SettingsData)) {
console.warn("Cannot set property, not found:", key);
log.warn("Cannot set property, not found:", key);
return "SETTINGS_INVALID_KEY";
}
@@ -894,12 +879,12 @@ Item {
throw "Unsupported type";
}
console.warn("Setting:", key, value);
log.warn("Setting:", key, value);
SettingsData[key] = value;
SettingsData.saveSettings();
return "SETTINGS_SET_SUCCESS";
} catch (e) {
console.warn("Failed to set property:", key, "error:", e);
log.warn("Failed to set property:", key, "error:", e);
return "SETTINGS_SET_FAILURE";
}
}

View File

@@ -1,5 +1,4 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Modals.Common
import qs.Widgets
@@ -7,6 +6,7 @@ import qs.Services
DankModal {
id: root
readonly property var log: Log.scoped("AppPickerModal")
property string title: I18n.tr("Select Application")
property string targetData: ""
@@ -30,52 +30,52 @@ DankModal {
onBackgroundClicked: close()
onDialogClosed: {
searchQuery = ""
selectedIndex = 0
keyboardNavigationActive = false
searchQuery = "";
selectedIndex = 0;
keyboardNavigationActive = false;
}
onOpened: {
searchQuery = ""
updateApplicationList()
selectedIndex = 0
searchQuery = "";
updateApplicationList();
selectedIndex = 0;
Qt.callLater(() => {
if (contentLoader.item && contentLoader.item.searchField) {
contentLoader.item.searchField.text = ""
contentLoader.item.searchField.forceActiveFocus()
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
}
})
});
}
function updateApplicationList() {
applicationsModel.clear()
const apps = AppSearchService.applications
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {}
let filteredApps = []
applicationsModel.clear();
const apps = AppSearchService.applications;
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {};
let filteredApps = [];
for (const app of apps) {
if (!app || !app.categories) continue
let matchesCategory = categoryFilter.length === 0
if (!app || !app.categories)
continue;
let matchesCategory = categoryFilter.length === 0;
if (categoryFilter.length > 0) {
try {
for (const cat of app.categories) {
if (categoryFilter.includes(cat)) {
matchesCategory = true
break
matchesCategory = true;
break;
}
}
} catch (e) {
console.warn("AppPicker: Error iterating categories for", app.name, ":", e)
continue
log.warn("AppPicker: Error iterating categories for", app.name, ":", e);
continue;
}
}
if (matchesCategory) {
const name = app.name || ""
const lowerName = name.toLowerCase()
const lowerQuery = searchQuery.toLowerCase()
const name = app.name || "";
const lowerName = name.toLowerCase();
const lowerQuery = searchQuery.toLowerCase();
if (searchQuery === "" || lowerName.includes(lowerQuery)) {
filteredApps.push({
@@ -84,21 +84,21 @@ DankModal {
exec: app.exec || app.execString || "",
startupClass: app.startupWMClass || "",
appData: app
})
});
}
}
}
filteredApps.sort((a, b) => {
const aId = a.appData.id || a.appData.execString || a.appData.exec || ""
const bId = b.appData.id || b.appData.execString || b.appData.exec || ""
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0
const aId = a.appData.id || a.appData.execString || a.appData.exec || "";
const bId = b.appData.id || b.appData.execString || b.appData.exec || "";
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0;
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0;
if (aUsage !== bUsage) {
return bUsage - aUsage
return bUsage - aUsage;
}
return (a.name || "").localeCompare(b.name || "")
})
return (a.name || "").localeCompare(b.name || "");
});
filteredApps.forEach(app => {
applicationsModel.append({
@@ -107,10 +107,10 @@ DankModal {
exec: app.exec,
startupClass: app.startupClass,
appId: app.appData.id || app.appData.execString || app.appData.exec || ""
})
})
});
});
console.log("AppPicker: Found " + filteredApps.length + " applications")
log.debug("AppPicker: Found " + filteredApps.length + " applications");
}
onSearchQueryChanged: updateApplicationList()
@@ -129,56 +129,57 @@ DankModal {
focus: true
Keys.onEscapePressed: event => {
root.close()
event.accepted = true
root.close();
event.accepted = true;
}
Keys.onPressed: event => {
if (applicationsModel.count === 0) return
if (applicationsModel.count === 0)
return;
// Toggle view mode with Tab key
if (event.key === Qt.Key_Tab) {
root.viewMode = root.viewMode === "grid" ? "list" : "grid"
event.accepted = true
return
root.viewMode = root.viewMode === "grid" ? "list" : "grid";
event.accepted = true;
return;
}
if (root.viewMode === "grid") {
if (event.key === Qt.Key_Left) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
event.accepted = true
root.keyboardNavigationActive = true;
root.selectedIndex = Math.max(0, root.selectedIndex - 1);
event.accepted = true;
} else if (event.key === Qt.Key_Right) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
event.accepted = true
root.keyboardNavigationActive = true;
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1);
event.accepted = true;
} else if (event.key === Qt.Key_Up) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns)
event.accepted = true
root.keyboardNavigationActive = true;
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns);
event.accepted = true;
} else if (event.key === Qt.Key_Down) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns)
event.accepted = true
root.keyboardNavigationActive = true;
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns);
event.accepted = true;
}
} else {
if (event.key === Qt.Key_Up) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
event.accepted = true
root.keyboardNavigationActive = true;
root.selectedIndex = Math.max(0, root.selectedIndex - 1);
event.accepted = true;
} else if (event.key === Qt.Key_Down) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
event.accepted = true
root.keyboardNavigationActive = true;
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1);
event.accepted = true;
}
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.selectedIndex >= 0 && root.selectedIndex < applicationsModel.count) {
const app = applicationsModel.get(root.selectedIndex)
launchApplication(app)
const app = applicationsModel.get(root.selectedIndex);
launchApplication(app);
}
event.accepted = true
event.accepted = true;
}
}
@@ -217,7 +218,7 @@ DankModal {
iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText
backgroundColor: root.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
onClicked: {
root.viewMode = "list"
root.viewMode = "list";
}
}
@@ -229,7 +230,7 @@ DankModal {
iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText
backgroundColor: root.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
onClicked: {
root.viewMode = "grid"
root.viewMode = "grid";
}
}
}
@@ -257,42 +258,42 @@ DankModal {
keyForwardTargets: [appContent]
onTextEdited: {
root.searchQuery = text
root.searchQuery = text;
}
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Escape) {
root.close()
event.accepted = true
return
root.close();
event.accepted = true;
return;
}
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key)
const hasText = text.length > 0
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key);
const hasText = text.length > 0;
if (isEnterKey && hasText) {
if (root.keyboardNavigationActive && applicationsModel.count > 0) {
const app = applicationsModel.get(root.selectedIndex)
launchApplication(app)
const app = applicationsModel.get(root.selectedIndex);
launchApplication(app);
} else if (applicationsModel.count > 0) {
const app = applicationsModel.get(0)
launchApplication(app)
const app = applicationsModel.get(0);
launchApplication(app);
}
event.accepted = true
return
event.accepted = true;
return;
}
const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab]
const isNavigationKey = navigationKeys.includes(event.key)
const isEmptyEnter = isEnterKey && !hasText
const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab];
const isNavigationKey = navigationKeys.includes(event.key);
const isEmptyEnter = isEnterKey && !hasText;
event.accepted = !(isNavigationKey || isEmptyEnter)
event.accepted = !(isNavigationKey || isEmptyEnter);
}
Connections {
function onShouldBeVisibleChanged() {
if (!root.shouldBeVisible) {
searchField.focus = false
searchField.focus = false;
}
}
@@ -303,12 +304,12 @@ DankModal {
Rectangle {
width: parent.width
height: {
let usedHeight = 40 + Theme.spacingS
usedHeight += 52 + Theme.spacingS
let usedHeight = 40 + Theme.spacingS;
usedHeight += 52 + Theme.spacingS;
if (root.showTargetData) {
usedHeight += 36 + Theme.spacingS
usedHeight += 36 + Theme.spacingS;
}
return parent.height - usedHeight
return parent.height - usedHeight;
}
radius: Theme.cornerRadius
color: "transparent"
@@ -320,14 +321,14 @@ DankModal {
property int itemSpacing: Theme.spacingS
function ensureVisible(index) {
if (index < 0 || index >= count) return
const itemY = index * (itemHeight + itemSpacing)
const itemBottom = itemY + itemHeight
if (index < 0 || index >= count)
return;
const itemY = index * (itemHeight + itemSpacing);
const itemBottom = itemY + itemHeight;
if (itemY < contentY) {
contentY = itemY
contentY = itemY;
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height
contentY = itemBottom - height;
}
}
@@ -343,9 +344,9 @@ DankModal {
spacing: itemSpacing
onCurrentIndexChanged: {
root.selectedIndex = currentIndex
root.selectedIndex = currentIndex;
if (root.keyboardNavigationActive) {
ensureVisible(currentIndex)
ensureVisible(currentIndex);
}
}
@@ -360,11 +361,11 @@ DankModal {
hoverUpdatesSelection: true
onItemClicked: (idx, modelData) => {
launchApplication(modelData)
launchApplication(modelData);
}
onKeyboardNavigationReset: {
root.keyboardNavigationActive = false
root.keyboardNavigationActive = false;
}
}
}
@@ -373,14 +374,14 @@ DankModal {
id: appGrid
function ensureVisible(index) {
if (index < 0 || index >= count) return
const itemY = Math.floor(index / root.gridColumns) * cellHeight
const itemBottom = itemY + cellHeight
if (index < 0 || index >= count)
return;
const itemY = Math.floor(index / root.gridColumns) * cellHeight;
const itemBottom = itemY + cellHeight;
if (itemY < contentY) {
contentY = itemY
contentY = itemY;
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height
contentY = itemBottom - height;
}
}
@@ -397,9 +398,9 @@ DankModal {
currentIndex: root.selectedIndex
onCurrentIndexChanged: {
root.selectedIndex = currentIndex
root.selectedIndex = currentIndex;
if (root.keyboardNavigationActive) {
ensureVisible(currentIndex)
ensureVisible(currentIndex);
}
}
@@ -413,11 +414,11 @@ DankModal {
hoverUpdatesSelection: true
onItemClicked: (idx, modelData) => {
launchApplication(modelData)
launchApplication(modelData);
}
onKeyboardNavigationReset: {
root.keyboardNavigationActive = false
root.keyboardNavigationActive = false;
}
}
}
@@ -449,22 +450,22 @@ DankModal {
}
function launchApplication(app) {
if (!app) return
root.applicationSelected(app, root.targetData)
if (!app)
return;
root.applicationSelected(app, root.targetData);
if (usageHistoryKey && app.appId) {
const usageHistory = SettingsData[usageHistoryKey] || {}
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0
const usageHistory = SettingsData[usageHistoryKey] || {};
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0;
usageHistory[app.appId] = {
count: currentCount + 1,
lastUsed: Date.now(),
name: app.name
}
SettingsData.set(usageHistoryKey, usageHistory)
};
SettingsData.set(usageHistoryKey, usageHistory);
}
root.close()
root.close();
}
}
}

View File

@@ -7,6 +7,7 @@ import qs.Widgets
DankModal {
id: root
readonly property var log: Log.scoped("BluetoothPairingModal")
layerNamespace: "dms:bluetooth-pairing"
@@ -24,7 +25,7 @@ DankModal {
property string passkeyInput: ""
function show(pairingData) {
console.log("BluetoothPairingModal.show() called:", JSON.stringify(pairingData));
log.debug("BluetoothPairingModal.show() called:", JSON.stringify(pairingData));
token = pairingData.token || "";
deviceName = pairingData.deviceName || "";
deviceAddress = pairingData.deviceAddr || "";
@@ -33,7 +34,7 @@ DankModal {
pinInput = "";
passkeyInput = "";
console.log("BluetoothPairingModal: Calling open()");
log.debug("Calling open()");
open();
Qt.callLater(() => {
if (contentLoader.item) {

View File

@@ -2,9 +2,11 @@ import QtQuick
import Quickshell
import qs.Common
import qs.Modals
import qs.Services
AppPickerModal {
id: root
readonly property var log: Log.scoped("BrowserPickerModal")
property string url: ""
@@ -17,35 +19,44 @@ AppPickerModal {
showTargetData: true
function shellEscape(str) {
return "'" + str.replace(/'/g, "'\\''") + "'"
return "'" + str.replace(/'/g, "'\\''") + "'";
}
onApplicationSelected: (app, url) => {
if (!app) return
if (!app)
return;
let cmd = app.exec || "";
const escapedUrl = shellEscape(url);
let cmd = app.exec || ""
const escapedUrl = shellEscape(url)
let hasField = false
if (cmd.includes("%u")) { cmd = cmd.replace("%u", escapedUrl); hasField = true }
else if (cmd.includes("%U")) { cmd = cmd.replace("%U", escapedUrl); hasField = true }
else if (cmd.includes("%f")) { cmd = cmd.replace("%f", escapedUrl); hasField = true }
else if (cmd.includes("%F")) { cmd = cmd.replace("%F", escapedUrl); hasField = true }
cmd = cmd.replace(/%[ikc]/g, "")
if (!hasField) {
cmd += " " + escapedUrl
let hasField = false;
if (cmd.includes("%u")) {
cmd = cmd.replace("%u", escapedUrl);
hasField = true;
} else if (cmd.includes("%U")) {
cmd = cmd.replace("%U", escapedUrl);
hasField = true;
} else if (cmd.includes("%f")) {
cmd = cmd.replace("%f", escapedUrl);
hasField = true;
} else if (cmd.includes("%F")) {
cmd = cmd.replace("%F", escapedUrl);
hasField = true;
}
console.log("BrowserPicker: Launching", cmd)
cmd = cmd.replace(/%[ikc]/g, "");
if (!hasField) {
cmd += " " + escapedUrl;
}
log.debug("BrowserPicker: Launching", cmd);
Quickshell.execDetached({
command: ["sh", "-c", cmd]
})
});
}
onViewModeChanged: {
SettingsData.set("browserPickerViewMode", viewMode)
SettingsData.set("browserPickerViewMode", viewMode);
}
}

View File

@@ -53,6 +53,19 @@ QtObject {
}
}
function togglePinSelected() {
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
if (!entries || entries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= entries.length) {
return;
}
const selectedEntry = entries[ClipboardService.selectedIndex];
if (modal.activeTab === "saved") {
modal.unpinEntry(selectedEntry);
} else {
modal.pinEntry(selectedEntry);
}
}
function handleKey(event) {
switch (event.key) {
case Qt.Key_Escape:
@@ -65,6 +78,12 @@ QtObject {
return;
case Qt.Key_Down:
case Qt.Key_Tab:
if (event.key === Qt.Key_Tab && (event.modifiers & Qt.ControlModifier)) {
modal.activeTab = modal.activeTab === "saved" ? "recents" : "saved";
ClipboardService.selectedIndex = 0;
event.accepted = true;
return;
}
if (!ClipboardService.keyboardNavigationActive) {
ClipboardService.keyboardNavigationActive = true;
ClipboardService.selectedIndex = 0;
@@ -75,6 +94,12 @@ QtObject {
return;
case Qt.Key_Up:
case Qt.Key_Backtab:
if (event.key === Qt.Key_Backtab && (event.modifiers & Qt.ControlModifier)) {
modal.activeTab = modal.activeTab === "saved" ? "recents" : "saved";
ClipboardService.selectedIndex = 0;
event.accepted = true;
return;
}
if (!ClipboardService.keyboardNavigationActive) {
ClipboardService.keyboardNavigationActive = true;
ClipboardService.selectedIndex = 0;
@@ -121,6 +146,12 @@ QtObject {
event.accepted = true;
}
return;
case Qt.Key_S:
if (ClipboardService.keyboardNavigationActive) {
togglePinSelected();
event.accepted = true;
}
return;
}
}

View File

@@ -9,8 +9,8 @@ Rectangle {
property bool enterToPaste: false
readonly property string hintsText: {
if (!wtypeAvailable)
return I18n.tr("Shift+Del: Clear All • Esc: Close");
return enterToPaste ? I18n.tr("Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close");
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close");
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close");
}
height: ClipboardConstants.keyboardHintsHeight
@@ -26,9 +26,7 @@ Rectangle {
spacing: 2
StyledText {
text: keyboardHints.enterToPaste
? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled")
: I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter

View File

@@ -6,6 +6,7 @@ import qs.Widgets
Item {
id: thumbnail
readonly property var log: Log.scoped("ClipboardThumbnail")
required property var entry
required property string entryType
@@ -52,7 +53,7 @@ Item {
modal.activeImageLoads--;
}
if (response.error) {
console.warn("ClipboardThumbnail: Failed to load image:", entry.id);
log.warn("Failed to load image:", entry.id);
return;
}
const data = response.result?.data;

View File

@@ -4,6 +4,7 @@ import qs.Services
Item {
id: root
readonly property var log: Log.scoped("DankModal")
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
property string layerNamespace: "dms:modal"
@@ -83,9 +84,31 @@ Item {
impl.item.toggle();
}
readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
property var _resolvedBackend: null
Component.onCompleted: _resolvedBackend = _desiredBackend
Connections {
target: SettingsData
function onConnectedFrameModeActiveChanged() {
root._maybeResolveBackend();
}
}
// Defer Loader source-component swap until impl is fully closed; avoids
// tearing down a modal mid-animation when frame mode is toggled.
function _maybeResolveBackend() {
if (_resolvedBackend === _desiredBackend)
return;
if (impl.item && (impl.item.shouldBeVisible || impl.item.isClosing))
return;
_resolvedBackend = _desiredBackend;
}
Loader {
id: impl
sourceComponent: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
sourceComponent: root._resolvedBackend
onItemChanged: if (item)
root._wireBackend(item)
}
@@ -136,20 +159,7 @@ Item {
it.useOverlayLayer = Qt.binding(() => root.useOverlayLayer);
it.shouldBeVisible = root.shouldBeVisible;
it.shouldBeVisibleChanged.connect(function () {
if (root.shouldBeVisible !== it.shouldBeVisible)
root.shouldBeVisible = it.shouldBeVisible;
});
it.shouldHaveFocus = root.shouldHaveFocus;
it.shouldHaveFocusChanged.connect(function () {
if (root.shouldHaveFocus !== it.shouldHaveFocus)
root.shouldHaveFocus = it.shouldHaveFocus;
});
it.opened.connect(root.opened);
it.dialogClosed.connect(root.dialogClosed);
it.backgroundClicked.connect(root.backgroundClicked);
if (it.modalFocusScope)
_modalFocusScope.parent = it.modalFocusScope;
@@ -166,4 +176,32 @@ Item {
impl.item.shouldHaveFocus = root.shouldHaveFocus;
}
}
Connections {
target: impl.item
ignoreUnknownSignals: true
function onShouldBeVisibleChanged() {
if (impl.item && root.shouldBeVisible !== impl.item.shouldBeVisible)
root.shouldBeVisible = impl.item.shouldBeVisible;
}
function onShouldHaveFocusChanged() {
if (impl.item && root.shouldHaveFocus !== impl.item.shouldHaveFocus)
root.shouldHaveFocus = impl.item.shouldHaveFocus;
}
function onOpened() {
root.opened();
}
function onDialogClosed() {
root.dialogClosed();
root._maybeResolveBackend();
}
function onBackgroundClicked() {
root.backgroundClicked();
}
}
}

View File

@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
@@ -7,6 +9,7 @@ import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankModalConnected")
property var modalHandle: root
property string layerNamespace: "dms:modal"
@@ -86,16 +89,22 @@ Item {
readonly property alias contentWindow: contentWindow
readonly property alias clickCatcher: clickCatcher
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground
readonly property bool useBackground: false
readonly property bool useSingleWindow: CompositorService.isHyprland
signal opened
signal dialogClosed
signal backgroundClicked
// Coalesce per-channel dirty bits; one ConnectedModeState write per tick.
Timer {
id: _syncTimer
interval: 0
onTriggered: root._flushSync()
}
property bool animationsEnabled: true
// ─── Connected chrome sync ────────────────────────────────────────────────
property string _chromeClaimId: ""
property bool _fullSyncPending: false
@@ -139,19 +148,37 @@ Item {
ConnectedModeState.releaseDockRetract(_chromeClaimId);
}
function _flushFullSync() {
_fullSyncPending = false;
_syncModalChromeState();
}
property bool _animSyncQueued: false
property bool _bodySyncQueued: false
function _queueFullSync() {
if (_fullSyncPending)
return;
_fullSyncPending = true;
Qt.callLater(() => {
if (root && typeof root._flushFullSync === "function")
root._flushFullSync();
});
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueAnimSync() {
_animSyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueBodySync() {
_bodySyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _flushSync() {
const fullDirty = _fullSyncPending;
const animDirty = _animSyncQueued;
const bodyDirty = _bodySyncQueued;
_fullSyncPending = false;
_animSyncQueued = false;
_bodySyncQueued = false;
if (fullDirty)
_syncModalChromeState();
if (animDirty)
_syncModalAnim();
if (bodyDirty)
_syncModalBody();
}
function _syncModalAnim() {
@@ -185,10 +212,10 @@ Item {
onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
onResolvedConnectedBarSideChanged: _queueFullSync()
onShouldBeVisibleChanged: _queueFullSync()
onAlignedXChanged: _syncModalBody()
onAlignedYChanged: _syncModalBody()
onAlignedWidthChanged: _syncModalBody()
onAlignedHeightChanged: _syncModalBody()
onAlignedXChanged: _queueBodySync()
onAlignedYChanged: _queueBodySync()
onAlignedWidthChanged: _queueBodySync()
onAlignedHeightChanged: _queueBodySync()
Component.onDestruction: _releaseModalChrome()
@@ -311,8 +338,6 @@ Item {
readonly property real shadowMotionPadding: {
if (Theme.isConnectedEffect)
return 0;
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0 && Theme.isDirectionalEffect)
return 0; // Wayland native overlap mask
if (animationType === "slide")
return 30;
if (Theme.isDirectionalEffect)
@@ -456,8 +481,8 @@ Item {
readonly property real s: Math.min(1, modalContainer.scaleValue)
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
blurWidth: (root.shouldBeVisible && animatedContent.opacity > 0 && !root.frameOwnsConnectedChrome) ? modalContainer.width * s : 0
blurHeight: (root.shouldBeVisible && animatedContent.opacity > 0 && !root.frameOwnsConnectedChrome) ? modalContainer.height * s : 0
blurWidth: (root.shouldBeVisible && !root.frameOwnsConnectedChrome) ? modalContainer.width * s : 0
blurHeight: (root.shouldBeVisible && !root.frameOwnsConnectedChrome) ? modalContainer.height * s : 0
blurRadius: root.effectiveCornerRadius
}
@@ -467,10 +492,10 @@ Item {
return WlrLayershell.Overlay;
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
@@ -586,8 +611,6 @@ Item {
}
return 0;
}
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect)
return 0;
if (slide && !directionalEffect && !depthEffect)
return 15;
if (directionalEffect) {
@@ -630,8 +653,6 @@ Item {
}
return 0;
}
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect)
return 0;
if (slide && !directionalEffect && !depthEffect)
return -30;
if (directionalEffect) {
@@ -666,43 +687,30 @@ Item {
return root.animationOffset;
}
property real animX: root.shouldBeVisible ? 0 : root.frozenMotionOffsetX
property real animY: root.shouldBeVisible ? 0 : root.frozenMotionOffsetY
readonly property real computedScaleCollapsed: root.animationScaleCollapsed
// openProgress: 0 = closed (at frozenMotionOffset, scaleCollapsed), 1 = open (at 0, scale 1).
QtObject {
id: morph
property real openProgress: root.shouldBeVisible ? 1 : 0
Behavior on openProgress {
enabled: root.animationsEnabled
NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
}
readonly property real animX: root.frozenMotionOffsetX * (1 - morph.openProgress)
readonly property real animY: root.frozenMotionOffsetY * (1 - morph.openProgress)
readonly property real scaleValue: computedScaleCollapsed + (1.0 - computedScaleCollapsed) * morph.openProgress
onAnimXChanged: if (root.frameOwnsConnectedChrome)
root._syncModalAnim()
root._queueAnimSync()
onAnimYChanged: if (root.frameOwnsConnectedChrome)
root._syncModalAnim()
readonly property real computedScaleCollapsed: (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) ? 0.0 : root.animationScaleCollapsed
property real scaleValue: root.shouldBeVisible ? 1.0 : computedScaleCollapsed
Behavior on animX {
enabled: root.animationsEnabled
NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on animY {
enabled: root.animationsEnabled
NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on scaleValue {
enabled: root.animationsEnabled
NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
root._queueAnimSync()
Item {
id: contentContainer
@@ -715,6 +723,9 @@ Item {
id: animatedContent
anchors.fill: parent
clip: false
property real publishedOpacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0)
opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0)
scale: modalContainer.scaleValue
transformOrigin: Item.Center
@@ -728,6 +739,15 @@ Item {
}
}
Behavior on publishedOpacity {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
NumberAnimation {
duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
ElevationShadow {
id: modalShadowLayer
anchors.fill: parent

View File

@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
@@ -7,6 +9,7 @@ import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankModalStandalone")
property var modalHandle: root
property string layerNamespace: "dms:modal"
@@ -241,8 +244,8 @@ Item {
readonly property real s: Math.min(1, modalContainer.scaleValue)
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
blurWidth: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.width * s : 0
blurHeight: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.height * s : 0
blurWidth: shouldBeVisible ? modalContainer.width * s : 0
blurHeight: shouldBeVisible ? modalContainer.height * s : 0
blurRadius: root.cornerRadius
}
@@ -252,10 +255,10 @@ Item {
return WlrLayershell.Overlay;
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
@@ -318,7 +321,8 @@ Item {
Behavior on opacity {
enabled: root.animationsEnabled
DankAnim {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
@@ -347,45 +351,22 @@ Item {
readonly property real offsetX: slide ? 15 : 0
readonly property real offsetY: slide ? -30 : root.animationOffset
property real animX: 0
property real animY: 0
property real scaleValue: root.animationScaleCollapsed
onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr)
onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr)
Connections {
target: root
function onShouldBeVisibleChanged() {
modalContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetX, root.dpr);
modalContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetY, root.dpr);
modalContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed;
// openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1).
QtObject {
id: morph
property real openProgress: root.shouldBeVisible ? 1 : 0
Behavior on openProgress {
enabled: root.animationsEnabled
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
}
Behavior on animX {
enabled: root.animationsEnabled
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on animY {
enabled: root.animationsEnabled
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on scaleValue {
enabled: root.animationsEnabled
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
readonly property real animX: modalContainer.offsetX * (1 - morph.openProgress)
readonly property real animY: modalContainer.offsetY * (1 - morph.openProgress)
readonly property real scaleValue: root.animationScaleCollapsed + (1.0 - root.animationScaleCollapsed) * morph.openProgress
Item {
id: contentContainer
@@ -398,6 +379,7 @@ Item {
id: animatedContent
anchors.fill: parent
clip: false
opacity: root.shouldBeVisible ? 1 : 0
scale: modalContainer.scaleValue
x: Theme.snap(modalContainer.animX, root.dpr) + (parent.width - width) * (1 - modalContainer.scaleValue) * 0.5

View File

@@ -9,6 +9,7 @@ import qs.Widgets
DankModal {
id: root
readonly property var log: Log.scoped("DankColorPickerModal")
layerNamespace: "dms:color-picker"
@@ -50,17 +51,17 @@ DankModal {
function toggle() {
if (shouldBeVisible) {
hide();
hide();
} else {
show();
show();
}
}
function toggleInstant() {
if (shouldBeVisible) {
hideInstant();
hideInstant();
} else {
show();
show();
}
}
@@ -111,7 +112,7 @@ DankModal {
hideInstant();
Proc.runCommand("dms-color-pick", ["dms", "color", "pick", "--json"], (output, exitCode) => {
if (exitCode !== 0) {
console.warn("dms color pick exited with code:", exitCode);
log.warn("dms color pick exited with code:", exitCode);
root.show();
return;
}
@@ -120,11 +121,11 @@ DankModal {
if (result.hex) {
applyPickedColor(result.hex);
} else {
console.warn("Failed to parse dms color pick output: missing hex");
log.warn("Failed to parse dms color pick output: missing hex");
root.show();
}
} catch (e) {
console.warn("Failed to parse dms color pick JSON:", e);
log.warn("Failed to parse dms color pick JSON:", e);
root.show();
}
}, 0, Proc.noTimeout);
@@ -142,39 +143,39 @@ DankModal {
onBackgroundClicked: hide()
IpcHandler {
function open(): string {
root.show();
return "COLOR_PICKER_MODAL_OPEN_SUCCESS";
}
function open(): string {
root.show();
return "COLOR_PICKER_MODAL_OPEN_SUCCESS";
}
function openColor(color: string): string {
root.selectedColor = Qt.color(color);
root.currentColor = Qt.color(color);
root.updateFromColor(Qt.color(color));
return open();
}
function openColor(color: string): string {
root.selectedColor = Qt.color(color);
root.currentColor = Qt.color(color);
root.updateFromColor(Qt.color(color));
return open();
}
function close(): string {
root.hide();
return "COLOR_PICKER_MODAL_CLOSE_SUCCESS";
}
function close(): string {
root.hide();
return "COLOR_PICKER_MODAL_CLOSE_SUCCESS";
}
function closeInstant(): string {
root.hideInstant();
return "COLOR_PICKER_MODAL_CLOSE_INSTANT_SUCCESS";
}
function closeInstant(): string {
root.hideInstant();
return "COLOR_PICKER_MODAL_CLOSE_INSTANT_SUCCESS";
}
function toggle(): string {
root.toggle();
return "COLOR_PICKER_MODAL_TOGGLE_SUCCESS";
}
function toggle(): string {
root.toggle();
return "COLOR_PICKER_MODAL_TOGGLE_SUCCESS";
}
function toggleInstant(): string {
root.toggleInstant();
return "COLOR_PICKER_MODAL_TOGGLE_INSTANT_SUCCESS";
}
function toggleInstant(): string {
root.toggleInstant();
return "COLOR_PICKER_MODAL_TOGGLE_INSTANT_SUCCESS";
}
target: "color-picker"
target: "color-picker"
}
content: Component {

View File

@@ -1442,7 +1442,8 @@ Item {
section: it.section || "",
isCore: it.isCore || false,
isBuiltInLauncher: it.isBuiltInLauncher || false,
pluginId: it.pluginId || ""
pluginId: it.pluginId || "",
source: it.source || ""
});
}
serializable.push({
@@ -1497,6 +1498,7 @@ Item {
isCore: it.isCore || false,
isBuiltInLauncher: it.isBuiltInLauncher || false,
pluginId: it.pluginId || "",
source: it.source || "",
data: {
id: it.id
},
@@ -1879,7 +1881,7 @@ Item {
function openTerminal(path) {
if (!path)
return;
var terminal = Quickshell.env("TERMINAL") || "xterm";
var terminal = SessionData.resolveTerminal() || "xterm";
Quickshell.execDetached({
command: [terminal],
workingDirectory: path

View File

@@ -101,6 +101,39 @@ function detectIconType(iconName) {
return "material";
}
function classifyAppSource(app) {
if (!app)
return "";
var execRaw = app.execString || app.exec || "";
if (!execRaw && !app.id)
return "";
var exec = execRaw.toLowerCase();
var cmd0 = (app.command && app.command.length > 0) ? String(app.command[0]).toLowerCase() : "";
var id = (app.id || "").toLowerCase();
if (cmd0 === "flatpak" || exec.indexOf("flatpak run ") !== -1)
return "flatpak";
if (cmd0 === "snap"
|| exec.indexOf("bamf_desktop_file_hint=") !== -1
|| exec.indexOf("/snap/bin/") !== -1
|| exec.indexOf("/snap/core") !== -1
|| exec.indexOf("snap run ") === 0)
return "snap";
if (/\.appimage(\s|$|")/i.test(execRaw) || id.indexOf("appimagekit_") === 0)
return "appimage";
if (exec.indexOf("/nix/store/") !== -1
|| exec.indexOf("/run/current-system/sw/") !== -1
|| exec.indexOf("/etc/profiles/per-user/") !== -1)
return "nix";
return "system";
}
function sortPluginIdsByOrder(pluginIds, order) {
if (!order || order.length === 0)
return pluginIds;

View File

@@ -1,8 +1,10 @@
import QtQuick
import qs.Common
import qs.Services
Item {
id: root
readonly property var log: Log.scoped("DankLauncherV2Modal")
readonly property bool spotlightOpen: impl.item ? impl.item.spotlightOpen : false
readonly property bool isClosing: impl.item ? impl.item.isClosing : false
@@ -20,6 +22,7 @@ Item {
readonly property real modalY: impl.item ? impl.item.modalY : 0
readonly property bool frameOwnsConnectedChrome: impl.item ? (impl.item.frameOwnsConnectedChrome ?? false) : false
readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : ""
readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false
signal dialogClosed
@@ -58,9 +61,31 @@ Item {
impl.item.toggleWithMode(mode);
}
readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
property var _resolvedBackend: null
Component.onCompleted: _resolvedBackend = _desiredBackend
Connections {
target: SettingsData
function onConnectedFrameModeActiveChanged() {
root._maybeResolveBackend();
}
}
// Defer Loader source-component swap until impl is fully closed; avoids
// tearing down the launcher mid-animation when frame mode is toggled.
function _maybeResolveBackend() {
if (_resolvedBackend === _desiredBackend)
return;
if (impl.item && (impl.item.spotlightOpen || impl.item.isClosing))
return;
_resolvedBackend = _desiredBackend;
}
Loader {
id: impl
sourceComponent: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
sourceComponent: root._resolvedBackend
onItemChanged: if (item)
root._wireBackend(item)
}
@@ -79,6 +104,15 @@ Item {
if (!it)
return;
it.modalHandle = root;
it.dialogClosed.connect(root.dialogClosed);
}
Connections {
target: impl.item
ignoreUnknownSignals: true
function onDialogClosed() {
root.dialogClosed();
root._maybeResolveBackend();
}
}
}

View File

@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
@@ -8,6 +10,7 @@ import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankLauncherV2ModalConnected")
property var modalHandle: root
@@ -72,6 +75,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== ""
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
function _dockOccupiesSide(side) {
if (!SettingsData.showDock)
@@ -109,10 +113,13 @@ Item {
{
const insetL = _frameEdgeInset("left");
const insetR = _frameEdgeInset("right");
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const usable = Math.max(0, screenWidth - insetL - insetR);
const usableH = Math.max(0, screenHeight - insetT - insetB);
return {
"x": insetL + Math.max(0, (usable - modalWidth) / 2),
"y": resolvedConnectedBarSide === "top" ? _frameEdgeInset("top") : screenHeight - modalHeight - _frameEdgeInset("bottom")
"y": launcherArcExtenderActive ? insetT + Math.max(0, (usableH - modalHeight) / 2) : (resolvedConnectedBarSide === "top" ? insetT : screenHeight - modalHeight - insetB)
};
}
case "left":
@@ -170,15 +177,34 @@ Item {
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
readonly property real alignedX: Theme.snap(modalX, dpr)
readonly property real alignedY: Theme.snap(modalY, dpr)
readonly property real _connectedChromeX: alignedX
readonly property real _connectedChromeY: {
if (!launcherArcExtenderActive)
return alignedY;
return resolvedConnectedBarSide === "top" ? Theme.snap(_frameEdgeInset("top"), dpr) : alignedY;
}
readonly property real _connectedChromeWidth: alignedWidth
readonly property real _connectedChromeHeight: {
if (!launcherArcExtenderActive)
return alignedHeight;
if (resolvedConnectedBarSide === "top")
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - _frameEdgeInset("top")), dpr);
if (resolvedConnectedBarSide === "bottom")
return Theme.snap(Math.max(alignedHeight, screenHeight - _frameEdgeInset("bottom") - alignedY), dpr);
return alignedHeight;
}
readonly property real contentSurfaceHeight: launcherArcExtenderActive ? _connectedChromeHeight : alignedHeight
// For directional/depth: window extends from screen top (content slides within)
// For standard: small window tightly around the modal + shadow padding
readonly property bool _needsExtendedWindow: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) || Theme.isDepthEffect
// Content window geometry
readonly property real _cwMarginLeft: Theme.snap(alignedX - shadowPad, dpr)
readonly property real _cwMarginTop: _needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr)
readonly property real _cwMarginTop: launcherArcExtenderActive ? _connectedChromeY : (_needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr))
readonly property real _cwWidth: alignedWidth + shadowPad * 2
readonly property real _cwHeight: {
if (launcherArcExtenderActive)
return _connectedChromeHeight;
if (Theme.isDirectionalEffect && !Theme.isConnectedEffect)
return screenHeight + shadowPad;
if (Theme.isDepthEffect)
@@ -187,11 +213,17 @@ Item {
}
// Where the content container sits inside the content window
readonly property real _ccX: shadowPad
readonly property real _ccY: _needsExtendedWindow ? alignedY : shadowPad
readonly property real _ccY: launcherArcExtenderActive ? 0 : (_needsExtendedWindow ? alignedY : shadowPad)
signal dialogClosed
// ─── Connected chrome sync ────────────────────────────────────────────────
// Coalesce per-channel dirty bits; one ConnectedModeState write per tick.
Timer {
id: _syncTimer
interval: 0
onTriggered: root._flushSync()
}
property string _chromeClaimId: ""
property bool _fullSyncPending: false
@@ -210,10 +242,10 @@ Item {
ConnectedModeState.setModalState(screenName, {
"visible": spotlightOpen || contentWindow.visible,
"barSide": resolvedConnectedBarSide,
"bodyX": alignedX,
"bodyY": alignedY,
"bodyW": alignedWidth,
"bodyH": alignedHeight,
"bodyX": _connectedChromeX,
"bodyY": _connectedChromeY,
"bodyW": _connectedChromeWidth,
"bodyH": _connectedChromeHeight,
"animX": contentContainer ? contentContainer.animX : 0,
"animY": contentContainer ? contentContainer.animY : 0,
"omitStartConnector": false,
@@ -235,19 +267,37 @@ Item {
ConnectedModeState.releaseDockRetract(_chromeClaimId);
}
function _flushFullSync() {
_fullSyncPending = false;
_syncModalChromeState();
}
property bool _animSyncQueued: false
property bool _bodySyncQueued: false
function _queueFullSync() {
if (_fullSyncPending)
return;
_fullSyncPending = true;
Qt.callLater(() => {
if (root && typeof root._flushFullSync === "function")
root._flushFullSync();
});
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueAnimSync() {
_animSyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueBodySync() {
_bodySyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _flushSync() {
const fullDirty = _fullSyncPending;
const animDirty = _animSyncQueued;
const bodyDirty = _bodySyncQueued;
_fullSyncPending = false;
_animSyncQueued = false;
_bodySyncQueued = false;
if (fullDirty)
_syncModalChromeState();
if (animDirty)
_syncModalAnim();
if (bodyDirty)
_syncModalBody();
}
function _syncModalAnim() {
@@ -265,7 +315,7 @@ Item {
const screenName = _currentScreenName();
if (!screenName)
return;
ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight);
ConnectedModeState.setModalBody(screenName, _connectedChromeX, _connectedChromeY, _connectedChromeWidth, _connectedChromeHeight);
}
function _releaseModalChrome() {
@@ -279,12 +329,13 @@ Item {
}
onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
onLauncherArcExtenderActiveChanged: _queueFullSync()
onResolvedConnectedBarSideChanged: _queueFullSync()
onSpotlightOpenChanged: _queueFullSync()
onAlignedXChanged: _syncModalBody()
onAlignedYChanged: _syncModalBody()
onAlignedWidthChanged: _syncModalBody()
onAlignedHeightChanged: _syncModalBody()
onAlignedXChanged: _queueBodySync()
onAlignedYChanged: _queueBodySync()
onAlignedWidthChanged: _queueBodySync()
onAlignedHeightChanged: _queueBodySync()
Component.onDestruction: _releaseModalChrome()
@@ -528,22 +579,26 @@ Item {
}
}
// ── Background window: fullscreen, handles darkening + click-to-dismiss ──
PanelWindow {
id: backgroundWindow
visible: false
color: "transparent"
readonly property real _topMargin: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0)
readonly property real _bottomMargin: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0)
readonly property real _leftMargin: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0)
readonly property real _rightMargin: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0)
WlrLayershell.namespace: "dms:spotlight:bg"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.margins {
top: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0)
bottom: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0)
left: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0)
right: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0)
top: backgroundWindow._topMargin
bottom: backgroundWindow._bottomMargin
left: backgroundWindow._leftMargin
right: backgroundWindow._rightMargin
}
anchors {
@@ -555,6 +610,11 @@ Item {
mask: Region {
item: (spotlightOpen || isClosing) ? bgFullScreenMask : null
Region {
item: bgContentHole
intersection: Intersection.Subtract
}
}
Item {
@@ -562,6 +622,15 @@ Item {
anchors.fill: parent
}
Item {
id: bgContentHole
visible: false
x: root._cwMarginLeft + contentContainer.x - backgroundWindow._leftMargin
y: root._cwMarginTop + contentContainer.y - backgroundWindow._topMargin
width: root.alignedWidth
height: root.contentSurfaceHeight
}
Rectangle {
id: backgroundDarken
anchors.fill: parent
@@ -571,7 +640,8 @@ Item {
Behavior on opacity {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
DankAnim {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve
}
@@ -585,7 +655,6 @@ Item {
}
}
// ── Content window: SMALL, positioned with margins — only renders the modal area ──
PanelWindow {
id: contentWindow
visible: false
@@ -597,8 +666,8 @@ Item {
readonly property real s: Math.min(1, contentContainer.scaleValue)
blurX: root._ccX + root.alignedWidth * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr)
blurY: root._ccY + root.alignedHeight * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr)
blurWidth: (root.spotlightOpen || root.isClosing) && contentWrapper.opacity > 0 && !root.frameOwnsConnectedChrome ? root.alignedWidth * s : 0
blurHeight: (root.spotlightOpen || root.isClosing) && contentWrapper.opacity > 0 && !root.frameOwnsConnectedChrome ? root.alignedHeight * s : 0
blurWidth: (root.spotlightOpen || root.isClosing) && !root.frameOwnsConnectedChrome ? root.alignedWidth * s : 0
blurHeight: (root.spotlightOpen || root.isClosing) && !root.frameOwnsConnectedChrome ? root.alignedHeight * s : 0
blurRadius: root.cornerRadius
}
@@ -606,10 +675,10 @@ Item {
WlrLayershell.layer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
console.error("DankLauncherV2Modal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
console.error("DankLauncherV2Modal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
@@ -640,10 +709,10 @@ Item {
Item {
id: contentInputMask
visible: false
x: contentContainer.x + contentWrapper.x
y: contentContainer.y + contentWrapper.y
x: contentContainer.x
y: contentContainer.y
width: root.alignedWidth
height: root.alignedHeight
height: root.contentSurfaceHeight
}
Item {
@@ -654,7 +723,7 @@ Item {
x: root._ccX
y: root._ccY
width: root.alignedWidth
height: root.alignedHeight
height: root.contentSurfaceHeight
readonly property int dockEdge: typeof SettingsData !== "undefined" ? SettingsData.dockPosition : 1
readonly property bool dockTop: dockEdge === 0
@@ -667,7 +736,7 @@ Item {
readonly property bool directionalEffect: Theme.isDirectionalEffect
readonly property bool depthEffect: Theme.isDepthEffect
readonly property real _connectedTravelX: Math.max(Theme.effectAnimOffset, root.alignedWidth + Theme.spacingL)
readonly property real _connectedTravelY: Math.max(Theme.effectAnimOffset, root.alignedHeight + Theme.spacingL)
readonly property real _connectedTravelY: root.launcherArcExtenderActive ? root._connectedChromeHeight : Math.max(Theme.effectAnimOffset, root.alignedHeight + Theme.spacingL)
readonly property real collapsedMotionX: {
if (root.frameOwnsConnectedChrome) {
switch (root.resolvedConnectedBarSide) {
@@ -710,43 +779,31 @@ Item {
return -Math.max((root.shadowPad || 0) + Theme.effectAnimOffset, 40);
}
// Declarative bindings — snap applied at render layer (contentWrapper x/y)
property real animX: root._motionActive ? 0 : root._frozenMotionX
property real animY: root._motionActive ? 0 : root._frozenMotionY
property real scaleValue: root._motionActive ? 1.0 : (Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 ? Theme.effectScaleCollapsed : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed))
// openProgress: 0 = closed (at frozenMotion, scaleCollapsed), 1 = open (at 0, scale 1).
QtObject {
id: morph
property real openProgress: root._motionActive ? 1 : 0
Behavior on openProgress {
enabled: root.animationsEnabled
DankAnim {
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
}
}
}
readonly property real animX: root._frozenMotionX * (1 - morph.openProgress)
readonly property real animY: root._frozenMotionY * (1 - morph.openProgress)
readonly property real scaleValue: Theme.effectScaleCollapsed + (1.0 - Theme.effectScaleCollapsed) * morph.openProgress
onAnimXChanged: if (root.frameOwnsConnectedChrome)
root._syncModalAnim()
root._queueAnimSync()
onAnimYChanged: if (root.frameOwnsConnectedChrome)
root._syncModalAnim()
Behavior on animX {
enabled: root.animationsEnabled
DankAnim {
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
}
}
Behavior on animY {
enabled: root.animationsEnabled
DankAnim {
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
}
}
Behavior on scaleValue {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2))
DankAnim {
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
}
}
root._queueAnimSync()
Item {
id: directionalClipMask
readonly property bool shouldClip: Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0
readonly property bool shouldClip: Theme.isDirectionalEffect
readonly property real clipOversize: 2000
clip: shouldClip
@@ -769,7 +826,7 @@ Item {
id: launcherShadowLayer
width: parent.width
height: parent.height
opacity: contentWrapper.opacity
opacity: contentWrapper.publishedOpacity
scale: contentWrapper.scale
x: contentWrapper.x
y: contentWrapper.y
@@ -787,20 +844,44 @@ Item {
id: contentWrapper
width: parent.width
height: parent.height
property bool _renderActive: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) || launcherMotionVisible
property real publishedOpacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (launcherMotionVisible ? 1 : 0)
opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (launcherMotionVisible ? 1 : 0)
visible: opacity > 0
visible: _renderActive
scale: contentContainer.scaleValue
x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
Behavior on opacity {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
DankAnim {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve
}
}
Behavior on publishedOpacity {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve
onRunningChanged: if (!running && contentWrapper.publishedOpacity === 0)
contentWrapper._renderActive = false
}
}
Connections {
target: root
function onLauncherMotionVisibleChanged() {
if (root.launcherMotionVisible)
contentWrapper._renderActive = true;
}
}
MouseArea {
anchors.fill: parent
onPressed: mouse => mouse.accepted = true

View File

@@ -8,6 +8,7 @@ import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankLauncherV2ModalStandalone")
property var modalHandle: root
@@ -61,6 +62,20 @@ Item {
readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100)
readonly property real modalX: (screenWidth - modalWidth) / 2
readonly property real modalY: (screenHeight - modalHeight) / 2
readonly property var shadowLevel: Theme.elevationLevel3
readonly property real shadowFallbackOffset: 6
readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr)
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
readonly property real alignedX: Theme.snap(modalX, dpr)
readonly property real alignedY: Theme.snap(modalY, dpr)
readonly property real windowX: Math.max(0, Theme.snap(alignedX - shadowPad, dpr))
readonly property real windowY: Math.max(0, Theme.snap(alignedY - shadowPad, dpr))
readonly property real contentX: Theme.snap(alignedX - windowX, dpr)
readonly property real contentY: Theme.snap(alignedY - windowY, dpr)
readonly property real windowWidth: alignedWidth + contentX + shadowPad
readonly property real windowHeight: alignedHeight + contentY + shadowPad
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property real cornerRadius: Theme.cornerRadius
@@ -278,37 +293,16 @@ Item {
}
PanelWindow {
id: launcherWindow
visible: spotlightOpen || isClosing
id: clickCatcher
screen: launcherWindow.screen
visible: spotlightOpen
color: "transparent"
exclusionMode: ExclusionMode.Ignore
updatesEnabled: false
WindowBlur {
targetWindow: launcherWindow
readonly property real s: Math.min(1, modalContainer.scale)
blurX: root.modalX + root.modalWidth * (1 - s) * 0.5
blurY: root.modalY + root.modalHeight * (1 - s) * 0.5
blurWidth: (contentVisible && modalContainer.opacity > 0) ? root.modalWidth * s : 0
blurHeight: (contentVisible && modalContainer.opacity > 0) ? root.modalHeight * s : 0
blurRadius: root.cornerRadius
}
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
WlrLayershell.namespace: "dms:spotlight:clickcatcher"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
anchors {
top: true
@@ -318,75 +312,154 @@ Item {
}
mask: Region {
item: spotlightOpen ? fullScreenMask : null
item: outsideClickMask
Region {
item: outsideClickHole
intersection: Intersection.Subtract
}
}
Item {
id: fullScreenMask
id: outsideClickMask
visible: false
anchors.fill: parent
}
Rectangle {
id: backgroundDarken
anchors.fill: parent
color: "black"
opacity: contentVisible && SettingsData.modalDarkenBackground ? 0.5 : 0
visible: contentVisible || opacity > 0
Behavior on opacity {
DankAnim {
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
id: outsideClickHole
visible: false
color: "transparent"
x: root.alignedX
y: root.alignedY
width: root.alignedWidth
height: root.alignedHeight
}
MouseArea {
anchors.fill: parent
enabled: spotlightOpen
onClicked: mouse => {
var contentX = modalContainer.x;
var contentY = modalContainer.y;
var contentW = modalContainer.width;
var contentH = modalContainer.height;
onClicked: root.hide()
}
}
if (mouse.x < contentX || mouse.x > contentX + contentW || mouse.y < contentY || mouse.y > contentY + contentH) {
root.hide();
}
PanelWindow {
id: launcherWindow
visible: spotlightOpen || isClosing
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WindowBlur {
targetWindow: launcherWindow
readonly property real s: Math.min(1, modalContainer.publishedScale)
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5
blurWidth: contentVisible ? modalContainer.width * s : 0
blurHeight: contentVisible ? modalContainer.height * s : 0
blurRadius: root.cornerRadius
}
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
top: true
left: true
}
WlrLayershell.margins {
left: root.windowX
top: root.windowY
right: 0
bottom: 0
}
implicitWidth: root.windowWidth
implicitHeight: root.windowHeight
mask: Region {
item: launcherInputMask
}
Rectangle {
id: launcherInputMask
visible: false
color: "transparent"
x: modalContainer.x
y: modalContainer.y
width: modalContainer.width
height: modalContainer.height
}
Item {
id: modalContainer
x: root.modalX
y: root.modalY
width: root.modalWidth
height: root.modalHeight
visible: contentVisible || opacity > 0
x: root.contentX
y: root.contentY
width: root.alignedWidth
height: root.alignedHeight
visible: _renderActive
property bool _renderActive: contentVisible
property real publishedScale: contentVisible ? 1 : 0.96
opacity: contentVisible ? 1 : 0
scale: contentVisible ? 1 : 0.96
transformOrigin: Item.Center
Behavior on opacity {
DankAnim {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
onRunningChanged: if (!running && !root.contentVisible)
modalContainer._renderActive = false
}
}
Behavior on scale {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
Behavior on scale {
DankAnim {
Behavior on publishedScale {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
Connections {
target: root
function onContentVisibleChanged() {
if (root.contentVisible)
modalContainer._renderActive = true;
}
}
ElevationShadow {
id: launcherShadowLayer
anchors.fill: parent
level: Theme.elevationLevel3
fallbackOffset: 6
level: root.shadowLevel
fallbackOffset: root.shadowFallbackOffset
targetColor: root.backgroundColor
borderColor: root.borderColor
borderWidth: root.borderWidth

View File

@@ -44,6 +44,15 @@ Rectangle {
cornerRadius: root.radius
}
SourceBadge {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Theme.spacingXS
source: root.item?.type === "app" ? (root.item.source || "") : ""
glyphSize: 14
z: 1
}
Column {
anchors.centerIn: parent
anchors.margins: Theme.spacingS

View File

@@ -27,6 +27,7 @@ function transformApp(app, override, defaultActions, primaryActionLabel) {
data: app,
keywords: app.keywords || [],
actions: actions,
source: Utils.classifyAppSource(app),
primaryAction: {
name: primaryActionLabel,
icon: "open_in_new",

View File

@@ -274,14 +274,16 @@ FocusScope {
Item {
id: footerBar
readonly property bool _connectedBottomEmerge: (root.parentModal?.frameOwnsConnectedChrome ?? false) && (root.parentModal?.resolvedConnectedBarSide === "bottom")
readonly property bool _connectedArcAtFooter: _connectedBottomEmerge && !(root.parentModal?.launcherArcExtenderActive ?? false)
readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: root.parentModal?.borderWidth ?? 1
anchors.rightMargin: root.parentModal?.borderWidth ?? 1
anchors.bottomMargin: root.parentModal?.borderWidth ?? 1
readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter
readonly property bool _connectedArcAtFooter: (root.parentModal?.frameOwnsConnectedChrome ?? false) && (root.parentModal?.resolvedConnectedBarSide === "bottom")
anchors.bottomMargin: _connectedBottomEmerge ? Theme.spacingM : (root.parentModal?.borderWidth ?? 1)
height: showFooter ? (_connectedArcAtFooter ? 76 : 36) : 0
visible: showFooter
clip: true

View File

@@ -372,7 +372,7 @@ Popup {
anchors.fill: parent
implicitWidth: Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2)
implicitHeight: menuColumn.implicitHeight + Theme.spacingS * 2
color: BlurService.enabled ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
color: Theme.floatingSurface
radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: BlurService.enabled ? BlurService.borderWidth : 1

View File

@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Widgets
import "../../Common/htmlElide.js" as HtmlElide
Rectangle {
id: root
@@ -72,125 +73,159 @@ Rectangle {
}
}
Row {
anchors.fill: parent
AppIconRenderer {
id: iconRenderer
width: 36
height: 36
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
iconValue: root.iconValue
iconSize: 36
fallbackText: (root.item?.name?.length > 0) ? root.item.name.charAt(0).toUpperCase() : "?"
materialIconSizeAdjustment: 12
}
Item {
id: textColumn
anchors.left: iconRenderer.right
anchors.leftMargin: Theme.spacingM
anchors.right: rightContent.left
anchors.rightMargin: rightContent.width > 0 ? Theme.spacingM : 0
anchors.verticalCenter: parent.verticalCenter
height: nameText.implicitHeight + (subText.visible ? subText.height + 2 : 0)
Text {
id: nameText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
text: root.item?._hName ?? root.item?.name ?? ""
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
font.family: Theme.fontFamily
color: Theme.surfaceText
wrapMode: Text.WordWrap
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignLeft
}
TextMetrics {
id: subProbe
font.pixelSize: Theme.fontSizeSmall
font.family: Theme.fontFamily
elide: Qt.ElideRight
elideWidth: textColumn.width
text: root.item?._hRich ? HtmlElide.stripHtmlTags(root.item?._hSub ?? "") : ""
}
readonly property int _richBudget: {
if (!subProbe.text)
return 0;
var e = subProbe.elidedText;
return e.endsWith("…") ? e.length - 1 : e.length;
}
Text {
id: subText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: nameText.bottom
anchors.topMargin: 2
text: root.item?._hRich ? HtmlElide.elideRichText(root.item._hSub ?? "", textColumn._richBudget) : (root.item?.subtitle ?? "")
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
font.pixelSize: Theme.fontSizeSmall
font.family: Theme.fontFamily
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
maximumLineCount: 1
elide: Text.ElideRight
visible: (root.item?.subtitle ?? "").length > 0
horizontalAlignment: Text.AlignLeft
}
}
Row {
id: rightContent
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
AppIconRenderer {
width: 36
height: 36
Rectangle {
id: allModeToggle
visible: root.item?.type === "plugin_browse"
width: 28
height: 28
radius: 14
anchors.verticalCenter: parent.verticalCenter
iconValue: root.iconValue
iconSize: 36
fallbackText: (root.item?.name?.length > 0) ? root.item.name.charAt(0).toUpperCase() : "?"
materialIconSizeAdjustment: 12
}
color: allModeToggleArea.containsMouse ? Theme.surfaceHover : "transparent"
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 36 - Theme.spacingM * 3 - rightContent.width
spacing: 2
Text {
width: parent.width
text: root.item?._hName ?? root.item?.name ?? ""
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
font.family: Theme.fontFamily
color: Theme.surfaceText
elide: Text.ElideRight
horizontalAlignment: Text.AlignLeft
property bool isAllowed: {
if (root.item?.type !== "plugin_browse")
return false;
var pluginId = root.item?.data?.pluginId;
if (!pluginId)
return false;
SettingsData.launcherPluginVisibility;
return SettingsData.getPluginAllowWithoutTrigger(pluginId);
}
Text {
width: parent.width
text: root.item?._hSub ?? root.item?.subtitle ?? ""
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
font.pixelSize: Theme.fontSizeSmall
font.family: Theme.fontFamily
color: Theme.surfaceVariantText
elide: Text.ElideRight
clip: true
visible: (root.item?.subtitle ?? "").length > 0
horizontalAlignment: Text.AlignLeft
DankIcon {
anchors.centerIn: parent
name: allModeToggle.isAllowed ? "visibility" : "visibility_off"
size: 18
color: allModeToggle.isAllowed ? Theme.primary : Theme.surfaceVariantText
}
}
Row {
id: rightContent
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
Rectangle {
id: allModeToggle
visible: root.item?.type === "plugin_browse"
width: 28
height: 28
radius: 14
anchors.verticalCenter: parent.verticalCenter
color: allModeToggleArea.containsMouse ? Theme.surfaceHover : "transparent"
property bool isAllowed: {
if (root.item?.type !== "plugin_browse")
return false;
MouseArea {
id: allModeToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var pluginId = root.item?.data?.pluginId;
if (!pluginId)
return false;
SettingsData.launcherPluginVisibility;
return SettingsData.getPluginAllowWithoutTrigger(pluginId);
}
DankIcon {
anchors.centerIn: parent
name: allModeToggle.isAllowed ? "visibility" : "visibility_off"
size: 18
color: allModeToggle.isAllowed ? Theme.primary : Theme.surfaceVariantText
}
MouseArea {
id: allModeToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var pluginId = root.item?.data?.pluginId;
if (!pluginId)
return;
SettingsData.setPluginAllowWithoutTrigger(pluginId, !allModeToggle.isAllowed);
}
return;
SettingsData.setPluginAllowWithoutTrigger(pluginId, !allModeToggle.isAllowed);
}
}
}
Rectangle {
visible: !!root.item?.type && root.item.type !== "app" && root.item.type !== "plugin_browse"
width: typeBadge.implicitWidth + Theme.spacingS * 2
height: 20
radius: 10
color: Theme.surfaceVariantAlpha
anchors.verticalCenter: parent.verticalCenter
Rectangle {
visible: !!root.item?.type && root.item.type !== "app" && root.item.type !== "plugin_browse"
width: typeBadge.implicitWidth + Theme.spacingS * 2
height: 20
radius: 10
color: Theme.surfaceVariantAlpha
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: typeBadge
anchors.centerIn: parent
text: {
if (!root.item)
return "";
switch (root.item.type) {
case "plugin":
return I18n.tr("Plugin");
case "file":
return root.item.data?.is_dir ? I18n.tr("Folder") : I18n.tr("File");
default:
return "";
}
StyledText {
id: typeBadge
anchors.centerIn: parent
text: {
if (!root.item)
return "";
switch (root.item.type) {
case "plugin":
return I18n.tr("Plugin");
case "file":
return root.item.data?.is_dir ? I18n.tr("Folder") : I18n.tr("File");
default:
return "";
}
font.pixelSize: Theme.fontSizeSmall - 2
color: Theme.surfaceVariantText
}
font.pixelSize: Theme.fontSizeSmall - 2
color: Theme.surfaceVariantText
}
}
SourceBadge {
anchors.verticalCenter: parent.verticalCenter
source: root.item?.type === "app" ? (root.item.source || "") : ""
glyphSize: 14
}
}
}

View File

@@ -58,9 +58,9 @@ Item {
item: items[i],
flatIndex: flatIdx,
sectionId: sectionId,
height: 52
height: 56
});
cumY += 52;
cumY += 56;
}
} else {
var cols = root.controller?.getGridColumns(sectionId) ?? root.gridColumns;
@@ -190,124 +190,135 @@ Item {
}
}
DankListView {
id: mainListView
Item {
id: listClip
anchors.fill: parent
anchors.topMargin: BlurService.enabled && stickyHeader.visible ? 32 : 0
clip: true
scrollBarTopMargin: (root.controller?.sections?.length > 0) ? 32 : 0
model: ScriptModel {
values: root._visualRows
objectProp: "_rowId"
}
DankListView {
id: mainListView
y: -listClip.anchors.topMargin
width: parent.width
height: parent.height + listClip.anchors.topMargin
clip: true
scrollBarTopMargin: (root.controller?.sections?.length > 0) ? 32 : 0
add: null
remove: null
displaced: null
move: null
delegate: Item {
id: delegateRoot
required property var modelData
required property int index
width: mainListView.width
height: modelData?.height ?? 52
SectionHeader {
anchors.fill: parent
visible: delegateRoot.modelData?.type === "header"
section: delegateRoot.modelData?.section ?? null
controller: root.controller
viewMode: {
var vt = root.controller?.viewModeVersion ?? 0;
void (vt);
return root.controller?.getSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? "list";
}
canChangeViewMode: {
var vt = root.controller?.viewModeVersion ?? 0;
void (vt);
return root.controller?.canChangeSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? false;
}
canCollapse: root.controller?.canCollapseSection(delegateRoot.modelData?.sectionId ?? "") ?? false
model: ScriptModel {
values: root._visualRows
objectProp: "_rowId"
}
ResultItem {
anchors.fill: parent
visible: delegateRoot.modelData?.type === "list_item"
item: delegateRoot.modelData?.type === "list_item" ? (delegateRoot.modelData?.item ?? null) : null
isSelected: delegateRoot.modelData?.type === "list_item" && (delegateRoot.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
controller: root.controller
flatIndex: delegateRoot.modelData?.type === "list_item" ? (delegateRoot.modelData?.flatIndex ?? -1) : -1
add: null
remove: null
displaced: null
move: null
onClicked: {
if (root.controller && delegateRoot.modelData?.item) {
root.controller.executeItem(delegateRoot.modelData.item);
delegate: Item {
id: delegateRoot
required property var modelData
required property int index
width: mainListView.width
height: modelData?.height ?? 52
SectionHeader {
anchors.fill: parent
visible: delegateRoot.modelData?.type === "header"
section: delegateRoot.modelData?.section ?? null
controller: root.controller
viewMode: {
var vt = root.controller?.viewModeVersion ?? 0;
void (vt);
return root.controller?.getSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? "list";
}
canChangeViewMode: {
var vt = root.controller?.viewModeVersion ?? 0;
void (vt);
return root.controller?.canChangeSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? false;
}
canCollapse: root.controller?.canCollapseSection(delegateRoot.modelData?.sectionId ?? "") ?? false
}
ResultItem {
anchors.fill: parent
anchors.topMargin: 2
anchors.bottomMargin: 2
visible: delegateRoot.modelData?.type === "list_item"
item: delegateRoot.modelData?.type === "list_item" ? (delegateRoot.modelData?.item ?? null) : null
isSelected: delegateRoot.modelData?.type === "list_item" && (delegateRoot.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
controller: root.controller
flatIndex: delegateRoot.modelData?.type === "list_item" ? (delegateRoot.modelData?.flatIndex ?? -1) : -1
onClicked: {
if (root.controller && delegateRoot.modelData?.item) {
root.controller.executeItem(delegateRoot.modelData.item);
}
}
onRightClicked: (mouseX, mouseY) => {
root.itemRightClicked(delegateRoot.modelData?.flatIndex ?? -1, delegateRoot.modelData?.item ?? null, mouseX, mouseY);
}
}
onRightClicked: (mouseX, mouseY) => {
root.itemRightClicked(delegateRoot.modelData?.flatIndex ?? -1, delegateRoot.modelData?.item ?? null, mouseX, mouseY);
}
}
Row {
id: gridRowContent
anchors.fill: parent
visible: delegateRoot.modelData?.type === "grid_row"
Row {
id: gridRowContent
anchors.fill: parent
visible: delegateRoot.modelData?.type === "grid_row"
Repeater {
model: delegateRoot.modelData?.type === "grid_row" ? (delegateRoot.modelData?.items ?? []) : []
Repeater {
model: delegateRoot.modelData?.type === "grid_row" ? (delegateRoot.modelData?.items ?? []) : []
Item {
id: gridCellDelegate
required property var modelData
required property int index
Item {
id: gridCellDelegate
required property var modelData
required property int index
readonly property real cellWidth: delegateRoot.modelData?.viewMode === "tile" ? Math.floor(delegateRoot.width / 3) : Math.floor(delegateRoot.width / (delegateRoot.modelData?.cols ?? root.gridColumns))
readonly property real cellWidth: delegateRoot.modelData?.viewMode === "tile" ? Math.floor(delegateRoot.width / 3) : Math.floor(delegateRoot.width / (delegateRoot.modelData?.cols ?? root.gridColumns))
width: cellWidth
height: delegateRoot.height
width: cellWidth
height: delegateRoot.height
GridItem {
width: parent.width - 4
height: parent.height - 4
anchors.centerIn: parent
visible: delegateRoot.modelData?.viewMode === "grid"
item: gridCellDelegate.modelData?.item ?? null
isSelected: (gridCellDelegate.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
controller: root.controller
flatIndex: gridCellDelegate.modelData?.flatIndex ?? -1
GridItem {
width: parent.width - 4
height: parent.height - 4
anchors.centerIn: parent
visible: delegateRoot.modelData?.viewMode === "grid"
item: gridCellDelegate.modelData?.item ?? null
isSelected: (gridCellDelegate.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
controller: root.controller
flatIndex: gridCellDelegate.modelData?.flatIndex ?? -1
onClicked: {
if (root.controller && gridCellDelegate.modelData?.item) {
root.controller.executeItem(gridCellDelegate.modelData.item);
}
}
onClicked: {
if (root.controller && gridCellDelegate.modelData?.item) {
root.controller.executeItem(gridCellDelegate.modelData.item);
onRightClicked: (mouseX, mouseY) => {
root.itemRightClicked(gridCellDelegate.modelData?.flatIndex ?? -1, gridCellDelegate.modelData?.item ?? null, mouseX, mouseY);
}
}
onRightClicked: (mouseX, mouseY) => {
root.itemRightClicked(gridCellDelegate.modelData?.flatIndex ?? -1, gridCellDelegate.modelData?.item ?? null, mouseX, mouseY);
}
}
TileItem {
width: parent.width - 4
height: parent.height - 4
anchors.centerIn: parent
visible: delegateRoot.modelData?.viewMode === "tile"
item: gridCellDelegate.modelData?.item ?? null
isSelected: (gridCellDelegate.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
controller: root.controller
flatIndex: gridCellDelegate.modelData?.flatIndex ?? -1
TileItem {
width: parent.width - 4
height: parent.height - 4
anchors.centerIn: parent
visible: delegateRoot.modelData?.viewMode === "tile"
item: gridCellDelegate.modelData?.item ?? null
isSelected: (gridCellDelegate.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
controller: root.controller
flatIndex: gridCellDelegate.modelData?.flatIndex ?? -1
onClicked: {
if (root.controller && gridCellDelegate.modelData?.item) {
root.controller.executeItem(gridCellDelegate.modelData.item);
onClicked: {
if (root.controller && gridCellDelegate.modelData?.item) {
root.controller.executeItem(gridCellDelegate.modelData.item);
}
}
}
onRightClicked: (mouseX, mouseY) => {
root.itemRightClicked(gridCellDelegate.modelData?.flatIndex ?? -1, gridCellDelegate.modelData?.item ?? null, mouseX, mouseY);
onRightClicked: (mouseX, mouseY) => {
root.itemRightClicked(gridCellDelegate.modelData?.flatIndex ?? -1, gridCellDelegate.modelData?.item ?? null, mouseX, mouseY);
}
}
}
}
@@ -365,7 +376,7 @@ Item {
anchors.top: parent.top
height: 32
z: 101
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
color: Theme.floatingSurface
visible: stickyHeaderSection !== null
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0

View File

@@ -50,7 +50,7 @@ Item {
id: listComponent
Column {
spacing: 2
spacing: 4
width: contentLoader.width
Repeater {

View File

@@ -0,0 +1,32 @@
import QtQuick
import Quickshell.Widgets
Item {
id: root
property string source: ""
property int glyphSize: 14
readonly property var sourceAsset: ({
"flatpak": "../../assets/package-sources/flatpak.svg",
"snap": "../../assets/package-sources/snap.svg",
"appimage": "../../assets/package-sources/appimage.svg",
"nix": "../../assets/package-sources/nix.svg"
})
readonly property string assetPath: sourceAsset[source] || ""
visible: assetPath.length > 0
implicitWidth: glyphSize
implicitHeight: glyphSize
IconImage {
anchors.fill: parent
source: root.assetPath ? Qt.resolvedUrl(root.assetPath) : ""
implicitSize: root.glyphSize * 2
backer.sourceSize: Qt.size(root.glyphSize * 2, root.glyphSize * 2)
smooth: true
mipmap: true
asynchronous: true
}
}

View File

@@ -168,6 +168,15 @@ Rectangle {
mipmap: true
}
}
SourceBadge {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Theme.spacingXS
source: root.item?.type === "app" ? (root.item.source || "") : ""
glyphSize: 16
visible: !root.isSelected && !!source
}
}
}

View File

@@ -158,6 +158,13 @@ FocusScope {
selectedFileIsDir = isDir;
}
function openItemContextMenu(sender, localX, localY, path, name, isDir) {
if (!sender)
return;
const pos = sender.mapToItem(root, localX, localY);
itemContextMenu.showAt(root, pos.x, pos.y, path, name, isDir);
}
function navigateUp() {
const path = currentPath;
if (path === homeDir)
@@ -759,6 +766,9 @@ FocusScope {
onItemSelected: (index, path, name, isDir) => {
setSelectedFileData(path, name, isDir);
}
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
}
Connections {
function onKeyboardSelectionRequestedChanged() {
@@ -817,6 +827,9 @@ FocusScope {
onItemSelected: (index, path, name, isDir) => {
setSelectedFileData(path, name, isDir);
}
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
}
Connections {
function onKeyboardSelectionRequestedChanged() {
@@ -917,4 +930,9 @@ FocusScope {
}
}
}
FileBrowserItemContextMenu {
id: itemContextMenu
parentFocusItem: root
}
}

View File

@@ -19,6 +19,7 @@ StyledRect {
signal itemClicked(int index, string path, string name, bool isDir)
signal itemSelected(int index, string path, string name, bool isDir)
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
function getFileExtension(fileName) {
const parts = fileName.split('.');
@@ -107,11 +108,11 @@ StyledRect {
const size = _thumbnailPx;
const fp = delegateRoot.filePath;
Paths.mkdir(thumbDir);
Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) {
Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) {
if (exitCode === 0) {
_videoThumb = thumbPath;
} else {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function(output, exitCode) {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function (output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
});
@@ -246,8 +247,16 @@ StyledRect {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
switch (mouse.button) {
case Qt.LeftButton:
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
break;
case Qt.RightButton:
itemContextMenuRequested(delegateRoot, mouse.x, mouse.y, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
break;
}
}
}
}

View File

@@ -0,0 +1,153 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Popup {
id: root
property string filePath: ""
property string fileName: ""
property bool fileIsDir: false
property var parentFocusItem: null
signal trashed
signal menuClosed
readonly property var menuItems: [
{
text: I18n.tr("Move to Trash"),
icon: "delete",
action: trashItem,
enabled: filePath.length > 0,
dangerous: true
},
{
text: I18n.tr("Copy Path"),
icon: "content_copy",
action: copyPath,
enabled: filePath.length > 0
}
]
function showAt(parentItem, localX, localY, path, name, isDir) {
if (!parentItem)
return;
parent = parentItem;
filePath = path || "";
fileName = name || "";
fileIsDir = !!isDir;
x = Math.max(0, Math.min(parentItem.width - width, localX));
y = Math.max(0, Math.min(parentItem.height - height, localY));
open();
}
function trashItem() {
if (!filePath)
return;
TrashService.trashPath(filePath, ok => {
if (ok)
root.trashed();
});
close();
}
function copyPath() {
if (!filePath)
return;
Quickshell.execDetached(["dms", "cl", "copy", filePath]);
close();
}
width: 220
height: menuColumn.implicitHeight + Theme.spacingS * 2
padding: 0
modal: false
closePolicy: Popup.CloseOnEscape
onClosed: {
closePolicy = Popup.CloseOnEscape;
menuClosed();
if (parentFocusItem)
Qt.callLater(() => parentFocusItem.forceActiveFocus());
}
onOpened: outsideClickTimer.start()
Timer {
id: outsideClickTimer
interval: 100
onTriggered: root.closePolicy = Popup.CloseOnEscape | Popup.CloseOnPressOutside
}
background: Rectangle {
color: "transparent"
}
contentItem: Rectangle {
color: Theme.floatingSurface
radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: BlurService.enabled ? BlurService.borderWidth : 1
Column {
id: menuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: 1
Repeater {
model: root.menuItems
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadius
opacity: modelData.enabled ? 1 : 0.5
color: {
if (!modelData.enabled || !area.containsMouse)
return "transparent";
if (modelData.dangerous)
return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12);
return BlurService.hoverColor(Theme.widgetBaseHoverColor);
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: modelData.icon
size: 16
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: modelData.text
font.pixelSize: Theme.fontSizeSmall
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
elide: Text.ElideRight
}
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
enabled: modelData.enabled
cursorShape: Qt.PointingHandCursor
onClicked: modelData.action()
}
}
}
}
}
}

View File

@@ -18,6 +18,7 @@ StyledRect {
signal itemClicked(int index, string path, string name, bool isDir)
signal itemSelected(int index, string path, string name, bool isDir)
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
function getFileExtension(fileName) {
const parts = fileName.split('.');
@@ -102,11 +103,11 @@ StyledRect {
const thumbPath = videoThumbnailPath;
const fp = listDelegateRoot.filePath;
Paths.mkdir(_xdgCacheHome + "/thumbnails/normal");
Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) {
Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) {
if (exitCode === 0) {
_videoThumb = thumbPath;
} else {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function(output, exitCode) {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function (output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
});
@@ -251,8 +252,16 @@ StyledRect {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
switch (mouse.button) {
case Qt.LeftButton:
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
break;
case Qt.RightButton:
itemContextMenuRequested(listDelegateRoot, mouse.x, mouse.y, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
break;
}
}
}
}

View File

@@ -1,5 +1,4 @@
import QtQuick
import QtCore
import Quickshell.Io
import qs.Common
import qs.Widgets
@@ -22,9 +21,9 @@ Rectangle {
onShowFileInfoChanged: {
if (showFileInfo && currentFileName && currentPath) {
const fullPath = currentPath + "/" + currentFileName
fileStatProcess.selectedFilePath = fullPath
fileStatProcess.running = true
const fullPath = currentPath + "/" + currentFileName;
fileStatProcess.selectedFilePath = fullPath;
fileStatProcess.running = true;
}
}
@@ -38,14 +37,14 @@ Rectangle {
stdout: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
const parts = text.trim().split('|')
const parts = text.trim().split('|');
if (parts.length >= 4) {
fileStatProcess.fileStats = {
"modifiedTime": parts[0],
"permissions": parts[1],
"size": parseInt(parts[2]) || 0,
"fullPath": parts[3]
}
};
}
}
}
@@ -60,31 +59,31 @@ Rectangle {
onCurrentFileNameChanged: {
if (showFileInfo && currentFileName && currentPath) {
const fullPath = currentPath + "/" + currentFileName
const fullPath = currentPath + "/" + currentFileName;
if (fullPath !== fileStatProcess.selectedFilePath) {
fileStatProcess.selectedFilePath = fullPath
fileStatProcess.running = true
fileStatProcess.selectedFilePath = fullPath;
fileStatProcess.running = true;
}
}
}
function updateFileInfo(filePath, fileName, isDirectory) {
if (filePath && filePath !== fileStatProcess.selectedFilePath) {
fileStatProcess.selectedFilePath = filePath
currentFileName = fileName || ""
currentFileIsDir = isDirectory || false
fileStatProcess.selectedFilePath = filePath;
currentFileName = fileName || "";
currentFileIsDir = isDirectory || false;
let ext = ""
let ext = "";
if (!isDirectory && fileName) {
const lastDot = fileName.lastIndexOf('.')
const lastDot = fileName.lastIndexOf('.');
if (lastDot > 0) {
ext = fileName.substring(lastDot + 1).toLowerCase()
ext = fileName.substring(lastDot + 1).toLowerCase();
}
}
currentFileExtension = ext
currentFileExtension = ext;
if (showFileInfo) {
fileStatProcess.running = true
fileStatProcess.running = true;
}
}
}
@@ -100,10 +99,10 @@ Rectangle {
"permissions": "",
"extension": "",
"position": "N/A"
}
};
}
const hasValidFile = currentFileName !== ""
const hasValidFile = currentFileName !== "";
return {
"exists": hasValidFile,
"name": hasValidFile ? currentFileName : "Loading...",
@@ -113,7 +112,7 @@ Rectangle {
"permissions": fileStatProcess.fileStats ? fileStatProcess.fileStats.permissions : "Loading...",
"extension": currentFileExtension,
"position": sourceFolderModel ? ((selectedIndex + 1) + " of " + sourceFolderModel.count) : "N/A"
}
};
}
Column {
@@ -209,23 +208,23 @@ Rectangle {
function formatFileSize(bytes) {
if (bytes === 0 || !bytes) {
return "0 B"
return "0 B";
}
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDateTime(dateTimeString) {
if (!dateTimeString) {
return "Unknown"
return "Unknown";
}
const parts = dateTimeString.split(' ')
const parts = dateTimeString.split(' ');
if (parts.length >= 2) {
return parts[0] + " " + parts[1].split('.')[0]
return parts[0] + " " + parts[1].split('.')[0];
}
return dateTimeString
return dateTimeString;
}
Behavior on opacity {

View File

@@ -1,10 +1,12 @@
import QtQuick
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("GreeterDoctorPage")
property bool isRunning: false
property bool hasRun: false
@@ -59,26 +61,22 @@ Item {
border.color: Theme.primary
opacity: 0
SequentialAnimation on opacity {
OpacityAnimator on opacity {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
}
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
}
SequentialAnimation on scale {
ScaleAnimator on scale {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 0.5
to: 1.5
duration: 1500
easing.type: Easing.OutQuad
}
from: 0.5
to: 1.5
duration: 1500
easing.type: Easing.OutQuad
}
}
@@ -93,26 +91,22 @@ Item {
border.color: Theme.secondary
opacity: 0
SequentialAnimation on opacity {
OpacityAnimator on opacity {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
}
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
}
SequentialAnimation on scale {
ScaleAnimator on scale {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 0.3
to: 1.3
duration: 1500
easing.type: Easing.OutQuad
}
from: 0.3
to: 1.3
duration: 1500
easing.type: Easing.OutQuad
}
}
@@ -228,9 +222,7 @@ Item {
text: {
if (root.errorCount === 0)
return I18n.tr("All checks passed", "greeter doctor page success");
return root.errorCount === 1
? I18n.tr("%1 issue found", "greeter doctor page error count").arg(root.errorCount)
: I18n.tr("%1 issues found", "greeter doctor page error count").arg(root.errorCount);
return root.errorCount === 1 ? I18n.tr("%1 issue found", "greeter doctor page error count").arg(root.errorCount) : I18n.tr("%1 issues found", "greeter doctor page error count").arg(root.errorCount);
}
font.pixelSize: Theme.fontSizeMedium
color: root.errorCount > 0 ? Theme.error : Theme.surfaceVariantText
@@ -412,7 +404,7 @@ Item {
else
root.selectedFilter = "ok";
} catch (e) {
console.error("GreeterDoctorPage: Failed to parse doctor output:", e);
log.error("Failed to parse doctor output:", e);
}
}
}

View File

@@ -7,6 +7,7 @@ import qs.Widgets
FloatingWindow {
id: root
readonly property var log: Log.scoped("GreeterModal")
property bool disablePopupTransparency: true
property int currentPage: 0
@@ -105,7 +106,7 @@ FloatingWindow {
root.cheatsheetData = JSON.parse(trimmed);
root.cheatsheetLoaded = true;
} catch (e) {
console.warn("Greeter: Failed to parse cheatsheet:", e);
log.warn("Greeter: Failed to parse cheatsheet:", e);
}
}
}

View File

@@ -9,6 +9,7 @@ import qs.Widgets
FloatingWindow {
id: processListModal
readonly property var log: Log.scoped("ProcessListModal")
property bool disablePopupTransparency: true
property int currentTab: 0
@@ -22,7 +23,7 @@ FloatingWindow {
function show() {
if (!DgopService.dgopAvailable) {
console.warn("ProcessListModal: dgop is not available");
log.warn("dgop is not available");
return;
}
visible = true;
@@ -36,7 +37,7 @@ FloatingWindow {
function toggle() {
if (!DgopService.dgopAvailable) {
console.warn("ProcessListModal: dgop is not available");
log.warn("dgop is not available");
return;
}
visible = !visible;
@@ -44,7 +45,7 @@ FloatingWindow {
function focusOrToggle() {
if (!DgopService.dgopAvailable) {
console.warn("ProcessListModal: dgop is not available");
log.warn("dgop is not available");
return;
}
if (visible) {

View File

@@ -112,7 +112,9 @@ FocusScope {
focus: active
sourceComponent: Component {
DockTab {}
DockTab {
parentModal: root.parentModal
}
}
onActiveChanged: {
@@ -218,7 +220,9 @@ FocusScope {
visible: active
focus: active
sourceComponent: ThemeColorsTab {}
sourceComponent: ThemeColorsTab {
parentModal: root.parentModal
}
onActiveChanged: {
if (active && item)

Some files were not shown because too many files have changed in this diff Show More