1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-05 04:58:30 -04:00

Compare commits

..

133 Commits

Author SHA1 Message Date
bbedward accdd0972c popout: fix lifecycle of blur with various popouts
fixes #2901
port 1.5

(cherry picked from commit 93c3030a1e)
2026-07-21 21:47:06 +00:00
bbedward 00c28f69fd wallpaper: use filter chips for rando transitions 2026-07-21 11:45:18 -04:00
bbedward 2e0337b68c lock: avoid lock surface churn
related #2885
port 1.5

(cherry picked from commit 30d9a34011)
2026-07-21 14:51:07 +00:00
bbedward 4975960ea4 compositor/toplevels: filter on screen for generic compositors, without
workspace distinction

fixes #2890
port 1.5

(cherry picked from commit f0bdaa7f72)
2026-07-21 14:27:43 +00:00
bbedward 0381a112b3 polkit: fix DMS_DISABLE_POLKIT regression from qs 0.2 deprecation commit
fixes #2897
port 1.5

(cherry picked from commit 68e60f036d)
2026-07-20 20:08:20 +00:00
bbedward ed5e2a22c0 audio: revert wpctl workaround
related #2899
port 1.5

(cherry picked from commit 70ceee139b)
2026-07-20 19:19:45 +00:00
bbedward 847718459c lock: fix dpms fade surface getting stuck after unlock
port 1.5

(cherry picked from commit 116cef0b3a)
2026-07-20 14:40:30 +00:00
bbedward 95b120b475 systray: fix menu positioning on vbars
fixes #2818
port 1.5

(cherry picked from commit 1402d2312a)
2026-07-19 20:37:56 +00:00
bbedward 4dd2f1d5be matugen: add config dirs to detection of presence
related #2894
port 1.5

(cherry picked from commit 57fd39d84d)
2026-07-19 16:45:46 +00:00
bbedward bf5faf725b mango: suppress excessive reload messages
fixes #2848
port 1.5

(cherry picked from commit 6239e5a8e4)
2026-07-19 16:45:44 +00:00
bbedward e5eff919b8 matugen: remove padding 0 from gtk light
fixes #2884
port 1.5

(cherry picked from commit 2a8b88c1f0)
2026-07-19 15:38:19 +00:00
bbedward 606115748a theme: put full theme catalog in neovim dropdown
fixes #2892
port 1.5

(cherry picked from commit dbaf2e922c)
2026-07-19 15:21:42 +00:00
bbedward ad936bfdc9 dash: fix color tokens for placeholder in calendar, and weather failed
fixes #2891
port 1.5

(cherry picked from commit eb330f62e1)
2026-07-19 15:18:40 +00:00
bbedward 110396cb2a powermenu: don't require key release for actions other than suspend and
hibernate

port 1.5

(cherry picked from commit 2846363f55)
2026-07-18 19:31:08 +00:00
bbedward 7b08e61863 matugen/neovim: respect terminalsAlwaysDark
port 1.5

(cherry picked from commit 4a90bd3fb1)
2026-07-18 15:52:47 +00:00
bbedward 876ead665b lock: add resilience for when the lock surface is rejected
related #2778
related #2841

port 1.5

(cherry picked from commit 698b66cf91)
2026-07-18 15:51:52 +00:00
dms-ci[bot] 74896fb87c bump VERSION to v1.5.2 2026-07-18 02:18:37 +00:00
bbedward f1f528b2cb widgets: fix hidden widgets disrupting index when arranging widgets
fixes #2844
port 1.5

(cherry picked from commit 5dfd875b9e)
2026-07-18 01:10:36 +00:00
purian23 9853e1431b fix(frame): restore frame edge inset states regression in framemode
Port 1.5

(cherry picked from commit 00b22df802)
2026-07-18 00:25:07 +00:00
bbedward e7b8196a4a i18n: preserve terms for 1.5 point releases
port 1.5

(cherry picked from commit 4200270b55)
2026-07-17 22:39:56 +00:00
purian23 85ebb402cb feat(nix): opt-in dankshell-u2f PAM service for lock screen security keys
- Updated instructional pushed:
https://danklinux.com/docs

Related: #2874
Port 1.5

(cherry picked from commit c2c180d304)
2026-07-17 22:07:23 +00:00
purian23 f37f4a1f35 refactor(auth): relay on distro system auth over DMS managed sessions
- Gate greeter external-auth status fprint availability only on confirmed setups
- Reload dankshell-U2F/U2F-Key watchers live

Related: #2874
Port 1.5

(cherry picked from commit 3938e60ce4)
2026-07-17 17:54:36 -04:00
bbedward fc11dfbc57 media: further optimizations for bar widget animations
related #2863
port 1.5

(cherry picked from commit 2986e354e8)
2026-07-17 19:49:25 +00:00
Hec 7806cf4a58 systemlogo: Add Zirconium (#2881)
Signed-off-by: Hec <hec@heccraft.com>
(cherry picked from commit ed44d02202)
2026-07-17 15:41:30 -04:00
bbedward 89895da4fa lock: add lockAndOutputsOff IPC function
(cherry picked from commit bf12665adb)
2026-07-17 15:25:04 -04:00
bbedward 4ff93bf46e screenshot: initial support for scroll capture mode
fixes #2871
port 1.5

(cherry picked from commit f8fe16738e)
2026-07-17 17:32:12 +00:00
purian23 63ffd7caec feat(notifications): introduce notification foreground layers settings override
port 1.5

(cherry picked from commit 3c688cfbd3)
2026-07-17 17:17:34 +00:00
purian23 dac08b34c6 feat(settings): add explicit border toggle and updated layouts
- Defaults On with blur enabled to clean up the border edges
- Darken Modal moves to Launcher settings

Closes #2446
port 1.5

(cherry picked from commit 92ba2bb57d)
2026-07-17 16:55:21 +00:00
bbedward 840a448b4a wallpaper: optimize blurred wallpaper layer
port 1.5

(cherry picked from commit 504c132f78)
2026-07-17 15:41:25 +00:00
bbedward 26d429fa31 fix(network): cancel in-flight connection when setting autoconnect
fixes #2882
port 1.5

(cherry picked from commit bdaa6bcc9d)
2026-07-17 15:01:36 +00:00
bbedward fd20cce9b7 sysupdate: tweak styling of ignored banner
port 1.5

(cherry picked from commit a50bb2cab3)
2026-07-17 14:32:55 +00:00
bbedward ad5a96d95b sysupdate: disallow partial updates on arch, add ignored to popout
port 1.5

(cherry picked from commit b17a90a412)
2026-07-17 14:19:41 +00:00
bbedward 8ba5e1a082 sysupdate: pass custom command to daemon instead of fire and forget from
QML

related #2873

port 1.5

(cherry picked from commit 0ffec5011b)
2026-07-17 13:46:54 +00:00
bbedward 7d6a7bae06 bar: fix bar order-creation regression caused by async loading
port 1.5

(cherry picked from commit 95e1d6b72e)
2026-07-17 13:17:40 +00:00
purian23 986a6e0230 fix(DankSlideout): update textureSize for improved rendering
- Fixes #2880
Port 1.5

(cherry picked from commit 4cb09dcc1b)
2026-07-17 12:24:58 +00:00
bbedward 64ba1fbb1b launcher: fix sticky header appearance without blur
port 1.5

(cherry picked from commit 6c45413d7a)
2026-07-16 14:51:10 +00:00
bbedward ab9df791b3 launcher: transparent sticky header when blur is enabled
port 1.5

(cherry picked from commit 149e1e64b1)
2026-07-16 14:48:27 +00:00
bbedward 65f4a9a044 dash/weather: fix X wheel scroll
port 1.5

(cherry picked from commit 5589cfb783)
2026-07-16 14:41:44 +00:00
bbedward 59164cbd30 popout: guard against bad state and reset input method on close
port 1.5

(cherry picked from commit 3951cd6b18)
2026-07-16 14:22:28 +00:00
bbedward 7ab1f6a956 core: fix log file deadlock
port 1.5

(cherry picked from commit 9686f29246)
2026-07-16 14:09:28 +00:00
bbedward 841167ac44 launcher: fix usage history loading by adding guards
port 1.5

(cherry picked from commit 6d206c9521)
2026-07-16 13:30:36 +00:00
purian23 c2a6a7aebd feat(auth): expand shared PAM support for custom policies
- Add support for DMS and System managed factor policies
- Add validated dedicated security-key PAM / U2F Key sources
- Restore U2F OR/AND flows

Related: #2874
Port 1.5

(cherry picked from commit 48e36ae146)
2026-07-16 13:24:40 +00:00
purian23 b17ef80c91 fix(qml): resolve lint regressions from async shell loading
port 1.5

(cherry picked from commit 7c24e85c19)
2026-07-16 10:11:01 +00:00
purian23 a55177f1d2 fix(media-player): update Mpris stable metadata handling
- Restores Brave browsers
port 1.5

(cherry picked from commit fe1a783ec2)
2026-07-16 00:57:21 +00:00
purian23 3d0ce17a8c refactor: update workfows & issue notes
port 1.5

(cherry picked from commit 358496134c)
2026-07-15 20:18:54 +00:00
dms-ci[bot] 6306bc7aa2 bump VERSION to v1.5.1 2026-07-15 19:32:57 +00:00
purian23 eca92898f9 refactor: unify media control calls to MprisController sync
(cherry picked from commit 6cc574ea5b)
2026-07-15 14:36:39 -04:00
bbedward db9fb0bd90 system tray: fix overflow of menus
port 1.5

(cherry picked from commit fb2dbced08)
2026-07-15 18:20:21 +00:00
bbedward 815820aa27 media: throttle frame rate of media-related animations
port 1.5

related #2869

(cherry picked from commit cdaedad969)
2026-07-15 16:58:04 +00:00
bbedward 01c0ed2778 i18n: cut down terms and sync
port 1.5

(cherry picked from commit bf408f8d00)
2026-07-15 15:05:42 +00:00
purian23 9967db0c5a feat: new PAM auth management & settings in greeter/lockscreen
- Introduce external management of greetd PAM
- New functionality to validate and apply custom PAM service paths in lockscreen

Port 1.5

(cherry picked from commit b169fe0d77)
2026-07-15 14:38:30 +00:00
bbedward 883a787db7 battery: dont scale values and skew the actual battery values
fixes #2867

port 1.5

(cherry picked from commit 9b67cedaa1)
2026-07-15 14:21:38 +00:00
bbedward 3b1a3c1c22 tailscale: fix inconsistency in styling of control center widget
port 1.5

(cherry picked from commit 2edf70a144)
2026-07-15 14:16:03 +00:00
bbedward 970262f276 hyprland: scale overview displays relative to their actual scale
port 1.5

(cherry picked from commit 72a71cacba)
2026-07-15 14:11:01 +00:00
bbedward b708e97ec5 compositor: improve compositor detection
port 1.5

(cherry picked from commit 0440e40a82)
2026-07-15 13:45:49 +00:00
bbedward e94fc7fe99 qs: improve initial load of wallpaper, dock, and desktop plugins
(cherry picked from commit ec27d4643c)
2026-07-15 09:44:04 -04:00
bbedward c4bca6f6cd screenshot: add --json flag to capture metadata
fixes #2852

port 1.5

(cherry picked from commit f18d36f6c2)
2026-07-15 13:18:14 +00:00
purian23 9e7fc833e9 update release & changelog docs formatting
port 1.5

(cherry picked from commit 3254cc6a1e)
2026-07-15 04:57:49 +00:00
bbedward 9e2d493749 network: avoid dropping to legacy service when loading
(cherry picked from commit 4af3225f21)
2026-07-14 23:22:07 -04:00
bbedward 93120fd73b qs: use asynchronous loaders to load shell core
(cherry picked from commit 3c5245914f)
2026-07-14 23:22:07 -04:00
bbedward 536ebf7b0a i18n: sync
(cherry picked from commit 89814a2c65)
2026-07-14 22:34:04 -04:00
bbedward 1105047a80 gamma: fix scheduler losing track during suspend
port 1.5

(cherry picked from commit 5d2093e54a)
2026-07-15 02:27:32 +00:00
purian23 8b46397200 greeter: update success message
(cherry picked from commit 3cd52ca327)
2026-07-14 20:28:39 -04:00
purian23 6b70491ba9 fix(greeter): reimplement regression to hide debug logs during login
- Provide more status feedback
- Allow 2 fprint tries within 10 seconds for DMS-managed PAM
- Updated dms greeter docs

Note: distro-managed PAM takes precedence and may use different limits.
DMS auth changes remain conditional on greetd being installed.

Fixes #2853
Port 1.5

(cherry picked from commit 5b41d699fa)
2026-07-14 23:41:52 +00:00
bbedward deb0138198 media: fix track art flash from chrome changing media art sizes on track
change

port 1.5

(cherry picked from commit 544599bf1c)
2026-07-14 22:45:42 +00:00
bbedward 2e0587ca16 mango: add separate dispatch socket
port 1.5

(cherry picked from commit 296b3a3d7e)
2026-07-14 21:10:01 +00:00
bbedward 45cafc6bdb dbar: missing show on overview from search index
port 1.5

(cherry picked from commit 729a990fa7)
2026-07-14 20:47:23 +00:00
bbedward 82d5081b7d cava: more optimizations for visualizer
related #2863

(cherry picked from commit c3fa7b2e1d)
2026-07-14 14:30:00 -04:00
bbedward b5b083305f cava: optimize CPU burn
related #631

(cherry picked from commit 2c5a1a2804)
2026-07-14 14:29:54 -04:00
Kilian Mio 039d14c790 Feature/split move size hyprland windowrules (#2824)
* windowrules: add split move/size fields for Lua table syntax

* windowrules: remove deprecated Move/Size fields, switch QML to split fields

* fix: use resolved dms binary path in Proc.runCommand

(cherry picked from commit 21eaaef056)
2026-07-14 11:57:56 -04:00
Alexis Corporal ee7ac9d2bd chore: fix indentation in niri media key bindings. (#2858)
(cherry picked from commit 52740290b2)
2026-07-14 11:57:46 -04:00
bbedward bd159be414 plugins: prevent churn of daemon plugins by not re-creating the entire map
fixes #2860

port 1.5

(cherry picked from commit 1973526c4e)
2026-07-14 15:47:14 +00:00
Callum Wong 4dda0e3a5e Smooth battery time remaining estimate (#2854)
* feat(battery): smooth time remaining estimate with moving average

* feat(battery): use a time-weighted EMA for time remaining estimate

port 1.5

(cherry picked from commit bba5502960)
2026-07-14 15:33:07 +00:00
bbedward 724ee9422d plugins: use grid-style plugin browser
port 1.5

(cherry picked from commit 27703575bc)
2026-07-14 15:25:42 +00:00
bbedward c91cb5d89a core/cli: expose QR code CLI
port 1.5

(cherry picked from commit e6504add7b)
2026-07-14 15:25:39 +00:00
Huỳnh Thiện Lộc 660953ebc5 fix(Modal): respect targetScreen property instead of always using focused screen (#2861)
The open() method in both DankModalStandalone and DankModalConnected unconditionally set contentWindow.screen to the focused screen, ignoring the targetScreen property.

Fix: use root.targetScreen ?? CompositorService.getFocusedScreen() so the modal appears on the configured screen when set, and falls back to focused screen otherwise.
(cherry picked from commit f1e9121295)
2026-07-14 12:42:38 +00:00
bbedward bd70a6286e dock: fix dock s howing with no apps
port 1.5

(cherry picked from commit 9ff751b82a)
2026-07-14 02:47:41 +00:00
bbedward d87e2fcb01 clipboard: store text alongside image when both are offered
related #2849
port 1.5

(cherry picked from commit bb0be2b215)
2026-07-13 22:23:39 +00:00
bbedward 928b272628 config/hyprland: set empty kb layout string by default
fixes #2851
port 1.5

(cherry picked from commit 25847c3f03)
2026-07-13 21:43:46 +00:00
bbedward 781ed16538 launcher/spotlight: improve height-change animation
port 1.5

(cherry picked from commit 7b5c25c50f)
2026-07-13 20:39:09 +00:00
arfan 417da7408d workspace switcher: fix apps icon won't re focus (#2830)
* fix(workspace): update delegate data on window focus change

* fix(workspace): update delegate data handling specific for hyprland event

(cherry picked from commit 4fb6995796)
2026-07-13 16:18:43 -04:00
Arsenijs Kitajevs 0d550662de Fixed bluetooth UI bug mentioned in #2627 (#2850)
(cherry picked from commit d379d251b9)
2026-07-13 16:18:42 -04:00
bbedward 35e35a16fa dash: allow hiding all tabs
port 1.5

(cherry picked from commit 7535b70fa6)
2026-07-13 16:16:48 -04:00
Rafi d96559f7df core: fix security and concurrency issues found in a backend audit (#2805)
* core: fix security and concurrency issues found in backend audit

Security:
- privesc: pipe the sudo password via stdin (sudo -S) instead of
  embedding it in the command string, so it no longer appears in argv
  (readable by any local user via /proc/<pid>/cmdline or ps)
- greeter: tokenize a session .desktop Exec= line into argv and execve
  directly instead of running it through /bin/sh -c, closing a command-
  injection path via user-writable ~/.local/share/wayland-sessions
- plugins: reject path-separator/.. in plugin id/name before joining
  into a filesystem path, closing an arbitrary-directory-delete in the
  uninstall/update fallback
- keybinds/hyprland: always quote unrecognized bind actions/keys when
  writing generated Lua; only re-emit genuine round-tripped custom Lua
  verbatim (tracked via an explicit flag), closing a Lua-injection path
- desktop/mimeapps: reject newline/bracket in mime/desktop-id fields so
  they can't inject fake sections into the shared mimeapps.list

Robustness / concurrency:
- server: recover panics in the request-dispatch path so one bad
  handler can't crash the daemon and drop every client
- go-wayland: recover panics in the shared dispatch choke point so a
  malformed compositor event can't crash CLI tools / the daemon
- server: per-connection D-Bus client ID instead of a shared constant,
  fixing cross-client signal delivery and subscription teardown
- network: guard the NetworkManager device maps with a mutex (a
  concurrent map read/write here is an unrecoverable fatal error)
- cups: close the event channel on Stop() so Unsubscribe() of the last
  subscriber no longer deadlocks; allocate the fresh channel in Start()
- freedesktop: reuse the shared session conn for the settings watcher
  and tear it down in Close(), fixing a per-Manager conn+goroutine leak
- clipboard: mutex-guard lazy dbusConn creation
- geolocation: use WithMatchMember for the GeoClue2 LocationUpdated
  signal (was WithMatchSender with an interface.member string, so the
  match never fired and live location updates never arrived)
- screenshot: set failed=true on buffer/pool creation errors so the
  dispatch loop doesn't wait forever for a ready/failed that never comes

* apply code review comments

---------

Co-authored-by: bbedward <bbedward@gmail.com>
(cherry picked from commit ca89e12963)
2026-07-13 16:16:48 -04:00
Huỳnh Thiện Lộc 7f37799e53 fix(dash): resolve pointer cursor hover issue in connected mode (#2845)
* fix(dash): cursor not changing to pointer in Connected Mode (#2831)

Replaces the full-screen background dismissal MouseArea in
DankPopoutConnected.qml with four edge strips that exclude
the popup body. The full-screen MouseArea at z:-1 was
suppressing child cursorShape propagation on Wayland when
combined with the full-screen input mask.

Blame: the connected popout architecture itself (the issue
does not repro in Separate Mode where background dismissal
lives in a separate PanelWindow).

* fix(dash): resolve pointer cursor hover issue in connected mode

(cherry picked from commit 3f5a54aa88)
2026-07-13 16:16:48 -04:00
bbedward fcda17f517 launcher: add option to choose spotlight style on niri overview
(cherry picked from commit 3da19e5c15)
2026-07-13 16:15:11 -04:00
purian23 4b05e7a0fa refactor(dms-updater): remove hidden updates display in popout view
(cherry picked from commit 7ab0e01573)
2026-07-13 12:38:28 -04:00
bbedward 7c6ad65b5a i18n: add arabic
(cherry picked from commit 31ea83584b)
2026-07-13 12:38:27 -04:00
bbedward acda199e28 i18n: sync
(cherry picked from commit 06c0ea2afb)
2026-07-13 12:38:27 -04:00
bbedward b3a129af2f listview: workaround delegates overlapping and make spotlight launcher
have a stable anchor

port 1.5

(cherry picked from commit 2cb48aaf6b)
2026-07-13 16:31:09 +00:00
purian23 e9d958b32b feat(dms updater): add support for ignoring specific packages during system updates
- Added UI component & popout settings to manage ignored packages
- Added CLI support available in danklinux docs

Fixes: #2827
Closes: #2344, #1741
Port 1.5

(cherry picked from commit e4657aa5f9)
2026-07-13 16:02:35 +00:00
bbedward dd9edd8a00 launcher: add IPC feature-parity to spotlight-bar and improve list view
transitions

port 1.5

(cherry picked from commit 197d17ac4e)
2026-07-13 15:25:59 +00:00
bbedward 28c25d46d5 launcher: don't select pre-filled queries, exclude from memory
port 1.5

(cherry picked from commit ea66b136ba)
2026-07-13 14:32:10 +00:00
bbedward 58530ae56c time: add follow-locale option and squash separate greeter time options
port 1.5

(cherry picked from commit f4f47c0bc5)
2026-07-13 12:53:54 +00:00
purian23 c0134c249a fix(port-audit): update git fetch command to include prune opt
port 1.5

(cherry picked from commit 8a0ed8a50f)
2026-07-13 05:14:51 +00:00
purian23 f7157f0e92 refactor(debian): prefer native Debian Quickshell stable package support in dankinstall
Port 1.5

(cherry picked from commit 63eea01243)
2026-07-13 04:39:34 +00:00
purian23 e29b9749df fix(dash-tabs): update visibility logic for dash tab elements
Fixes #2822

Port 1.5

(cherry picked from commit f590a2965a)
2026-07-13 03:57:44 +00:00
purian23 6791c20f40 refactor(settings): allow search settings to remain on sidebar until dismissed
Closes #2780

Port 1.5

(cherry picked from commit 4ab03deded)
2026-07-13 03:30:58 +00:00
A042 6c2572fbfe fix: use sleep_monitor/wakeup_monitor for DPMS to preserve monitor layout (#2842)
Since Mango v0.15.0, disable_monitor sets only_sleep=0 which removes the
output from the layout entirely. On NVIDIA (proprietary driver), the DRM
handshake fails on re-enable, leaving a black screen recoverable only via
TTY switch.

sleep_monitor/wakeup_monitor set only_sleep=1, keeping the output in the
layout while powered off. This matches the pre-v0.15 behavior and fixes
DPMS wake on NVIDIA.

These commands are available since Mango v0.15.0 (commit b0326d7).

(cherry picked from commit da7cc5648b)
2026-07-12 23:14:04 -04:00
sweenu 3ee07f7f20 feat(ipc): add settings dump to print full live config as JSON (#2817)
Adds `dms ipc call settings dump`, returning the complete live
configuration via SettingsData.getCurrentSettingsJson() — the same
serialization used by the read-only banner's copy button.

Useful when settings.json is a read-only symlink (NixOS/home-manager):
changes made in the Settings UI only exist in the running shell's
memory, and this makes them retrievable from the CLI instead of only
through the clipboard button in the Settings modal.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Port 1.5

(cherry picked from commit 846d07d86a)
2026-07-13 02:58:09 +00:00
AiraNadih ef244cb645 fix(process-list): defer IPC actions until modal is loaded (#2829)
Port 1.5

(cherry picked from commit 55d88d8dfb)
2026-07-13 02:57:51 +00:00
Mati7235 860c347362 dms doctor: detect missing XDG_MENU_PREFIX when KDE apps are installed (#2840)
* dms doctor: warn when XDG_MENU_PREFIX is missing with KDE apps

Under non-Plasma compositors (niri, Hyprland, Sway), Dolphin's
'Open with...' dialog appears empty because KService/KApplicationTrader
requires a populated XDG menu tree, which depends on XDG_MENU_PREFIX.

This check warns the user when kde-cli-tools (keditfiletype) is
installed but XDG_MENU_PREFIX is not set.

Ref: https://specifications.freedesktop.org/menu-spec/latest/

* fix(dms doctor): improve warning message for missing XDG_MENU_PREFIX

Port 1.5

---------

Co-authored-by: Matias <git@matias7235.com.ar>
(cherry picked from commit a803f96f41)
2026-07-13 02:54:43 +00:00
bbedward c7b58e52d7 fix(printers): fix add by address with manually entered host
port 1.5

(cherry picked from commit 75443758f9)
2026-07-12 22:28:30 +00:00
purian23 e0fb817a75 add(workflows): update notify_issues input to release flows
Port 1.5

(cherry picked from commit eaecff93a4)
2026-07-12 20:27:47 +00:00
purian23 b5908f4d1b feat(release): add issue notification script for retesting on new releases
Port 1.5

(cherry picked from commit 6e7c20c59c)
2026-07-12 19:58:34 +00:00
purian23 ee6c91e094 refactor(workflows): update release refs to stable in CI configurations
Port 1.5

(cherry picked from commit 406dcfe110)
2026-07-12 19:23:22 +00:00
Huỳnh Thiện Lộc 59d367f4a4 feat(media): add toggle for album art accent colors (#2831) (#2832)
* fix(media): use system colours in player instead of album cover accent

Closes #2831

Removes ColorQuantizer-based album art accent extraction from
MediaAccentService. All accent properties now return Theme.primary
and Theme.onPrimary directly, so the Dank Dash player always
matches the system colour scheme.

Blame: ee6f7b47 (introduced MediaAccentService & ColorQuantizer)
       d799175c (tweaked seekbar accent colours)
       a62ae336 (further integrated accent into player/album art)
       c44ffae7 (monochrome art edge case fixes)

* feat(media): add toggle for album art accent colours (#2831)

Adds mediaUseAlbumArtAccent setting (default: off) to Settings.
When enabled, MediaAccentService extracts accent from album art
via ColorQuantizer. When disabled, uses Theme.primary.
Toggle is in Settings > Media Player.

* fix: use american english spelling (colour -> color)

Port 1.5

(cherry picked from commit e2b3a2e3ca)
2026-07-12 02:30:04 +00:00
purian23 ee84489b95 fix(void): updated dms-greeter stability support
- Updates will land in DMS v1.5.1 and users are advised to hold off on v1.5.0

Related #2788

Port 1.5

(cherry picked from commit 71ab752e1b)
2026-07-11 05:07:25 +00:00
purian23 b1beecb083 feat(void): switch package repositories to R2
(cherry picked from commit 204ecd0461)
2026-07-10 18:27:26 -04:00
purian23 ffd3840058 ci(void): publish XBPS repository to R2
(cherry picked from commit 0fdfac366e)
2026-07-10 18:27:25 -04:00
Artem Timofeev 447b4d89b6 fix(notifications): dismiss popups when senders close notifications (#2815)
When a sender calls CloseNotification (per freedesktop spec) for a
persistent notification (expire-timeout=0), the onDropped handler
removed the wrapper from internal arrays but did not dismiss the
visible popup. The popup remained on screen forever for non-critical
notifications like YubiKey touch prompts.

Set wrapper.popup=false in onDropped so the popup exits via the
normal signal chain. This is minimal; it does not change timeout
handling, queue management, or the isPersistent marker.

Also reverts the previous workaround that special-cased non-critical
expireTimeout=0 to use DMS timeout. Upstream spec-correct behavior
is to honor the sender timeout, and fix the dismissal bug instead.

Closes #2814

(cherry picked from commit 26b2955cf3)
2026-07-10 18:27:25 -04:00
Kangheng Liu 36772735f3 fix(player): combine trackid with text identity (#2808)
fixes #2807

(cherry picked from commit c445597f83)
2026-07-10 18:27:25 -04:00
bbedward 375afaaf3a widgets: fix dropdown/icon picker referencing a dead window
port 1.5

(cherry picked from commit 52123458c7)
2026-07-10 21:21:42 +00:00
bbedward fe0214d522 theme: prevent failed portal writes from reverting theme mode
related: #2786

port 1.5

(cherry picked from commit d82d86df5c)
2026-07-10 19:45:35 +00:00
bbedward fce1a09790 osd/media playback: delay showing until album art is ready and avoid
re-showing when duplicate metadata comes from mpris
related #2787
port 1.5

(cherry picked from commit 6a58adfb29)
2026-07-10 17:37:01 +00:00
bbedward 554e55c63a audio: only show mic volume OSD through DMS IPCs, not external source
changes
fixes #2790
port 1.5

(cherry picked from commit 05feb211ba)
2026-07-10 16:44:19 +00:00
purian23 f7d8e2b56c workflow: enhance porting logic to support inline verbiage & audits
(cherry picked from commit a3b2167e58)
2026-07-10 12:04:50 -04:00
purian23 1d4572ac1c workflow: updated target extraction regex options
(cherry picked from commit 494144a7c7)
2026-07-10 12:04:50 -04:00
bbedward c02a24fecf calendar: add action button to events for opening links
fixes #2799
port 1.5

(cherry picked from commit 2a2c1ca9e6)
2026-07-10 12:04:50 -04:00
bbedward bf644516f8 qs/common: fix path decoding
fixes #2802
port 1.5

(cherry picked from commit 56b7ecb008)
2026-07-10 12:04:50 -04:00
Evan Maddock 61f9ea4954 build: Add support for DESTDIR (#2783)
This makes it easier for distros to use the Makefile when creating
packages. It enables us to specify a base destination directory to
install the project files to. E.g., on Solus, when creating eopkgs, files
must be installed to a special directory path, which becomes the package
files. I believe Fedora packages, and others, are the same.

Signed-off-by: Evan Maddock <maddock.evan@vivaldi.net>
(cherry picked from commit 45f6232e32)
2026-07-10 12:04:50 -04:00
Scott McKendry 5a169bea72 fix(settings): battery tab items not in search (#2794)
(cherry picked from commit 4bdb7d17b2)
2026-07-10 12:04:50 -04:00
14Do 3d60f35683 fix(ListViewTransitions): null transitions when duration truncates to 0ms (#2791)
A zero-duration ViewTransition still engages ListView's transition
machinery but resolves within the same frame, so removed delegates are
never released and displaced items are never repositioned. On a filtered
ScriptModel that re-filters on every keystroke this leaves stale rows and
empty gaps behind, visible in any DankListView-backed list.

The shortest sub-duration (remove/add = expressiveDurations.fast =
base * 0.4) is coerced to an int, so it truncates to 0ms for any
animation base duration below 3: animation speed None (0), or a Custom
duration of 1-2ms (1*0.4=0.4->0, 2*0.4=0.8->0, only 3*0.4=1.2->1
survives). Presets (250/500/750) are unaffected, which is why this only
reproduces with animations disabled or a very small custom value.

Gate the four transitions to null whenever that shortest sub-duration
would truncate to 0, so the view takes the correct instant path instead
of a broken 0ms transition. Base >= 3 is unchanged.

(cherry picked from commit ce1595d62d)
2026-07-10 12:04:49 -04:00
bbedward b95f4cdbf3 settings: restore lost geometric centering option
port 1.5

(cherry picked from commit 0b69feaa1a)
2026-07-10 15:12:40 +00:00
bbedward b834e82668 network/iwd: improve bad credential handling
related #2804

port: 1.5
(cherry picked from commit 867102b82c)
2026-07-10 14:37:22 +00:00
bbedward 302cfda2f4 fix(media): resolve monochrome album art accents
port: 1.5
(cherry picked from commit c44ffae751)
2026-07-09 23:40:55 +00:00
bbedward c86676df9b auth: add some more intelligent pam config resolution for lock screen
and greeter
related #2789

port: 1.5
(cherry picked from commit 9cf2ca7196)
2026-07-09 19:12:54 +00:00
purian23 fa26dbb544 workflows: update deps
port/1.5

(cherry picked from commit c0eeed4e89)
2026-07-09 16:54:26 +00:00
purian23 067d01f678 workflow: update porting logic
(cherry picked from commit cb0dc9c68d)
2026-07-09 12:53:05 -04:00
purian23 95c8cc80f6 Add GitHub workflows for release management
(cherry picked from commit 8008238ca0)
2026-07-09 12:53:05 -04:00
purian23 b8f216dc0f fix(workflow): void stable packages
(cherry picked from commit a48cce59d4)
2026-07-09 12:53:05 -04:00
bbedward e84e63fa3a default apps: add configuration for geo: URIs
port: 1.5
(cherry picked from commit d0a4c1c56e)
2026-07-09 16:24:19 +00:00
bbedward 6d08b91004 fix(notifications): handle sound-name hint
(cherry picked from commit 0439d017b9)
2026-07-09 12:07:35 -04:00
117 changed files with 6448 additions and 2355 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -51,7 +51,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
+11 -2
View File
@@ -43,7 +43,7 @@ jobs:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -55,6 +55,15 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ steps.app_token.outputs.token }} token: ${{ steps.app_token.outputs.token }}
- name: Preflight — verify Release workflow is dispatchable
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/${{ github.repository }}/actions/workflows/release.yml" \
--jq '.state' | grep -qx active ||
{ echo "::error::release.yml is not dispatchable; aborting before any push"; exit 1; }
- name: Port audit (informational) - name: Port audit (informational)
env: env:
GH_TOKEN: ${{ steps.app_token.outputs.token }} GH_TOKEN: ${{ steps.app_token.outputs.token }}
@@ -83,7 +92,7 @@ jobs:
- name: Dispatch Release workflow - name: Dispatch Release workflow
env: env:
GH_TOKEN: ${{ steps.app_token.outputs.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \ gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
-f tag="${{ steps.derive.outputs.tag }}" \ -f tag="${{ steps.derive.outputs.tag }}" \
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
+114
View File
@@ -1,7 +1,10 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -56,10 +59,121 @@ var authResolveLockCmd = &cobra.Command{
}, },
} }
var authListServicesCmd = &cobra.Command{
Use: "list-services",
Short: "List candidate lock-screen PAM services available on this system",
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
Run: func(cmd *cobra.Command, args []string) {
asJSON, _ := cmd.Flags().GetBool("json")
services := sharedpam.ListLockscreenPamServices()
if asJSON {
payload := struct {
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
}{Services: services}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
log.Fatalf("Error encoding services: %v", err)
}
fmt.Println(string(data))
return
}
if len(services) == 0 {
fmt.Println("No candidate lock-screen PAM services found.")
return
}
for _, s := range services {
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
}
},
}
var authValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate a PAM service file for use by the DMS lock screen",
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
Run: func(cmd *cobra.Command, args []string) {
path, _ := cmd.Flags().GetString("path")
service, _ := cmd.Flags().GetString("service")
purpose, _ := cmd.Flags().GetString("purpose")
asJSON, _ := cmd.Flags().GetBool("json")
if (path == "") == (service == "") {
log.Fatalf("Error: exactly one of --path or --service is required")
}
if purpose != "password" && purpose != "u2f" {
log.Fatalf("Error: --purpose must be password or u2f")
}
var result sharedpam.LockscreenPamValidation
switch {
case service != "":
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamService(service)
} else {
result = sharedpam.ValidateLockscreenPamService(service)
}
case !filepath.IsAbs(path):
result = sharedpam.LockscreenPamValidation{
Path: path,
MissingModules: []string{},
Warnings: []string{},
Errors: []string{"--path must be an absolute file path"},
}
default:
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamPath(path)
} else {
result = sharedpam.ValidateLockscreenPamPath(path)
}
}
if asJSON {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatalf("Error encoding validation: %v", err)
}
fmt.Println(string(data))
} else {
printLockscreenPamValidation(result)
}
if !result.Valid {
os.Exit(1)
}
},
}
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
fmt.Printf("Path: %s\n", result.Path)
fmt.Printf("Valid: %t\n", result.Valid)
fmt.Printf("Has auth: %t\n", result.HasAuth)
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
if len(result.MissingModules) > 0 {
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
}
for _, w := range result.Warnings {
fmt.Println("⚠ " + w)
}
for _, e := range result.Errors {
fmt.Println("✗ " + e)
}
}
func init() { func init() {
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts") authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)") authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path") authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
authListServicesCmd.Flags().Bool("json", false, "Output as JSON")
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
} }
func syncAuth(nonInteractive bool) error { func syncAuth(nonInteractive bool) error {
+108 -14
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -27,8 +28,19 @@ var (
ssNoConfirm bool ssNoConfirm bool
ssReset bool ssReset bool
ssStdout bool ssStdout bool
ssJSON bool
) )
type screenshotMetadata struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Scale float64 `json:"scale,omitempty"`
Mime string `json:"mime,omitempty"`
Error string `json:"error,omitempty"`
}
var screenshotCmd = &cobra.Command{ var screenshotCmd = &cobra.Command{
Use: "screenshot", Use: "screenshot",
Short: "Capture screenshots", Short: "Capture screenshots",
@@ -39,8 +51,9 @@ Modes:
full - Capture the focused output full - Capture the focused output
all - Capture all outputs combined all - Capture all outputs combined
output - Capture a specific output by name output - Capture a specific output by name
window - Capture the focused window (Hyprland/Mango) window - Capture the focused window (Hyprland/Mango/niri)
last - Capture the last selected region last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
Output format (--format): Output format (--format):
png - PNG format (default) png - PNG format (default)
@@ -59,7 +72,10 @@ Examples:
dms screenshot --no-file # Clipboard only dms screenshot --no-file # Clipboard only
dms screenshot --no-confirm # Region capture on mouse release dms screenshot --no-confirm # Region capture on mouse release
dms screenshot --cursor=on # Include cursor dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85`, dms screenshot -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
} }
var ssRegionCmd = &cobra.Command{ var ssRegionCmd = &cobra.Command{
@@ -97,10 +113,33 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{ var ssWindowCmd = &cobra.Command{
Use: "window", Use: "window",
Short: "Capture the focused window", Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland and Mango.`, Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
Run: runScreenshotWindow, Run: runScreenshotWindow,
} }
var ssScrollInterval int
var ssScrollCmd = &cobra.Command{
Use: "scroll",
Short: "Capture a scrolling region stitched into one tall image",
Long: `Select a region, then scroll the content beneath with the mouse wheel or
touchpad while frames are captured and stitched vertically. Finish with the
on-screen done button; cancel with the cancel button. Enter and Esc work
everywhere: most compositors hold the keyboard on the overlay (keyboard
scrolling does not reach the app there), while Hyprland leaves the keyboard
with the application — keyboard scrolling works, and Enter/Esc act through
temporary global binds for the session. The cursor is never included in
frames.
Frames are stitched continuously while scrolling, and revisited content is
never duplicated — scrolling up past the starting point extends the image
upward. Content jumped past faster than capture can follow is skipped rather
than stitched incorrectly.
Rotated outputs are not supported.`,
Run: runScreenshotScroll,
}
var ssListCmd = &cobra.Command{ var ssListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List available outputs", Short: "List available outputs",
@@ -128,8 +167,12 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation") screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing") screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)") screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
screenshotCmd.AddCommand(ssRegionCmd) screenshotCmd.AddCommand(ssRegionCmd)
screenshotCmd.AddCommand(ssScrollCmd)
screenshotCmd.AddCommand(ssFullCmd) screenshotCmd.AddCommand(ssFullCmd)
screenshotCmd.AddCommand(ssAllCmd) screenshotCmd.AddCommand(ssAllCmd)
screenshotCmd.AddCommand(ssOutputCmd) screenshotCmd.AddCommand(ssOutputCmd)
@@ -203,10 +246,39 @@ func setPopoutScreenshotMode(begin bool) {
_ = exec.Command("qs", cmdArgs...).Run() _ = exec.Command("qs", cmdArgs...).Run()
} }
func writeScreenshotJSON(meta screenshotMetadata) {
_ = json.NewEncoder(os.Stdout).Encode(meta)
}
func exitScreenshotError(context string, err error) {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "error", Error: err.Error()})
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Error%s: %v\n", context, err)
os.Exit(1)
}
func formatMime(format screenshot.Format) string {
switch format {
case screenshot.FormatJPEG:
return "image/jpeg"
case screenshot.FormatPPM:
return "image/x-portable-pixmap"
default:
return "image/png"
}
}
func runScreenshot(config screenshot.Config) { func runScreenshot(config screenshot.Config) {
if ssJSON && config.Stdout {
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
os.Exit(1)
}
// Region select needs the keyboard; drop popout grabs for its duration. // Region select needs the keyboard; drop popout grabs for its duration.
result, err := func() (*screenshot.CaptureResult, error) { result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
if interactive { if interactive {
setPopoutScreenshotMode(true) setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false) defer setPopoutScreenshotMode(false)
@@ -215,11 +287,13 @@ func runScreenshot(config screenshot.Config) {
}() }()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) exitScreenshotError("", err)
os.Exit(1)
} }
if result == nil { if result == nil {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
}
os.Exit(0) os.Exit(0)
} }
@@ -231,8 +305,7 @@ func runScreenshot(config screenshot.Config) {
if config.Stdout { if config.Stdout {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil { if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err) exitScreenshotError(" writing to stdout", err)
os.Exit(1)
} }
return return
} }
@@ -252,22 +325,37 @@ func runScreenshot(config screenshot.Config) {
filePath = filepath.Join(outputDir, filename) filePath = filepath.Join(outputDir, filename)
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil { if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err) exitScreenshotError(" writing file", err)
os.Exit(1) }
if !ssJSON {
fmt.Println(filePath)
} }
fmt.Println(filePath)
} }
if config.Clipboard { if config.Clipboard {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil { if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err) exitScreenshotError(" copying to clipboard", err)
os.Exit(1)
} }
if !config.SaveFile { if !ssJSON && !config.SaveFile {
fmt.Println("Copied to clipboard") fmt.Println("Copied to clipboard")
} }
} }
if ssJSON {
scale := result.Scale
if scale <= 0 {
scale = 1.0
}
writeScreenshotJSON(screenshotMetadata{
Status: "success",
Path: filePath,
Width: result.Buffer.Width,
Height: result.Buffer.Height,
Scale: scale,
Mime: formatMime(config.Format),
})
}
if config.Notify { if config.Notify {
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format) thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
screenshot.SendNotification(screenshot.NotifyResult{ screenshot.SendNotification(screenshot.NotifyResult{
@@ -378,6 +466,12 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
runScreenshot(config) runScreenshot(config)
} }
func runScreenshotScroll(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeScroll)
config.IntervalMs = min(max(ssScrollInterval, 30), 1000)
runScreenshot(config)
}
func runScreenshotFull(cmd *cobra.Command, args []string) { func runScreenshotFull(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeFullScreen) config := getScreenshotConfig(screenshot.ModeFullScreen)
runScreenshot(config) runScreenshot(config)
+1 -1
View File
@@ -20,7 +20,7 @@ func init() {
runCmd.Flags().MarkHidden("daemon-child") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
authCmd.AddCommand(authSyncCmd, authResolveLockCmd) authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd) updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
+1 -1
View File
@@ -20,7 +20,7 @@ func init() {
runCmd.Flags().MarkHidden("daemon-child") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
authCmd.AddCommand(authSyncCmd, authResolveLockCmd) authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(getCommonCommands()...)
+7 -2
View File
@@ -124,7 +124,9 @@ func GetLogger() *Logger {
logger = &Logger{base} logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" { if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path) logMu.Lock()
_ = setLogFile(logger, path)
logMu.Unlock()
} }
}) })
return logger return logger
@@ -145,15 +147,18 @@ func SetLevel(level string) {
// profile when stderr is a TTY and route the file through ansiStripWriter so // profile when stderr is a TTY and route the file through ansiStripWriter so
// the file stays plain while stderr keeps its colors. // the file stays plain while stderr keeps its colors.
func SetLogFile(path string) error { func SetLogFile(path string) error {
l := GetLogger()
logMu.Lock() logMu.Lock()
defer logMu.Unlock() defer logMu.Unlock()
return setLogFile(l, path)
}
func setLogFile(l *Logger, path string) error {
if logFile != nil { if logFile != nil {
logFile.Close() logFile.Close()
logFile = nil logFile = nil
} }
l := GetLogger()
if path == "" { if path == "" {
l.SetOutput(logStderr) l.SetOutput(logStderr)
applyColorProfile(l, logStderr) applyColorProfile(l, logStderr)
+25 -9
View File
@@ -44,6 +44,7 @@ type TemplateDef struct {
ID string ID string
Commands []string Commands []string
Flatpaks []string Flatpaks []string
ConfigDirs []string
ConfigFile string ConfigFile string
Kind TemplateKind Kind TemplateKind
RunUnconditionally bool RunUnconditionally bool
@@ -60,9 +61,9 @@ var templateRegistry = []TemplateDef{
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"}, {ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"}, {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: "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: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigDirs: []string{"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: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigDirs: []string{"Vencord"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"}, {ID: "equibop", Commands: []string{"equibop"}, ConfigDirs: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal}, {ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal}, {ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal}, {ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
@@ -459,9 +460,9 @@ output_path = '%s'
case TemplateKindGTK: case TemplateKindGTK:
switch opts.Mode { switch opts.Mode {
case ColorModeLight: case ColorModeLight:
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml") appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-light.toml")
default: default:
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml") appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-dark.toml")
} }
case TemplateKindTerminal: case TemplateKindTerminal:
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
@@ -474,10 +475,10 @@ output_path = '%s'
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir) appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
case TemplateKindEmacs: case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" { if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
} }
default: default:
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
} }
} }
@@ -512,13 +513,14 @@ func appendConfig(
cfgFile *os.File, cfgFile *os.File,
checkCmd []string, checkCmd []string,
checkFlatpaks []string, checkFlatpaks []string,
checkConfigDirs []string,
fileName string, fileName string,
) { ) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName) configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
return return
} }
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) && !configDirExists(checkConfigDirs) {
return return
} }
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
@@ -599,6 +601,20 @@ func templateSessionActive(tmpl TemplateDef) bool {
return err == nil return err == nil
} }
func configDirExists(names []string) bool {
configHome := utils.XDGConfigHome()
if configHome == "" {
return false
}
for _, name := range names {
info, err := os.Stat(filepath.Join(configHome, name))
if err == nil && info.IsDir() {
return true
}
}
return false
}
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool { func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
// Both nil is treated as "skip check" / unconditionally run // Both nil is treated as "skip check" / unconditionally run
if checkCmd == nil && checkFlatpaks == nil { if checkCmd == nil && checkFlatpaks == nil {
@@ -1096,7 +1112,7 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
case tmpl.Kind == TemplateKindEmacs: case tmpl.Kind == TemplateKindEmacs:
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != "" detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
default: default:
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && templateSessionActive(tmpl) detected = (appExists(checker, tmpl.Commands, tmpl.Flatpaks) || configDirExists(tmpl.ConfigDirs)) && templateSessionActive(tmpl)
} }
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected}) checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
+95 -8
View File
@@ -38,7 +38,7 @@ func TestAppendConfigBinaryExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml") appendConfig(opts, cfgFile, []string{"sh"}, nil, nil, "test.toml")
cfgFile.Close() cfgFile.Close()
output, err := os.ReadFile(outFile) output, err := os.ReadFile(outFile)
@@ -82,7 +82,7 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml") appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, nil, "test.toml")
cfgFile.Close() cfgFile.Close()
output, err := os.ReadFile(outFile) output, err := os.ReadFile(outFile)
@@ -122,7 +122,7 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml") appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, nil, "test.toml")
cfgFile.Close() cfgFile.Close()
output, err := os.ReadFile(outFile) output, err := os.ReadFile(outFile)
@@ -163,7 +163,7 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml") appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
cfgFile.Close() cfgFile.Close()
output, err := os.ReadFile(outFile) output, err := os.ReadFile(outFile)
@@ -203,7 +203,7 @@ func TestAppendConfigBothExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml") appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, nil, "test.toml")
cfgFile.Close() cfgFile.Close()
output, err := os.ReadFile(outFile) output, err := os.ReadFile(outFile)
@@ -244,7 +244,7 @@ func TestAppendConfigNeitherExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml") appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
cfgFile.Close() cfgFile.Close()
output, err := os.ReadFile(outFile) output, err := os.ReadFile(outFile)
@@ -281,7 +281,7 @@ func TestAppendConfigNoChecks(t *testing.T) {
opts := &Options{ShellDir: shellDir} opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "test.toml") appendConfig(opts, cfgFile, nil, nil, nil, "test.toml")
cfgFile.Close() cfgFile.Close()
output, err := os.ReadFile(outFile) output, err := os.ReadFile(outFile)
@@ -312,7 +312,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir} opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml") appendConfig(opts, cfgFile, nil, nil, nil, "nonexistent.toml")
cfgFile.Close() cfgFile.Close()
output, err := os.ReadFile(outFile) output, err := os.ReadFile(outFile)
@@ -487,3 +487,90 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
} }
assert.NotContains(t, string(output), "[templates.dmsmango]") assert.NotContains(t, string(output), "[templates.dmsmango]")
} }
func TestAppendConfigConfigDirExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "vencord config content"
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
configHome := filepath.Join(tempDir, "config")
if err := os.MkdirAll(filepath.Join(configHome, "Vencord"), 0o755); err != nil {
t.Fatalf("failed to create Vencord config dir: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", configHome)
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
assert.Equal(t, testConfig+"\n", string(output))
}
func TestAppendConfigConfigDirDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte("vencord config content"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
configHome := filepath.Join(tempDir, "config")
if err := os.MkdirAll(configHome, 0o755); err != nil {
t.Fatalf("failed to create config home: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", configHome)
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
assert.Empty(t, string(output))
}
+338 -5
View File
@@ -70,10 +70,11 @@ var includedPamAuthFiles = []string{
} }
type AuthSettings struct { type AuthSettings struct {
EnableFprint bool `json:"enableFprint"` EnableFprint bool `json:"enableFprint"`
EnableU2f bool `json:"enableU2f"` EnableU2f bool `json:"enableU2f"`
GreeterEnableFprint bool `json:"greeterEnableFprint"` GreeterEnableFprint bool `json:"greeterEnableFprint"`
GreeterEnableU2f bool `json:"greeterEnableU2f"` GreeterEnableU2f bool `json:"greeterEnableU2f"`
GreeterPamExternallyManaged bool `json:"greeterPamExternallyManaged"`
} }
type SyncAuthOptions struct { type SyncAuthOptions struct {
@@ -236,6 +237,14 @@ func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options S
return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err) return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err)
} }
if settings.GreeterPamExternallyManaged {
if err := removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, deps); err != nil {
return err
}
logFunc(" /etc/pam.d/greetd is externally managed. Skipping DMS greeter PAM sync.")
return nil
}
if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil { if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil {
return err return err
} }
@@ -583,6 +592,330 @@ func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) (
return b.String(), nil return b.String(), nil
} }
var lockscreenPamCandidateServices = []string{
"login",
"system-auth",
"system-login",
"system-local-login",
"common-auth",
"base-auth",
}
type LockscreenPamServiceInfo struct {
Name string `json:"name"`
Dir string `json:"dir"`
Path string `json:"path"`
HasAuth bool `json:"hasAuth"`
InlineFingerprint bool `json:"inlineFingerprint"`
InlineU2f bool `json:"inlineU2f"`
}
type LockscreenPamValidation struct {
Valid bool `json:"valid"`
Path string `json:"path"`
HasAuth bool `json:"hasAuth"`
InlineFingerprint bool `json:"inlineFingerprint"`
InlineU2f bool `json:"inlineU2f"`
MissingModules []string `json:"missingModules"`
Warnings []string `json:"warnings"`
Errors []string `json:"errors"`
}
type lockscreenPamValidateDeps struct {
baseDirs []string
readFile func(string) ([]byte, error)
stat func(string) (os.FileInfo, error)
pamModuleExists func(string) bool
}
func defaultValidateDeps() lockscreenPamValidateDeps {
return lockscreenPamValidateDeps{
baseDirs: lockscreenPamBaseDirs,
readFile: os.ReadFile,
stat: os.Stat,
pamModuleExists: pamModuleExists,
}
}
// lockscreenPamAnalysis is a non-destructive walk of a PAM service. Unlike
// resolveService it detects (rather than strips) pam_fprintd/pam_u2f and
// records unknown directives instead of hard-failing on them.
type lockscreenPamAnalysis struct {
lines []string
hasAuth bool
inlineFingerprint bool
inlineU2f bool
modules []string
authModules []string
unknownDirectives []string
err error
}
func (r lockscreenPamResolver) analyzePath(path string) lockscreenPamAnalysis {
var acc lockscreenPamAnalysis
if err := r.analyzeInto(filepath.Clean(path), "", nil, &acc); err != nil {
acc.err = err
}
return acc
}
func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack []string, acc *lockscreenPamAnalysis) error {
for _, seen := range stack {
if seen == path {
chain := append(append([]string{}, stack...), path)
display := make([]string, 0, len(chain))
for _, item := range chain {
display = append(display, filepath.Base(item))
}
return fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
}
}
data, err := r.readFile(path)
if err != nil {
return fmt.Errorf("failed to read PAM file %s: %w", path, err)
}
for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
rawLine = strings.TrimRight(rawLine, "\r")
trimmed := strings.TrimSpace(rawLine)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
lineType := pamDirectiveType(trimmed)
if filterType != "" && lineType != "" && lineType != filterType {
continue
}
nestedPath := include.target
if filepath.IsAbs(nestedPath) {
nestedPath = filepath.Clean(nestedPath)
} else {
located, err := r.locate(include.target)
if err != nil {
return fmt.Errorf("failed to read PAM file %s: %w", include.target, err)
}
nestedPath = located
}
if err := r.analyzeInto(nestedPath, include.filterType, append(stack, path), acc); err != nil {
return err
}
continue
}
lineType := pamDirectiveType(trimmed)
if lineType == "" {
acc.unknownDirectives = append(acc.unknownDirectives, trimmed)
continue
}
if filterType != "" && lineType != filterType {
continue
}
acc.lines = append(acc.lines, rawLine)
if lineType == "auth" {
acc.hasAuth = true
}
foundModule := false
for _, field := range strings.Fields(trimmed) {
if strings.HasPrefix(field, "#") {
break
}
if strings.Contains(field, "pam_fprintd") {
acc.inlineFingerprint = true
}
if strings.Contains(field, "pam_u2f") {
acc.inlineU2f = true
}
if !foundModule && strings.HasSuffix(field, ".so") {
acc.modules = append(acc.modules, field)
if lineType == "auth" {
acc.authModules = append(acc.authModules, field)
}
foundModule = true
}
}
}
return nil
}
// Earlier base dir wins per name (libpam precedence).
func ListLockscreenPamServices() []LockscreenPamServiceInfo {
return listLockscreenPamServices(lockscreenPamBaseDirs, os.ReadFile)
}
func listLockscreenPamServices(baseDirs []string, readFile func(string) ([]byte, error)) []LockscreenPamServiceInfo {
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
out := make([]LockscreenPamServiceInfo, 0, len(lockscreenPamCandidateServices))
for _, name := range lockscreenPamCandidateServices {
path, err := resolver.locate(name)
if err != nil {
continue
}
info := LockscreenPamServiceInfo{
Name: name,
Dir: filepath.Dir(path),
Path: path,
}
if analysis := resolver.analyzePath(path); analysis.err == nil {
info.HasAuth = analysis.hasAuth
info.InlineFingerprint = analysis.inlineFingerprint
info.InlineU2f = analysis.inlineU2f
}
out = append(out, info)
}
return out
}
func ValidateLockscreenPamService(name string) LockscreenPamValidation {
return validateLockscreenPam(name, "", defaultValidateDeps())
}
func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
return validateLockscreenPam("", path, defaultValidateDeps())
}
func ValidateLockscreenU2fPamService(name string) LockscreenPamValidation {
return validateLockscreenU2fPam(name, "", defaultValidateDeps())
}
func ValidateLockscreenU2fPamPath(path string) LockscreenPamValidation {
return validateLockscreenU2fPam("", path, defaultValidateDeps())
}
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
result := LockscreenPamValidation{
MissingModules: []string{},
Warnings: []string{},
Errors: []string{},
}
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
var analysis lockscreenPamAnalysis
if path != "" {
result.Path = path
analysis = resolver.analyzePath(path)
} else {
located, err := resolver.locate(serviceName)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("PAM service %q not found: %v", serviceName, err))
return result
}
result.Path = located
analysis = resolver.analyzePath(located)
}
if analysis.err != nil {
result.Errors = append(result.Errors, analysis.err.Error())
return result
}
result.HasAuth = analysis.hasAuth
result.InlineFingerprint = analysis.inlineFingerprint
result.InlineU2f = analysis.inlineU2f
if !analysis.hasAuth {
result.Errors = append(result.Errors, "no auth directives found after include resolution")
}
for _, directive := range analysis.unknownDirectives {
result.Warnings = append(result.Warnings, "unsupported PAM directive (libpam may still handle it at runtime): "+directive)
}
seen := map[string]bool{}
for _, ref := range analysis.modules {
name := filepath.Base(ref)
if seen[name] {
continue
}
seen[name] = true
if moduleReferenceExists(ref, deps) {
continue
}
result.MissingModules = append(result.MissingModules, name)
result.Warnings = append(result.Warnings, "referenced PAM module not found: "+name)
}
if analysis.inlineFingerprint {
result.Warnings = append(result.Warnings, "pam_fprintd is present in the resolved stack; may double-prompt with DMS's separate fingerprint context")
}
if analysis.inlineU2f {
result.Warnings = append(result.Warnings, "pam_u2f is present in the resolved stack; may double-prompt with DMS's separate U2F context")
}
result.Valid = len(result.Errors) == 0
return result
}
func validateLockscreenU2fPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
result := validateLockscreenPam(serviceName, path, deps)
if result.Path == "" {
return result
}
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
analysis := resolver.analyzePath(result.Path)
if analysis.err != nil {
return result
}
filteredWarnings := result.Warnings[:0]
for _, warning := range result.Warnings {
if strings.Contains(warning, "pam_u2f is present") && strings.Contains(warning, "double-prompt") {
continue
}
filteredWarnings = append(filteredWarnings, warning)
}
result.Warnings = filteredWarnings
hasU2fAuth := false
unsafeModules := []string{}
unsafeSeen := map[string]bool{}
for _, ref := range analysis.authModules {
name := filepath.Base(ref)
if name == "pam_u2f.so" {
hasU2fAuth = true
continue
}
switch name {
case "pam_env.so", "pam_faildelay.so", "pam_nologin.so":
continue
default:
if !unsafeSeen[name] {
unsafeSeen[name] = true
unsafeModules = append(unsafeModules, name)
}
}
}
if !hasU2fAuth {
result.Errors = append(result.Errors, "no pam_u2f auth directive found; select a dedicated security-key PAM service")
}
for _, name := range unsafeModules {
result.Errors = append(result.Errors, fmt.Sprintf("additional auth module %s is not allowed in a dedicated security-key PAM service", name))
}
for _, name := range result.MissingModules {
if strings.Contains(name, "pam_u2f") {
result.Errors = append(result.Errors, fmt.Sprintf("%s is not installed or its configured path is unavailable", name))
break
}
}
result.Valid = len(result.Errors) == 0
return result
}
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
if filepath.IsAbs(ref) {
_, err := deps.stat(ref)
return err == nil
}
return deps.pamModuleExists(ref)
}
const UserLockscreenPamService = "dankshell" const UserLockscreenPamService = "dankshell"
func UserLockscreenPamDir() string { func UserLockscreenPamDir() string {
@@ -632,7 +965,7 @@ func buildManagedLockscreenU2FPamContent() string {
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error { func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
if deps.isNixOS() { if deps.isNixOS() {
logFunc(" NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.") logFunc(" NixOS detected. DMS does not write /etc/pam.d/dankshell; the lock screen uses a sanitized password-only service in the user state directory unless you select a custom PAM source.")
return nil return nil
} }
+330 -2
View File
@@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err) t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
} }
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") { if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") {
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs) t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs)
} }
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) { if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err) t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
@@ -786,6 +786,301 @@ func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) {
} }
} }
func (e *pamTestEnv) validateDeps() lockscreenPamValidateDeps {
return lockscreenPamValidateDeps{
baseDirs: []string{e.pamDir},
readFile: os.ReadFile,
stat: os.Stat,
pamModuleExists: func(module string) bool { return e.availableModules[module] },
}
}
func TestListLockscreenPamServices(t *testing.T) {
t.Parallel()
t.Run("dedupes by name with earlier base dir winning", func(t *testing.T) {
t.Parallel()
etcDir := t.TempDir()
vendorDir := t.TempDir()
// login exists in both dirs; system-auth only in the vendor dir.
writeTestFile(t, filepath.Join(etcDir, "login"), "#%PAM-1.0\nauth required pam_unix.so\naccount required pam_unix.so\n")
writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth required pam_deny.so\n")
writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
services := listLockscreenPamServices([]string{etcDir, vendorDir}, os.ReadFile)
if len(services) != 2 {
t.Fatalf("expected 2 services (login, system-auth), got %d: %+v", len(services), services)
}
byName := map[string]LockscreenPamServiceInfo{}
for _, s := range services {
byName[s.Name] = s
}
login, ok := byName["login"]
if !ok {
t.Fatalf("expected login service, got %+v", services)
}
if login.Dir != etcDir || login.Path != filepath.Join(etcDir, "login") {
t.Fatalf("expected login to resolve in earlier dir %s, got dir=%s path=%s", etcDir, login.Dir, login.Path)
}
if !login.HasAuth {
t.Fatalf("expected login to report hasAuth")
}
if byName["system-auth"].Dir != vendorDir {
t.Fatalf("expected system-auth to resolve in vendor dir, got %s", byName["system-auth"].Dir)
}
})
t.Run("include resolution sets hasAuth and detects inline fprintd/u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
services := listLockscreenPamServices([]string{env.pamDir}, os.ReadFile)
var login LockscreenPamServiceInfo
for _, s := range services {
if s.Name == "login" {
login = s
}
}
if !login.HasAuth {
t.Fatalf("expected hasAuth via resolved include")
}
if !login.InlineFingerprint || !login.InlineU2f {
t.Fatalf("expected inline fingerprint and u2f detection, got %+v", login)
}
})
}
func TestValidateLockscreenPam(t *testing.T) {
t.Parallel()
t.Run("valid service with resolved auth", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("login", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid result, got %+v", result)
}
if !result.HasAuth {
t.Fatalf("expected hasAuth true")
}
if len(result.Errors) != 0 {
t.Fatalf("expected no errors, got %v", result.Errors)
}
})
t.Run("path outside base dirs is read directly", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
outside := filepath.Join(t.TempDir(), "custom-pam")
writeTestFile(t, outside, "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("", outside, env.validateDeps())
if !result.Valid || result.Path != outside {
t.Fatalf("expected valid result for outside path, got %+v", result)
}
})
t.Run("missing module produces warning and missingModules but stays valid", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth required pam_absent.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid despite missing module, got %+v", result)
}
if len(result.MissingModules) != 1 || result.MissingModules[0] != "pam_absent.so" {
t.Fatalf("expected missing pam_absent.so, got %v", result.MissingModules)
}
if !containsSubstr(result.Warnings, "pam_absent.so") {
t.Fatalf("expected warning about missing module, got %v", result.Warnings)
}
})
t.Run("unknown directive is a warning not an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_foo.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nbadtype required pam_foo.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid with unknown directive, got %+v", result)
}
if len(result.Errors) != 0 {
t.Fatalf("expected no errors, got %v", result.Errors)
}
if !containsSubstr(result.Warnings, "unsupported PAM directive") {
t.Fatalf("expected unsupported directive warning, got %v", result.Warnings)
}
})
t.Run("cyclic include is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\n")
env.writePamFile(t, "system-auth", "auth include login\n")
result := validateLockscreenPam("login", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected invalid on cyclic include, got %+v", result)
}
if !containsSubstr(result.Errors, "cyclic PAM include detected") {
t.Fatalf("expected cyclic include error, got %v", result.Errors)
}
})
t.Run("no auth directives is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected invalid when no auth directives, got %+v", result)
}
if !containsSubstr(result.Errors, "no auth directives") {
t.Fatalf("expected no-auth error, got %v", result.Errors)
}
})
t.Run("missing file is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
result := validateLockscreenPam("", filepath.Join(env.pamDir, "does-not-exist"), env.validateDeps())
if result.Valid || len(result.Errors) == 0 {
t.Fatalf("expected invalid for missing file, got %+v", result)
}
})
t.Run("inline fingerprint and u2f produce warnings", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_fprintd.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid, got %+v", result)
}
if !result.InlineFingerprint || !result.InlineU2f {
t.Fatalf("expected inline flags set, got %+v", result)
}
if !containsSubstr(result.Warnings, "pam_fprintd") || !containsSubstr(result.Warnings, "pam_u2f") {
t.Fatalf("expected double-prompt warnings, got %v", result.Warnings)
}
})
}
func TestValidateLockscreenU2fPam(t *testing.T) {
t.Parallel()
t.Run("accepts a dedicated U2F stack with custom options", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue authfile=/etc/u2f-mappings\naccount required pam_permit.so\n")
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid dedicated U2F stack, got %+v", result)
}
if !result.InlineU2f {
t.Fatalf("expected inline U2F detection, got %+v", result)
}
if containsSubstr(result.Warnings, "double-prompt") {
t.Fatalf("dedicated U2F validation should not warn about its expected U2F module: %v", result.Warnings)
}
})
t.Run("rejects a primary login stack that also prompts for a password", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "login", "#%PAM-1.0\nauth required pam_unix.so\nauth required pam_u2f.so cue\naccount required pam_unix.so\n")
result := validateLockscreenU2fPam("login", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected mixed password/U2F stack to be rejected, got %+v", result)
}
if !containsSubstr(result.Errors, "pam_unix.so") || !containsSubstr(result.Errors, "dedicated security-key") {
t.Fatalf("expected actionable mixed-stack error, got %v", result.Errors)
}
})
t.Run("rejects a stack without pam_u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "password-only", "#%PAM-1.0\nauth required pam_unix.so\n")
result := validateLockscreenU2fPam("password-only", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "pam_u2f") {
t.Fatalf("expected missing-U2F error, got %+v", result)
}
})
t.Run("does not accept a similarly named module as pam_u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_u2f_helper.so"] = true
env.writePamFile(t, "not-u2f", "#%PAM-1.0\nauth required pam_u2f_helper.so\n")
result := validateLockscreenU2fPam("not-u2f", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "no pam_u2f auth directive") {
t.Fatalf("expected exact pam_u2f module validation, got %+v", result)
}
})
t.Run("rejects a missing pam_u2f module", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue\n")
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "pam_u2f.so is not installed") {
t.Fatalf("expected missing-module error, got %+v", result)
}
})
}
func containsSubstr(items []string, substr string) bool {
for _, item := range items {
if strings.Contains(item, substr) {
return true
}
}
return false
}
func TestSyncAuthConfigWithDeps(t *testing.T) { func TestSyncAuthConfigWithDeps(t *testing.T) {
t.Parallel() t.Parallel()
@@ -848,6 +1143,39 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
} }
}) })
t.Run("externally managed greetd is stripped and greeter sync skipped", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.writeSettings(t, `{"greeterPamExternallyManaged":true,"greeterEnableFprint":true}`)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\n"+
GreeterPamManagedBlockStart+"\n"+
"auth sufficient pam_fprintd.so max-tries=2 timeout=10\n"+
GreeterPamManagedBlockEnd+"\n")
var logs []string
err := syncAuthConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
greetd := readFileString(t, env.greetdPath)
if strings.Contains(greetd, GreeterPamManagedBlockStart) || strings.Contains(greetd, "pam_fprintd") {
t.Fatalf("expected DMS-managed block stripped from externally managed greetd:\n%s", greetd)
}
if !strings.Contains(greetd, "auth include system-auth") {
t.Fatalf("expected non-DMS greetd lines to remain:\n%s", greetd)
}
if !containsSubstr(logs, "externally managed") {
t.Fatalf("expected externally-managed skip log, got %v", logs)
}
})
t.Run("NixOS remains informational and non-mutating", func(t *testing.T) { t.Run("NixOS remains informational and non-mutating", func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -0,0 +1,25 @@
package qmlchecks
import (
"os"
"strings"
"testing"
)
func TestGreeterExternalAuthStatusUsesEffectiveFingerprintAvailability(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Greetd/GreeterContent.qml")
if err != nil {
t.Fatalf("read greeter QML: %v", err)
}
content := string(data)
for _, required := range []string{
"readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f",
"if (greeterPamHasFprint && greeterPamHasU2f)",
"if (greeterPamHasFprint)",
} {
if !strings.Contains(content, required) {
t.Fatalf("greeter external-auth status must contain %q", required)
}
}
}
@@ -23,3 +23,51 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control") t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
} }
} }
func TestLockScreenAuthenticationCardOwnsFactorControls(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Settings/LockScreenTab.qml")
if err != nil {
t.Fatalf("read lock screen settings QML: %v", err)
}
content := string(data)
authCard := strings.Index(content, `title: I18n.tr("Authentication")`)
behaviorCard := strings.Index(content, `title: I18n.tr("Behavior")`)
fingerprintToggle := strings.Index(content, `settingKey: "enableFprint"`)
u2fToggle := strings.Index(content, `settingKey: "enableU2f"`)
u2fSource := strings.Index(content, `settingKey: "lockU2fPamPath"`)
if authCard < 0 || behaviorCard < 0 || fingerprintToggle < 0 || u2fToggle < 0 || u2fSource < 0 {
t.Fatalf("expected authentication card, factor toggles, and U2F source setting")
}
for name, position := range map[string]int{
"fingerprint toggle": fingerprintToggle,
"U2F toggle": u2fToggle,
"U2F source": u2fSource,
} {
if position < authCard || position > behaviorCard {
t.Fatalf("%s must remain in the authentication card", name)
}
}
}
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
if err != nil {
t.Fatalf("read lock screen PAM QML: %v", err)
}
content := string(data)
for _, required := range []string{
"SettingsData.lockPamExternallyManaged",
"SettingsData.lockU2fPamPath",
"customU2fPamActive",
"u2fSuppressedByPrimaryPam",
} {
if !strings.Contains(content, required) {
t.Fatalf("lock screen PAM must contain %q", required)
}
}
if strings.Contains(content, "runningFromNixStore || resolveUserPam.running") {
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
}
}
+22 -33
View File
@@ -30,41 +30,30 @@ func DetectCompositor() Compositor {
return detectedCompositor return detectedCompositor
} }
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") candidates := []struct {
niriSocket := os.Getenv("NIRI_SOCKET") socket string
swaySocket := os.Getenv("SWAYSOCK") needsStat bool
scrollSocket := os.Getenv("SCROLLSOCK") compositor Compositor
miracleSocket := os.Getenv("MIRACLESOCK") }{
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE") {os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
{os.Getenv("MIRACLESOCK"), true, CompositorMiracle},
{os.Getenv("SWAYSOCK"), true, CompositorSway},
{os.Getenv("HYPRLAND_INSTANCE_SIGNATURE"), false, CompositorHyprland},
}
switch { // A stale env var from a previous session must not mask the live compositor
case mangoSocket != "": for _, c := range candidates {
if _, err := os.Stat(mangoSocket); err == nil { if c.socket == "" {
detectedCompositor = CompositorMango continue
return detectedCompositor
} }
case niriSocket != "": if c.needsStat {
if _, err := os.Stat(niriSocket); err == nil { if _, err := os.Stat(c.socket); err != nil {
detectedCompositor = CompositorNiri continue
return detectedCompositor }
} }
case scrollSocket != "": detectedCompositor = c.compositor
if _, err := os.Stat(scrollSocket); err == nil {
detectedCompositor = CompositorScroll
return detectedCompositor
}
case miracleSocket != "":
if _, err := os.Stat(miracleSocket); err == nil {
detectedCompositor = CompositorMiracle
return detectedCompositor
}
case swaySocket != "":
if _, err := os.Stat(swaySocket); err == nil {
detectedCompositor = CompositorSway
return detectedCompositor
}
case hyprlandSig != "":
detectedCompositor = CompositorHyprland
return detectedCompositor return detectedCompositor
} }
@@ -90,7 +79,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
case CompositorMango: case CompositorMango:
return getMangoActiveWindow() return getMangoActiveWindow()
default: default:
return nil, fmt.Errorf("window capture requires Hyprland or Mango") return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
} }
} }
+52
View File
@@ -54,6 +54,58 @@ func BufferToImageWithFormat(buf *ShmBuffer, format uint32) *image.RGBA {
return img return img
} }
func ImageToBuffer(img image.Image) (*ShmBuffer, error) {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
buf, err := CreateShmBuffer(w, h, w*4)
if err != nil {
return nil, err
}
data := buf.Data()
switch src := img.(type) {
case *image.NRGBA:
for y := range h {
srcOff := y * src.Stride
dstOff := y * buf.Stride
for x := range w {
si, di := srcOff+x*4, dstOff+x*4
a := uint32(src.Pix[si+3])
data[di+0] = uint8(uint32(src.Pix[si+2]) * a / 255)
data[di+1] = uint8(uint32(src.Pix[si+1]) * a / 255)
data[di+2] = uint8(uint32(src.Pix[si+0]) * a / 255)
data[di+3] = uint8(a)
}
}
case *image.RGBA:
for y := range h {
srcOff := y * src.Stride
dstOff := y * buf.Stride
for x := range w {
si, di := srcOff+x*4, dstOff+x*4
data[di+0] = src.Pix[si+2]
data[di+1] = src.Pix[si+1]
data[di+2] = src.Pix[si+0]
data[di+3] = src.Pix[si+3]
}
}
default:
for y := range h {
dstOff := y * buf.Stride
for x := range w {
cr, cg, cb, ca := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
di := dstOff + x*4
data[di+0] = uint8(cb >> 8)
data[di+1] = uint8(cg >> 8)
data[di+2] = uint8(cr >> 8)
data[di+3] = uint8(ca >> 8)
}
}
}
buf.Format = FormatARGB8888
return buf, nil
}
func EncodePNG(w io.Writer, img image.Image) error { func EncodePNG(w io.Writer, img image.Image) error {
enc := png.Encoder{CompressionLevel: png.BestSpeed} enc := png.Encoder{CompressionLevel: png.BestSpeed}
return enc.Encode(w, img) return enc.Encode(w, img)
+164
View File
@@ -0,0 +1,164 @@
package screenshot
import (
"bufio"
"encoding/json"
"fmt"
"image"
"image/png"
"net"
"os"
"path/filepath"
"time"
)
const niriScreenshotTimeout = 5 * time.Second
// CaptureNiriWindowImage captures the focused window through niri's
// ScreenshotWindow action; niri replies before writing the file, so a second
// event-stream connection waits for ScreenshotCaptured. niri also copies the
// capture to its own clipboard, which cannot be disabled.
func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
socket := os.Getenv("NIRI_SOCKET")
if socket == "" {
return nil, fmt.Errorf("NIRI_SOCKET not set")
}
dir := os.Getenv("XDG_RUNTIME_DIR")
if dir == "" {
dir = os.TempDir()
}
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
events, err := subscribeNiriEvents(socket)
if err != nil {
return nil, err
}
defer events.Close()
if err := requestNiriWindowScreenshot(socket, path, showPointer); err != nil {
return nil, err
}
defer os.Remove(path)
if err := awaitNiriScreenshot(events, path); err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open niri screenshot: %w", err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode niri screenshot: %w", err)
}
return img, nil
}
func subscribeNiriEvents(socket string) (net.Conn, error) {
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
if err != nil {
return nil, fmt.Errorf("connect niri socket: %w", err)
}
_ = conn.SetDeadline(time.Now().Add(niriScreenshotTimeout))
if _, err := conn.Write([]byte("\"EventStream\"\n")); err != nil {
conn.Close()
return nil, fmt.Errorf("subscribe niri events: %w", err)
}
return conn, nil
}
func awaitNiriScreenshot(events net.Conn, path string) error {
scanner := bufio.NewScanner(events)
scanner.Buffer(make([]byte, 0, 64<<10), 1<<20)
for scanner.Scan() {
var event struct {
ScreenshotCaptured *struct {
Path string `json:"path"`
} `json:"ScreenshotCaptured"`
}
if json.Unmarshal(scanner.Bytes(), &event) != nil {
continue
}
if event.ScreenshotCaptured != nil && event.ScreenshotCaptured.Path == path {
return nil
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("await niri screenshot: %w", err)
}
return fmt.Errorf("niri event stream closed before screenshot completed")
}
func requestNiriWindowScreenshot(socket, path string, showPointer bool) error {
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
if err != nil {
return fmt.Errorf("connect niri socket: %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
request := map[string]any{
"Action": map[string]any{
"ScreenshotWindow": map[string]any{
"id": nil,
"write_to_disk": true,
"show_pointer": showPointer,
"path": path,
},
},
}
payload, err := json.Marshal(request)
if err != nil {
return err
}
if _, err := conn.Write(append(payload, '\n')); err != nil {
return fmt.Errorf("niri request: %w", err)
}
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return fmt.Errorf("niri reply: %w", err)
}
var reply map[string]json.RawMessage
if err := json.Unmarshal(line, &reply); err != nil {
return fmt.Errorf("parse niri reply: %w", err)
}
if raw, ok := reply["Err"]; ok {
var msg string
_ = json.Unmarshal(raw, &msg)
return fmt.Errorf("niri screenshot: %s", msg)
}
return nil
}
func (s *Screenshoter) captureNiriWindow() (*CaptureResult, error) {
img, err := CaptureNiriWindowImage(s.config.Cursor == CursorOn)
if err != nil {
return nil, err
}
buf, err := ImageToBuffer(img)
if err != nil {
return nil, err
}
scale := 1.0
if output := s.findOutputByName(GetFocusedMonitor()); output != nil {
scale = output.effectiveScale()
}
return &CaptureResult{
Buffer: buf,
YInverted: false,
Format: uint32(FormatARGB8888),
Scale: scale,
}, nil
}
+32 -8
View File
@@ -95,6 +95,9 @@ type RegionSelector struct {
showCapturedCursor bool showCapturedCursor bool
shiftHeld bool shiftHeld bool
phase selectorPhase
scroll *scrollSession
running bool running bool
cancelled bool cancelled bool
result Region result Region
@@ -167,20 +170,32 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
r.running = true r.running = true
for r.running { for r.running {
if err := r.ctx.Dispatch(); err != nil { if err := r.dispatchOrTick(); err != nil {
return nil, false, fmt.Errorf("dispatch: %w", err) return nil, false, fmt.Errorf("dispatch: %w", err)
} }
} }
if r.scroll != nil && r.scroll.abortErr != nil {
return nil, false, r.scroll.abortErr
}
if r.cancelled || r.capturedBuffer == nil { if r.cancelled || r.capturedBuffer == nil {
return nil, r.cancelled, nil return nil, r.cancelled, nil
} }
yInverted := false yInverted := false
var format uint32 var format uint32
scale := 1.0
if r.selection.surface != nil { if r.selection.surface != nil {
yInverted = r.selection.surface.yInverted yInverted = r.selection.surface.yInverted
format = r.selection.surface.screenFormat format = r.selection.surface.screenFormat
if s := r.selection.surface.output.fractionalScale; s > 0 {
scale = s
}
}
if r.scroll != nil {
yInverted = false
format = uint32(r.scroll.format)
} }
return &CaptureResult{ return &CaptureResult{
@@ -188,6 +203,7 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
Region: r.result, Region: r.result,
YInverted: yInverted, YInverted: yInverted,
Format: format, Format: format,
Scale: scale,
}, false, nil }, false, nil
} }
@@ -691,7 +707,9 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
} }
slot.pool = pool slot.pool = pool
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat) // niri latches surface opacity from the first buffer's format
// (observed), so slots are ARGB from the start with A=255 when opaque
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), alphaFormat(os.screenFormat))
if err != nil { if err != nil {
log.Error("create render slot wl_buffer failed", "err", err) log.Error("create render slot wl_buffer failed", "err", err)
pool.Destroy() pool.Destroy()
@@ -733,8 +751,9 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
x1 := float64(r.preSelect.X-os.output.x) * scaleX x1 := float64(r.preSelect.X-os.output.x) * scaleX
y1 := float64(r.preSelect.Y-os.output.y) * scaleY y1 := float64(r.preSelect.Y-os.output.y) * scaleY
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX // selection edges are inclusive; the exclusive width edge is one device px past it
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width)*scaleX - scaleX
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height)*scaleY - scaleY
r.selection.hasSelection = true r.selection.hasSelection = true
r.selection.dragging = false r.selection.dragging = false
@@ -764,10 +783,13 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
return return
} }
slot.shm.CopyFrom(srcBuf) switch r.phase {
case phaseScroll:
// Draw overlay (dimming + selection) into this slot r.drawScrollOverlay(os, slot.shm)
r.drawOverlay(os, slot.shm) default:
slot.shm.CopyFrom(srcBuf)
r.drawOverlay(os, slot.shm)
}
if os.viewport != nil { if os.viewport != nil {
_ = os.wlSurface.SetBufferScale(1) _ = os.wlSurface.SetBufferScale(1)
@@ -803,6 +825,8 @@ func (r *RegionSelector) cleanup() {
r.cursorBuffer.Close() r.cursorBuffer.Close()
} }
r.cleanupScroll()
for _, os := range r.surfaces { for _, os := range r.surfaces {
for _, slot := range os.slots { for _, slot := range os.slots {
if slot == nil { if slot == nil {
+49 -24
View File
@@ -94,6 +94,20 @@ func (r *RegionSelector) setupPointerHandlers() {
return return
} }
if r.phase == phaseScroll {
if e.Button != 0x110 || e.State != 1 || r.activeSurface != r.selection.surface {
return
}
switch r.scrollBarHit(r.pointerX, r.pointerY) {
case "done":
r.finishScroll()
case "cancel":
r.cancelled = true
r.running = false
}
return
}
switch e.Button { switch e.Button {
case 0x110: // BTN_LEFT case 0x110: // BTN_LEFT
switch e.State { switch e.State {
@@ -135,6 +149,17 @@ func (r *RegionSelector) setupKeyboardHandlers() {
return return
} }
if r.phase == phaseScroll {
switch e.Key {
case 1:
r.cancelled = true
r.running = false
case 28, 96:
r.finishScroll()
}
return
}
switch e.Key { switch e.Key {
case 1: case 1:
r.cancelled = true r.cancelled = true
@@ -152,17 +177,15 @@ func (r *RegionSelector) setupKeyboardHandlers() {
}) })
} }
func (r *RegionSelector) finishSelection() { func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
if r.selection.surface == nil { if r.selection.surface == nil {
r.running = false return nil, 0, 0, 0, 0
return
} }
os := r.selection.surface os := r.selection.surface
srcBuf := r.getSourceBuffer(os) srcBuf := r.getSourceBuffer(os)
if srcBuf == nil { if srcBuf == nil {
r.running = false return nil, 0, 0, 0, 0
return
} }
x1, y1 := r.selection.anchorX, r.selection.anchorY x1, y1 := r.selection.anchorX, r.selection.anchorY
@@ -181,24 +204,10 @@ func (r *RegionSelector) finishSelection() {
scaleY = float64(srcBuf.Height) / float64(os.logicalH) scaleY = float64(srcBuf.Height) / float64(os.logicalH)
} }
bx1 := int(x1 * scaleX) bx1 := clamp(int(x1*scaleX), 0, srcBuf.Width)
by1 := int(y1 * scaleY) by1 := clamp(int(y1*scaleY), 0, srcBuf.Height)
bx2 := int(x2 * scaleX) bx2 := clamp(int(x2*scaleX), 0, srcBuf.Width)
by2 := int(y2 * scaleY) by2 := clamp(int(y2*scaleY), 0, srcBuf.Height)
// Clamp to buffer bounds
if bx1 < 0 {
bx1 = 0
}
if by1 < 0 {
by1 = 0
}
if bx2 > srcBuf.Width {
bx2 = srcBuf.Width
}
if by2 > srcBuf.Height {
by2 = srcBuf.Height
}
w, h := bx2-bx1+1, by2-by1+1 w, h := bx2-bx1+1, by2-by1+1
if r.shiftHeld && w != h { if r.shiftHeld && w != h {
@@ -215,7 +224,23 @@ func (r *RegionSelector) finishSelection() {
h = 1 h = 1
} }
// Create cropped buffer and copy pixels directly return os, bx1, by1, w, h
}
func (r *RegionSelector) finishSelection() {
os, bx1, by1, w, h := r.selectionDeviceRect()
if os == nil {
r.running = false
return
}
if r.screenshoter != nil && r.screenshoter.config.Mode == ModeScroll {
r.enterScrollPhase(os, bx1, by1, w, h)
return
}
srcBuf := r.getSourceBuffer(os)
cropped, err := CreateShmBuffer(w, h, w*4) cropped, err := CreateShmBuffer(w, h, w*4)
if err != nil { if err != nil {
r.running = false r.running = false
+78 -2
View File
@@ -57,7 +57,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
w, h := renderBuf.Width, renderBuf.Height w, h := renderBuf.Width, renderBuf.Height
format := os.screenFormat format := os.screenFormat
// Dim the entire buffer // dim, forcing alpha: the X-format source's padding byte is undefined
for y := 0; y < h; y++ { for y := 0; y < h; y++ {
off := y * stride off := y * stride
for x := 0; x < w; x++ { for x := 0; x < w; x++ {
@@ -68,6 +68,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data[i+0] = uint8(int(data[i+0]) * 3 / 5) data[i+0] = uint8(int(data[i+0]) * 3 / 5)
data[i+1] = uint8(int(data[i+1]) * 3 / 5) data[i+1] = uint8(int(data[i+1]) * 3 / 5)
data[i+2] = uint8(int(data[i+2]) * 3 / 5) data[i+2] = uint8(int(data[i+2]) * 3 / 5)
data[i+3] = 255
} }
} }
@@ -110,7 +111,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data[di+0] = srcData[si+0] data[di+0] = srcData[si+0]
data[di+1] = srcData[si+1] data[di+1] = srcData[si+1]
data[di+2] = srcData[si+2] data[di+2] = srcData[si+2]
data[di+3] = srcData[si+3] data[di+3] = 255
} }
} }
@@ -126,6 +127,81 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format) r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format)
} }
func (r *RegionSelector) drawScrollOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data := renderBuf.Data()
stride := renderBuf.Stride
w, h := renderBuf.Width, renderBuf.Height
// 40% premultiplied scrim
for y := 0; y < h; y++ {
off := y * stride
for x := 0; x < w; x++ {
i := off + x*4
if i+3 >= len(data) {
continue
}
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 102
}
}
s := r.scroll
if s == nil || r.selection.surface != os {
return
}
// hole oversized 2px so overlay pixels never land in captured frames
holeX := s.holeX - 2
holeY := s.holeY - 2
holeW := s.holeW + 4
holeH := s.holeH + 4
x1 := clamp(holeX, 0, w)
y1 := clamp(holeY, 0, h)
x2 := clamp(holeX+holeW, 0, w)
y2 := clamp(holeY+holeH, 0, h)
for y := y1; y < y2; y++ {
off := y * stride
for x := x1; x < x2; x++ {
i := off + x*4
if i+3 >= len(data) {
continue
}
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 0
}
}
r.drawBorder(data, stride, w, h, holeX-1, holeY-1, holeW+2, holeH+2, os.screenFormat)
r.drawScrollBar(data, stride, w, h, os.screenFormat)
}
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
s := r.scroll
style := LoadOverlayStyle()
const charH = 12
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format)
labelY := s.doneY + (s.btnH-charH)/2
r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
style.AccentR, style.AccentG, style.AccentB, 255, format)
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
r.fillRect(data, stride, bufW, bufH, s.cancelX, s.cancelY, s.cancelW, s.btnH,
70, 70, 70, 255, format)
r.drawText(data, stride, bufW, bufH, s.cancelX+12, labelY, "cancel",
style.TextR, style.TextG, style.TextB, format)
rows := 0
if s.st != nil {
rows = s.st.rows()
}
counter := fmt.Sprintf("%d shots %dpx", s.kept, rows)
r.drawText(data, stride, bufW, bufH, s.cancelX+s.cancelW+16, labelY, counter,
style.TextR, style.TextG, style.TextB, format)
}
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) { func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
if r.selection.dragging { if r.selection.dragging {
return return
+33 -22
View File
@@ -28,6 +28,21 @@ type CaptureResult struct {
Region Region Region Region
YInverted bool YInverted bool
Format uint32 Format uint32
Scale float64
}
func (o *WaylandOutput) effectiveScale() float64 {
scale := o.fractionalScale
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(o.name)
}
if scale <= 0 {
scale = float64(o.scale)
}
if scale <= 0 {
return 1.0
}
return scale
} }
type Screenshoter struct { type Screenshoter struct {
@@ -77,7 +92,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
switch s.config.Mode { switch s.config.Mode {
case ModeLastRegion: case ModeLastRegion:
return s.captureLastRegion() return s.captureLastRegion()
case ModeRegion: case ModeRegion, ModeScroll:
return s.captureRegion() return s.captureRegion()
case ModeWindow: case ModeWindow:
return s.captureWindow() return s.captureWindow()
@@ -130,6 +145,10 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
} }
func (s *Screenshoter) captureWindow() (*CaptureResult, error) { func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorNiri {
return s.captureNiriWindow()
}
geom, err := GetActiveWindow() geom, err := GetActiveWindow()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -255,6 +274,7 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
Region: region, Region: region,
YInverted: false, YInverted: false,
Format: result.Format, Format: result.Format,
Scale: scale,
}, nil }, nil
} }
@@ -430,6 +450,7 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
Buffer: composite, Buffer: composite,
Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)}, Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)},
Format: format, Format: format,
Scale: maxScale,
}, nil }, nil
} }
@@ -502,6 +523,7 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
if err != nil { if err != nil {
return nil, err return nil, err
} }
result.Scale = output.effectiveScale()
if result.YInverted { if result.YInverted {
result.Buffer.FlipVertical() result.Buffer.FlipVertical()
@@ -604,6 +626,7 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
Region: region, Region: region,
YInverted: false, YInverted: false,
Format: result.Format, Format: result.Format,
Scale: scale,
}, nil }, nil
} }
@@ -612,16 +635,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
return s.captureRegionOnTransformedOutput(output, region) return s.captureRegionOnTransformedOutput(output, region)
} }
scale := output.fractionalScale scale := output.effectiveScale()
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(output.name)
}
if scale <= 0 {
scale = float64(output.scale)
}
if scale <= 0 {
scale = 1.0
}
localX := int32(float64(region.X-output.x) * scale) localX := int32(float64(region.X-output.x) * scale)
localY := int32(float64(region.Y-output.y) * scale) localY := int32(float64(region.Y-output.y) * scale)
@@ -660,7 +674,12 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
return nil, fmt.Errorf("capture region: %w", err) return nil, fmt.Errorf("capture region: %w", err)
} }
return s.processFrame(frame, region) result, err := s.processFrame(frame, region)
if err != nil {
return nil, err
}
result.Scale = scale
return result, nil
} }
func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) { func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
@@ -669,16 +688,7 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
return nil, err return nil, err
} }
scale := output.fractionalScale scale := output.effectiveScale()
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(output.name)
}
if scale <= 0 {
scale = float64(output.scale)
}
if scale <= 0 {
scale = 1.0
}
localX := int(float64(region.X-output.x) * scale) localX := int(float64(region.X-output.x) * scale)
localY := int(float64(region.Y-output.y) * scale) localY := int(float64(region.Y-output.y) * scale)
@@ -730,6 +740,7 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
Region: region, Region: region,
YInverted: false, YInverted: false,
Format: result.Format, Format: result.Format,
Scale: scale,
}, nil }, nil
} }
+574
View File
@@ -0,0 +1,574 @@
package screenshot
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"os/signal"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
type selectorPhase int
const (
phaseSelect selectorPhase = iota
phaseScroll
)
const (
scrollMaxFailures = 5
scrollSeamTicks = 4
)
type scrollSession struct {
output *WaylandOutput
// wire coords for CaptureOutputRegion (logical or device px per compositor)
capX, capY, capW, capH int32
// device-pixel rect in the overlay buffer, for hole/border drawing
holeX, holeY, holeW, holeH int
interval time.Duration
nextTick time.Time
inFlight bool
failures int
kept int
abortErr error
buf *ShmBuffer
pool *client.ShmPool
wlBuf *client.Buffer
frame *wlr_screencopy.ZwlrScreencopyFrameV1
format PixelFormat
frameW, frameH int
yInverted bool
prevSig []float32
prevPlaced bool
unmatched bool
unmatchedTicks int
// control bar geometry in overlay buffer pixels
barX, barY, barW, barH int
doneX, doneY, doneW int
cancelX, cancelY int
cancelW int
btnH int
sigCh chan os.Signal
keysBound bool
st *stitcher
}
func (r *RegionSelector) dispatchOrTick() error {
timeout := -1
if s := r.scroll; r.phase == phaseScroll && s != nil && s.sigCh != nil {
select {
case sig := <-s.sigCh:
switch sig {
case unix.SIGUSR2:
r.cancelled = true
r.running = false
default:
r.finishScroll()
}
return nil
default:
}
}
if s := r.scroll; r.phase == phaseScroll && s.abortErr == nil && (s.st == nil || !s.st.full) {
timeout = max(int(time.Until(s.nextTick).Milliseconds()), 0)
}
fds := []unix.PollFd{{Fd: int32(r.ctx.Fd()), Events: unix.POLLIN}}
n, err := unix.Poll(fds, timeout)
switch {
case err == unix.EINTR:
return nil
case err != nil:
return err
case n > 0:
return r.ctx.Dispatch()
}
r.scrollTick()
return nil
}
func (r *RegionSelector) scrollTick() {
s := r.scroll
if s == nil {
return
}
if s.inFlight || (s.st != nil && s.st.full) {
s.nextTick = time.Now().Add(s.interval)
return
}
r.startScrollCapture()
}
func (r *RegionSelector) enterScrollPhase(os *OutputSurface, x, y, w, h int) {
switch {
case os.output.transform != TransformNormal:
r.abortScroll(fmt.Errorf("scroll capture does not support rotated outputs"))
return
case w < 1 || h < 1:
r.abortScroll(fmt.Errorf("empty scroll capture region"))
return
}
interval := 45
if r.screenshoter != nil && r.screenshoter.config.IntervalMs > 0 {
interval = r.screenshoter.config.IntervalMs
}
capX, capY, capW, capH := x, y, w, h
switch DetectCompositor() {
case CompositorHyprland, CompositorMango:
// both take device pixels, deviating from spec (observed)
default:
// spec: logical coordinates, scaled by the compositor
// https://wayland.app/protocols/wlr-screencopy-unstable-v1#zwlr_screencopy_manager_v1:request:capture_output_region
if scale := os.output.fractionalScale; scale > 1 {
capX = int(float64(x)/scale + 0.5)
capY = int(float64(y)/scale + 0.5)
capW = int(float64(w)/scale + 0.5)
capH = int(float64(h)/scale + 0.5)
}
}
r.scroll = &scrollSession{
output: os.output,
capX: int32(capX),
capY: int32(capY),
capW: int32(capW),
capH: int32(capH),
holeX: x,
holeY: y,
holeW: w,
holeH: h,
interval: time.Duration(interval) * time.Millisecond,
nextTick: time.Now(),
}
r.layoutScrollBar(os)
for _, surf := range r.surfaces {
r.setInputPassthrough(surf, surf == os)
}
// Hyprland routes all pointer input to exclusive-keyboard layers
// (https://github.com/hyprwm/Hyprland/discussions/14136), so the keyboard
// is released there and Enter/Esc come back via temporary global binds
if DetectCompositor() == CompositorHyprland {
r.enterHyprlandScrollInput(os)
}
r.phase = phaseScroll
for _, surf := range r.surfaces {
r.redrawSurface(surf)
}
}
// sized for the worst-case counter so the input region is set once
func (r *RegionSelector) layoutScrollBar(os *OutputSurface) {
s := r.scroll
const charAdv, pad, gap = 9, 12, 16
s.btnH = 24
s.doneW = len("done")*charAdv + 24
s.cancelW = len("cancel")*charAdv + 24
counterW := len("99999 shots 999999px") * charAdv
s.barW = pad + s.doneW + gap + s.cancelW + gap + counterW + pad
s.barH = s.btnH + 24
bufW, bufH := os.screenBuf.Width, os.screenBuf.Height
s.barX = (bufW - s.barW) / 2
s.barY = bufH - s.barH - 24
borderX1, borderY1 := s.holeX-3, s.holeY-3
borderX2, borderY2 := s.holeX+s.holeW+3, s.holeY+s.holeH+3
overlaps := s.barX < borderX2 && s.barX+s.barW > borderX1 &&
s.barY < borderY2 && s.barY+s.barH > borderY1
if overlaps {
s.barY = 24
}
s.doneX = s.barX + pad
s.doneY = s.barY + (s.barH-s.btnH)/2
s.cancelX = s.doneX + s.doneW + gap
s.cancelY = s.doneY
}
func (r *RegionSelector) setInputPassthrough(os *OutputSurface, withBar bool) {
reg, err := r.compositor.CreateRegion()
if err != nil {
return
}
if withBar && os.screenBuf != nil && os.logicalW > 0 {
s := r.scroll
scaleX := float64(os.logicalW) / float64(os.screenBuf.Width)
scaleY := float64(os.logicalH) / float64(os.screenBuf.Height)
_ = reg.Add(int32(float64(s.barX)*scaleX), int32(float64(s.barY)*scaleY),
int32(float64(s.barW)*scaleX)+1, int32(float64(s.barH)*scaleY)+1)
}
_ = os.wlSurface.SetInputRegion(reg)
_ = reg.Destroy()
}
func (r *RegionSelector) enterHyprlandScrollInput(osurf *OutputSurface) {
for _, surf := range r.surfaces {
_ = surf.layerSurf.SetKeyboardInteractivity(uint32(wlr_layer_shell.ZwlrLayerSurfaceV1KeyboardInteractivityNone))
}
if r.shortcutsInhibitor != nil {
_ = r.shortcutsInhibitor.Destroy()
r.shortcutsInhibitor = nil
}
s := r.scroll
scale := osurf.output.fractionalScale
if scale <= 0 {
scale = 1
}
cx := int(float64(osurf.output.x) + float64(s.holeX+s.holeW/2)/scale)
cy := int(float64(osurf.output.y) + float64(s.holeY+s.holeH/2)/scale)
hyprlandFocusWindowAt(cx, cy)
s.sigCh = make(chan os.Signal, 2)
signal.Notify(s.sigCh, unix.SIGUSR1, unix.SIGUSR2)
s.keysBound = hyprlandBindScrollKeys(os.Getpid())
}
func hyprlandFocusWindowAt(x, y int) {
out, err := exec.Command("hyprctl", "-j", "clients").Output()
if err != nil {
return
}
var clients []struct {
Address string `json:"address"`
At [2]int `json:"at"`
Size [2]int `json:"size"`
Mapped bool `json:"mapped"`
Hidden bool `json:"hidden"`
FocusHistoryID int `json:"focusHistoryID"`
}
if json.Unmarshal(out, &clients) != nil {
return
}
best := -1
for i, c := range clients {
if !c.Mapped || c.Hidden {
continue
}
if x < c.At[0] || x >= c.At[0]+c.Size[0] || y < c.At[1] || y >= c.At[1]+c.Size[1] {
continue
}
if best < 0 || c.FocusHistoryID < clients[best].FocusHistoryID {
best = i
}
}
if best < 0 {
return
}
_ = exec.Command("hyprctl", "dispatch", "focuswindow", "address:"+clients[best].Address).Run()
}
func hyprlandBindScrollKeys(pid int) bool {
batch := fmt.Sprintf("keyword bind ,Return,exec,kill -USR1 %d ; keyword bind ,Escape,exec,kill -USR2 %d", pid, pid)
return exec.Command("hyprctl", "--batch", batch).Run() == nil
}
func hyprlandUnbindScrollKeys() {
_ = exec.Command("hyprctl", "--batch", "keyword unbind ,Return ; keyword unbind ,Escape").Run()
}
func (r *RegionSelector) scrollBarHit(x, y float64) string {
s := r.scroll
os := r.selection.surface
if s == nil || os == nil || os.screenBuf == nil || os.logicalW == 0 {
return ""
}
bx := int(x * float64(os.screenBuf.Width) / float64(os.logicalW))
by := int(y * float64(os.screenBuf.Height) / float64(os.logicalH))
switch {
case bx >= s.doneX && bx < s.doneX+s.doneW && by >= s.doneY && by < s.doneY+s.btnH:
return "done"
case bx >= s.cancelX && bx < s.cancelX+s.cancelW && by >= s.cancelY && by < s.cancelY+s.btnH:
return "cancel"
default:
return ""
}
}
func alphaFormat(format uint32) uint32 {
switch format {
case uint32(FormatXRGB8888):
return uint32(FormatARGB8888)
case uint32(FormatXBGR8888):
return uint32(FormatABGR8888)
default:
return format
}
}
func (r *RegionSelector) startScrollCapture() {
s := r.scroll
frame, err := r.screencopy.CaptureOutputRegion(0, s.output.wlOutput, s.capX, s.capY, s.capW, s.capH)
if err != nil {
r.abortScroll(fmt.Errorf("scroll capture: %w", err))
return
}
s.inFlight = true
s.frame = frame
s.nextTick = time.Now().Add(s.interval)
frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) {
if err := s.ensureCaptureBuffer(r, e); err != nil {
r.abortScroll(err)
return
}
if err := frame.Copy(s.wlBuf); err != nil {
log.Error("scroll frame copy failed", "err", err)
}
})
frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) {
s.yInverted = (e.Flags & 1) != 0
})
frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) {
frame.Destroy()
s.frame = nil
s.inFlight = false
s.failures = 0
s.nextTick = time.Now().Add(s.interval)
r.handleScrollFrame()
})
frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) {
frame.Destroy()
s.frame = nil
s.inFlight = false
s.failures++
s.nextTick = time.Now().Add(s.interval)
if s.failures >= scrollMaxFailures {
r.abortScroll(fmt.Errorf("screencopy failed %d consecutive times", s.failures))
}
})
}
func (s *scrollSession) ensureCaptureBuffer(r *RegionSelector, e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) error {
if s.buf != nil {
if int(e.Width) != s.frameW || int(e.Height) != s.frameH || PixelFormat(e.Format) != s.format {
return fmt.Errorf("output changed during scroll capture")
}
return nil
}
format := PixelFormat(e.Format)
if int(e.Stride) < int(e.Width)*format.BytesPerPixel() {
return fmt.Errorf("invalid stride from compositor: %d for width %d", e.Stride, e.Width)
}
buf, err := CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
if err != nil {
return fmt.Errorf("create scroll buffer: %w", err)
}
buf.Format = format
pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size()))
if err != nil {
buf.Close()
return fmt.Errorf("create scroll pool: %w", err)
}
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), e.Format)
if err != nil {
pool.Destroy()
buf.Close()
return fmt.Errorf("create scroll wl_buffer: %w", err)
}
s.buf = buf
s.pool = pool
s.wlBuf = wlBuf
s.format = format
s.frameW = int(e.Width)
s.frameH = int(e.Height)
return nil
}
func (r *RegionSelector) handleScrollFrame() {
s := r.scroll
if s == nil || s.buf == nil {
return
}
rows, err := s.extractRows()
if err != nil {
r.abortScroll(err)
return
}
if s.st == nil {
s.st = newStitcher(s.frameW * 4)
}
cols := s.st.rowSamples(rows)
sig := s.st.frameSig(rows)
dup := duplicateFrame(sig, s.prevSig)
s.prevSig = sig
// moving content: recapture at compositor speed, the timer paces idle only
if !dup {
s.nextTick = time.Now()
}
var added int
switch {
case dup && s.unmatched:
// settled somewhere unreachable: seam a new segment after a few ticks
s.unmatchedTicks++
if s.unmatchedTicks < scrollSeamTicks {
return
}
var placed bool
added, placed = s.st.pushFrame(rows, cols)
if !placed {
added = s.st.seamAppend(rows, cols)
}
s.prevPlaced = true
s.unmatched = false
s.unmatchedTicks = 0
case dup && s.prevPlaced:
return
default:
var placed bool
added, placed = s.st.pushFrame(rows, cols)
s.prevPlaced = placed
s.unmatched = !placed
s.unmatchedTicks = 0
}
if scrollDebug {
log.Error("scroll frame", "dup", dup, "unmatched", s.unmatched,
"placed", s.prevPlaced, "added", added, "canvas", s.st.rows(), "kept", s.kept)
}
if added == 0 {
return
}
s.kept++
if r.selection.surface != nil {
r.redrawSurface(r.selection.surface)
}
}
var scrollDebug = os.Getenv("DMS_SCROLL_DEBUG") != ""
func (s *scrollSession) extractRows() ([]byte, error) {
src := s.buf
format := s.format
if format.Is24Bit() {
converted, newFormat, err := src.ConvertTo32Bit(format)
if err != nil {
return nil, fmt.Errorf("convert scroll frame: %w", err)
}
defer converted.Close()
src = converted
s.format = newFormat
}
rows := make([]byte, s.frameW*4*s.frameH)
data := src.Data()
for y := 0; y < s.frameH; y++ {
srcY := y
if s.yInverted {
srcY = s.frameH - 1 - y
}
srcOff := srcY * src.Stride
dstOff := y * s.frameW * 4
if srcOff+s.frameW*4 > len(data) {
continue
}
copy(rows[dstOff:dstOff+s.frameW*4], data[srcOff:srcOff+s.frameW*4])
}
return rows, nil
}
func (r *RegionSelector) finishScroll() {
s := r.scroll
if s == nil || s.st == nil || s.st.rows() == 0 {
r.cancelled = true
r.running = false
return
}
buf, err := CreateShmBuffer(s.frameW, s.st.rows(), s.frameW*4)
if err != nil {
r.abortScroll(fmt.Errorf("create stitched buffer: %w", err))
return
}
copy(buf.Data(), s.st.canvas)
buf.Format = s.format
r.capturedBuffer = buf
r.capturedRegion = Region{
X: int32(s.holeX),
Y: int32(s.holeY),
Width: int32(s.holeW),
Height: int32(s.holeH),
Output: s.output.name,
}
// same convention as finishSelection or preselect breaks on scaled outputs
r.result = Region{
X: int32(s.holeX) + s.output.x,
Y: int32(s.holeY) + s.output.y,
Width: int32(s.holeW),
Height: int32(s.holeH),
Output: s.output.name,
}
r.running = false
}
func (r *RegionSelector) abortScroll(err error) {
if r.scroll == nil {
r.scroll = &scrollSession{}
}
r.scroll.abortErr = err
r.running = false
}
func (r *RegionSelector) cleanupScroll() {
s := r.scroll
if s == nil {
return
}
if s.keysBound {
hyprlandUnbindScrollKeys()
}
if s.sigCh != nil {
signal.Stop(s.sigCh)
}
if s.frame != nil {
s.frame.Destroy()
}
if s.wlBuf != nil {
s.wlBuf.Destroy()
}
if s.pool != nil {
s.pool.Destroy()
}
if s.buf != nil {
s.buf.Close()
}
}
+278
View File
@@ -0,0 +1,278 @@
package screenshot
import (
"math/rand"
"slices"
"testing"
)
// mirrors handleScrollFrame's stitch logic so glides run without a compositor
type simSession struct {
prevSig []float32
prevPlaced bool
unmatched bool
unmatchedTicks int
st *stitcher
}
func (s *simSession) observe(rows []byte) {
cols := s.st.rowSamples(rows)
sig := s.st.frameSig(rows)
dup := duplicateFrame(sig, s.prevSig)
s.prevSig = sig
switch {
case dup && s.unmatched:
s.unmatchedTicks++
if s.unmatchedTicks < scrollSeamTicks {
return
}
if _, placed := s.st.pushFrame(rows, cols); !placed {
s.st.seamAppend(rows, cols)
}
s.prevPlaced = true
s.unmatched = false
s.unmatchedTicks = 0
case dup && s.prevPlaced:
return
default:
_, placed := s.st.pushFrame(rows, cols)
s.prevPlaced = placed
s.unmatched = !placed
s.unmatchedTicks = 0
}
}
// the page at a fractional scroll offset, as a compositor renders mid-glide
func fractionalFrame(page []byte, stride, frameH int, offset float64) []byte {
top := int(offset)
frac := offset - float64(top)
out := make([]byte, frameH*stride)
for y := 0; y < frameH; y++ {
a := page[(top+y)*stride : (top+y+1)*stride]
b := page[(top+y+1)*stride : (top+y+2)*stride]
row := out[y*stride : (y+1)*stride]
for x := range row {
row[x] = byte(float64(a[x])*(1-frac) + float64(b[x])*frac)
}
}
return out
}
// blank gaps between paragraphs plus identical card blocks repeated around
func webbyPage(rng *rand.Rand, stride, rows int) []byte {
page := make([]byte, rows*stride)
card := make([]byte, 40*stride)
rng.Read(card)
row := 0
for row < rows {
switch rng.Intn(4) {
case 0: // blank gap
row += 10 + rng.Intn(20)
case 1: // repeated card block
n := copy(page[row*stride:], card)
row += n / stride
default: // paragraph of distinct rows
n := (8 + rng.Intn(22)) * stride
if row*stride+n > len(page) {
n = len(page) - row*stride
}
rng.Read(page[row*stride : row*stride+n])
row += n / stride
}
}
return page
}
// screen-fixed sidebar in the unsampled outer 8% plus per-frame hover noise
func addFixedChrome(rng *rand.Rand, frame []byte, stride, frameH int, sidebar []byte) {
sbw := len(sidebar) / frameH
for y := 0; y < frameH; y++ {
copy(frame[y*stride:y*stride+sbw], sidebar[y*sbw:(y+1)*sbw])
}
hoverTop := 40 + rng.Intn(frameH-80)
for y := hoverTop; y < hoverTop+24; y++ {
off := y*stride + stride/3
for x := 0; x < 60; x++ {
frame[off+x] ^= 0x08
}
}
}
// starting at the page bottom and scrolling up must prepend, never stall
func TestScrollSimulationBottomUp(t *testing.T) {
const stride = 2048
const frameH = 240
rng := rand.New(rand.NewSource(99))
page := webbyPage(rng, stride, 4000)
st := newStitcher(stride)
sidebar := make([]byte, frameH*140)
rng.Read(sidebar)
sim := &simSession{st: st}
pos := 3700.0
capture := func() []byte {
f := fractionalFrame(page, stride, frameH, pos)
addFixedChrome(rng, f, stride, frameH, sidebar)
return f
}
glide := func(target float64) {
for i := 0; ; i++ {
step := (target - pos) * 0.45
if step > -1 && step < 1 {
break
}
pos += step
if i%4 != 3 {
pos = float64(int(pos))
}
sim.observe(capture())
}
pos = target
sim.observe(capture())
sim.observe(capture())
}
sim.observe(capture())
for _, target := range []float64{3640, 3560, 3460, 3340, 3240} {
glide(target)
}
wantRows := (3700 + frameH) - 3240
got := sim.st.rows()
if got < wantRows-stitchMinAppend || got > wantRows+2 {
t.Fatalf("canvas has %d rows, want ~%d (upward scrolling must prepend)", got, wantRows)
}
topPage := 3240 + (wantRows - got)
for _, cr := range []int{0, 100, 300} {
if !rowMatchesPage(sim.st.canvas, page, stride, cr, topPage+cr) {
t.Fatalf("canvas row %d does not map onto page row %d", cr, topPage+cr)
}
}
}
// exact page row or a blend of neighbors, allowing a one-row offset
func rowMatchesPage(canvas, page []byte, stride, canvasRow, pageRow int) bool {
for x := 200; x < stride-1400; x++ {
c := int(canvas[canvasRow*stride+x])
lo, hi := 255, 0
for k := pageRow - 1; k <= pageRow+1; k++ {
v := int(page[k*stride+x])
lo, hi = min(lo, v), max(hi, v)
}
if c < lo-1 || c > hi+1 {
return false
}
}
return true
}
// a fling past a full frame height must seam a new segment, not go dead
func TestScrollSimulationFastFlingRecovers(t *testing.T) {
const stride = 2048
const frameH = 240
rng := rand.New(rand.NewSource(7))
page := webbyPage(rng, stride, 4000)
sim := &simSession{st: newStitcher(stride)}
frame := func(top int) []byte {
return slices.Clone(page[top*stride : (top+frameH)*stride])
}
rest := func(top int) {
for range scrollSeamTicks + 2 {
sim.observe(frame(top))
}
}
rest(0)
sim.observe(frame(60))
sim.observe(frame(130))
rest(130)
firstRange := 130 + frameH
sim.observe(frame(900))
sim.observe(frame(1400))
rest(1800)
sim.observe(frame(1860))
sim.observe(frame(1930))
rest(1930)
wantRows := firstRange + (1930 - 1800) + frameH
if got := sim.st.rows(); got != wantRows {
t.Fatalf("canvas has %d rows, want %d (first range %d + new segment)", got, wantRows, firstRange)
}
seamStart := firstRange
if !slices.Equal(sim.st.canvas[seamStart*stride:], page[1800*stride:(1930+frameH)*stride]) {
t.Fatal("new segment content wrong after fling recovery")
}
}
// eased glides with up/down scrubbing must cover the range exactly once
func TestScrollSimulationSmoothGlide(t *testing.T) {
const stride = 2048
const frameH = 240
rng := rand.New(rand.NewSource(99))
page := webbyPage(rng, stride, 4000)
st := newStitcher(stride)
sidebar := make([]byte, frameH*140)
rng.Read(sidebar)
sim := &simSession{st: st}
pos := 0.0
capture := func() []byte {
f := fractionalFrame(page, stride, frameH, pos)
addFixedChrome(rng, f, stride, frameH, sidebar)
return f
}
glide := func(target float64) {
for i := 0; ; i++ {
step := (target - pos) * 0.45
if step > -1 && step < 1 {
break
}
pos += step
// mostly snapped to device pixels, with the odd fractional frame
if i%4 != 3 {
pos = float64(int(pos))
}
sim.observe(capture())
}
pos = target
sim.observe(capture())
sim.observe(capture())
}
sim.observe(capture())
for _, target := range []float64{160, 330, 480, 650, 800, 960, 1100} {
glide(target)
}
for _, target := range []float64{700, 300, 900, 1100} {
glide(target)
}
wantRows := 1100 + frameH
got := sim.st.rows()
if got < wantRows-stitchMinAppend || got > wantRows+2 {
t.Fatalf("canvas has %d rows, want ~%d (more = duplicated bands, fewer = gaps)", got, wantRows)
}
hoverLo, hoverHi := stride/3, stride/3+60
mismatched := 0
for row := 0; row < min(got, wantRows); row += 7 {
off := row * stride
a1, b1 := sim.st.canvas[off+200:off+hoverLo], page[off+200:off+hoverLo]
a2, b2 := sim.st.canvas[off+hoverHi:off+stride], page[off+hoverHi:off+stride]
if !slices.Equal(a1, b1) || !slices.Equal(a2, b2) {
mismatched++
}
}
if mismatched > (wantRows/7)/20 {
t.Fatalf("%d of %d sampled rows mismatch page content (mid-animation pixels baked in)", mismatched, wantRows/7)
}
}
+407
View File
@@ -0,0 +1,407 @@
package screenshot
// Frame stitcher after mark-shot's column-sampling design
// (https://github.com/jswysnemc/mark-shot, src/scroll/stitcher_algorithm.cpp).
// Only rows overhanging the captured range are committed; frames that match
// nothing are dropped without touching state.
const (
stitchMaxCanvasBytes = 256 << 20
stitchMaxRowsCap = 30000
// mark-shot: StitchConfig{100, 9.0f, 15, 1.0f}
stitchAcceptDiff = 9.0
stitchApproxDiff = 1.0
stitchMinCompare = 50
stitchMinCanvas = 100
stitchMinAppend = 15
stitchCoarseStep = 8
stitchPredictWindow = 160
stitchBandSamples = 17
// mark-shot: kDuplicateAvgDiff=1.1f, kDuplicateMaxDiff=4, 18x24 grid
stitchDupAvgDiff = 1.1
stitchDupMaxDiff = 4.0
stitchSigCols = 18
stitchSigRows = 24
// blank rows agree at every offset and must not decide a match
stitchActivityMin = 2.0
stitchRowMatchTol = 4.0
stitchMinActive = 12
)
// mean luminance per band (8-32%, 34-66%, 68-92%); the outer 8% is chrome
type rowCols [3]float32
type stitcher struct {
stride int
sampleOffs [3][]int
canvas []byte
cols []rowCols
anchor int
last []rowCols
lastOffset int
maxRows int
full bool
}
func newStitcher(stride int) *stitcher {
px := stride / 4
st := &stitcher{
stride: stride,
maxRows: min(stitchMaxCanvasBytes/stride, stitchMaxRowsCap),
}
bands := [3][2]float64{{0.08, 0.32}, {0.34, 0.66}, {0.68, 0.92}}
for b, band := range bands {
lo := int(float64(px) * band[0])
hi := max(int(float64(px)*band[1]), lo+1)
n := min(stitchBandSamples, hi-lo)
for s := range n {
st.sampleOffs[b] = append(st.sampleOffs[b], (lo+(hi-lo)*s/n)*4)
}
}
return st
}
func (st *stitcher) rowSamples(data []byte) []rowCols {
rows := len(data) / st.stride
cols := make([]rowCols, rows)
for y := range rows {
row := data[y*st.stride:]
for b := range 3 {
var sum float32
for _, off := range st.sampleOffs[b] {
sum += 0.114*float32(row[off]) + 0.587*float32(row[off+1]) + 0.299*float32(row[off+2])
}
cols[y][b] = sum / float32(len(st.sampleOffs[b]))
}
}
return cols
}
func (st *stitcher) frameSig(data []byte) []float32 {
rows := len(data) / st.stride
px := st.stride / 4
sig := make([]float32, 0, stitchSigCols*stitchSigRows)
for gy := range stitchSigRows {
y := (2*gy + 1) * rows / (2 * stitchSigRows)
for gx := range stitchSigCols {
x := (2*gx + 1) * px / (2 * stitchSigCols)
off := y*st.stride + x*4
sig = append(sig, 0.114*float32(data[off])+0.587*float32(data[off+1])+0.299*float32(data[off+2]))
}
}
return sig
}
func (st *stitcher) rows() int {
return len(st.cols)
}
func rowColsDiff(a, b rowCols) float32 {
return (abs32(a[0]-b[0]) + abs32(a[1]-b[1]) + abs32(a[2]-b[2])) / 3
}
func duplicateFrame(a, b []float32) bool {
if len(a) != len(b) || len(a) == 0 {
return false
}
var sum, maxDiff float32
for i := range a {
d := abs32(a[i] - b[i])
sum += d
maxDiff = max(maxDiff, d)
}
return sum/float32(len(a)) <= stitchDupAvgDiff && maxDiff <= stitchDupMaxDiff
}
// sticky header/footer zones, per mark-shot: 10% top, 8% bottom, min 16px
func matchIgnores(h int) (top, bottom int) {
if h < 80 {
return 0, 0
}
return clamp(h/10, 16, h/4), clamp(h*8/100, 16, h/4)
}
func activity(f []rowCols) []bool {
active := make([]bool, len(f))
for i := 1; i < len(f); i++ {
active[i] = rowColsDiff(f[i], f[i-1]) > stitchActivityMin
}
return active
}
func (st *stitcher) pushFrame(frame []byte, f []rowCols) (int, bool) {
if st.full || len(f) == 0 {
return 0, true
}
h := len(f)
if len(st.cols) == 0 {
n := st.appendRows(frame, f, 0)
st.anchor = 0
st.last = f
st.lastOffset = 0
return n, true
}
pos, ok := st.locateFrame(f, activity(f))
if !ok {
return 0, false
}
delta := pos - st.anchor
added := 0
if over := pos + h - len(st.cols); over >= stitchMinAppend {
added += st.appendRows(frame, f, h-over)
}
if over := -pos; over >= stitchMinAppend {
n := st.prependRows(frame, f, over)
added += n
pos += n
}
st.anchor = pos
st.last = f
st.lastOffset = delta
return added, true
}
// seamAppend starts a new segment after a jump capture couldn't follow.
func (st *stitcher) seamAppend(frame []byte, f []rowCols) int {
if st.full || len(f) == 0 {
return 0
}
pos := len(st.cols)
n := st.appendRows(frame, f, 0)
st.anchor = pos
st.last = f
st.lastOffset = 0
return n
}
func (st *stitcher) locateFrame(f []rowCols, active []bool) (int, bool) {
d, diff := st.adjacentOffset(f, active)
pred := st.anchor + d
if diff <= stitchAcceptDiff {
if _, ok := st.verifyAt(f, active, pred); ok {
return pred, true
}
}
if pos, _, ok := st.scanPositions(f, active, pred, true); ok {
return pos, true
}
pos, _, ok := st.scanPositions(f, active, pred, false)
return pos, ok
}
func (st *stitcher) verifyAt(f []rowCols, active []bool, pos int) (float32, bool) {
diff, count, activeMatches := st.canvasDiff(f, active, pos)
ok := count >= stitchMinCanvas && diff <= stitchAcceptDiff && activeMatches >= stitchMinActive
return diff, ok
}
// signed deltas searched outward from the previous one (mark-shot's
// predictOffsetIter), early-exiting once a diff beats approxDiff
func (st *stitcher) adjacentOffset(f []rowCols, active []bool) (int, float32) {
h := len(f)
if len(st.last) != h {
return 0, float32(1e9)
}
limit := max(h-stitchMinCompare-1, 0)
bestD, bestDiff := 0, float32(1e9)
countdown := -1
try := func(d int) bool {
if d < -limit || d > limit {
return false
}
diff, activeMatches := st.pairDiff(f, active, d)
if activeMatches >= stitchMinActive && diff < bestDiff {
bestDiff, bestD = diff, d
}
switch {
case bestDiff < stitchApproxDiff/4:
return true
case bestDiff < stitchApproxDiff && countdown < 0:
countdown = 10
}
if countdown > 0 {
countdown--
}
return countdown == 0
}
if try(st.lastOffset) {
return bestD, bestDiff
}
for k := 1; ; k++ {
lo, hi := st.lastOffset-k, st.lastOffset+k
if lo < -limit && hi > limit {
break
}
if try(hi) || try(lo) {
break
}
}
return bestD, bestDiff
}
func (st *stitcher) pairDiff(f []rowCols, active []bool, d int) (float32, int) {
h := len(f)
top, bottom := matchIgnores(h)
lo := max(top, -d)
hi := min(h-bottom, h-d)
count := hi - lo
if count < stitchMinCompare {
return float32(1e9), 0
}
var sum float32
activeMatches := 0
for i := lo; i < hi; i++ {
rd := rowColsDiff(f[i], st.last[i+d])
sum += rd
if active[i] && rd <= stitchRowMatchTol {
activeMatches++
}
}
return sum / float32(count), activeMatches
}
func (st *stitcher) canvasDiff(f []rowCols, active []bool, pos int) (float32, int, int) {
h := len(f)
top, bottom := matchIgnores(h)
lo := max(top, -pos)
hi := min(h-bottom, len(st.cols)-pos)
count := hi - lo
if count < 1 {
return float32(1e9), 0, 0
}
var sum float32
activeMatches := 0
for i := lo; i < hi; i++ {
rd := rowColsDiff(f[i], st.cols[pos+i])
sum += rd
if active[i] && rd <= stitchRowMatchTol {
activeMatches++
}
}
return sum / float32(count), count, activeMatches
}
// mark-shot's findEdgePosition (nearOnly: edges + prediction window, 1px) and
// findKnownPosition (coarse sweep refined around the winner)
func (st *stitcher) scanPositions(f []rowCols, active []bool, pred int, nearOnly bool) (int, float32, bool) {
h := len(f)
C := len(st.cols)
minPos := stitchMinCanvas - h
maxPos := C - stitchMinCanvas
bestPos, bestDiff := 0, float32(1e9)
bestDist := 1 << 30
consider := func(pos int) {
if pos < minPos || pos > maxPos {
return
}
diff, ok := st.verifyAt(f, active, pos)
if !ok {
return
}
dist := pos - pred
if dist < 0 {
dist = -dist
}
better := diff < bestDiff
if !nearOnly {
better = dist < bestDist || dist == bestDist && diff < bestDiff
}
if better {
bestPos, bestDiff, bestDist = pos, diff, dist
}
}
if nearOnly {
for pos := pred - stitchPredictWindow; pos <= pred+stitchPredictWindow; pos++ {
consider(pos)
}
for pos := C - h; pos <= maxPos; pos++ {
consider(pos)
}
for pos := minPos; pos <= 0; pos++ {
consider(pos)
}
if bestDiff > stitchAcceptDiff {
return 0, 0, false
}
return bestPos, bestDiff, true
}
for pos := minPos; pos <= maxPos; pos += stitchCoarseStep {
consider(pos)
}
if bestDiff > stitchAcceptDiff {
return 0, 0, false
}
refined, refinedDiff := bestPos, bestDiff
for pos := bestPos - stitchCoarseStep + 1; pos < bestPos+stitchCoarseStep; pos++ {
if pos == bestPos {
continue
}
if diff, ok := st.verifyAt(f, active, pos); ok && diff < refinedDiff {
refined, refinedDiff = pos, diff
}
}
return refined, refinedDiff, true
}
func (st *stitcher) appendRows(frame []byte, f []rowCols, from int) int {
n := len(f) - from
if room := st.maxRows - len(st.cols); n > room {
n = room
st.full = true
}
if n <= 0 {
st.full = true
return 0
}
st.canvas = append(st.canvas, frame[from*st.stride:(from+n)*st.stride]...)
st.cols = append(st.cols, f[from:from+n]...)
return n
}
func (st *stitcher) prependRows(frame []byte, f []rowCols, n int) int {
if room := st.maxRows - len(st.cols); n > room {
n = room
st.full = true
}
if n <= 0 {
st.full = true
return 0
}
canvas := make([]byte, n*st.stride+len(st.canvas))
copy(canvas, frame[:n*st.stride])
copy(canvas[n*st.stride:], st.canvas)
st.canvas = canvas
cols := make([]rowCols, 0, n+len(st.cols))
cols = append(cols, f[:n]...)
st.cols = append(cols, st.cols...)
return n
}
func abs32(f float32) float32 {
if f < 0 {
return -f
}
return f
}
+169
View File
@@ -0,0 +1,169 @@
package screenshot
import (
"bytes"
"math/rand"
"slices"
"testing"
)
const (
testStride = 512
testFrameH = 240
)
func makePage(t *testing.T, rows int) []byte {
t.Helper()
rng := rand.New(rand.NewSource(42))
page := make([]byte, rows*testStride)
rng.Read(page)
return page
}
func frameAt(page []byte, top int) []byte {
return page[top*testStride : (top+testFrameH)*testStride]
}
func pushFrame(st *stitcher, frame []byte) int {
n, _ := st.pushFrame(frame, st.rowSamples(frame))
return n
}
func TestStitchSlidingWindows(t *testing.T) {
page := makePage(t, 1000)
for _, delta := range []int{20, 60, 110} {
st := newStitcher(testStride)
lastTop := 0
for top := 0; top+testFrameH <= 900; top += delta {
lastTop = top
pushFrame(st, frameAt(page, top))
}
wantRows := lastTop + testFrameH
if st.rows() != wantRows {
t.Fatalf("delta %d: got %d rows, want %d", delta, st.rows(), wantRows)
}
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
t.Fatalf("delta %d: canvas does not match source rows", delta)
}
}
}
func TestStitchDropsNoOverlap(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
pushFrame(st, frameAt(page, 0))
if appended := pushFrame(st, frameAt(page, testFrameH+50)); appended != 0 {
t.Fatalf("unmatched jump appended %d rows", appended)
}
if !bytes.Equal(st.canvas, page[:testFrameH*testStride]) {
t.Fatal("canvas changed on unmatched frame")
}
}
func TestStitchNoGrowthCases(t *testing.T) {
page := makePage(t, 1000)
blank := make([]byte, testFrameH*testStride)
cases := []struct {
name string
first, second []byte
}{
{"identical frame", frameAt(page, 0), frameAt(page, 0)},
{"jitter below min append", frameAt(page, 0), frameAt(page, stitchMinAppend-5)},
{"blank on blank", blank, blank},
}
for _, tc := range cases {
st := newStitcher(testStride)
pushFrame(st, tc.first)
if appended := pushFrame(st, tc.second); appended != 0 {
t.Fatalf("%s: appended %d rows", tc.name, appended)
}
if st.rows() != testFrameH {
t.Fatalf("%s: got %d rows, want %d", tc.name, st.rows(), testFrameH)
}
}
}
func TestStitchRevisitNeverDuplicates(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
pushFrame(st, frameAt(page, 0))
pushFrame(st, frameAt(page, 100))
pushFrame(st, frameAt(page, 200))
for _, top := range []int{150, 60, 0, 80, 190} {
if appended := pushFrame(st, frameAt(page, top)); appended != 0 {
t.Fatalf("revisited frame at %d appended %d rows", top, appended)
}
}
pushFrame(st, frameAt(page, 300))
wantRows := 300 + testFrameH
if st.rows() != wantRows {
t.Fatalf("got %d rows, want %d", st.rows(), wantRows)
}
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
t.Fatal("canvas corrupted by revisited frames")
}
}
func TestStitchScrollUpPrepends(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
pushFrame(st, frameAt(page, 500))
if appended := pushFrame(st, frameAt(page, 420)); appended != 80 {
t.Fatalf("upward frame appended %d rows, want 80", appended)
}
pushFrame(st, frameAt(page, 560))
if !bytes.Equal(st.canvas, page[420*testStride:(560+testFrameH)*testStride]) {
t.Fatal("canvas does not match page range after prepend + append")
}
}
func TestStitchNoisyChromeStillMatches(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
addChrome := func(frame []byte, seed byte) []byte {
f := slices.Clone(frame)
for y := range testFrameH {
for x := range 32 {
f[y*testStride+x] = seed + byte(y)
}
}
for y := 100; y < 124; y++ {
for x := testStride / 2; x < testStride/2+40; x++ {
f[y*testStride+x] ^= 0x08
}
}
return f
}
pushFrame(st, addChrome(frameAt(page, 0), 1))
if appended := pushFrame(st, addChrome(frameAt(page, 90), 2)); appended != 90 {
t.Fatalf("appended %d rows, want 90", appended)
}
}
func TestStitchMaxRowsCap(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
st.maxRows = testFrameH + 10
pushFrame(st, frameAt(page, 0))
if appended := pushFrame(st, frameAt(page, 100)); appended != 10 {
t.Fatalf("appended %d rows past cap, want 10", appended)
}
if !st.full {
t.Fatal("stitcher not marked full at cap")
}
if pushFrame(st, frameAt(page, 300)) != 0 {
t.Fatal("push after full appended rows")
}
}
+2
View File
@@ -9,6 +9,7 @@ const (
ModeAllScreens ModeAllScreens
ModeOutput ModeOutput
ModeLastRegion ModeLastRegion
ModeScroll
) )
type Format int type Format int
@@ -62,6 +63,7 @@ type Config struct {
SaveFile bool SaveFile bool
Notify bool Notify bool
Stdout bool Stdout bool
IntervalMs int
} }
func DefaultConfig() Config { func DefaultConfig() Config {
@@ -744,6 +744,32 @@ func (b *IWDBackend) DisconnectWiFi() error {
return nil return nil
} }
func (b *IWDBackend) abortInFlightConnection(ssid string) {
b.stateMutex.Lock()
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
b.stateMutex.Unlock()
return
}
b.state.IsConnecting = false
b.state.ConnectingSSID = ""
b.state.LastError = ""
b.stateMutex.Unlock()
b.attemptMutex.RLock()
att := b.curAttempt
b.attemptMutex.RUnlock()
if att != nil && att.ssid == ssid {
att.mu.Lock()
att.finalized = true
att.mu.Unlock()
}
if err := b.DisconnectWiFi(); err != nil {
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
}
}
func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error { func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
b.stateMutex.RLock() b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID currentSSID := b.state.WiFiSSID
@@ -812,6 +838,10 @@ func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
return fmt.Errorf("failed to set autoconnect: %w", call.Err) return fmt.Errorf("failed to set autoconnect: %w", call.Err)
} }
if !autoconnect {
b.abortInFlightConnection(ssid)
}
b.updateState() b.updateState()
if b.onStateChange != nil { if b.onStateChange != nil {
@@ -359,6 +359,24 @@ func (b *NetworkManagerBackend) DisconnectWiFi() error {
return nil return nil
} }
func (b *NetworkManagerBackend) abortInFlightConnection(ssid string) {
b.stateMutex.Lock()
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
b.stateMutex.Unlock()
return
}
b.state.IsConnecting = false
b.state.ConnectingSSID = ""
b.state.LastError = ""
b.stateMutex.Unlock()
b.clearCachedWiFiSecretBySSID(ssid)
if err := b.DisconnectWiFi(); err != nil {
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
}
}
func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error { func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
conn, err := b.findConnection(ssid) conn, err := b.findConnection(ssid)
if err != nil { if err != nil {
@@ -963,6 +981,10 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
return fmt.Errorf("failed to update connection: %w", err) return fmt.Errorf("failed to update connection: %w", err)
} }
if !autoconnect {
b.abortInFlightConnection(ssid)
}
b.updateWiFiNetworks() b.updateWiFiNetworks()
if b.onStateChange != nil { if b.onStateChange != nil {
@@ -50,11 +50,42 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
} }
func pacmanUpgradeArgv(opts UpgradeOptions) []string { func pacmanUpgradeArgv(opts UpgradeOptions) []string {
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"} return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
if len(opts.Ignored) > 0 { }
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
// AUR packages are exempt — holding those cannot break the repo dependency graph.
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
if len(ignored) == 0 {
return ignored
}
repoPending := make(map[string]bool, len(pending))
for _, p := range pending {
if p.Repo == RepoSystem {
repoPending[p.Name] = true
}
}
out := make([]string, 0, len(ignored))
for _, name := range ignored {
if repoPending[name] {
continue
}
out = append(out, name)
}
return out
}
func isPacmanFamily(b Backend) bool {
if b == nil {
return false
}
switch b.ID() {
case "pacman", "paru", "yay":
return true
default:
return false
} }
return privilegedArgv(opts, argv...)
} }
type archHelperBackend struct { type archHelperBackend struct {
@@ -112,7 +143,7 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
} }
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ") cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
title := fmt.Sprintf("DMS — System Update (%s)", b.id) title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine}) return Run(ctx, wrapInTerminal(term, title, cmd, opts.TerminalArgs), RunOptions{OnLine: onLine})
} }
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string { func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
+14 -13
View File
@@ -119,7 +119,7 @@ func findTerminal(override string) string {
return "" return ""
} }
func wrapInTerminal(term, title, shellCmd string) []string { func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
const appID = "com.danklinux.dms" const appID = "com.danklinux.dms"
banner := fmt.Sprintf( 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'`, `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'`,
@@ -129,24 +129,25 @@ func wrapInTerminal(term, title, shellCmd string) []string {
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; ` export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer full := export + banner + "; " + shellCmd + "; " + closer
var argv []string
execFlag := "-e"
switch term { switch term {
case "kitty": case "kitty", "alacritty", "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full} argv = []string{term, "--class", appID, "-T", title}
case "alacritty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "foot": case "foot":
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full} argv = []string{term, "--app-id=" + appID, "--title=" + title}
case "ghostty": case "ghostty":
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full} argv = []string{term, "--class=" + appID, "--title=" + title}
case "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "xterm": case "xterm":
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full} argv = []string{term, "-class", appID, "-T", title}
case "konsole": case "konsole":
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full} argv = []string{term, "-p", "tabtitle=" + title}
case "gnome-terminal": case "gnome-terminal":
return []string{term, "--title=" + title, "--", "sh", "-c", full} argv = []string{term, "--title=" + title}
execFlag = "--"
default: default:
return []string{term, "-e", "sh", "-c", full} argv = []string{term}
} }
argv = append(argv, extraArgs...)
return append(argv, execFlag, "sh", "-c", full)
} }
@@ -46,6 +46,7 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
DryRun: params.BoolOpt(req.Params, "dry", false), DryRun: params.BoolOpt(req.Params, "dry", false),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""), CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""), Terminal: params.StringOpt(req.Params, "terminal", ""),
TerminalArgs: stringSliceOpt(req.Params, "terminalArgs"),
Ignored: stringSliceOpt(req.Params, "ignored"), Ignored: stringSliceOpt(req.Params, "ignored"),
} }
if err := m.Upgrade(opts); err != nil { if err := m.Upgrade(opts); err != nil {
+8 -4
View File
@@ -336,7 +336,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
}() }()
if opts.CustomCommand != "" { if opts.CustomCommand != "" {
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal) m.runCustomUpgrade(ctx, opts)
return return
} }
@@ -345,6 +345,9 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
opts.Targets = append([]Package(nil), m.state.Packages...) opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock() m.mu.RUnlock()
} }
if isPacmanFamily(m.selection.System) {
opts.Ignored = dropPacmanRepoIgnores(opts.Ignored, opts.Targets)
}
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored) opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
backends := upgradeBackends(m.selection, opts) backends := upgradeBackends(m.selection, opts)
@@ -389,8 +392,8 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
m.finishSuccessfulUpgrade(true) m.finishSuccessfulUpgrade(true)
} }
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) { func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
term := findTerminal(terminalOverride) term := findTerminal(opts.Terminal)
if term == "" { if term == "" {
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)") m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
return return
@@ -407,7 +410,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
m.markDirty() m.markDirty()
onLine := func(line string) { m.appendLog(line) } onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", command) argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil { if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed code := ErrCodeBackendFailed
switch { switch {
@@ -425,6 +428,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
} }
m.finishSuccessfulUpgrade(false) m.finishSuccessfulUpgrade(false)
m.runRefresh(context.Background(), false)
} }
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) { func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
+1
View File
@@ -80,6 +80,7 @@ type UpgradeOptions struct {
AttachStdio bool AttachStdio bool
CustomCommand string CustomCommand string
Terminal string Terminal string
TerminalArgs []string
Targets []Package Targets []Package
Ignored []string Ignored []string
} }
@@ -50,9 +50,9 @@ func TestUpgradeCommandBuilders(t *testing.T) {
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"}, want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
}, },
{ {
name: "pacman with ignored packages", name: "pacman never passes --ignore",
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}), got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"}, want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
}, },
{ {
name: "dnf with ignored packages", name: "dnf with ignored packages",
@@ -106,6 +106,18 @@ func TestUpgradeCommandBuilders(t *testing.T) {
} }
} }
func TestDropPacmanRepoIgnoresKeepsAURHolds(t *testing.T) {
pending := []Package{
{Name: "linux", Repo: RepoSystem},
{Name: "librewolf", Repo: RepoAUR},
}
got := dropPacmanRepoIgnores([]string{"linux", "librewolf", "not-pending"}, pending)
want := []string{"librewolf", "not-pending"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ignored = %#v, want %#v", got, want)
}
}
func TestAptUpgradeArgvHoldsIgnored(t *testing.T) { func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}}) argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
if len(argv) < 2 || argv[len(argv)-2] != "-c" { if len(argv) < 2 || argv[len(argv)-2] != "-c" {
+3 -1
View File
@@ -200,7 +200,9 @@ in
]; ];
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS. # DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
# Declare security.pam.services.dankshell only if you want to override that runtime fallback. # Declare security.pam.services.dankshell only if you want to override that runtime fallback.
# U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here. # Do not add pam_u2f or pam_fprintd here for security-key unlock, enable
# programs.dank-material-shell.lockscreen.securityKey.enable, which declares the
# dedicated dankshell-u2f service DMS drives on its own.
# security.pam.services.dankshell = { # security.pam.services.dankshell = {
# # Example: add faillock # # Example: add faillock
# faillock.enable = true; # faillock.enable = true;
+21
View File
@@ -23,6 +23,15 @@ in
description = "Systemd target to bind to."; description = "Systemd target to bind to.";
default = "graphical-session.target"; default = "graphical-session.target";
}; };
options.programs.dank-material-shell.lockscreen.securityKey = {
enable = lib.mkEnableOption "FIDO2/U2F security key unlock for the DMS lock screen via a dedicated dankshell-u2f PAM service";
package = lib.mkPackageOption pkgs "pam_u2f" { };
moduleArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "cue" ];
description = "Arguments passed to pam_u2f.so in the dankshell-u2f PAM service.";
};
};
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
systemd.user.services.dms = lib.mkIf cfg.systemd.enable { systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
description = "DankMaterialShell"; description = "DankMaterialShell";
@@ -46,6 +55,18 @@ in
inherit value; inherit value;
}) common.plugins; }) common.plugins;
# DMS's bundled U2F fallback stack references pam_u2f.so by name, which NixOS's
# libpam cannot resolve; the dedicated service below uses the absolute store path
# and is picked up automatically by the lock screen when present.
security.pam.services."dankshell-u2f" = lib.mkIf cfg.lockscreen.securityKey.enable {
text = ''
auth required ${cfg.lockscreen.securityKey.package}/lib/security/pam_u2f.so ${lib.concatStringsSep " " cfg.lockscreen.securityKey.moduleArgs}
account required pam_permit.so
password required pam_deny.so
session required pam_permit.so
'';
};
services.power-profiles-daemon.enable = lib.mkDefault true; services.power-profiles-daemon.enable = lib.mkDefault true;
services.accounts-daemon.enable = lib.mkDefault true; services.accounts-daemon.enable = lib.mkDefault true;
services.geoclue2.enable = lib.mkDefault true; services.geoclue2.enable = lib.mkDefault true;
+2
View File
@@ -19,6 +19,7 @@ pkgs.testers.runNixOSTest {
programs.dank-material-shell = { programs.dank-material-shell = {
enable = true; enable = true;
systemd.enable = true; systemd.enable = true;
lockscreen.securityKey.enable = true;
plugins = { plugins = {
TestPlugin = { TestPlugin = {
src = pkgs.emptyDirectory; src = pkgs.emptyDirectory;
@@ -39,6 +40,7 @@ pkgs.testers.runNixOSTest {
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'") machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins") machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service") machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
machine.succeed("grep -q 'lib/security/pam_u2f.so cue' /etc/pam.d/dankshell-u2f")
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'")) payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
t.assertIn("summary", payload) t.assertIn("summary", payload)
+1 -1
View File
@@ -1 +1 @@
Marble Tabby The Wolverine
+9 -1
View File
@@ -11,6 +11,8 @@ Singleton {
property var appUsageRanking: {} property var appUsageRanking: {}
property bool _saving: false property bool _saving: false
property bool _hasLoaded: false
property bool _parseError: false
Component.onCompleted: { Component.onCompleted: {
loadSettings(); loadSettings();
@@ -21,15 +23,21 @@ Singleton {
} }
function parseSettings(content) { function parseSettings(content) {
_parseError = false;
try { try {
if (content && content.trim()) { if (content && content.trim()) {
var settings = JSON.parse(content); var settings = JSON.parse(content);
appUsageRanking = settings.appUsageRanking || {}; appUsageRanking = settings.appUsageRanking || {};
} }
} catch (e) {} _hasLoaded = true;
} catch (e) {
_parseError = true;
}
} }
function saveSettings() { function saveSettings() {
if (_parseError || !_hasLoaded)
return;
settingsFile.setText(JSON.stringify({ settingsFile.setText(JSON.stringify({
"appUsageRanking": appUsageRanking "appUsageRanking": appUsageRanking
}, null, 2)); }, null, 2));
+1
View File
@@ -63,6 +63,7 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" }, { id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" },
{ id: "spawn dms ipc call keybinds close", label: "Keybinds Cheatsheet: Close" }, { id: "spawn dms ipc call keybinds close", label: "Keybinds Cheatsheet: Close" },
{ id: "spawn dms ipc call lock lock", label: "Lock Screen" }, { id: "spawn dms ipc call lock lock", label: "Lock Screen" },
{ id: "spawn dms ipc call lock lockAndOutputsOff", label: "Lock Screen & Outputs Off" },
{ id: "spawn dms ipc call lock demo", label: "Lock Screen: Demo" }, { id: "spawn dms ipc call lock demo", label: "Lock Screen: Demo" },
{ id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" }, { id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" },
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" }, { id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
+21 -2
View File
@@ -65,16 +65,35 @@ Singleton {
function _openPopout(popout) { function _openPopout(popout) {
if (popout.dashVisible !== undefined) { if (popout.dashVisible !== undefined) {
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing) let flagStayedTrue = popout.dashVisible === true;
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing) {
popout.dashVisible = false; popout.dashVisible = false;
flagStayedTrue = false;
}
popout.dashVisible = true; popout.dashVisible = true;
// Flag already true (e.g. retargeting to another monitor) won't re-fire
// the change handler, so drive the surface open explicitly.
if (flagStayedTrue)
_ensureSurfaceOpen(popout);
return; return;
} }
if (popout.notificationHistoryVisible !== undefined) { if (popout.notificationHistoryVisible !== undefined) {
const flagStayedTrue = popout.notificationHistoryVisible === true;
popout.notificationHistoryVisible = true; popout.notificationHistoryVisible = true;
if (flagStayedTrue)
_ensureSurfaceOpen(popout);
return; return;
} }
popout.open(); _ensureSurfaceOpen(popout);
}
function _ensureSurfaceOpen(popout) {
if (typeof popout.present === "function") {
popout.present();
return;
}
if (typeof popout.open === "function")
popout.open();
} }
function _closePopout(popout) { function _closePopout(popout) {
+9
View File
@@ -240,6 +240,8 @@ Singleton {
onBlurForegroundLayersChanged: saveSettings() onBlurForegroundLayersChanged: saveSettings()
property real blurLayerOutlineOpacity: 0.12 property real blurLayerOutlineOpacity: 0.12
onBlurLayerOutlineOpacityChanged: saveSettings() onBlurLayerOutlineOpacityChanged: saveSettings()
property bool blurBorderEnabled: true
onBlurBorderEnabledChanged: saveSettings()
property string blurBorderColor: "outline" property string blurBorderColor: "outline"
onBlurBorderColorChanged: saveSettings() onBlurBorderColorChanged: saveSettings()
property string blurBorderCustomColor: "#ffffff" property string blurBorderCustomColor: "#ffffff"
@@ -859,6 +861,7 @@ Singleton {
property bool notificationOverlayEnabled: false property bool notificationOverlayEnabled: false
property bool notificationPopupShadowEnabled: true property bool notificationPopupShadowEnabled: true
property bool notificationPopupPrivacyMode: false property bool notificationPopupPrivacyMode: false
property bool notificationForegroundLayers: true
property int overviewRows: 2 property int overviewRows: 2
property int overviewColumns: 5 property int overviewColumns: 5
property real overviewScale: 0.16 property real overviewScale: 0.16
@@ -895,6 +898,12 @@ Singleton {
readonly property bool greeterU2fReady: Processes.greeterU2fReady readonly property bool greeterU2fReady: Processes.greeterU2fReady
readonly property string greeterU2fReason: Processes.greeterU2fReason readonly property string greeterU2fReason: Processes.greeterU2fReason
readonly property string greeterU2fSource: Processes.greeterU2fSource readonly property string greeterU2fSource: Processes.greeterU2fSource
property string lockPamPath: ""
property bool lockPamInlineFprint: false
property bool lockPamInlineU2f: false
property bool lockPamExternallyManaged: false
property string lockU2fPamPath: ""
property bool greeterPamExternallyManaged: false
property string lockScreenInactiveColor: "#000000" property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0 property int lockScreenNotificationMode: 0
property bool lockScreenVideoEnabled: false property bool lockScreenVideoEnabled: false
+4
View File
@@ -580,11 +580,15 @@ Singleton {
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true) readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency) readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency) readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0) readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0) readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
readonly property color nestedSurface: floatingSurfaceHigh readonly property color nestedSurface: floatingSurfaceHigh
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
readonly property color notificationNestedSurface: notificationFloatingSurfaceHigh
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12))) readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0 readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
+14 -1
View File
@@ -63,6 +63,7 @@ Singleton {
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : "" readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== "" readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f") readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f")
readonly property bool lockU2fCustomSourceDetected: (settingsRoot?.lockU2fPamPath || "") !== "" && customU2fPamWatcher.loaded
readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd") readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd")
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f") readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
@@ -176,7 +177,7 @@ Singleton {
readonly property bool lockU2fReady: { readonly property bool lockU2fReady: {
if (forcedU2fAvailable !== null) if (forcedU2fAvailable !== null)
return forcedU2fAvailable; return forcedU2fAvailable;
return lockU2fCustomConfigDetected || homeU2fKeysDetected; return lockU2fCustomSourceDetected || lockU2fCustomConfigDetected || homeU2fKeysDetected;
} }
readonly property bool lockU2fCanEnable: { readonly property bool lockU2fCanEnable: {
@@ -246,6 +247,10 @@ Singleton {
readonly property var _pamProbeCommand: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"] readonly property var _pamProbeCommand: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"]
function detectAuthCapabilities() { function detectAuthCapabilities() {
// FileView cannot watch paths that do not exist yet, so reload the U2F PAM
dankshellU2fPamWatcher.reload();
u2fKeysWatcher.reload();
if (forcedFprintAvailable === null) { if (forcedFprintAvailable === null) {
fingerprintProbeFinalized = false; fingerprintProbeFinalized = false;
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => { Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
@@ -722,14 +727,22 @@ Singleton {
FileView { FileView {
id: dankshellU2fPamWatcher id: dankshellU2fPamWatcher
path: "/etc/pam.d/dankshell-u2f" path: "/etc/pam.d/dankshell-u2f"
watchChanges: true
printErrors: false printErrors: false
onLoaded: root.dankshellU2fPamText = text() onLoaded: root.dankshellU2fPamText = text()
onLoadFailed: root.dankshellU2fPamText = "" onLoadFailed: root.dankshellU2fPamText = ""
} }
FileView {
id: customU2fPamWatcher
path: root.settingsRoot?.lockU2fPamPath || ""
printErrors: false
}
FileView { FileView {
id: u2fKeysWatcher id: u2fKeysWatcher
path: root.u2fKeysPath path: root.u2fKeysPath
watchChanges: true
printErrors: false printErrors: false
onLoaded: root.u2fKeysText = text() onLoaded: root.u2fKeysText = text()
onLoadFailed: root.u2fKeysText = "" onLoadFailed: root.u2fKeysText = ""
@@ -70,6 +70,7 @@ var SPEC = {
blurEnabled: { def: false }, blurEnabled: { def: false },
blurForegroundLayers: { def: true }, blurForegroundLayers: { def: true },
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit }, blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
blurBorderEnabled: { def: true },
blurBorderColor: { def: "outline" }, blurBorderColor: { def: "outline" },
blurBorderCustomColor: { def: "#ffffff" }, blurBorderCustomColor: { def: "#ffffff" },
blurBorderOpacity: { def: 0.35, coerce: percentToUnit }, blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
@@ -233,6 +234,7 @@ var SPEC = {
greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" }, greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" }, greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" }, greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
greeterPamExternallyManaged: { def: false, onChange: "markGreeterSyncPending" },
greeterSyncPending: { def: false }, greeterSyncPending: { def: false },
greeterSyncBaseline: { def: {} }, greeterSyncBaseline: { def: {} },
mediaSize: { def: 1 }, mediaSize: { def: 1 },
@@ -436,6 +438,7 @@ var SPEC = {
notificationOverlayEnabled: { def: false }, notificationOverlayEnabled: { def: false },
notificationPopupShadowEnabled: { def: true }, notificationPopupShadowEnabled: { def: true },
notificationPopupPrivacyMode: { def: false }, notificationPopupPrivacyMode: { def: false },
notificationForegroundLayers: { def: true },
overviewRows: { def: 2, persist: false }, overviewRows: { def: 2, persist: false },
overviewColumns: { def: 5, persist: false }, overviewColumns: { def: 5, persist: false },
overviewScale: { def: 0.16, persist: false }, overviewScale: { def: 0.16, persist: false },
@@ -455,6 +458,11 @@ var SPEC = {
maxFprintTries: { def: 15 }, maxFprintTries: { def: 15 },
enableU2f: { def: false, onChange: "scheduleAuthApply" }, enableU2f: { def: false, onChange: "scheduleAuthApply" },
u2fMode: { def: "or" }, u2fMode: { def: "or" },
lockPamPath: { def: "" },
lockPamInlineFprint: { def: false },
lockPamInlineU2f: { def: false },
lockPamExternallyManaged: { def: false },
lockU2fPamPath: { def: "" },
lockScreenInactiveColor: { def: "#000000" }, lockScreenInactiveColor: { def: "#000000" },
lockScreenNotificationMode: { def: 0 }, lockScreenNotificationMode: { def: 0 },
lockScreenVideoEnabled: { def: false }, lockScreenVideoEnabled: { def: false },
+62 -67
View File
@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import qs.Common import qs.Common
@@ -53,6 +55,7 @@ Item {
delegate: Loader { delegate: Loader {
id: fadeWindowLoader id: fadeWindowLoader
required property var modelData required property var modelData
readonly property FadeToLockWindow loadedWindow: item as FadeToLockWindow
active: SettingsData.fadeToLockEnabled active: SettingsData.fadeToLockEnabled
asynchronous: false asynchronous: false
@@ -64,29 +67,29 @@ Item {
} }
onFadeCancelled: { onFadeCancelled: {
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name); root.log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
} }
} }
Connections { Connections {
target: IdleService target: IdleService
enabled: fadeWindowLoader.item !== null enabled: fadeWindowLoader.loadedWindow !== null
function onFadeToLockRequested() { function onFadeToLockRequested() {
if (fadeWindowLoader.item) { if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.item.startFade(); fadeWindowLoader.loadedWindow.startFade();
} }
} }
function onCancelFadeToLock() { function onCancelFadeToLock() {
if (fadeWindowLoader.item) { if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.item.cancelFade(); fadeWindowLoader.loadedWindow.cancelFade();
} }
} }
function onDismissFadeToLock() { function onDismissFadeToLock() {
if (fadeWindowLoader.item) { if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.item.dismiss(); fadeWindowLoader.loadedWindow.dismiss();
} }
} }
} }
@@ -99,6 +102,7 @@ Item {
delegate: Loader { delegate: Loader {
id: fadeDpmsWindowLoader id: fadeDpmsWindowLoader
required property var modelData required property var modelData
readonly property FadeToDpmsWindow loadedWindow: item as FadeToDpmsWindow
active: SettingsData.fadeToDpmsEnabled active: SettingsData.fadeToDpmsEnabled
asynchronous: false asynchronous: false
@@ -110,30 +114,38 @@ Item {
} }
onFadeCancelled: { onFadeCancelled: {
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name); root.log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
} }
} }
Connections { Connections {
target: IdleService target: IdleService
enabled: fadeDpmsWindowLoader.item !== null enabled: fadeDpmsWindowLoader.loadedWindow !== null
function onFadeToDpmsRequested() { function onFadeToDpmsRequested() {
if (fadeDpmsWindowLoader.item) { if (fadeDpmsWindowLoader.loadedWindow) {
fadeDpmsWindowLoader.item.startFade(); fadeDpmsWindowLoader.loadedWindow.startFade();
} }
} }
function onCancelFadeToDpms() { function onCancelFadeToDpms() {
if (fadeDpmsWindowLoader.item) { if (fadeDpmsWindowLoader.loadedWindow) {
fadeDpmsWindowLoader.item.cancelFade(); fadeDpmsWindowLoader.loadedWindow.cancelFade();
} }
} }
function onRequestMonitorOn() { function onRequestMonitorOn() {
if (!fadeDpmsWindowLoader.item) if (!fadeDpmsWindowLoader.loadedWindow)
return; return;
fadeDpmsWindowLoader.item.cancelFade(); fadeDpmsWindowLoader.loadedWindow.cancelFade();
}
function onMonitorsOffChanged() {
if (IdleService.monitorsOff)
return;
if (!fadeDpmsWindowLoader.loadedWindow)
return;
fadeDpmsWindowLoader.loadedWindow.dismiss();
} }
} }
} }
@@ -213,7 +225,7 @@ Item {
} }
Component.onCompleted: { Component.onCompleted: {
dockRecreateDebounce.start(); dockEnabled = true;
loginSoundTimer.start(); loginSoundTimer.start();
osdStartupTimer.start(); osdStartupTimer.start();
@@ -351,9 +363,7 @@ Item {
Variants { Variants {
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications") model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
delegate: NotificationPopupManager { delegate: NotificationPopupManager {}
modelData: item
}
} }
LazyLoader { LazyLoader {
@@ -387,6 +397,7 @@ Item {
LazyLoader { LazyLoader {
id: wifiPasswordModalLoader id: wifiPasswordModalLoader
active: false active: false
readonly property WifiPasswordModal loadedModal: item as WifiPasswordModal
Component.onCompleted: { Component.onCompleted: {
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader; PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
@@ -421,6 +432,7 @@ Item {
LazyLoader { LazyLoader {
id: polkitAuthModalLoader id: polkitAuthModalLoader
active: false active: false
readonly property PolkitAuthModal loadedModal: item as PolkitAuthModal
PolkitAuthModal { PolkitAuthModal {
id: polkitAuthModal id: polkitAuthModal
@@ -439,8 +451,8 @@ Item {
if (PopoutService.systemUpdatePopout?.shouldBeVisible) if (PopoutService.systemUpdatePopout?.shouldBeVisible)
return; return;
polkitAuthModalLoader.active = true; polkitAuthModalLoader.active = true;
if (polkitAuthModalLoader.item) if (polkitAuthModalLoader.loadedModal)
polkitAuthModalLoader.item.show(); polkitAuthModalLoader.loadedModal.show();
} }
} }
@@ -459,20 +471,20 @@ Item {
target: NetworkService target: NetworkService
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) { function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
const alreadyShown = wifiPasswordModalLoader.item && wifiPasswordModalLoader.item.shouldBeVisible; const alreadyShown = wifiPasswordModalLoader.loadedModal && wifiPasswordModalLoader.loadedModal.shouldBeVisible;
if (alreadyShown && token === lastCredentialsToken) if (alreadyShown && token === root.lastCredentialsToken)
return; return;
wifiPasswordModalLoader.active = true; wifiPasswordModalLoader.active = true;
if (!wifiPasswordModalLoader.item) if (!wifiPasswordModalLoader.loadedModal)
return; return;
if (alreadyShown && lastCredentialsToken !== "" && lastCredentialsToken !== token) if (alreadyShown && root.lastCredentialsToken !== "" && root.lastCredentialsToken !== token)
NetworkService.cancelCredentials(lastCredentialsToken); NetworkService.cancelCredentials(root.lastCredentialsToken);
lastCredentialsToken = token; root.lastCredentialsToken = token;
lastCredentialsTime = Date.now(); root.lastCredentialsTime = Date.now();
wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo); wifiPasswordModalLoader.loadedModal.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
} }
} }
@@ -769,10 +781,10 @@ Item {
} }
function onAppPickerRequested(data) { function onAppPickerRequested(data) {
log.debug("App picker requested with data:", JSON.stringify(data)); root.log.debug("App picker requested with data:", JSON.stringify(data));
if (!data || !data.target) { if (!data || !data.target) {
log.warn("Invalid app picker request data"); root.log.warn("Invalid app picker request data");
return; return;
} }
@@ -889,7 +901,6 @@ Item {
delegate: DankSlideout { delegate: DankSlideout {
id: notepadSlideout id: notepadSlideout
modelData: item
title: I18n.tr("Notepad") title: I18n.tr("Notepad")
slideoutWidth: 480 slideoutWidth: 480
expandable: true expandable: true
@@ -985,8 +996,8 @@ Item {
onSwitchUserRequested: { onSwitchUserRequested: {
switchUserModalLoader.active = true; switchUserModalLoader.active = true;
Qt.callLater(() => { Qt.callLater(() => {
if (switchUserModalLoader.item) if (switchUserModalLoader.loadedModal)
switchUserModalLoader.item.showFromPowerMenu(); switchUserModalLoader.loadedModal.showFromPowerMenu();
}); });
} }
@@ -1000,6 +1011,7 @@ Item {
id: switchUserModalLoader id: switchUserModalLoader
active: false active: false
readonly property SwitchUserModal loadedModal: item as SwitchUserModal
SwitchUserModal { SwitchUserModal {
id: switchUserModal id: switchUserModal
@@ -1055,7 +1067,6 @@ Item {
model: SettingsData.getFilteredScreens("toast") model: SettingsData.getFilteredScreens("toast")
delegate: Toast { delegate: Toast {
modelData: item
visible: ToastService.toastVisible visible: ToastService.toastVisible
} }
} }
@@ -1070,73 +1081,55 @@ Item {
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: VolumeOSD { delegate: VolumeOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: MediaVolumeOSD { delegate: MediaVolumeOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: MediaPlaybackOSD { delegate: MediaPlaybackOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: MicVolumeOSD { delegate: MicVolumeOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: BrightnessOSD { delegate: BrightnessOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: IdleInhibitorOSD { delegate: IdleInhibitorOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : [] model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
delegate: PowerProfileOSD { delegate: PowerProfileOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: CapsLockOSD { delegate: CapsLockOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: AudioOutputOSD { delegate: AudioOutputOSD {}
modelData: item
}
} }
} }
} }
@@ -1153,6 +1146,7 @@ Item {
Loader { Loader {
id: greeterLoader id: greeterLoader
active: false active: false
readonly property GreeterModal loadedModal: item as GreeterModal
sourceComponent: GreeterModal { sourceComponent: GreeterModal {
onGreeterCompleted: greeterLoader.active = false onGreeterCompleted: greeterLoader.active = false
Component.onCompleted: show() Component.onCompleted: show()
@@ -1161,8 +1155,8 @@ Item {
Connections { Connections {
target: FirstLaunchService target: FirstLaunchService
function onGreeterRequested() { function onGreeterRequested() {
if (greeterLoader.active && greeterLoader.item) { if (greeterLoader.active && greeterLoader.loadedModal) {
greeterLoader.item.show(); greeterLoader.loadedModal.show();
return; return;
} }
greeterLoader.active = true; greeterLoader.active = true;
@@ -1173,6 +1167,7 @@ Item {
Loader { Loader {
id: changelogLoader id: changelogLoader
active: false active: false
readonly property ChangelogModal loadedModal: item as ChangelogModal
sourceComponent: ChangelogModal { sourceComponent: ChangelogModal {
onChangelogDismissed: changelogLoader.active = false onChangelogDismissed: changelogLoader.active = false
Component.onCompleted: show() Component.onCompleted: show()
@@ -1181,8 +1176,8 @@ Item {
Connections { Connections {
target: ChangelogService target: ChangelogService
function onChangelogRequested() { function onChangelogRequested() {
if (changelogLoader.active && changelogLoader.item) { if (changelogLoader.active && changelogLoader.loadedModal) {
changelogLoader.item.show(); changelogLoader.loadedModal.show();
return; return;
} }
changelogLoader.active = true; changelogLoader.active = true;
+1 -3
View File
@@ -531,9 +531,7 @@ Item {
} }
function next(): void { function next(): void {
if (MprisController.activePlayer && MprisController.activePlayer.canGoNext) { MprisController.next();
MprisController.activePlayer.next();
}
} }
function stop(): void { function stop(): void {
@@ -214,7 +214,7 @@ Item {
Item { Item {
id: listClip id: listClip
anchors.fill: parent anchors.fill: parent
anchors.topMargin: BlurService.enabled && stickyHeader.visible ? 32 : 0 anchors.topMargin: stickyHeader.visible ? 32 : 0
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0 anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
clip: true clip: true
@@ -417,7 +417,7 @@ Item {
anchors.top: parent.top anchors.top: parent.top
height: 32 height: 32
z: 101 z: 101
color: Theme.floatingSurface color: "transparent"
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0 readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
+61 -28
View File
@@ -27,7 +27,7 @@ DankModal {
property real holdProgress: 0 property real holdProgress: 0
property bool showHoldHint: false property bool showHoldHint: false
property bool holdFromKeyboard: false property bool holdFromKeyboard: false
property string pendingKeyAction: "" property string committedAction: ""
readonly property bool needsConfirmation: SettingsData.powerActionConfirm readonly property bool needsConfirmation: SettingsData.powerActionConfirm
readonly property int holdDurationMs: SettingsData.powerActionHoldDuration * 1000 readonly property int holdDurationMs: SettingsData.powerActionHoldDuration * 1000
@@ -39,10 +39,16 @@ DankModal {
return action !== "lock" && action !== "restart"; return action !== "lock" && action !== "restart";
} }
function actionWakesOnKeyRelease(action) {
return action === "suspend" || action === "hibernate";
}
function startHold(action, actionIndex) { function startHold(action, actionIndex) {
if (committedAction !== "")
return;
if (!needsConfirmation || !actionNeedsConfirm(action)) { if (!needsConfirmation || !actionNeedsConfirm(action)) {
if (holdFromKeyboard) { if (holdFromKeyboard && actionWakesOnKeyRelease(action)) {
pendingKeyAction = action; commitAction(action, actionIndex);
return; return;
} }
executeAction(action); executeAction(action);
@@ -56,7 +62,6 @@ DankModal {
} }
function cancelHold() { function cancelHold() {
pendingKeyAction = "";
if (holdAction === "") if (holdAction === "")
return; return;
const wasHolding = holdProgress > 0; const wasHolding = holdProgress > 0;
@@ -76,8 +81,8 @@ DankModal {
return; return;
} }
holdTimer.stop(); holdTimer.stop();
if (holdFromKeyboard) { if (holdFromKeyboard && actionWakesOnKeyRelease(holdAction)) {
pendingKeyAction = holdAction; commitAction(holdAction, holdActionIndex);
return; return;
} }
const action = holdAction; const action = holdAction;
@@ -87,6 +92,25 @@ DankModal {
executeAction(action); executeAction(action);
} }
function commitAction(action, actionIndex) {
holdTimer.stop();
holdAction = "";
holdActionIndex = actionIndex;
committedAction = action;
commitFallbackTimer.restart();
}
function executeCommittedAction() {
if (committedAction === "")
return;
commitFallbackTimer.stop();
const action = committedAction;
committedAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
}
signal switchUserRequested signal switchUserRequested
function executeAction(action) { function executeAction(action) {
@@ -128,6 +152,12 @@ DankModal {
onTriggered: root.showHoldHint = false onTriggered: root.showHoldHint = false
} }
Timer {
id: commitFallbackTimer
interval: 5000
onTriggered: root.executeCommittedAction()
}
function openCentered() { function openCentered() {
parentBounds = Qt.rect(0, 0, 0, 0); parentBounds = Qt.rect(0, 0, 0, 0);
parentScreen = null; parentScreen = null;
@@ -286,7 +316,8 @@ DankModal {
holdProgress = 0; holdProgress = 0;
showHoldHint = false; showHoldHint = false;
holdFromKeyboard = false; holdFromKeyboard = false;
pendingKeyAction = ""; committedAction = "";
commitFallbackTimer.stop();
updateVisibleActions(); updateVisibleActions();
const defaultIndex = getDefaultActionIndex(); const defaultIndex = getDefaultActionIndex();
selectedIndex = defaultIndex; selectedIndex = defaultIndex;
@@ -309,6 +340,10 @@ DankModal {
event.accepted = true; event.accepted = true;
return; return;
} }
if (committedAction !== "") {
event.accepted = true;
return;
}
holdFromKeyboard = true; holdFromKeyboard = true;
if (SettingsData.powerMenuGridLayout) { if (SettingsData.powerMenuGridLayout) {
handleGridNavigation(event, true); handleGridNavigation(event, true);
@@ -321,6 +356,11 @@ DankModal {
event.accepted = true; event.accepted = true;
return; return;
} }
if (committedAction !== "") {
event.accepted = true;
executeCommittedAction();
return;
}
if (SettingsData.powerMenuGridLayout) { if (SettingsData.powerMenuGridLayout) {
handleGridNavigation(event, false); handleGridNavigation(event, false);
} else { } else {
@@ -331,16 +371,7 @@ DankModal {
function handleListNavigation(event, isPressed) { function handleListNavigation(event, isPressed) {
if (!isPressed) { if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
if (pendingKeyAction !== "") { cancelHold();
const action = pendingKeyAction;
pendingKeyAction = "";
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
} else {
cancelHold();
}
event.accepted = true; event.accepted = true;
} }
return; return;
@@ -434,16 +465,7 @@ DankModal {
function handleGridNavigation(event, isPressed) { function handleGridNavigation(event, isPressed) {
if (!isPressed) { if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
if (pendingKeyAction !== "") { cancelHold();
const action = pendingKeyAction;
pendingKeyAction = "";
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
} else {
cancelHold();
}
event.accepted = true; event.accepted = true;
} }
return; return;
@@ -812,6 +834,7 @@ DankModal {
Row { Row {
id: hintRow id: hintRow
readonly property bool selectedNeedsHold: root.actionNeedsConfirm(root.getActionAtIndex(root.selectedIndex))
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.bottomMargin: Theme.spacingS anchors.bottomMargin: Theme.spacingS
@@ -826,7 +849,13 @@ DankModal {
} }
DankIcon { DankIcon {
name: root.showHoldHint ? "warning" : "touch_app" name: {
if (root.showHoldHint)
return "warning";
if (!hintRow.selectedNeedsHold)
return "bolt";
return "touch_app";
}
size: Theme.fontSizeSmall size: Theme.fontSizeSmall
color: root.showHoldHint ? Theme.warning : Theme.surfaceTextSecondary color: root.showHoldHint ? Theme.warning : Theme.surfaceTextSecondary
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -836,8 +865,12 @@ DankModal {
readonly property real totalMs: SettingsData.powerActionHoldDuration * 1000 readonly property real totalMs: SettingsData.powerActionHoldDuration * 1000
readonly property int remainingMs: Math.ceil(totalMs * (1 - root.holdProgress)) readonly property int remainingMs: Math.ceil(totalMs * (1 - root.holdProgress))
text: { text: {
if (root.committedAction !== "")
return I18n.tr("Release to confirm");
if (root.showHoldHint) if (root.showHoldHint)
return I18n.tr("Hold longer to confirm"); return I18n.tr("Hold longer to confirm");
if (!hintRow.selectedNeedsHold)
return I18n.tr("Activates immediately");
if (root.holdProgress > 0) { if (root.holdProgress > 0) {
if (totalMs < 1000) if (totalMs < 1000)
return I18n.tr("Hold to confirm (%1 ms)").arg(remainingMs); return I18n.tr("Hold to confirm (%1 ms)").arg(remainingMs);
+144 -232
View File
@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import qs.Common import qs.Common
@@ -33,6 +32,8 @@ Variants {
color: "transparent" color: "transparent"
visible: root.contentReady && !root.surfaceBounce
updatesEnabled: root.renderActive || root._settleFrames > 0 updatesEnabled: root.renderActive || root._settleFrames > 0
mask: Region { mask: Region {
@@ -55,55 +56,96 @@ Variants {
} }
property string source: SessionData.getMonitorWallpaper(modelData.name) || "" property string source: SessionData.getMonitorWallpaper(modelData.name) || ""
property bool isColorSource: source.startsWith("#") readonly property bool isColorSource: source.startsWith("#")
readonly property string displaySource: {
Connections { if (!source || isColorSource)
target: SessionData return "";
function onIsLightModeChanged() { return source.startsWith("file://") ? source : encodeFileUrl(source);
if (SessionData.perModeWallpaper) {
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
if (newSource !== root.source) {
root.source = newSource;
}
}
}
} }
property bool contentReady: false
property bool surfaceBounce: false
function getFillMode(modeName) { // Live stack is captured into frozenLayer once stable, then unloaded; if the capture stalls the stack just stays live.
switch (modeName) { property bool liveActive: false
case "Stretch": property bool frozenValid: false
return Image.Stretch; property string _frozenSource: ""
case "Fit": property bool loadFailed: false
case "PreserveAspectFit": property int _freezeWaitFrames: 0
return Image.PreserveAspectFit;
case "Fill": readonly property var backingWindow: Window.window
case "PreserveAspectCrop": readonly property bool renderActive: !source || liveActive || _freezeWaitFrames > 0
return Image.PreserveAspectCrop; property int _settleFrames: 3
case "Tile":
return Image.Tile; readonly property int maxTextureSize: 8192
case "TileVertically": readonly property int textureWidth: Math.min(modelData.width, maxTextureSize)
return Image.TileVertically; readonly property int textureHeight: Math.min(modelData.height, maxTextureSize)
case "TileHorizontally":
return Image.TileHorizontally;
case "Pad":
return Image.Pad;
default:
return Image.PreserveAspectCrop;
}
}
Component.onCompleted: { Component.onCompleted: {
isInitialized = true; if (!displaySource) {
contentReady = true;
return;
}
liveActive = true;
} }
property bool isInitialized: false onDisplaySourceChanged: {
property real transitionProgress: 0 invalidate();
readonly property bool transitioning: transitionAnimation.running loadFailed = false;
property bool effectActive: false _freezeWaitFrames = 0;
property bool useNextForEffect: false if (!displaySource) {
readonly property var backingWindow: Window.window liveActive = false;
readonly property bool renderActive: !source || effectActive || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading frozenValid = false;
property int _settleFrames: 3 _frozenSource = "";
contentReady = true;
return;
}
liveActive = true;
}
function regenerate() {
invalidate();
if (!displaySource)
return;
if (liveActive) {
scheduleFreeze();
return;
}
liveActive = true;
}
function handleDisplayable() {
contentReady = true;
if (liveLoader.item?.currentFailed) {
if (!frozenValid)
loadFailed = true;
liveActive = false;
}
invalidate();
}
function scheduleFreeze() {
if (!liveLoader.item?.stable)
return;
frozenLayer.scheduleUpdate();
_freezeWaitFrames = 3;
_settleFrames = 3;
// No wedge watchdog: an occluded surface may never produce frames, the freeze just waits
backingWindow?.update();
}
function completeFreeze() {
const live = liveLoader.item;
if (!live || !live.stable)
return;
frozenValid = true;
_frozenSource = displaySource;
liveActive = false;
log.info("froze blur layer for", modelData.name);
invalidate();
}
onTextureWidthChanged: regenerate()
onTextureHeightChanged: regenerate()
function invalidate() { function invalidate() {
_settleFrames = 3; _settleFrames = 3;
@@ -136,9 +178,9 @@ Variants {
interval: 0 interval: 0
repeat: false repeat: false
onTriggered: { onTriggered: {
blurWallpaperWindow.visible = false; root.surfaceBounce = true;
Qt.callLater(() => { Qt.callLater(() => {
blurWallpaperWindow.visible = true; root.surfaceBounce = false;
}); });
} }
} }
@@ -150,29 +192,35 @@ Variants {
root._settleFrames--; root._settleFrames--;
root._wedgeBounced = false; root._wedgeBounced = false;
wedgeWatchdog.stop(); wedgeWatchdog.stop();
if (root._freezeWaitFrames > 0 && --root._freezeWaitFrames === 0)
root.completeFreeze();
} }
function onVisibleChanged() { function onVisibleChanged() {
root.invalidate(); root.invalidate();
} }
function onWidthChanged() { function onWidthChanged() {
root.invalidate(); root.regenerate();
} }
function onHeightChanged() { function onHeightChanged() {
root.invalidate(); root.regenerate();
}
function onResourcesLost() {
root.frozenValid = false;
root.regenerate();
} }
} }
Connections { Connections {
target: Quickshell target: Quickshell
function onScreensChanged() { function onScreensChanged() {
root.invalidate(); root.regenerate();
} }
} }
Connections { Connections {
target: SettingsData target: SettingsData
function onWallpaperFillModeChanged() { function onWallpaperFillModeChanged() {
root.invalidate(); root.regenerate();
} }
function onEffectiveWallpaperBackgroundColorChanged() { function onEffectiveWallpaperBackgroundColorChanged() {
root.invalidate(); root.invalidate();
@@ -181,18 +229,26 @@ Variants {
Connections { Connections {
target: SessionData target: SessionData
function onIsLightModeChanged() {
if (SessionData.perModeWallpaper) {
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
if (newSource !== root.source) {
root.source = newSource;
}
}
}
function onMonitorWallpaperFillModesChanged() { function onMonitorWallpaperFillModesChanged() {
root.invalidate(); root.regenerate();
} }
function onPerMonitorWallpaperChanged() { function onPerMonitorWallpaperChanged() {
root.invalidate(); root.regenerate();
} }
} }
// Theme changes repaint DankBackdrop but nothing else wakes the render loop // Theme changes repaint DankBackdrop but nothing else wakes the render loop
Connections { Connections {
target: Theme target: Theme
enabled: root.isColorSource || currentWallpaper.status === Image.Error enabled: root.isColorSource || root.loadFailed
function onPrimaryChanged() { function onPrimaryChanged() {
root.invalidate(); root.invalidate();
} }
@@ -210,87 +266,23 @@ Variants {
} }
} }
function handleTransitionLoadError(failedSource) { Connections {
log.warn("failed to load candidate wallpaper for", modelData.name + ":", failedSource); target: liveLoader.item
transitionDelayTimer.stop(); function onBecameDisplayable() {
transitionAnimation.stop(); root.handleDisplayable();
root.useNextForEffect = false;
root.effectActive = false;
root.transitionProgress = 0.0;
nextWallpaper.source = "";
}
onSourceChanged: {
invalidate();
if (!source || source.startsWith("#")) {
setWallpaperImmediate("");
return;
} }
function onStableChanged() {
const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source); if (liveLoader.item.stable)
root.scheduleFreeze();
if (!isInitialized || !currentWallpaper.source) {
setWallpaperImmediate(formattedSource);
isInitialized = true;
return;
} }
if (CompositorService.isNiri && SessionData.isSwitchingMode) { function onTransitioningChanged() {
setWallpaperImmediate(formattedSource); root.invalidate();
return;
} }
changeWallpaper(formattedSource);
}
function setWallpaperImmediate(newSource) {
transitionDelayTimer.stop();
transitionAnimation.stop();
root.transitionProgress = 0.0;
root.effectActive = false;
currentWallpaper.source = newSource;
nextWallpaper.source = "";
}
function startTransition() {
root.useNextForEffect = true;
root.effectActive = true;
if (srcNext.scheduleUpdate)
srcNext.scheduleUpdate();
transitionDelayTimer.start();
}
Timer {
id: transitionDelayTimer
interval: 16
repeat: false
onTriggered: transitionAnimation.start()
}
function changeWallpaper(newPath) {
if (newPath === currentWallpaper.source)
return;
if (!newPath || newPath.startsWith("#"))
return;
if (root.transitioning) {
transitionAnimation.stop();
root.transitionProgress = 0;
root.effectActive = false;
currentWallpaper.source = nextWallpaper.source;
nextWallpaper.source = "";
}
if (!currentWallpaper.source) {
setWallpaperImmediate(newPath);
return;
}
nextWallpaper.source = newPath;
if (nextWallpaper.status === Image.Ready)
root.startTransition();
} }
Loader { Loader {
anchors.fill: parent anchors.fill: parent
active: !root.source || root.isColorSource || currentWallpaper.status === Image.Error active: !root.source || root.isColorSource || root.loadFailed
asynchronous: true asynchronous: true
sourceComponent: DankBackdrop { sourceComponent: DankBackdrop {
@@ -298,123 +290,43 @@ Variants {
} }
} }
readonly property int maxTextureSize: 8192
property int textureWidth: Math.min(modelData.width, maxTextureSize)
property int textureHeight: Math.min(modelData.height, maxTextureSize)
Image {
id: currentWallpaper
anchors.fill: parent
visible: false
opacity: 1
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(modelData.name))
onStatusChanged: {
if (status === Image.Error) {
log.warn("failed to load active wallpaper for", modelData.name + ":", source);
}
}
}
Image {
id: nextWallpaper
anchors.fill: parent
visible: false
opacity: 0
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(modelData.name))
onStatusChanged: {
if (status === Image.Error) {
root.handleTransitionLoadError(source);
return;
}
if (status !== Image.Ready)
return;
if (!root.transitioning) {
root.startTransition();
}
}
}
ShaderEffectSource { ShaderEffectSource {
id: srcNext id: frozenLayer
sourceItem: root.effectActive ? nextWallpaper : null anchors.fill: parent
hideSource: root.effectActive sourceItem: liveContainer
live: root.effectActive
mipmap: false
recursive: false
textureSize: Qt.size(root.textureWidth, root.textureHeight)
}
Rectangle {
id: dummyRect
width: 1
height: 1
visible: false
color: "transparent"
}
ShaderEffectSource {
id: srcDummy
sourceItem: dummyRect
hideSource: true
live: false live: false
mipmap: false mipmap: false
recursive: false recursive: false
smooth: true
visible: root.frozenValid || root.liveActive
textureSize: Qt.size(root.textureWidth, root.textureHeight)
} }
Item { Item {
id: blurredLayer id: liveContainer
anchors.fill: parent anchors.fill: parent
visible: root.liveActive
MultiEffect { Loader {
id: liveLoader
anchors.fill: parent anchors.fill: parent
source: currentWallpaper active: root.liveActive
visible: currentWallpaper.source !== "" asynchronous: false
blurEnabled: true
blur: 0.8
blurMax: 75
opacity: 1 - root.transitionProgress
autoPaddingEnabled: false
}
MultiEffect { // Cached images reach Ready synchronously during creation, before Connections retargets
anchors.fill: parent onLoaded: {
source: root.useNextForEffect ? srcNext : srcDummy if (item.displayableNow)
visible: nextWallpaper.source !== "" && root.useNextForEffect root.handleDisplayable();
blurEnabled: true if (item.stable)
blur: 0.8 root.scheduleFreeze();
blurMax: 75 }
opacity: root.transitionProgress
autoPaddingEnabled: false
}
}
NumberAnimation { sourceComponent: BlurredWallpaperLive {
id: transitionAnimation wallpaperSource: root.displaySource
target: root initialSource: root._frozenSource
property: "transitionProgress" screenName: modelData.name
from: 0.0 blurTextureSize: Qt.size(root.textureWidth, root.textureHeight)
to: 1.0 }
duration: 1000
easing.type: Easing.InOutCubic
onFinished: {
if (nextWallpaper.source && nextWallpaper.status === Image.Ready)
currentWallpaper.source = nextWallpaper.source;
root.useNextForEffect = false;
nextWallpaper.source = "";
root.transitionProgress = 0.0;
root.effectActive = false;
} }
} }
} }
+255
View File
@@ -0,0 +1,255 @@
import QtQuick
import QtQuick.Effects
import qs.Common
import qs.Services
Item {
id: root
readonly property var log: Log.scoped("BlurredWallpaperLive")
required property string wallpaperSource
required property string initialSource
required property string screenName
required property size blurTextureSize
readonly property bool currentFailed: currentWallpaper.status === Image.Error
readonly property bool displayableNow: currentWallpaper.status === Image.Ready || currentWallpaper.status === Image.Error
readonly property bool stable: isInitialized && !effectActive && !transitionAnimation.running && !transitionDelayTimer.running && currentWallpaper.status === Image.Ready && currentWallpaper.source.toString() === wallpaperSource && !nextWallpaper.source.toString()
signal becameDisplayable
property bool isInitialized: false
property real transitionProgress: 0
readonly property bool transitioning: transitionAnimation.running
property bool effectActive: false
property bool useNextForEffect: false
function getFillMode(modeName) {
switch (modeName) {
case "Stretch":
return Image.Stretch;
case "Fit":
case "PreserveAspectFit":
return Image.PreserveAspectFit;
case "Fill":
case "PreserveAspectCrop":
return Image.PreserveAspectCrop;
case "Tile":
return Image.Tile;
case "TileVertically":
return Image.TileVertically;
case "TileHorizontally":
return Image.TileHorizontally;
case "Pad":
return Image.Pad;
default:
return Image.PreserveAspectCrop;
}
}
Component.onCompleted: {
if (initialSource && initialSource !== wallpaperSource && !(CompositorService.isNiri && SessionData.isSwitchingMode)) {
currentWallpaper.source = initialSource;
isInitialized = true;
changeWallpaper(wallpaperSource);
return;
}
currentWallpaper.source = wallpaperSource;
isInitialized = true;
}
onWallpaperSourceChanged: {
if (!isInitialized)
return;
if (!wallpaperSource) {
setWallpaperImmediate("");
return;
}
if (!currentWallpaper.source.toString()) {
setWallpaperImmediate(wallpaperSource);
return;
}
if (CompositorService.isNiri && SessionData.isSwitchingMode) {
setWallpaperImmediate(wallpaperSource);
return;
}
changeWallpaper(wallpaperSource);
}
function handleTransitionLoadError(failedSource) {
log.warn("failed to load candidate wallpaper for", screenName + ":", failedSource);
transitionDelayTimer.stop();
transitionAnimation.stop();
useNextForEffect = false;
effectActive = false;
transitionProgress = 0.0;
nextWallpaper.source = "";
}
function setWallpaperImmediate(newSource) {
transitionDelayTimer.stop();
transitionAnimation.stop();
transitionProgress = 0.0;
effectActive = false;
currentWallpaper.source = newSource;
nextWallpaper.source = "";
}
function startTransition() {
useNextForEffect = true;
effectActive = true;
if (srcNext.scheduleUpdate)
srcNext.scheduleUpdate();
transitionDelayTimer.start();
}
function changeWallpaper(newPath) {
if (!newPath)
return;
if (newPath === currentWallpaper.source.toString())
return;
if (transitioning) {
transitionAnimation.stop();
transitionProgress = 0;
effectActive = false;
currentWallpaper.source = nextWallpaper.source;
nextWallpaper.source = "";
}
if (!currentWallpaper.source.toString()) {
setWallpaperImmediate(newPath);
return;
}
nextWallpaper.source = newPath;
if (nextWallpaper.status === Image.Ready)
startTransition();
}
Timer {
id: transitionDelayTimer
interval: 16
repeat: false
onTriggered: transitionAnimation.start()
}
Image {
id: currentWallpaper
anchors.fill: parent
visible: false
opacity: 1
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: root.blurTextureSize
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(root.screenName))
onStatusChanged: {
if (status === Image.Error) {
root.log.warn("failed to load active wallpaper for", root.screenName + ":", source);
}
if (status === Image.Ready || status === Image.Error) {
root.becameDisplayable();
}
}
}
Image {
id: nextWallpaper
anchors.fill: parent
visible: false
opacity: 0
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: root.blurTextureSize
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(root.screenName))
onStatusChanged: {
if (status === Image.Error) {
root.handleTransitionLoadError(source);
return;
}
if (status !== Image.Ready)
return;
if (!root.transitioning) {
root.startTransition();
}
}
}
ShaderEffectSource {
id: srcNext
sourceItem: root.effectActive ? nextWallpaper : null
hideSource: root.effectActive
live: root.effectActive
mipmap: false
recursive: false
textureSize: root.blurTextureSize
}
Rectangle {
id: dummyRect
width: 1
height: 1
visible: false
color: "transparent"
}
ShaderEffectSource {
id: srcDummy
sourceItem: dummyRect
hideSource: true
live: false
mipmap: false
recursive: false
}
Item {
id: blurredLayer
anchors.fill: parent
MultiEffect {
anchors.fill: parent
source: currentWallpaper
visible: currentWallpaper.source !== ""
blurEnabled: true
blur: 0.8
blurMax: 75
opacity: 1 - root.transitionProgress
autoPaddingEnabled: false
}
MultiEffect {
anchors.fill: parent
source: root.useNextForEffect ? srcNext : srcDummy
visible: nextWallpaper.source !== "" && root.useNextForEffect
blurEnabled: true
blur: 0.8
blurMax: 75
opacity: root.transitionProgress
autoPaddingEnabled: false
}
}
NumberAnimation {
id: transitionAnimation
target: root
property: "transitionProgress"
from: 0.0
to: 1.0
duration: 1000
easing.type: Easing.InOutCubic
onFinished: {
if (nextWallpaper.source && nextWallpaper.status === Image.Ready)
currentWallpaper.source = nextWallpaper.source;
root.useNextForEffect = false;
nextWallpaper.source = "";
root.transitionProgress = 0.0;
root.effectActive = false;
}
}
}
@@ -44,7 +44,9 @@ PluginComponent {
implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2 implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.nestedSurface
border.color: Theme.outlineMedium
border.width: Theme.layerOutlineWidth
Column { Column {
id: detailColumn id: detailColumn
@@ -132,7 +134,11 @@ PluginComponent {
width: connButtonRow.implicitWidth + Theme.spacingM * 2 width: connButtonRow.implicitWidth + Theme.spacingM * 2
readonly property bool isConnected: TailscaleService.connected readonly property bool isConnected: TailscaleService.connected
color: isConnected ? (connButtonArea.containsMouse ? Theme.errorHover : Theme.surfaceLight) : (connButtonArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight) color: {
if (!connButtonArea.containsMouse)
return Theme.surfaceLight;
return isConnected ? Theme.errorHover : Theme.primaryHoverLight;
}
Row { Row {
id: connButtonRow id: connButtonRow
@@ -330,13 +336,15 @@ PluginComponent {
required property var modelData required property var modelData
required property int index required property int index
readonly property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "")
readonly property bool isExpanded: detailRoot.expandedHostname === modelData.hostname
width: peerListColumn.width width: peerListColumn.width
height: peerCardColumn.implicitHeight + Theme.spacingS * 2 height: peerCardColumn.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") ? Theme.primaryHoverLight : Theme.surfaceContainerHighest color: peerMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
border.color: isSelf ? Theme.primary : Theme.outlineLight
property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") border.width: isSelf ? 2 : 1
property bool isExpanded: detailRoot.expandedHostname === modelData.hostname
Column { Column {
id: peerCardColumn id: peerCardColumn
@@ -354,14 +362,14 @@ PluginComponent {
width: 8 width: 8
height: 8 height: 8
radius: 4 radius: 4
color: modelData.online ? "#4caf50" : Theme.surfaceVariantText color: modelData.online ? Theme.success : Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
} }
StyledText { StyledText {
text: modelData.hostname || "" text: modelData.hostname || ""
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Bold font.weight: isSelf ? Font.Medium : Font.Normal
color: Theme.surfaceText color: Theme.surfaceText
Layout.fillWidth: true Layout.fillWidth: true
elide: Text.ElideRight elide: Text.ElideRight
@@ -370,7 +378,7 @@ PluginComponent {
StyledText { StyledText {
visible: isSelf visible: isSelf
text: I18n.tr("This device", "Label for the user's own device in Tailscale") text: I18n.tr("This device", "Label for the user's own device in Tailscale")
font.pixelSize: 10 font.pixelSize: Theme.fontSizeSmall
color: Theme.primary color: Theme.primary
font.weight: Font.Medium font.weight: Font.Medium
} }
@@ -409,7 +417,7 @@ PluginComponent {
} }
return parts.join(" \u2022 "); return parts.join(" \u2022 ");
} }
font.pixelSize: 10 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
width: parent.width width: parent.width
elide: Text.ElideRight elide: Text.ElideRight
@@ -429,7 +437,7 @@ PluginComponent {
StyledText { StyledText {
text: modelData.dnsName || "" text: modelData.dnsName || ""
font.pixelSize: 10 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
Layout.fillWidth: true Layout.fillWidth: true
elide: Text.ElideRight elide: Text.ElideRight
@@ -447,14 +455,14 @@ PluginComponent {
StyledText { StyledText {
visible: (modelData.tags || []).length > 0 visible: (modelData.tags || []).length > 0
text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", ")) text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", "))
font.pixelSize: 10 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
StyledText { StyledText {
visible: (modelData.owner || "").length > 0 visible: (modelData.owner || "").length > 0
text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "") text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "")
font.pixelSize: 10 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
} }
@@ -77,6 +77,12 @@ DankPopout {
readonly property color _containerBg: Theme.nestedSurface readonly property color _containerBg: Theme.nestedSurface
// Defer open one tick so screen-change geometry settles before the surface
// maps; a synchronous open churns the surface and loses the blur on a switch.
function present() {
Qt.callLater(open);
}
function openWithSection(section) { function openWithSection(section) {
StateUtils.openWithSection(root, section); StateUtils.openWithSection(root, section);
} }
@@ -39,7 +39,7 @@ Item {
readonly property real _barInsetPaddingAuto: _barIsVertical ? Theme.spacingXS : _edgeBaseMargin readonly property real _barInsetPaddingAuto: _barIsVertical ? Theme.spacingXS : _edgeBaseMargin
readonly property real _barInsetPadding: _barInsetPaddingRaw < 0 ? _barInsetPaddingAuto : _barInsetPaddingRaw readonly property real _barInsetPadding: _barInsetPaddingRaw < 0 ? _barInsetPaddingAuto : _barInsetPaddingRaw
// Connected-frame Bar Inset Padding: absolute free-end inset (auto < 0 = frameThickness, 0 = edge-to-edge). // Connected-frame Bar Inset Padding: absolute free-end inset (auto < 0 = frameThickness, 0 = edge-to-edge).
// Any amount beyond the frameThickness baseline is also added at the bar-clearance ends (all ends). // FrameExclusions already moves a free bar end inward by frameThickness so use frame inset to mangage the gap
readonly property real _frameInsetResolved: SettingsData.frameBarInsetPadding < 0 ? SettingsData.frameThickness : SettingsData.frameBarInsetPadding readonly property real _frameInsetResolved: SettingsData.frameBarInsetPadding < 0 ? SettingsData.frameThickness : SettingsData.frameBarInsetPadding
readonly property real _frameInsetExtra: Math.max(0, _frameInsetResolved - SettingsData.frameThickness) readonly property real _frameInsetExtra: Math.max(0, _frameInsetResolved - SettingsData.frameThickness)
@@ -47,28 +47,28 @@ Item {
if (_barIsVertical) if (_barIsVertical)
return _edgeBaseMargin; return _edgeBaseMargin;
if (_usesFrameBarChrome) if (_usesFrameBarChrome)
return hasAdjacentLeftBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : Math.max(0, _frameInsetResolved); return hasAdjacentLeftBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : _frameInsetExtra;
return Math.max(0, _barInsetPadding); return Math.max(0, _barInsetPadding);
} }
readonly property real _rightMargin: { readonly property real _rightMargin: {
if (_barIsVertical) if (_barIsVertical)
return _edgeBaseMargin; return _edgeBaseMargin;
if (_usesFrameBarChrome) if (_usesFrameBarChrome)
return hasAdjacentRightBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : Math.max(0, _frameInsetResolved); return hasAdjacentRightBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : _frameInsetExtra;
return Math.max(0, _barInsetPadding); return Math.max(0, _barInsetPadding);
} }
readonly property real _topMargin: { readonly property real _topMargin: {
if (!_barIsVertical) if (!_barIsVertical)
return 0; return 0;
if (_usesFrameBarChrome) if (_usesFrameBarChrome)
return hasAdjacentTopBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : Math.max(0, _frameInsetResolved); return hasAdjacentTopBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : _frameInsetExtra;
return Math.max(0, _barInsetPadding); return Math.max(0, _barInsetPadding);
} }
readonly property real _bottomMargin: { readonly property real _bottomMargin: {
if (!_barIsVertical) if (!_barIsVertical)
return 0; return 0;
if (_usesFrameBarChrome) if (_usesFrameBarChrome)
return hasAdjacentBottomBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : Math.max(0, _frameInsetResolved); return hasAdjacentBottomBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : _frameInsetExtra;
return Math.max(0, _barInsetPadding); return Math.max(0, _barInsetPadding);
} }
@@ -74,7 +74,7 @@ DankPopout {
color: "transparent" color: "transparent"
focus: true focus: true
readonly property bool hasTerminalBackend: (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true) readonly property bool upgradeRunsInTerminal: SystemUpdateService.useCustomCommand || (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
property int nowUnix: Math.floor(Date.now() / 1000) property int nowUnix: Math.floor(Date.now() / 1000)
@@ -272,7 +272,7 @@ DankPopout {
includeAUR: SettingsData.updaterAllowAUR, includeAUR: SettingsData.updaterAllowAUR,
terminal: SessionData.terminalOverride terminal: SessionData.terminalOverride
}; };
if (updaterPanel.hasTerminalBackend) { if (updaterPanel.upgradeRunsInTerminal) {
systemUpdatePopout._reopenAfterUpgrade = true; systemUpdatePopout._reopenAfterUpgrade = true;
SystemUpdateService.runUpdates(opts); SystemUpdateService.runUpdates(opts);
systemUpdatePopout.close(); systemUpdatePopout.close();
@@ -336,7 +336,10 @@ DankPopout {
StyledText { StyledText {
id: statusText id: statusText
anchors.fill: parent anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: ignoredSection.top
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
@@ -361,7 +364,10 @@ DankPopout {
DankListView { DankListView {
id: packagesList id: packagesList
anchors.fill: parent anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: ignoredSection.top
anchors.margins: Theme.spacingS anchors.margins: Theme.spacingS
visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
clip: true clip: true
@@ -467,18 +473,128 @@ DankPopout {
iconName: "visibility_off" iconName: "visibility_off"
iconSize: 16 iconSize: 16
iconColor: Theme.surfaceVariantText iconColor: Theme.surfaceVariantText
visible: rowHoverHandler.hovered visible: rowHoverHandler.hovered && SystemUpdateService.canIgnorePackage(packageRow.modelData)
tooltipText: I18n.tr("Ignore this package") tooltipText: I18n.tr("Ignore this package")
onClicked: SystemUpdateService.ignorePackage(packageRow.modelData.name) onClicked: SystemUpdateService.ignorePackage(packageRow.modelData.name)
} }
} }
} }
Column {
id: ignoredSection
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.spacingS
spacing: Theme.spacingXS
readonly property var ignoredNames: SettingsData.updaterIgnoredPackages || []
readonly property bool shown: ignoredNames.length > 0 && !SystemUpdateService.isUpgrading && !SystemUpdateService.isChecking
property bool expanded: false
visible: shown
height: shown ? implicitHeight : 0
Rectangle {
id: ignoredToggle
width: parent.width
height: 32
radius: Theme.cornerRadius
color: ignoredToggleArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
DankIcon {
id: ignoredToggleIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "visibility_off"
size: 16
color: Theme.surfaceVariantText
}
StyledText {
anchors.left: ignoredToggleIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("Ignored (%1)").arg(ignoredSection.ignoredNames.length)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankIcon {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: ignoredSection.expanded ? "expand_less" : "expand_more"
size: 16
color: Theme.surfaceVariantText
}
MouseArea {
id: ignoredToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: ignoredSection.expanded = !ignoredSection.expanded
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
DankListView {
width: parent.width
height: ignoredSection.expanded ? Math.min(contentHeight, 150) : 0
visible: ignoredSection.expanded
clip: true
spacing: Theme.spacingXS
model: ignoredSection.ignoredNames
delegate: Rectangle {
id: ignoredRow
width: ListView.view.width
height: 32
radius: Theme.cornerRadius
color: Theme.surfaceLight
required property string modelData
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.right: restoreButton.left
anchors.verticalCenter: parent.verticalCenter
text: ignoredRow.modelData
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
elide: Text.ElideRight
}
DankActionButton {
id: restoreButton
anchors.right: parent.right
anchors.rightMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
buttonSize: 24
iconName: "visibility"
iconSize: 16
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Stop ignoring %1").arg(ignoredRow.modelData)
onClicked: SystemUpdateService.unignorePackage(ignoredRow.modelData)
}
}
}
}
Column { Column {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
spacing: Theme.spacingS spacing: Theme.spacingS
visible: SystemUpdateService.isUpgrading && updaterPanel.hasTerminalBackend visible: SystemUpdateService.isUpgrading && updaterPanel.upgradeRunsInTerminal
DankIcon { DankIcon {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
@@ -509,7 +625,7 @@ DankPopout {
DankFlickable { DankFlickable {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
visible: SystemUpdateService.isUpgrading && !updaterPanel.hasTerminalBackend visible: SystemUpdateService.isUpgrading && !updaterPanel.upgradeRunsInTerminal
contentWidth: width contentWidth: width
contentHeight: logText.implicitHeight contentHeight: logText.implicitHeight
clip: true clip: true
@@ -63,8 +63,8 @@ BasePill {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: mouse => root.triggerRipple(this, mouse.x, mouse.y) onPressed: function (mouse) {
onClicked: function (mouse) { root.triggerRipple(this, mouse.x, mouse.y);
switch (mouse.button) { switch (mouse.button) {
case Qt.RightButton: case Qt.RightButton:
openContextMenu(); openContextMenu();
+67 -60
View File
@@ -13,33 +13,6 @@ BasePill {
readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer) readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer)
readonly property bool _isPlaying: !!activePlayer && activePlayer.playbackState === 1 && !_hoverPreview readonly property bool _isPlaying: !!activePlayer && activePlayer.playbackState === 1 && !_hoverPreview
property string _stableTitle: ""
property string _stableArtist: ""
Connections {
target: root.activePlayer
function onTrackTitleChanged() {
root._syncMeta();
}
function onTrackArtistChanged() {
root._syncMeta();
}
}
onActivePlayerChanged: _syncMeta()
function _syncMeta() {
if (!activePlayer) {
_stableTitle = "";
_stableArtist = "";
return;
}
if (MprisController.isFirefoxYoutubeHoverPreview(activePlayer))
return;
_stableTitle = activePlayer.trackTitle || "";
_stableArtist = activePlayer.trackArtist || "";
}
readonly property bool __isChromeBrowser: { readonly property bool __isChromeBrowser: {
if (!activePlayer?.identity) if (!activePlayer?.identity)
return false; return false;
@@ -129,7 +102,7 @@ BasePill {
if (deltaY > 0) { if (deltaY > 0) {
MprisController.previousOrRewind(); MprisController.previousOrRewind();
} else { } else {
activePlayer.next(); MprisController.next();
} }
} else { } else {
scrollAccumulatorY += deltaY; scrollAccumulatorY += deltaY;
@@ -137,7 +110,7 @@ BasePill {
if (scrollAccumulatorY > 0) { if (scrollAccumulatorY > 0) {
MprisController.previousOrRewind(); MprisController.previousOrRewind();
} else { } else {
activePlayer.next(); MprisController.next();
} }
scrollAccumulatorY = 0; scrollAccumulatorY = 0;
} }
@@ -266,7 +239,7 @@ BasePill {
} else if (mouse.button === Qt.MiddleButton) { } else if (mouse.button === Qt.MiddleButton) {
MprisController.previousOrRewind(); MprisController.previousOrRewind();
} else if (mouse.button === Qt.RightButton) { } else if (mouse.button === Qt.RightButton) {
activePlayer.next(); MprisController.next();
} }
} }
} }
@@ -310,10 +283,10 @@ BasePill {
readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari") readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari")
property string displayText: { property string displayText: {
if (!activePlayer || !root._stableTitle) if (!activePlayer || !MprisController.stableTitle)
return ""; return "";
const title = isWebMedia ? root._stableTitle : (root._stableTitle || "Unknown Track"); const title = MprisController.stableTitle;
const subtitle = isWebMedia ? (root._stableArtist || cachedIdentity) : (root._stableArtist || ""); const subtitle = isWebMedia ? (MprisController.stableArtist || cachedIdentity) : MprisController.stableArtist;
return subtitle.length > 0 ? title + " • " + subtitle : title; return subtitle.length > 0 ? title + " • " + subtitle : title;
} }
@@ -344,9 +317,45 @@ BasePill {
id: mediaText id: mediaText
readonly property bool onScreen: Window.window?.visible ?? false readonly property bool onScreen: Window.window?.visible ?? false
property bool needsScrolling: implicitWidth > textContainer.width && SettingsData.scrollTitleEnabled property bool needsScrolling: implicitWidth > textContainer.width && SettingsData.scrollTitleEnabled
readonly property bool scrollActive: needsScrolling && textContainer.visible && onScreen && root._isPlaying
readonly property real maxScrollOffset: Math.max(0, implicitWidth - textContainer.width + 5)
property real scrollOffset: 0 property real scrollOffset: 0
property int scrollDirection: 1
property real scrollHoldMs: 2000
property real textShift: 0 property real textShift: 0
function resetScroll() {
scrollOffset = 0;
scrollDirection = 1;
scrollHoldMs = 2000;
}
function stepScroll(deltaMs) {
if (scrollHoldMs > 0) {
scrollHoldMs -= deltaMs;
return;
}
const next = scrollOffset + scrollDirection * deltaMs / 60;
if (next >= maxScrollOffset) {
scrollOffset = maxScrollOffset;
scrollDirection = -1;
scrollHoldMs = 2000;
return;
}
if (next <= 0) {
scrollOffset = 0;
scrollDirection = 1;
scrollHoldMs = 2000;
return;
}
scrollOffset = next;
}
onScrollActiveChanged: {
if (!scrollActive)
resetScroll();
}
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: textContainer.displayText text: textContainer.displayText
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText) font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
@@ -356,40 +365,38 @@ BasePill {
opacity: 1 opacity: 1
onTextChanged: { onTextChanged: {
scrollOffset = 0; resetScroll();
textShift = 0; textShift = 0;
textChangeAnimation.restart(); textChangeAnimation.restart();
} }
SequentialAnimation { // Timer stepping, not NumberAnimation a running animation commits frames every vsync (#2863).
id: scrollAnimation // When cava frames are already driving renders, scroll steps ride those ticks instead
running: mediaText.needsScrolling && textContainer.visible && mediaText.onScreen && root._isPlaying // two unsynchronized tick sources nearly double the surface commit rate (#2863).
loops: Animation.Infinite Timer {
onStopped: mediaText.scrollOffset = 0 id: scrollTimer
PauseAnimation { interval: 60
duration: 2000 repeat: true
running: mediaText.scrollActive
onTriggered: {
if (cavaTickWatch.running)
return;
mediaText.stepScroll(60);
} }
}
NumberAnimation { Timer {
target: mediaText id: cavaTickWatch
property: "scrollOffset" interval: 150
from: 0 }
to: mediaText.implicitWidth - textContainer.width + 5
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
easing.type: Easing.Linear
}
PauseAnimation { Connections {
duration: 2000 target: CavaService
} enabled: mediaText.scrollActive && SettingsData.audioVisualizerEnabled && CavaService.cavaAvailable
function onValuesChanged() {
NumberAnimation { cavaTickWatch.restart();
target: mediaText mediaText.stepScroll(40);
property: "scrollOffset"
to: 0
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
easing.type: Easing.Linear
} }
} }
@@ -520,7 +527,7 @@ BasePill {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if (activePlayer) { if (activePlayer) {
activePlayer.next(); MprisController.next();
} }
} }
} }
@@ -1250,13 +1250,8 @@ BasePill {
if (root.isVerticalOrientation) { if (root.isVerticalOrientation) {
const edge = root.axis?.edge; const edge = root.axis?.edge;
if (edge === "left") { const targetX = edge === "left" ? overflowMenu.anchorPos.x : overflowMenu.anchorPos.x - alignedWidth;
const targetX = overflowMenu.anchorPos.x; return Math.max(10, Math.min(overflowMenu.width - alignedWidth - 10, targetX));
return Math.max(left, Math.min(right, targetX));
} else {
const targetX = overflowMenu.anchorPos.x - alignedWidth;
return Math.max(left, Math.min(right, targetX));
}
} else { } else {
const want = overflowMenu.anchorPos.x - alignedWidth / 2; const want = overflowMenu.anchorPos.x - alignedWidth / 2;
return Math.max(left, Math.min(right, want)); return Math.max(left, Math.min(right, want));
@@ -1271,13 +1266,8 @@ BasePill {
const want = overflowMenu.anchorPos.y - alignedHeight / 2; const want = overflowMenu.anchorPos.y - alignedHeight / 2;
return Math.max(top, Math.min(bottom, want)); return Math.max(top, Math.min(bottom, want));
} else { } else {
if (root.isAtBottom) { const targetY = root.isAtBottom ? overflowMenu.anchorPos.y - alignedHeight : overflowMenu.anchorPos.y;
const targetY = overflowMenu.anchorPos.y - alignedHeight; return Math.max(10, Math.min(overflowMenu.height - alignedHeight - 10, targetY));
return Math.max(top, Math.min(bottom, targetY));
} else {
const targetY = overflowMenu.anchorPos.y;
return Math.max(top, Math.min(bottom, targetY));
}
} }
})(), overflowMenu.dpr) })(), overflowMenu.dpr)
@@ -1752,7 +1742,11 @@ BasePill {
id: trayMenuContainer id: trayMenuContainer
readonly property real rawWidth: Math.min(500, Math.max(250, menuColumn.implicitWidth + Theme.spacingS * 2)) readonly property real rawWidth: Math.min(500, Math.max(250, menuColumn.implicitWidth + Theme.spacingS * 2))
readonly property real rawHeight: Math.max(40, menuColumn.implicitHeight + Theme.spacingS * 2) readonly property real rawHeight: {
const desiredHeight = Math.max(40, menuColumn.implicitHeight + Theme.spacingS * 2);
const maxHeight = Math.max(40, menuWindow.maskHeight - 20);
return Math.min(desiredHeight, maxHeight);
}
readonly property real alignedWidth: Theme.px(rawWidth, menuWindow.dpr) readonly property real alignedWidth: Theme.px(rawWidth, menuWindow.dpr)
readonly property real alignedHeight: Theme.px(rawHeight, menuWindow.dpr) readonly property real alignedHeight: Theme.px(rawHeight, menuWindow.dpr)
@@ -1766,13 +1760,8 @@ BasePill {
if (menuRoot.isVertical) { if (menuRoot.isVertical) {
const edge = menuRoot.axis?.edge; const edge = menuRoot.axis?.edge;
if (edge === "left") { const targetX = edge === "left" ? menuWindow.anchorPos.x : menuWindow.anchorPos.x - alignedWidth;
const targetX = menuWindow.anchorPos.x; return Math.max(10, Math.min(menuWindow.width - alignedWidth - 10, targetX));
return Math.max(left, Math.min(right, targetX));
} else {
const targetX = menuWindow.anchorPos.x - alignedWidth;
return Math.max(left, Math.min(right, targetX));
}
} else { } else {
const want = menuWindow.anchorPos.x - alignedWidth / 2; const want = menuWindow.anchorPos.x - alignedWidth / 2;
return Math.max(left, Math.min(right, want)); return Math.max(left, Math.min(right, want));
@@ -1787,13 +1776,8 @@ BasePill {
const want = menuWindow.anchorPos.y - alignedHeight / 2; const want = menuWindow.anchorPos.y - alignedHeight / 2;
return Math.max(top, Math.min(bottom, want)); return Math.max(top, Math.min(bottom, want));
} else { } else {
if (menuRoot.isAtBottom) { const targetY = menuRoot.isAtBottom ? menuWindow.anchorPos.y - alignedHeight : menuWindow.anchorPos.y;
const targetY = menuWindow.anchorPos.y - alignedHeight; return Math.max(10, Math.min(menuWindow.height - alignedHeight - 10, targetY));
return Math.max(top, Math.min(bottom, targetY));
} else {
const targetY = menuWindow.anchorPos.y;
return Math.max(top, Math.min(bottom, targetY));
}
} }
})(), menuWindow.dpr) })(), menuWindow.dpr)
@@ -1852,236 +1836,243 @@ BasePill {
} }
} }
Column { DankFlickable {
id: menuColumn id: menuFlickable
anchors.fill: parent
anchors.margins: Theme.spacingS
contentWidth: width
contentHeight: menuColumn.implicitHeight
clip: true
interactive: contentHeight > height
width: parent.width - Theme.spacingS * 2 Column {
anchors.horizontalCenter: parent.horizontalCenter id: menuColumn
anchors.top: parent.top
anchors.topMargin: Theme.spacingS
spacing: 1
Rectangle { width: menuFlickable.width
visible: entryStack.count === 0 spacing: 1
width: parent.width
height: 28
radius: Theme.cornerRadius
color: visibilityToggleArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: {
const itemId = menuRoot.trayItem?.id || "Unknown";
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
return itemId + " · " + I18n.tr("Keep in Bar");
return itemId;
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
elide: Text.ElideMiddle
width: parent.width - Theme.spacingS * 2 - 24
}
DankIcon {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: {
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
return "push_pin";
return root.isManualHiddenTrayItem(menuRoot.trayItem) ? "visibility" : "visibility_off";
}
size: 16
color: Theme.widgetTextColor
}
MouseArea {
id: visibilityToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
const itemKey = root.getTrayItemKey(menuRoot.trayItem);
if (!itemKey)
return;
if (root.isAutoOverflowTrayItem(menuRoot.trayItem)) {
root.promoteTrayItemToBar(menuRoot.trayItem);
} else if (root.isManualHiddenTrayItem(menuRoot.trayItem)) {
SessionData.showTrayId(itemKey);
} else {
SessionData.hideTrayId(itemKey);
}
menuRoot.close();
}
}
}
Rectangle {
visible: entryStack.count === 0
width: parent.width
height: 1
color: Theme.outlineHeavy
}
Rectangle {
visible: entryStack.count > 0
width: parent.width
height: 28
radius: Theme.cornerRadius
color: backArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
DankIcon {
name: "arrow_back"
size: 16
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Back")
font.pixelSize: Theme.fontSizeSmall
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: backArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: menuRoot.goBack()
}
}
Rectangle {
visible: entryStack.count > 0
width: parent.width
height: 1
color: Theme.outlineHeavy
}
Repeater {
model: entryStack.count ? (subOpener.children ? subOpener.children : (menuRoot.topEntry()?.children || [])) : rootOpener.children
Rectangle { Rectangle {
property var menuEntry: modelData visible: entryStack.count === 0
width: parent.width
height: 28
radius: Theme.cornerRadius
color: visibilityToggleArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
width: menuColumn.width StyledText {
height: menuEntry?.isSeparator ? 1 : 28 anchors.left: parent.left
radius: menuEntry?.isSeparator ? 0 : Theme.cornerRadius anchors.leftMargin: Theme.spacingS
color: { anchors.verticalCenter: parent.verticalCenter
if (menuEntry?.isSeparator) text: {
return Theme.outlineHeavy; const itemId = menuRoot.trayItem?.id || "Unknown";
return itemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0); if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
return itemId + " · " + I18n.tr("Keep in Bar");
return itemId;
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
elide: Text.ElideMiddle
width: parent.width - Theme.spacingS * 2 - (Theme.iconSizeSmall + Theme.spacingS)
}
DankIcon {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: {
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
return "push_pin";
return root.isManualHiddenTrayItem(menuRoot.trayItem) ? "visibility" : "visibility_off";
}
size: Theme.iconSizeSmall
color: Theme.widgetTextColor
} }
MouseArea { MouseArea {
id: itemArea id: visibilityToggleArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false)
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if (!menuEntry || menuEntry.isSeparator) const itemKey = root.getTrayItemKey(menuRoot.trayItem);
return; if (!itemKey)
if (menuEntry.hasChildren) {
menuRoot.showSubMenu(menuEntry);
return; return;
if (root.isAutoOverflowTrayItem(menuRoot.trayItem)) {
root.promoteTrayItemToBar(menuRoot.trayItem);
} else if (root.isManualHiddenTrayItem(menuRoot.trayItem)) {
SessionData.showTrayId(itemKey);
} else {
SessionData.hideTrayId(itemKey);
} }
menuRoot.close();
if (typeof menuEntry.activate === "function") {
menuEntry.activate();
} else if (typeof menuEntry.triggered === "function") {
menuEntry.triggered();
}
pendingActionCloseTimer.restart();
} }
} }
}
Rectangle {
visible: entryStack.count === 0
width: parent.width
height: 1
color: Theme.outlineHeavy
}
Rectangle {
visible: entryStack.count > 0
width: parent.width
height: 28
radius: Theme.cornerRadius
color: backArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
Row { Row {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingS anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS spacing: Theme.spacingXS
visible: !menuEntry?.isSeparator
Rectangle { DankIcon {
width: 16 name: "arrow_back"
height: 16 size: Theme.iconSizeSmall
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
radius: menuEntry?.buttonType === 2 ? 8 : 2
border.width: 1
border.color: Theme.outline
color: "transparent"
Rectangle {
anchors.centerIn: parent
width: parent.width - 6
height: parent.height - 6
radius: parent.radius - 3
color: Theme.primary
visible: menuEntry?.checkState === 2
}
DankIcon {
anchors.centerIn: parent
name: "check"
size: 10
color: Theme.primaryText
visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2
}
}
Item {
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
visible: (menuEntry?.icon ?? "") !== ""
Image {
anchors.fill: parent
source: menuEntry?.icon || ""
sourceSize.width: 16
sourceSize.height: 16
fillMode: Image.PreserveAspectFit
smooth: true
}
} }
StyledText { StyledText {
text: menuEntry?.text || "" text: I18n.tr("Back")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium color: Theme.widgetTextColor
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: Math.max(150, parent.width - 64) }
wrapMode: Text.NoWrap }
MouseArea {
id: backArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: menuRoot.goBack()
}
}
Rectangle {
visible: entryStack.count > 0
width: parent.width
height: 1
color: Theme.outlineHeavy
}
Repeater {
model: entryStack.count ? (subOpener.children ? subOpener.children : (menuRoot.topEntry()?.children || [])) : rootOpener.children
Rectangle {
property var menuEntry: modelData
width: menuColumn.width
height: menuEntry?.isSeparator ? 1 : 28
radius: menuEntry?.isSeparator ? 0 : Theme.cornerRadius
color: {
if (menuEntry?.isSeparator)
return Theme.outlineHeavy;
return itemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0);
} }
Item { MouseArea {
width: 16 id: itemArea
height: 16 anchors.fill: parent
anchors.verticalCenter: parent.verticalCenter hoverEnabled: true
enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false)
cursorShape: Qt.PointingHandCursor
DankIcon { onClicked: {
anchors.centerIn: parent if (!menuEntry || menuEntry.isSeparator)
name: "chevron_right" return;
size: 14 if (menuEntry.hasChildren) {
color: Theme.widgetTextColor menuRoot.showSubMenu(menuEntry);
visible: menuEntry?.hasChildren ?? false return;
}
if (typeof menuEntry.activate === "function") {
menuEntry.activate();
} else if (typeof menuEntry.triggered === "function") {
menuEntry.triggered();
}
pendingActionCloseTimer.restart();
}
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
visible: !menuEntry?.isSeparator
Rectangle {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
radius: menuEntry?.buttonType === 2 ? width / 2 : 2
border.width: 1
border.color: Theme.outline
color: "transparent"
Rectangle {
anchors.centerIn: parent
width: parent.width - 6
height: parent.height - 6
radius: parent.radius - 3
color: Theme.primary
visible: menuEntry?.checkState === 2
}
DankIcon {
anchors.centerIn: parent
name: "check"
size: Theme.iconSizeSmall - 6
color: Theme.primaryText
visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2
}
}
Item {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
visible: (menuEntry?.icon ?? "") !== ""
Image {
anchors.fill: parent
source: menuEntry?.icon || ""
sourceSize.width: Theme.iconSizeSmall
sourceSize.height: Theme.iconSizeSmall
fillMode: Image.PreserveAspectFit
smooth: true
}
}
StyledText {
text: menuEntry?.text || ""
font.pixelSize: Theme.fontSizeSmall
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
width: Math.max(150, parent.width - 64)
wrapMode: Text.NoWrap
}
Item {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: "chevron_right"
size: Theme.iconSizeSmall - 2
color: Theme.widgetTextColor
visible: menuEntry?.hasChildren ?? false
}
} }
} }
} }
@@ -14,6 +14,8 @@ Item {
property int dropdownType: 0 property int dropdownType: 0
property var activePlayer: null property var activePlayer: null
property var allPlayers: [] property var allPlayers: []
// Chromium keeps dead MPRIS services registered w/empty metadata; avoid listing them
readonly property var selectablePlayers: (allPlayers || []).filter(p => p && !MprisController.isIdle(p))
property point anchorPos: Qt.point(0, 0) property point anchorPos: Qt.point(0, 0)
property bool isRightEdge: false property bool isRightEdge: false
property var targetWindow: null property var targetWindow: null
@@ -64,6 +66,22 @@ Item {
panelExited(); panelExited();
} }
property real _wheelAccum: 0
function volumeWheel(wheelEvent) {
if (!volumeAvailable)
return;
wheelEvent.accepted = true;
_wheelAccum += wheelEvent.angleDelta.y;
const notches = _wheelAccum > 0 ? Math.floor(_wheelAccum / 120) : Math.ceil(_wheelAccum / 120);
if (notches === 0)
return;
_wheelAccum -= notches * 120;
SessionData.suppressOSDTemporarily();
const next = currentVolume + notches * AudioService.wheelVolumeStep / 100;
root.volumeChanged(Math.max(0, Math.min(1, next)));
}
readonly property Item __activePanel: { readonly property Item __activePanel: {
switch (dropdownType) { switch (dropdownType) {
case 1: case 1:
@@ -144,6 +162,7 @@ Item {
hoverEnabled: true hoverEnabled: true
onEntered: panelAreaEntered() onEntered: panelAreaEntered()
onExited: panelAreaExited() onExited: panelAreaExited()
onWheel: wheelEvent => root.volumeWheel(wheelEvent)
} }
Item { Item {
@@ -203,6 +222,7 @@ Item {
onEntered: panelAreaEntered() onEntered: panelAreaEntered()
onExited: panelAreaExited() onExited: panelAreaExited()
onWheel: wheelEvent => root.volumeWheel(wheelEvent)
onPressed: mouse => updateVolume(mouse) onPressed: mouse => updateVolume(mouse)
onPositionChanged: mouse => { onPositionChanged: mouse => {
if (pressed) if (pressed)
@@ -428,7 +448,7 @@ Item {
id: playersPanel id: playersPanel
visible: dropdownType === 3 visible: dropdownType === 3
width: 240 width: 240
height: Math.max(180, Math.min(240, (allPlayers?.length || 0) * 50 + 80)) height: Math.max(180, Math.min(240, (root.selectablePlayers?.length || 0) * 50 + 80))
x: isRightEdge ? anchorPos.x : anchorPos.x - width x: isRightEdge ? anchorPos.x : anchorPos.x - width
y: anchorPos.y - height / 2 y: anchorPos.y - height / 2
radius: Theme.cornerRadius * 2 radius: Theme.cornerRadius * 2
@@ -485,7 +505,7 @@ Item {
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
StyledText { StyledText {
text: I18n.tr("Media Players (") + (allPlayers?.length || 0) + ")" text: I18n.tr("Media Players (") + (root.selectablePlayers?.length || 0) + ")"
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium font.weight: Font.Medium
color: Theme.surfaceText color: Theme.surfaceText
@@ -506,7 +526,7 @@ Item {
spacing: Theme.spacingS spacing: Theme.spacingS
Repeater { Repeater {
model: allPlayers || [] model: root.selectablePlayers || []
delegate: Rectangle { delegate: Rectangle {
required property var modelData required property var modelData
required property int index required property int index
@@ -582,5 +602,4 @@ Item {
} }
} }
} }
} }
+70 -74
View File
@@ -47,8 +47,6 @@ Item {
playersExpanded = false; playersExpanded = false;
} }
readonly property bool isRightEdge: { readonly property bool isRightEdge: {
if (barPosition === SettingsData.Position.Right) if (barPosition === SettingsData.Position.Right)
return true; return true;
@@ -342,54 +340,6 @@ Item {
root.maybeFinishSwitch(); root.maybeFinishSwitch();
} }
component BgBlurLayer: ClippingRectangle {
id: layer
property alias art: layerImg.source
readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != ""
property bool front: false
signal loaded
anchors.fill: parent
radius: Theme.cornerRadius
color: "transparent"
antialiasing: true
opacity: front ? 0.7 : 0
Behavior on opacity {
NumberAnimation {
duration: 350
easing.type: Easing.InOutQuad
}
}
Image {
id: layerImg
anchors.centerIn: parent
width: Math.max(parent.width, parent.height) * 1.1
height: width
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
visible: false
onStatusChanged: {
if (status === Image.Ready && source != "")
layer.loaded();
}
}
MultiEffect {
anchors.centerIn: parent
width: layerImg.width
height: layerImg.height
source: layerImg
blurEnabled: true
blurMax: 64
blur: 0.8
saturation: -0.2
brightness: -0.25
}
}
BgBlurLayer { BgBlurLayer {
id: layerA id: layerA
front: bgContainer._showA front: bgContainer._showA
@@ -410,6 +360,54 @@ Item {
} }
} }
component BgBlurLayer: ClippingRectangle {
id: layer
property alias art: layerImg.source
readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != ""
property bool front: false
signal loaded
anchors.fill: parent
radius: Theme.cornerRadius
color: "transparent"
antialiasing: true
opacity: front ? 0.7 : 0
Behavior on opacity {
NumberAnimation {
duration: 350
easing.type: Easing.InOutQuad
}
}
Image {
id: layerImg
anchors.centerIn: parent
width: Math.max(parent.width, parent.height) * 1.1
height: width
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
visible: false
onStatusChanged: {
if (status === Image.Ready && source != "")
layer.loaded();
}
}
MultiEffect {
anchors.centerIn: parent
width: layerImg.width
height: layerImg.height
source: layerImg
blurEnabled: true
blurMax: 64
blur: 0.8
saturation: -0.2
brightness: -0.25
}
}
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingM spacing: Theme.spacingM
@@ -469,7 +467,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
StyledText { StyledText {
text: activePlayer?.trackTitle || I18n.tr("Unknown Track") text: MprisController.stableTitle || I18n.tr("Unknown Track")
font.pixelSize: Theme.fontSizeLarge font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold font.weight: Font.Bold
color: Theme.surfaceText color: Theme.surfaceText
@@ -481,7 +479,7 @@ Item {
} }
StyledText { StyledText {
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist") text: MprisController.stableArtist || I18n.tr("Unknown Artist")
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium color: Theme.surfaceTextMedium
width: parent.width width: parent.width
@@ -662,11 +660,12 @@ Item {
weight: 500 weight: 500
} }
MouseArea { StateLayer {
anchors.fill: parent id: playPauseArea
hoverEnabled: true disabled: !root.activePlayer || !root.activePlayer.canTogglePlaying
cursorShape: Qt.PointingHandCursor stateColor: root.onAccent
onClicked: activePlayer && activePlayer.togglePlaying() cornerRadius: parent.radius
onClicked: root.activePlayer.togglePlaying()
} }
ElevationShadow { ElevationShadow {
@@ -706,7 +705,7 @@ Item {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: activePlayer && activePlayer.next() onClicked: MprisController.next()
} }
} }
} }
@@ -798,16 +797,17 @@ Item {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if (playersExpanded) { if (playersExpanded) {
if (allPlayers && allPlayers.length > 1) { const players = (root.allPlayers || []).filter(p => p && !MprisController.isIdle(p));
if (players.length > 1) {
let currentIndex = -1; let currentIndex = -1;
for (let i = 0; i < allPlayers.length; i++) { for (let i = 0; i < players.length; i++) {
if (allPlayers[i] === activePlayer) { if (players[i] === root.activePlayer) {
currentIndex = i; currentIndex = i;
break; break;
} }
} }
const nextIndex = (currentIndex + 1) % allPlayers.length; const nextIndex = (currentIndex + 1) % players.length;
MprisController.setActivePlayer(allPlayers[nextIndex]); MprisController.setActivePlayer(players[nextIndex]);
} }
return; return;
} }
@@ -884,19 +884,15 @@ Item {
onClicked: { onClicked: {
toggleMute(); toggleMute();
} }
property real wheelAccum: 0
onWheel: wheelEvent => { onWheel: wheelEvent => {
SessionData.suppressOSDTemporarily();
const delta = wheelEvent.angleDelta.y;
const current = (currentVolume * 100) || 0;
const maxVol = usePlayerVolume ? 100 : AudioService.sinkMaxVolume;
const newVolume = delta > 0 ? Math.min(maxVol, current + 5) : Math.max(0, current - 5);
if (usePlayerVolume) {
activePlayer.volume = newVolume / 100;
} else if (AudioService.sink?.audio) {
AudioService.sink.audio.volume = newVolume / 100;
}
wheelEvent.accepted = true; wheelEvent.accepted = true;
wheelAccum += wheelEvent.angleDelta.y;
const notches = wheelAccum > 0 ? Math.floor(wheelAccum / 120) : Math.ceil(wheelAccum / 120);
if (notches === 0)
return;
wheelAccum -= notches * 120;
root.adjustVolume(notches * AudioService.wheelVolumeStep);
} }
} }
} }
@@ -1079,7 +1079,7 @@ Rectangle {
Text { Text {
text: I18n.tr("Add a task...", "placeholder in the new-task input field") text: I18n.tr("Add a task...", "placeholder in the new-task input field")
color: Theme.onSurface_38 color: Theme.outlineButton
visible: taskInput.text.length === 0 visible: taskInput.text.length === 0
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -88,7 +88,7 @@ Card {
topPadding: Theme.spacingL topPadding: Theme.spacingL
StyledText { StyledText {
text: activePlayer?.trackTitle || I18n.tr("Unknown") text: MprisController.stableTitle || I18n.tr("Unknown")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium font.weight: Font.Medium
color: Theme.surfaceText color: Theme.surfaceText
@@ -99,7 +99,7 @@ Card {
} }
StyledText { StyledText {
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist") text: MprisController.stableArtist || I18n.tr("Unknown Artist")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium color: Theme.surfaceTextMedium
width: parent.width width: parent.width
@@ -190,7 +190,7 @@ Card {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: activePlayer?.next() onClicked: MprisController.next()
} }
} }
} }
@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import QtQuick.Controls
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
@@ -19,6 +18,7 @@ Card {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingS spacing: Theme.spacingS
visible: !WeatherService.weather.available visible: !WeatherService.weather.available
z: 1
DankSpinner { DankSpinner {
size: 24 size: 24
@@ -42,9 +42,9 @@ Card {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
Button { DankButton {
text: I18n.tr("Refresh") text: I18n.tr("Refresh")
flat: true buttonHeight: 32
visible: !WeatherService.weather.loading visible: !WeatherService.weather.loading
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
onClicked: WeatherService.forceRefresh() onClicked: WeatherService.forceRefresh()
+50 -95
View File
@@ -141,52 +141,27 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
DankIcon { DankActionButton {
id: refreshButtonTwo id: refreshButtonTwo
name: "refresh"
size: Theme.iconSize - 4
color: Theme.withAlpha(Theme.surfaceText, 0.4)
anchors.top: parent.top
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
smoothTransform: isRefreshing iconName: isRefreshing ? "" : "refresh"
iconColor: Theme.withAlpha(Theme.surfaceText, 0.4)
property bool isRefreshing: false tooltipText: I18n.tr("Refresh Weather")
tooltipSide: "left"
enabled: !isRefreshing enabled: !isRefreshing
MouseArea { property bool isRefreshing: false
id: refreshButtonMouseAreaTwo
anchors.fill: parent
hoverEnabled: true
cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
enabled: parent.enabled
Timer { onClicked: {
id: hoverDelayTwo isRefreshing = true;
interval: 300 WeatherService.forceRefresh();
repeat: false refreshTimerTwo.restart();
onTriggered: {
refreshButtonTooltipTwo.show(I18n.tr("Refresh Weather"), refreshButtonTwo, 0, 0, "left");
}
}
onEntered: {
hoverDelayTwo.restart();
}
onExited: {
hoverDelayTwo.stop();
refreshButtonTooltipTwo.hide();
}
onClicked: {
refreshButtonTwo.isRefreshing = true;
WeatherService.forceRefresh();
refreshTimerTwo.restart();
}
} }
DankTooltipV2 { DankSpinner {
id: refreshButtonTooltipTwo anchors.centerIn: parent
size: refreshButtonTwo.iconSize
visible: refreshButtonTwo.isRefreshing
} }
Timer { Timer {
@@ -194,14 +169,6 @@ Item {
interval: 2000 interval: 2000
onTriggered: refreshButtonTwo.isRefreshing = false onTriggered: refreshButtonTwo.isRefreshing = false
} }
RotationAnimator on rotation {
running: refreshButtonTwo.isRefreshing
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
}
} }
} }
} }
@@ -864,52 +831,28 @@ Item {
onClicked: SessionData.setWeatherHourlyDetailed(!SessionData.weatherHourlyDetailed) onClicked: SessionData.setWeatherHourlyDetailed(!SessionData.weatherHourlyDetailed)
} }
DankIcon { DankActionButton {
id: refreshButton id: refreshButton
name: "refresh"
size: Theme.iconSize - 4
color: Theme.withAlpha(Theme.surfaceText, 0.4)
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
smoothTransform: isRefreshing iconName: isRefreshing ? "" : "refresh"
iconColor: Theme.withAlpha(Theme.surfaceText, 0.4)
property bool isRefreshing: false tooltipText: I18n.tr("Refresh Weather")
tooltipSide: "left"
enabled: !isRefreshing enabled: !isRefreshing
MouseArea { property bool isRefreshing: false
id: refreshButtonMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
enabled: parent.enabled
Timer { onClicked: {
id: hoverDelay isRefreshing = true;
interval: 300 WeatherService.forceRefresh();
repeat: false refreshTimer.restart();
onTriggered: {
refreshButtonTooltip.show(I18n.tr("Refresh Weather"), refreshButton, 0, 0, "left");
}
}
onEntered: {
hoverDelay.restart();
}
onExited: {
hoverDelay.stop();
refreshButtonTooltip.hide();
}
onClicked: {
refreshButton.isRefreshing = true;
WeatherService.forceRefresh();
refreshTimer.restart();
}
} }
DankTooltipV2 { DankSpinner {
id: refreshButtonTooltip anchors.centerIn: parent
size: refreshButton.iconSize
visible: refreshButton.isRefreshing
} }
Timer { Timer {
@@ -917,14 +860,6 @@ Item {
interval: 2000 interval: 2000
onTriggered: refreshButton.isRefreshing = false onTriggered: refreshButton.isRefreshing = false
} }
RotationAnimator on rotation {
running: refreshButton.isRefreshing
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
}
} }
} }
@@ -936,7 +871,7 @@ Item {
id: dailyLoader id: dailyLoader
anchors.fill: parent anchors.fill: parent
sourceComponent: dailyComponent sourceComponent: dailyComponent
active: root.visible && root.available active: root.visible && root.available && width > 0 && height > 0
visible: !root.showHourly visible: !root.showHourly
asynchronous: true asynchronous: true
opacity: 0 opacity: 0
@@ -953,7 +888,7 @@ Item {
id: hourlyLoader id: hourlyLoader
anchors.fill: parent anchors.fill: parent
sourceComponent: hourlyComponent sourceComponent: hourlyComponent
active: root.visible && root.available active: root.visible && root.available && width > 0 && height > 0
visible: root.showHourly visible: root.showHourly
asynchronous: true asynchronous: true
opacity: 0 opacity: 0
@@ -1038,6 +973,7 @@ Item {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
property real hWheelAccum: 0
onWheel: wheel => { onWheel: wheel => {
if (wheel.modifiers & Qt.ShiftModifier) { if (wheel.modifiers & Qt.ShiftModifier) {
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) { if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
@@ -1049,6 +985,15 @@ Item {
} }
} }
} }
if (wheel.angleDelta.x !== 0) {
hWheelAccum += wheel.angleDelta.x;
const steps = Math.trunc(hWheelAccum / 120);
hWheelAccum -= steps * 120;
if (steps !== 0)
hourlyList.currentIndex = Math.max(0, Math.min(hourlyList.model - 1, hourlyList.currentIndex - steps));
wheel.accepted = true;
return;
}
wheel.accepted = false; wheel.accepted = false;
} }
} }
@@ -1096,6 +1041,7 @@ Item {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
property real hWheelAccum: 0
onWheel: wheel => { onWheel: wheel => {
if (wheel.modifiers & Qt.ShiftModifier) { if (wheel.modifiers & Qt.ShiftModifier) {
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) { if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
@@ -1107,6 +1053,15 @@ Item {
} }
} }
} }
if (wheel.angleDelta.x !== 0) {
hWheelAccum += wheel.angleDelta.x;
const steps = Math.trunc(hWheelAccum / 120);
hWheelAccum -= steps * 120;
if (steps !== 0)
dailyList.currentIndex = Math.max(0, Math.min(dailyList.model - 1, dailyList.currentIndex - steps));
wheel.accepted = true;
return;
}
wheel.accepted = false; wheel.accepted = false;
} }
} }
+13
View File
@@ -340,7 +340,19 @@ Variants {
return ConnectedModeState.dockRetractActiveForSide(dock._dockScreenName, dock.connectedBarSide); return ConnectedModeState.dockRetractActiveForSide(dock._dockScreenName, dock.connectedBarSide);
} }
property bool startupRevealDone: false
Timer {
id: startupRevealTimer
interval: 200
running: true
onTriggered: dock.startupRevealDone = true
}
property bool reveal: { property bool reveal: {
if (!startupRevealDone)
return false;
if (_modalRetractActive) if (_modalRetractActive)
return false; return false;
@@ -628,6 +640,7 @@ Variants {
id: dockContainer id: dockContainer
anchors.fill: parent anchors.fill: parent
clip: false clip: false
opacity: dock.startupRevealDone ? 1 : 0
transform: Translate { transform: Translate {
id: dockSlide id: dockSlide
+3 -3
View File
@@ -73,14 +73,14 @@ Item {
readonly property bool greeterPamHasFprint: greeterPamStackHasFprint && (!fprintdProbeComplete || fprintdHasDevice) readonly property bool greeterPamHasFprint: greeterPamStackHasFprint && (!fprintdProbeComplete || fprintdHasDevice)
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f") readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f) readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f)
readonly property bool greeterPamHasExternalAuth: greeterPamStackHasFprint || greeterPamHasU2f readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f
readonly property bool externalAuthInProgress: awaitingExternalAuth || (Greetd.state !== GreetdState.Inactive && passwordSubmitRequested && greeterPamHasExternalAuth && !pendingPasswordResponse) readonly property bool externalAuthInProgress: awaitingExternalAuth || (Greetd.state !== GreetdState.Inactive && passwordSubmitRequested && greeterPamHasExternalAuth && !pendingPasswordResponse)
readonly property string externalAuthStatusMessage: { readonly property string externalAuthStatusMessage: {
if (!externalAuthInProgress) if (!externalAuthInProgress)
return ""; return "";
if (greeterPamStackHasFprint && greeterPamHasU2f) if (greeterPamHasFprint && greeterPamHasU2f)
return I18n.tr("Awaiting fingerprint or security key authentication"); return I18n.tr("Awaiting fingerprint or security key authentication");
if (greeterPamStackHasFprint) if (greeterPamHasFprint)
return I18n.tr("Awaiting fingerprint authentication"); return I18n.tr("Awaiting fingerprint authentication");
return I18n.tr("Awaiting security key authentication"); return I18n.tr("Awaiting security key authentication");
} }
+15 -1
View File
@@ -4,6 +4,7 @@ import QtQuick
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import qs.Common import qs.Common
import qs.Services
PanelWindow { PanelWindow {
id: root id: root
@@ -65,10 +66,23 @@ PanelWindow {
} }
function cancelFade() { function cancelFade() {
dismiss();
fadeCancelled();
}
function dismiss() {
fadeSeq.stop(); fadeSeq.stop();
fadeOverlay.opacity = 0.0; fadeOverlay.opacity = 0.0;
active = false; active = false;
fadeCancelled(); }
Connections {
target: IdleService
function onIsShellLockedChanged() {
if (IdleService.isShellLocked)
return;
root.dismiss();
}
} }
MouseArea { MouseArea {
+208 -45
View File
@@ -10,38 +10,61 @@ import qs.Services
Scope { Scope {
id: root id: root
readonly property var log: Log.scoped("Lock")
property string sharedPasswordBuffer: "" property string sharedPasswordBuffer: ""
property bool shouldLock: false property bool shouldLock: false
onShouldLockChanged: { onSharedPasswordBufferChanged: {
IdleService.isShellLocked = shouldLock; if (!powerOffFadeTimer.running)
if (shouldLock && lockPowerOffArmed) { return;
lockStateCheck.restart(); cancelPowerOffFade();
}
} }
Timer { onShouldLockChanged: {
id: lockStateCheck IdleService.isShellLocked = shouldLock;
interval: 100
repeat: false
onTriggered: {
if (sessionLock.locked && lockPowerOffArmed) {
pendingLock = false;
IdleService.monitorsOff = true;
CompositorService.powerOffMonitors();
lockWakeAllowed = false;
lockWakeDebounce.restart();
lockPowerOffArmed = false;
dpmsReapplyTimer.start();
}
}
} }
property bool lockInitiatedLocally: false property bool lockInitiatedLocally: false
property bool pendingLock: false property bool pendingLock: false
readonly property int maxLockRetries: 3
property int lockRetryAttempts: 0
property bool lockRetryPending: false
property bool lockPowerOffArmed: false property bool lockPowerOffArmed: false
property bool lockWakeAllowed: false property bool lockWakeAllowed: false
property bool customLockerSpawned: false property bool customLockerSpawned: false
readonly property bool powerOffOnLock: SettingsData.lockScreenPowerOffMonitorsOnLock || IdleService.lockPowerOffRequested
property real powerOffFadeTarget: 0
property bool powerOffFadeInstant: false
function beginPowerOff() {
if (!SettingsData.fadeToDpmsEnabled || SettingsData.fadeToDpmsGracePeriod <= 0) {
applyMonitorsOff();
return;
}
powerOffFadeInstant = false;
powerOffFadeTarget = 1;
powerOffFadeTimer.restart();
}
function applyMonitorsOff() {
IdleService.monitorsOff = true;
CompositorService.powerOffMonitors();
lockWakeAllowed = false;
lockWakeDebounce.restart();
dpmsReapplyTimer.start();
}
function resetPowerOffFade() {
powerOffFadeTimer.stop();
powerOffFadeInstant = true;
powerOffFadeTarget = 0;
}
function cancelPowerOffFade() {
resetPowerOffFade();
IdleService.lockPowerOffRequested = false;
}
Component.onCompleted: { Component.onCompleted: {
IdleService.lockComponent = this; IdleService.lockComponent = this;
@@ -65,6 +88,7 @@ Scope {
} }
function spawnCustomLocker() { function spawnCustomLocker() {
IdleService.lockPowerOffRequested = false;
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]); Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]);
// The custom locker manages its own surface; DMS never engages // The custom locker manages its own surface; DMS never engages
// WlSessionLock here, so isShellLocked stays false and the fade // WlSessionLock here, so isShellLocked stays false and the fade
@@ -81,6 +105,24 @@ Scope {
return true; return true;
} }
function resetLockRetry() {
lockRetryAttempts = 0;
lockRetryPending = false;
lockRetryTimer.stop();
}
function handleLockLost() {
if (lockRetryAttempts >= maxLockRetries) {
log.error("Compositor refused session lock", maxLockRetries, "times - resetting lock state");
forceReset();
return;
}
lockRetryAttempts++;
lockRetryPending = true;
lockRetryTimer.restart();
log.warn("Session lock lost while lock requested - retry", lockRetryAttempts, "/", maxLockRetries);
}
function lock() { function lock() {
if (SettingsData.customPowerActionLock?.length > 0) { if (SettingsData.customPowerActionLock?.length > 0) {
spawnCustomLocker(); spawnCustomLocker();
@@ -89,8 +131,9 @@ Scope {
if (shouldLock || pendingLock) if (shouldLock || pendingLock)
return; return;
resetLockRetry();
lockInitiatedLocally = true; lockInitiatedLocally = true;
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock; lockPowerOffArmed = powerOffOnLock;
if (!SessionService.active && SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration) { if (!SessionService.active && SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration) {
pendingLock = true; pendingLock = true;
@@ -102,9 +145,20 @@ Scope {
notifyLoginctl(true); notifyLoginctl(true);
} }
function lockAndOutputsOff() {
IdleService.lockPowerOffRequested = true;
if (sessionLock.secure) {
beginPowerOff();
return;
}
lockPowerOffArmed = true;
lock();
}
function unlock() { function unlock() {
if (!shouldLock) if (!shouldLock)
return; return;
resetLockRetry();
lockInitiatedLocally = false; lockInitiatedLocally = false;
notifyLoginctl(false); notifyLoginctl(false);
shouldLock = false; shouldLock = false;
@@ -115,6 +169,9 @@ Scope {
pendingLock = false; pendingLock = false;
shouldLock = false; shouldLock = false;
customLockerSpawned = false; customLockerSpawned = false;
resetLockRetry();
resetPowerOffFade();
IdleService.lockPowerOffRequested = false;
} }
function activate() { function activate() {
@@ -135,7 +192,7 @@ Scope {
return; return;
} }
lockInitiatedLocally = false; lockInitiatedLocally = false;
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock; lockPowerOffArmed = powerOffOnLock;
shouldLock = true; shouldLock = true;
} }
@@ -151,11 +208,17 @@ Scope {
shouldLock = false; shouldLock = false;
} }
function onSessionResumed() {
if (!shouldLock || sessionLock.locked)
return;
resumeRelockTimer.restart();
}
function onLoginctlStateChanged() { function onLoginctlStateChanged() {
if (SessionService.active && pendingLock) { if (SessionService.active && pendingLock) {
pendingLock = false; pendingLock = false;
lockInitiatedLocally = true; lockInitiatedLocally = true;
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock; lockPowerOffArmed = powerOffOnLock;
shouldLock = true; shouldLock = true;
return; return;
} }
@@ -163,7 +226,7 @@ Scope {
if (handleLoginctlCustomLock()) if (handleLoginctlCustomLock())
return; return;
lockInitiatedLocally = false; lockInitiatedLocally = false;
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock; lockPowerOffArmed = powerOffOnLock;
shouldLock = true; shouldLock = true;
} }
} }
@@ -175,6 +238,11 @@ Scope {
function onLockRequested() { function onLockRequested() {
lock(); lock();
} }
function onMonitorsOffChanged() {
if (!IdleService.monitorsOff)
root.resetPowerOffFade();
}
} }
Pam { Pam {
@@ -187,7 +255,7 @@ Scope {
WlSessionLock { WlSessionLock {
id: sessionLock id: sessionLock
locked: shouldLock locked: shouldLock && !lockRetryPending
WlSessionLockSurface { WlSessionLockSurface {
id: lockSurface id: lockSurface
@@ -214,32 +282,78 @@ Scope {
root.sharedPasswordBuffer = newPassword; root.sharedPasswordBuffer = newPassword;
} }
} }
Rectangle {
anchors.fill: parent
color: "black"
opacity: root.powerOffFadeTarget
visible: opacity > 0 || powerOffFadeTimer.running
Behavior on opacity {
enabled: !root.powerOffFadeInstant
NumberAnimation {
duration: SettingsData.fadeToDpmsGracePeriod * 1000
easing.type: Easing.OutCubic
}
}
MouseArea {
property real baselineX: -1
property real baselineY: -1
anchors.fill: parent
enabled: powerOffFadeTimer.running
hoverEnabled: enabled
onEnabledChanged: {
baselineX = -1;
baselineY = -1;
}
onPressed: root.cancelPowerOffFade()
onWheel: root.cancelPowerOffFade()
onPositionChanged: mouse => {
if (baselineX < 0) {
baselineX = mouse.x;
baselineY = mouse.y;
return;
}
if (Math.abs(mouse.x - baselineX) < 5 && Math.abs(mouse.y - baselineY) < 5)
return;
root.cancelPowerOffFade();
}
}
}
} }
} }
Connections { Connections {
target: sessionLock target: sessionLock
function onLockedChanged() { function onSecureChanged() {
notifyLockedHint(sessionLock.locked); notifyLockedHint(sessionLock.secure);
if (sessionLock.locked) { if (!sessionLock.secure)
pendingLock = false;
if (lockPowerOffArmed && SettingsData.lockScreenPowerOffMonitorsOnLock) {
IdleService.monitorsOff = true;
CompositorService.powerOffMonitors();
lockWakeAllowed = false;
lockWakeDebounce.restart();
}
lockPowerOffArmed = false;
dpmsReapplyTimer.start();
return; return;
} IdleService.dismissFadeToLock();
lockRetryAttempts = 0;
pendingLock = false;
if (lockPowerOffArmed && powerOffOnLock)
beginPowerOff();
lockPowerOffArmed = false;
}
function onLockedChanged() {
if (sessionLock.locked)
return;
if (shouldLock && (IdleService.monitorsOff || lockPowerOffArmed || IdleService.lockPowerOffRequested))
return;
lockWakeAllowed = false; lockWakeAllowed = false;
if (IdleService.monitorsOff && SettingsData.lockScreenPowerOffMonitorsOnLock) { resetPowerOffFade();
if (IdleService.monitorsOff && powerOffOnLock) {
IdleService.monitorsOff = false; IdleService.monitorsOff = false;
CompositorService.powerOnMonitors(); CompositorService.powerOnMonitors();
} }
IdleService.lockPowerOffRequested = false;
if (shouldLock && !lockRetryPending)
handleLockLost();
} }
} }
@@ -254,6 +368,10 @@ Scope {
root.lock(); root.lock();
} }
function lockAndOutputsOff() {
root.lockAndOutputsOff();
}
function unlock() { function unlock() {
root.unlock(); root.unlock();
} }
@@ -267,13 +385,15 @@ Scope {
} }
function isLocked(): bool { function isLocked(): bool {
return sessionLock.locked; return sessionLock.secure;
} }
function status(): string { function status(): string {
return JSON.stringify({ return JSON.stringify({
shouldLock: root.shouldLock, shouldLock: root.shouldLock,
sessionLockLocked: sessionLock.locked, sessionLockLocked: sessionLock.locked,
sessionLockSecure: sessionLock.secure,
lockRetryAttempts: root.lockRetryAttempts,
lockInitiatedLocally: root.lockInitiatedLocally, lockInitiatedLocally: root.lockInitiatedLocally,
pendingLock: root.pendingLock, pendingLock: root.pendingLock,
loginctlLocked: SessionService.locked, loginctlLocked: SessionService.locked,
@@ -282,6 +402,49 @@ Scope {
} }
} }
Timer {
id: powerOffFadeTimer
interval: SettingsData.fadeToDpmsGracePeriod * 1000
repeat: false
onTriggered: root.applyMonitorsOff()
}
IdleMonitor {
timeout: 1
respectInhibitors: false
enabled: powerOffFadeTimer.running
onIsIdleChanged: {
if (isIdle)
return;
if (!powerOffFadeTimer.running)
return;
root.cancelPowerOffFade();
}
}
Timer {
id: lockRetryTimer
interval: 1000
repeat: false
onTriggered: root.lockRetryPending = false
}
Timer {
id: resumeRelockTimer
interval: 1000
repeat: false
onTriggered: {
if (!root.shouldLock || sessionLock.locked)
return;
if (IdleService.monitorsOff || root.lockPowerOffArmed || IdleService.lockPowerOffRequested)
return;
root.log.warn("Session lock dead after resume - re-locking");
root.resetLockRetry();
root.lockRetryPending = true;
root.lockRetryPending = false;
}
}
Timer { Timer {
id: dpmsReapplyTimer id: dpmsReapplyTimer
interval: 100 interval: 100
@@ -294,9 +457,9 @@ Scope {
interval: 200 interval: 200
repeat: false repeat: false
onTriggered: { onTriggered: {
if (!sessionLock.locked) if (!sessionLock.secure)
return; return;
if (!SettingsData.lockScreenPowerOffMonitorsOnLock) if (!powerOffOnLock)
return; return;
if (!IdleService.monitorsOff) { if (!IdleService.monitorsOff) {
lockWakeAllowed = true; lockWakeAllowed = true;
@@ -313,7 +476,7 @@ Scope {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
enabled: sessionLock.locked enabled: sessionLock.secure
hoverEnabled: enabled hoverEnabled: enabled
onPressed: lockWakeDebounce.restart() onPressed: lockWakeDebounce.restart()
onPositionChanged: lockWakeDebounce.restart() onPositionChanged: lockWakeDebounce.restart()
@@ -322,10 +485,10 @@ Scope {
FocusScope { FocusScope {
anchors.fill: parent anchors.fill: parent
focus: sessionLock.locked focus: sessionLock.secure
Keys.onPressed: event => { Keys.onPressed: event => {
if (!sessionLock.locked) if (!sessionLock.secure)
return; return;
lockWakeDebounce.restart(); lockWakeDebounce.restart();
} }
@@ -1635,7 +1635,7 @@ Item {
enabled: MprisController.activePlayer?.canGoNext ?? false enabled: MprisController.activePlayer?.canGoNext ?? false
hoverEnabled: enabled hoverEnabled: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: MprisController.activePlayer?.next() onClicked: MprisController.next()
} }
} }
} }
+75 -20
View File
@@ -71,7 +71,7 @@ Scope {
} }
function proceedAfterPrimaryAuth(): void { function proceedAfterPrimaryAuth(): void {
if (SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) { if (!root.u2fSuppressedByPrimaryPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) {
u2f.startForSecondFactor(); u2f.startForSecondFactor();
} else { } else {
completeUnlock(); completeUnlock();
@@ -90,6 +90,18 @@ Scope {
fprint.checkAvail(); fprint.checkAvail();
} }
readonly property bool customPamActive: SettingsData.lockPamPath !== "" && customPamWatcher.loaded
readonly property bool fprintSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineFprint)
readonly property bool u2fSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineU2f)
readonly property bool customU2fPamActive: SettingsData.lockU2fPamPath !== "" && customU2fPamWatcher.loaded
FileView {
id: customPamWatcher
path: SettingsData.lockPamPath !== "" ? SettingsData.lockPamPath : ""
printErrors: false
}
FileView { FileView {
id: dankshellConfigWatcher id: dankshellConfigWatcher
@@ -98,16 +110,17 @@ Scope {
} }
FileView { FileView {
id: nixosMarker id: u2fConfigWatcher
path: "/etc/NIXOS" path: "/etc/pam.d/dankshell-u2f"
watchChanges: true
printErrors: false printErrors: false
} }
FileView { FileView {
id: u2fConfigWatcher id: customU2fPamWatcher
path: "/etc/pam.d/dankshell-u2f" path: SettingsData.lockU2fPamPath !== "" ? SettingsData.lockU2fPamPath : ""
printErrors: false printErrors: false
} }
@@ -134,32 +147,35 @@ Scope {
} }
function ensureUserPamConfig(): void { function ensureUserPamConfig(): void {
if (root.runningFromNixStore || resolveUserPam.running) if (SettingsData.lockPamExternallyManaged || resolveUserPam.running)
return; return;
resolveUserPam.running = true; resolveUserPam.running = true;
} }
Component.onCompleted: ensureUserPamConfig() Component.onCompleted: ensureUserPamConfig()
// Detects Nix-installed DMS on non-NixOS systems
readonly property bool runningFromNixStore: Quickshell.shellDir.startsWith("/nix/store/")
PamContext { PamContext {
id: passwd id: passwd
config: { config: {
if (root.customPamActive)
return SettingsData.lockPamPath.slice(SettingsData.lockPamPath.lastIndexOf("/") + 1);
if (SettingsData.lockPamExternallyManaged)
return "login";
if (dankshellConfigWatcher.loaded) if (dankshellConfigWatcher.loaded)
return "dankshell"; return "dankshell";
if (nixosMarker.loaded || root.runningFromNixStore)
return "login";
if (userPamWatcher.loaded) if (userPamWatcher.loaded)
return "dankshell"; return "dankshell";
return "login"; return "login";
} }
configDirectory: { configDirectory: {
if (dankshellConfigWatcher.loaded) if (root.customPamActive) {
const idx = SettingsData.lockPamPath.lastIndexOf("/");
return idx > 0 ? SettingsData.lockPamPath.slice(0, idx) : "/";
}
if (SettingsData.lockPamExternallyManaged)
return "/etc/pam.d"; return "/etc/pam.d";
if (nixosMarker.loaded || root.runningFromNixStore) if (dankshellConfigWatcher.loaded)
return "/etc/pam.d"; return "/etc/pam.d";
if (userPamWatcher.loaded) if (userPamWatcher.loaded)
return root.userPamDir; return root.userPamDir;
@@ -248,7 +264,7 @@ Scope {
property int errorTries property int errorTries
function checkAvail(): void { function checkAvail(): void {
if (!available || !SettingsData.enableFprint || !root.lockSecured) { if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam) {
abort(); abort();
return; return;
} }
@@ -308,7 +324,7 @@ Scope {
property bool available: SettingsData.lockU2fReady property bool available: SettingsData.lockU2fReady
function checkAvail(): void { function checkAvail(): void {
if (!available || !SettingsData.enableU2f || !root.lockSecured) { if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByPrimaryPam) {
abort(); abort();
return; return;
} }
@@ -318,7 +334,7 @@ Scope {
} }
function startForSecondFactor(): void { function startForSecondFactor(): void {
if (!available || !SettingsData.enableU2f) { if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam) {
root.completeUnlock(); root.completeUnlock();
return; return;
} }
@@ -331,7 +347,7 @@ Scope {
} }
function startForAlternativeAuth(): void { function startForAlternativeAuth(): void {
if (!available || !SettingsData.enableU2f || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active) if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active)
return; return;
abort(); abort();
root.u2fPending = true; root.u2fPending = true;
@@ -341,8 +357,18 @@ Scope {
start(); start();
} }
config: u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f" config: {
configDirectory: u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" if (root.customU2fPamActive)
return SettingsData.lockU2fPamPath.slice(SettingsData.lockU2fPamPath.lastIndexOf("/") + 1);
return u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f";
}
configDirectory: {
if (root.customU2fPamActive) {
const idx = SettingsData.lockU2fPamPath.lastIndexOf("/");
return idx > 0 ? SettingsData.lockU2fPamPath.slice(0, idx) : "/";
}
return u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam";
}
onMessageChanged: { onMessageChanged: {
if (message.toLowerCase().includes("touch")) if (message.toLowerCase().includes("touch"))
@@ -461,8 +487,11 @@ Scope {
root.attemptInfoMessages = []; root.attemptInfoMessages = [];
root.lockoutAnnouncedThisAttempt = false; root.lockoutAnnouncedThisAttempt = false;
root.resetAuthFlows(); root.resetAuthFlows();
if (!dankshellConfigWatcher.loaded && !nixosMarker.loaded && !userPamWatcher.loaded) if (!SettingsData.lockPamExternallyManaged && !dankshellConfigWatcher.loaded && !userPamWatcher.loaded)
ensureUserPamConfig(); ensureUserPamConfig();
// FileView cannot watch a path that does not exist yet; re-read so a
// dedicated service created after startup is used on the next lock.
u2fConfigWatcher.reload();
fprint.checkAvail(); fprint.checkAvail();
u2f.checkAvail(); u2f.checkAvail();
} }
@@ -486,6 +515,32 @@ Scope {
u2f.checkAvail(); u2f.checkAvail();
} }
function onLockPamPathChanged(): void {
fprint.checkAvail();
u2f.checkAvail();
}
function onLockPamInlineFprintChanged(): void {
fprint.checkAvail();
}
function onLockPamInlineU2fChanged(): void {
u2f.checkAvail();
}
function onLockPamExternallyManagedChanged(): void {
root.resetAuthFlows();
if (!SettingsData.lockPamExternallyManaged)
root.ensureUserPamConfig();
fprint.checkAvail();
u2f.checkAvail();
}
function onLockU2fPamPathChanged(): void {
u2f.abort();
u2f.checkAvail();
}
function onU2fModeChanged(): void { function onU2fModeChanged(): void {
if (root.lockSecured) { if (root.lockSecured) {
u2f.abort(); u2f.abort();
@@ -49,7 +49,7 @@ Rectangle {
color: { color: {
if (isSelected && keyboardNavigationActive) if (isSelected && keyboardNavigationActive)
return Theme.primaryPressed; return Theme.primaryPressed;
return Theme.floatingSurfaceHigh; return Theme.notificationFloatingSurfaceHigh;
} }
border.color: { border.color: {
if (isSelected && keyboardNavigationActive) if (isSelected && keyboardNavigationActive)
@@ -153,7 +153,7 @@ Rectangle {
if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) { if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
return Theme.primaryHoverLight; return Theme.primaryHoverLight;
} }
return Theme.floatingSurfaceHigh; return Theme.notificationFloatingSurfaceHigh;
} }
border.color: { border.color: {
if (isGroupSelected && keyboardNavigationActive) { if (isGroupSelected && keyboardNavigationActive) {
@@ -583,7 +583,7 @@ Rectangle {
return expandedBaseHeight; return expandedBaseHeight;
} }
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: isSelected ? Theme.primaryPressed : Theme.nestedSurface color: isSelected ? Theme.primaryPressed : Theme.notificationNestedSurface
border.color: isSelected ? Theme.withAlpha(Theme.primary, 0.4) : Theme.outlineMedium border.color: isSelected ? Theme.withAlpha(Theme.primary, 0.4) : Theme.outlineMedium
border.width: 1 border.width: 1
@@ -61,6 +61,11 @@ DankPopout {
notificationHistoryVisible = !notificationHistoryVisible; notificationHistoryVisible = !notificationHistoryVisible;
} }
// Re-open without toggling the flag (used when retargeting to another monitor).
function present() {
openSized();
}
function openSized() { function openSized() {
if (!notificationHistoryVisible) if (!notificationHistoryVisible)
return; return;
@@ -676,7 +676,7 @@ PanelWindow {
sourceWidth: Math.max(0, content.width - (content.cardInset * 2)) sourceWidth: Math.max(0, content.width - (content.cardInset * 2))
sourceHeight: Math.max(0, content.height - (content.cardInset * 2)) sourceHeight: Math.max(0, content.height - (content.cardInset * 2))
targetRadius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius targetRadius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius
targetColor: win.connectedFrameMode ? Theme.floatingSurface : Theme.readableSurface targetColor: win.connectedFrameMode ? Theme.notificationFloatingSurface : Theme.readableSurface
borderColor: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08) borderColor: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08)
borderWidth: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? 2 : 0 borderWidth: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? 2 : 0
} }
@@ -359,6 +359,17 @@ Item {
anchors.fill: parent anchors.fill: parent
active: root.widgetEnabled && root.activeComponent !== null active: root.widgetEnabled && root.activeComponent !== null
sourceComponent: root.activeComponent sourceComponent: root.activeComponent
opacity: 0
NumberAnimation {
id: revealFade
target: contentLoader
property: "opacity"
from: 0
to: 1
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
function reloadComponent() { function reloadComponent() {
active = false; active = false;
@@ -384,6 +395,8 @@ Item {
if (!item) if (!item)
return; return;
revealFade.restart();
if (item.pluginService !== undefined) { if (item.pluginService !== undefined) {
item.pluginService = root.isInstance ? instanceScopedPluginService : root.pluginService; item.pluginService = root.isInstance ? instanceScopedPluginService : root.pluginService;
} }
+43 -47
View File
@@ -18,57 +18,40 @@ Item {
readonly property bool greeterU2fToggleAvailable: SettingsData.greeterU2fCanEnable || SettingsData.greeterEnableU2f readonly property bool greeterU2fToggleAvailable: SettingsData.greeterU2fCanEnable || SettingsData.greeterEnableU2f
function greeterFingerprintDescription() { function greeterFingerprintDescription() {
const source = SettingsData.greeterFingerprintSource; if (SettingsData.greeterPamExternallyManaged)
const reason = SettingsData.greeterFingerprintReason; return I18n.tr("Managed by the primary PAM source.", "factor managed by PAM source status");
if (SettingsData.greeterFingerprintSource === "pam")
return I18n.tr("PAM already provides fingerprint auth. Enable this to show it at login.", "greeter fingerprint login setting");
if (source === "pam") { switch (SettingsData.greeterFingerprintReason) {
switch (reason) {
case "configured_externally":
return SettingsData.greeterEnableFprint ? I18n.tr("Enabled. PAM already provides fingerprint auth.") : I18n.tr("PAM already provides fingerprint auth. Enable this to show it at login.");
case "missing_enrollment":
return SettingsData.greeterEnableFprint ? I18n.tr("Enabled. PAM provides fingerprint auth, but no prints are enrolled yet.") : I18n.tr("PAM provides fingerprint auth, but no prints are enrolled yet.");
case "missing_reader":
return I18n.tr("PAM provides fingerprint auth, but no reader was detected.");
default:
return I18n.tr("PAM provides fingerprint auth, but availability could not be confirmed.");
}
}
switch (reason) {
case "ready": case "ready":
return SettingsData.greeterEnableFprint ? I18n.tr("Authentication changes apply automatically. Fingerprint-only login may not unlock Keyring.") : I18n.tr("Only affects DMS-managed PAM. If greetd already includes pam_fprintd, fingerprint stays enabled."); return I18n.tr("Authentication changes apply automatically.", "greeter auth setting description");
case "missing_enrollment": case "missing_enrollment":
if (SettingsData.greeterEnableFprint) return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and run Sync later.", "greeter fingerprint login setting");
return I18n.tr("Enabled, but no prints are enrolled yet. Enroll fingerprints and run Sync.");
return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and run Sync later.");
case "missing_reader": case "missing_reader":
return SettingsData.greeterEnableFprint ? I18n.tr("Enabled, but no fingerprint reader was detected.") : I18n.tr("No fingerprint reader detected."); return I18n.tr("No fingerprint reader detected.", "fingerprint setting status");
case "missing_pam_support": case "missing_pam_support":
return I18n.tr("Not available — install fprintd and pam_fprintd, or configure greetd PAM."); return I18n.tr("Not available — install fprintd and pam_fprintd, or configure greetd PAM.", "greeter fingerprint login setting");
default: default:
return SettingsData.greeterEnableFprint ? I18n.tr("Enabled, but fingerprint availability could not be confirmed.") : I18n.tr("Fingerprint availability could not be confirmed."); return I18n.tr("Fingerprint availability could not be confirmed.", "fingerprint setting status");
} }
} }
function greeterU2fDescription() { function greeterU2fDescription() {
const source = SettingsData.greeterU2fSource; if (SettingsData.greeterPamExternallyManaged)
const reason = SettingsData.greeterU2fReason; return I18n.tr("Managed by the primary PAM source.", "factor managed by PAM source status");
if (SettingsData.greeterU2fSource === "pam")
return I18n.tr("PAM already provides security-key auth. Enable this to show it at login.", "greeter security key login setting");
if (source === "pam") { switch (SettingsData.greeterU2fReason) {
return SettingsData.greeterEnableU2f ? I18n.tr("Enabled. PAM already provides security-key auth.") : I18n.tr("PAM already provides security-key auth. Enable this to show it at login.");
}
switch (reason) {
case "ready": case "ready":
return SettingsData.greeterEnableU2f ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Available."); return I18n.tr("Authentication changes apply automatically.", "greeter auth setting description");
case "missing_key_registration": case "missing_key_registration":
if (SettingsData.greeterEnableU2f) return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.", "security key setting status");
return I18n.tr("Enabled, but no registered security key was found yet. Register a key and run Sync.");
return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.");
case "missing_pam_support": case "missing_pam_support":
return I18n.tr("Not available — install or configure pam_u2f, or configure greetd PAM."); return I18n.tr("Not available — install or configure pam_u2f, or configure greetd PAM.", "greeter security key login setting");
default: default:
return SettingsData.greeterEnableU2f ? I18n.tr("Enabled, but security-key availability could not be confirmed.") : I18n.tr("Security-key availability could not be confirmed."); return I18n.tr("Security-key availability could not be confirmed.", "security key setting status");
} }
} }
@@ -322,7 +305,7 @@ Item {
onExited: exitCode => { onExited: exitCode => {
root.greeterSyncRunning = false; root.greeterSyncRunning = false;
if (exitCode === 0) { if (exitCode === 0) {
var launched = root.greeterTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete sync authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete sync there; it will close automatically when done."); var launched = root.greeterTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication there; it will close automatically when done.");
root.greeterStatusText = root.greeterStatusText ? root.greeterStatusText + "\n\n" + launched : launched; root.greeterStatusText = root.greeterStatusText ? root.greeterStatusText + "\n\n" + launched : launched;
SettingsData.clearGreeterSyncPending(); SettingsData.clearGreeterSyncPending();
return; return;
@@ -413,7 +396,7 @@ Item {
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "info" iconName: "info"
title: I18n.tr("Greeter Status") title: I18n.tr("Status")
settingKey: "greeterStatus" settingKey: "greeterStatus"
StyledText { StyledText {
@@ -487,7 +470,7 @@ Item {
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "fingerprint" iconName: "fingerprint"
title: I18n.tr("Login Authentication") title: I18n.tr("Authentication")
settingKey: "greeterAuth" settingKey: "greeterAuth"
StyledText { StyledText {
@@ -499,6 +482,15 @@ Item {
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
} }
SettingsToggleRow {
settingKey: "greeterPamExternallyManaged"
tags: ["greeter", "pam", "managed", "external", "greetd", "auth"]
text: I18n.tr("Use system PAM authentication", "system PAM policy toggle")
description: I18n.tr("DMS removes its managed block from /etc/pam.d/greetd and stops writing to it", "greeter system PAM toggle description")
checked: SettingsData.greeterPamExternallyManaged
onToggled: checked => SettingsData.set("greeterPamExternallyManaged", checked)
}
SettingsToggleRow { SettingsToggleRow {
settingKey: "greeterEnableFprint" settingKey: "greeterEnableFprint"
tags: ["greeter", "fingerprint", "fprintd", "login", "auth"] tags: ["greeter", "fingerprint", "fprintd", "login", "auth"]
@@ -506,7 +498,7 @@ Item {
description: root.greeterFingerprintDescription() description: root.greeterFingerprintDescription()
descriptionColor: (SettingsData.greeterFingerprintReason === "ready" || SettingsData.greeterFingerprintReason === "configured_externally") ? Theme.surfaceVariantText : Theme.warning descriptionColor: (SettingsData.greeterFingerprintReason === "ready" || SettingsData.greeterFingerprintReason === "configured_externally") ? Theme.surfaceVariantText : Theme.warning
checked: SettingsData.greeterEnableFprint checked: SettingsData.greeterEnableFprint
enabled: root.greeterFprintToggleAvailable enabled: root.greeterFprintToggleAvailable && !SettingsData.greeterPamExternallyManaged
onToggled: checked => SettingsData.set("greeterEnableFprint", checked) onToggled: checked => SettingsData.set("greeterEnableFprint", checked)
} }
@@ -517,7 +509,7 @@ Item {
description: root.greeterU2fDescription() description: root.greeterU2fDescription()
descriptionColor: (SettingsData.greeterU2fReason === "ready" || SettingsData.greeterU2fReason === "configured_externally") ? Theme.surfaceVariantText : Theme.warning descriptionColor: (SettingsData.greeterU2fReason === "ready" || SettingsData.greeterU2fReason === "configured_externally") ? Theme.surfaceVariantText : Theme.warning
checked: SettingsData.greeterEnableU2f checked: SettingsData.greeterEnableU2f
enabled: root.greeterU2fToggleAvailable enabled: root.greeterU2fToggleAvailable && !SettingsData.greeterPamExternallyManaged
onToggled: checked => SettingsData.set("greeterEnableU2f", checked) onToggled: checked => SettingsData.set("greeterEnableU2f", checked)
} }
} }
@@ -525,7 +517,7 @@ Item {
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "palette" iconName: "palette"
title: I18n.tr("Greeter Appearance") title: I18n.tr("Appearance")
settingKey: "greeterAppearance" settingKey: "greeterAppearance"
StyledText { StyledText {
@@ -566,7 +558,7 @@ Item {
currentValue: { currentValue: {
var current = (SettingsData.greeterLockDateFormat !== undefined && SettingsData.greeterLockDateFormat !== "") ? SettingsData.greeterLockDateFormat : SettingsData.lockDateFormat || ""; var current = (SettingsData.greeterLockDateFormat !== undefined && SettingsData.greeterLockDateFormat !== "") ? SettingsData.greeterLockDateFormat : SettingsData.lockDateFormat || "";
var match = root._lockDateFormatPresets.find(p => p.format === current); var match = root._lockDateFormatPresets.find(p => p.format === current);
return match ? match.label : (current ? I18n.tr("Custom: ") + current : root._lockDateFormatPresets[0].label); return match ? match.label : (current ? I18n.tr("Custom") + ": " + current : root._lockDateFormatPresets[0].label);
} }
onValueChanged: value => { onValueChanged: value => {
var preset = root._lockDateFormatPresets.find(p => p.label === value); var preset = root._lockDateFormatPresets.find(p => p.label === value);
@@ -609,7 +601,7 @@ Item {
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "history" iconName: "history"
title: I18n.tr("Greeter Behavior") title: I18n.tr("Behavior")
settingKey: "greeterBehavior" settingKey: "greeterBehavior"
StyledText { StyledText {
@@ -718,9 +710,13 @@ Item {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
cursorShape: Qt.PointingHandCursor hoverEnabled: true
enabled: !root.greeterSyncRunning && !root.greeterInstallActionRunning acceptedButtons: Qt.AllButtons
onClicked: root.runGreeterSync() cursorShape: !root.greeterSyncRunning && !root.greeterInstallActionRunning ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: mouse => {
if (mouse.button === Qt.LeftButton && !root.greeterSyncRunning && !root.greeterInstallActionRunning)
root.runGreeterSync();
}
} }
Row { Row {
@@ -747,6 +747,31 @@ Item {
} }
} }
SettingsCard {
width: parent.width
iconName: "layers"
title: I18n.tr("Modal Background")
settingKey: "modalBackground"
tags: ["modal", "darken", "background", "overlay", "launcher"]
SettingsControlledByFrame {
visible: SettingsData.frameEnabled
parentModal: root.parentModal
settingLabel: I18n.tr("Darken Modal Background")
reason: I18n.tr("Disabled by Frame Mode")
}
SettingsToggleRow {
settingKey: "modalDarkenBackground"
tags: ["modal", "darken", "background", "overlay", "launcher"]
text: I18n.tr("Darken Modal Background")
description: I18n.tr("Show darkened overlay behind modal dialogs")
visible: !SettingsData.frameEnabled
checked: SettingsData.modalDarkenBackground
onToggled: checked => SettingsData.set("modalDarkenBackground", checked)
}
}
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "open_in_new" iconName: "open_in_new"
+391 -67
View File
@@ -1,5 +1,5 @@
import QtQuick import QtQuick
import Quickshell import Quickshell.Io
import qs.Common import qs.Common
import qs.Modals.FileBrowser import qs.Modals.FileBrowser
import qs.Services import qs.Services
@@ -11,36 +11,113 @@ Item {
readonly property bool lockFprintToggleAvailable: SettingsData.lockFingerprintCanEnable || SettingsData.enableFprint readonly property bool lockFprintToggleAvailable: SettingsData.lockFingerprintCanEnable || SettingsData.enableFprint
readonly property bool lockU2fToggleAvailable: SettingsData.lockU2fCanEnable || SettingsData.enableU2f readonly property bool lockU2fToggleAvailable: SettingsData.lockU2fCanEnable || SettingsData.enableU2f
readonly property bool primaryPamHasFprint: SettingsData.lockPamPath !== "" && SettingsData.lockPamInlineFprint
readonly property bool primaryPamHasU2f: SettingsData.lockPamPath !== "" && SettingsData.lockPamInlineU2f
readonly property bool lockFprintControlledByPrimary: SettingsData.lockPamExternallyManaged || primaryPamHasFprint
readonly property bool lockU2fControlledByPrimary: SettingsData.lockPamExternallyManaged || primaryPamHasU2f
property var authServices: []
property bool authValidateRunning: false
property bool authValidateOk: false
property bool authValidateWarn: false
property string authValidateMessage: ""
property string authPendingApplyPath: ""
property bool authShowCustom: false
property bool u2fValidateRunning: false
property bool u2fValidateOk: false
property bool u2fValidateWarn: false
property string u2fValidateMessage: ""
property string u2fPendingApplyPath: ""
property bool u2fShowCustom: false
readonly property string authAutoLabel: I18n.tr("Auto", "automatic PAM authentication source option")
readonly property string authCustomLabel: I18n.tr("Custom...", "custom PAM authentication source option")
function authServiceLabel(service) {
const label = service.name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join("-");
return service.dir === "/etc/pam.d" ? label : label + " (" + service.dir + ")";
}
readonly property var authOptions: [authAutoLabel, ...authServices.map(s => authServiceLabel(s)), authCustomLabel]
readonly property string authCurrentValue: {
if (SettingsData.lockPamPath === "")
return authAutoLabel;
const svc = authServices.find(s => s.path === SettingsData.lockPamPath);
return svc ? authServiceLabel(svc) : authCustomLabel;
}
readonly property string u2fAuthCurrentValue: SettingsData.lockU2fPamPath === "" ? authAutoLabel : authCustomLabel
function refreshAuthServices() {
authListServicesProcess.running = true;
}
function applyAutoAuthSource() {
SettingsData.set("lockPamPath", "");
SettingsData.set("lockPamInlineFprint", false);
SettingsData.set("lockPamInlineU2f", false);
root.authValidateOk = false;
root.authValidateWarn = false;
root.authValidateMessage = "";
}
function validateAndApplyAuthSource(path) {
if (!path)
return;
root.authPendingApplyPath = path;
root.authValidateMessage = "";
root.authValidateOk = false;
root.authValidateWarn = false;
root.authValidateRunning = true;
authValidateProcess.command = ["dms", "auth", "validate", "--path", path, "--json"];
authValidateProcess.running = true;
}
function applyAutoU2fSource() {
SettingsData.set("lockU2fPamPath", "");
root.u2fValidateOk = false;
root.u2fValidateWarn = false;
root.u2fValidateMessage = "";
}
function validateAndApplyU2fSource(path) {
if (!path)
return;
root.u2fPendingApplyPath = path;
root.u2fValidateMessage = "";
root.u2fValidateOk = false;
root.u2fValidateWarn = false;
root.u2fValidateRunning = true;
u2fValidateProcess.command = ["dms", "auth", "validate", "--purpose", "u2f", "--path", path, "--json"];
u2fValidateProcess.running = true;
}
function lockFingerprintDescription() { function lockFingerprintDescription() {
switch (SettingsData.lockFingerprintReason) { switch (SettingsData.lockFingerprintReason) {
case "ready": case "ready":
return SettingsData.enableFprint ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Use fingerprint authentication for the lock screen."); return I18n.tr("Use fingerprint authentication for the lock screen.", "lock screen fingerprint setting");
case "missing_enrollment": case "missing_enrollment":
if (SettingsData.enableFprint) return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and enroll later.", "lock screen fingerprint setting");
return I18n.tr("Enabled, but no prints are enrolled yet. Authentication changes apply automatically once you enroll fingerprints.");
return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and enroll later.");
case "missing_reader": case "missing_reader":
return SettingsData.enableFprint ? I18n.tr("Enabled, but no fingerprint reader was detected.") : I18n.tr("No fingerprint reader detected."); return I18n.tr("No fingerprint reader detected.", "fingerprint setting status");
case "missing_pam_support": case "missing_pam_support":
return I18n.tr("Not available — install fprintd and pam_fprintd."); return I18n.tr("Not available — install fprintd and pam_fprintd.", "lock screen fingerprint setting");
default: default:
return SettingsData.enableFprint ? I18n.tr("Enabled, but fingerprint availability could not be confirmed.") : I18n.tr("Fingerprint availability could not be confirmed."); return I18n.tr("Fingerprint availability could not be confirmed.", "fingerprint setting status");
} }
} }
function lockU2fDescription() { function lockU2fDescription() {
switch (SettingsData.lockU2fReason) { switch (SettingsData.lockU2fReason) {
case "ready": case "ready":
return SettingsData.enableU2f ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Use a security key for lock screen authentication.", "lock screen U2F security key setting"); return I18n.tr("Use a security key for lock screen authentication.", "lock screen U2F security key setting");
case "missing_key_registration": case "missing_key_registration":
if (SettingsData.enableU2f) return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.", "security key setting status");
return I18n.tr("Enabled, but no registered security key was found yet. Authentication changes apply automatically once your key is registered or your U2F config is updated.");
return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.");
case "missing_pam_support": case "missing_pam_support":
return I18n.tr("Not available — install or configure pam_u2f."); return I18n.tr("Not available — install or configure pam_u2f.", "lock screen security key setting");
default: default:
return SettingsData.enableU2f ? I18n.tr("Enabled, but security-key availability could not be confirmed.") : I18n.tr("Security-key availability could not be confirmed."); return I18n.tr("Security-key availability could not be confirmed.", "security key setting status");
} }
} }
@@ -48,10 +125,15 @@ Item {
SettingsData.refreshAuthAvailability(); SettingsData.refreshAuthAvailability();
} }
Component.onCompleted: refreshAuthDetection() Component.onCompleted: {
refreshAuthDetection();
refreshAuthServices();
}
onVisibleChanged: { onVisibleChanged: {
if (visible) if (visible) {
refreshAuthDetection(); refreshAuthDetection();
refreshAuthServices();
}
} }
FileBrowserModal { FileBrowserModal {
@@ -64,6 +146,110 @@ Item {
onFileSelected: path => SettingsData.set("lockScreenVideoPath", path) onFileSelected: path => SettingsData.set("lockScreenVideoPath", path)
} }
Process {
id: authListServicesProcess
command: ["dms", "auth", "list-services", "--json"]
running: false
property string collected: ""
stdout: StdioCollector {
onStreamFinished: authListServicesProcess.collected = text || ""
}
onExited: exitCode => {
if (exitCode !== 0) {
root.authServices = [];
return;
}
try {
const data = JSON.parse(authListServicesProcess.collected);
root.authServices = (data && Array.isArray(data.services)) ? data.services.filter(s => s.hasAuth) : [];
} catch (e) {
root.authServices = [];
}
}
}
Process {
id: authValidateProcess
running: false
property string collected: ""
stdout: StdioCollector {
onStreamFinished: authValidateProcess.collected = text || ""
}
onExited: exitCode => {
root.authValidateRunning = false;
root.authValidateOk = false;
root.authValidateWarn = false;
let data = null;
try {
data = JSON.parse(authValidateProcess.collected);
} catch (e) {}
if (!data) {
root.authValidateMessage = I18n.tr("Config validation failed");
return;
}
if (!data.valid) {
const errs = Array.isArray(data.errors) ? data.errors : [];
root.authValidateMessage = [I18n.tr("Config validation failed"), ...errs].join("\n");
return;
}
SettingsData.set("lockPamPath", root.authPendingApplyPath);
SettingsData.set("lockPamInlineFprint", data.inlineFingerprint === true);
SettingsData.set("lockPamInlineU2f", data.inlineU2f === true);
const warns = Array.isArray(data.warnings) ? data.warnings : [];
root.authValidateOk = true;
root.authValidateWarn = warns.length > 0;
root.authValidateMessage = [I18n.tr("Authentication changes applied."), ...warns].join("\n");
}
}
Process {
id: u2fValidateProcess
running: false
property string collected: ""
stdout: StdioCollector {
onStreamFinished: u2fValidateProcess.collected = text || ""
}
onExited: exitCode => {
root.u2fValidateRunning = false;
root.u2fValidateOk = false;
root.u2fValidateWarn = false;
let data = null;
try {
data = JSON.parse(u2fValidateProcess.collected);
} catch (e) {}
if (!data) {
root.u2fValidateMessage = I18n.tr("Config validation failed");
return;
}
if (!data.valid) {
const errs = Array.isArray(data.errors) ? data.errors : [];
root.u2fValidateMessage = [I18n.tr("Config validation failed"), ...errs].join("\n");
return;
}
SettingsData.set("lockU2fPamPath", root.u2fPendingApplyPath);
const warns = Array.isArray(data.warnings) ? data.warnings : [];
root.u2fValidateOk = true;
root.u2fValidateWarn = warns.length > 0;
root.u2fValidateMessage = [I18n.tr("Authentication changes applied."), ...warns].join("\n");
root.refreshAuthDetection();
}
}
DankFlickable { DankFlickable {
anchors.fill: parent anchors.fill: parent
clip: true clip: true
@@ -80,7 +266,7 @@ Item {
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "lock" iconName: "lock"
title: I18n.tr("Lock Screen layout") title: I18n.tr("Layout")
settingKey: "lockLayout" settingKey: "lockLayout"
SettingsToggleRow { SettingsToggleRow {
@@ -159,7 +345,7 @@ Item {
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "palette" iconName: "palette"
title: I18n.tr("Lock Screen Appearance") title: I18n.tr("Appearance")
settingKey: "lockAppearance" settingKey: "lockAppearance"
StyledText { StyledText {
@@ -207,10 +393,195 @@ Item {
} }
} }
SettingsCard {
width: parent.width
iconName: "key"
title: I18n.tr("Authentication")
settingKey: "lockAuthSource"
StyledText {
text: I18n.tr("Authentication changes apply automatically.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
wrapMode: Text.Wrap
}
SettingsDropdownRow {
settingKey: "lockPamPath"
tags: ["lock", "screen", "pam", "authentication", "source", "service"]
text: I18n.tr("Authentication Source", "lock screen PAM source setting")
description: SettingsData.lockPamPath !== "" ? SettingsData.lockPamPath : I18n.tr("Which PAM service the lock screen uses to authenticate", "lock screen PAM source setting")
options: root.authOptions
currentValue: root.authCurrentValue
onValueChanged: value => {
if (value === root.authAutoLabel) {
root.authShowCustom = false;
root.applyAutoAuthSource();
return;
}
if (value === root.authCustomLabel) {
root.authShowCustom = true;
return;
}
root.authShowCustom = false;
const svc = root.authServices.find(s => root.authServiceLabel(s) === value);
if (svc)
root.validateAndApplyAuthSource(svc.path);
}
}
Row {
width: parent.width
spacing: Theme.spacingS
visible: root.authShowCustom || root.authCurrentValue === root.authCustomLabel
DankTextField {
id: customPamField
width: parent.width - validatePamButton.width - Theme.spacingS
placeholderText: "/etc/pam.d/my-service"
text: SettingsData.lockPamPath
backgroundColor: Theme.surfaceContainerHighest
}
DankButton {
id: validatePamButton
text: I18n.tr("Apply Changes", "validate and apply custom PAM authentication source")
enabled: !root.authValidateRunning && customPamField.text.trim() !== ""
onClicked: root.validateAndApplyAuthSource(customPamField.text.trim())
}
}
Rectangle {
width: parent.width
height: Math.min(160, authStatusText.implicitHeight + Theme.spacingM * 2)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
visible: root.authValidateMessage !== ""
StyledText {
id: authStatusText
anchors.fill: parent
anchors.margins: Theme.spacingM
text: root.authValidateMessage
font.pixelSize: Theme.fontSizeSmall
font.family: "monospace"
color: !root.authValidateOk ? Theme.error : (root.authValidateWarn ? Theme.warning : Theme.surfaceVariantText)
wrapMode: Text.Wrap
verticalAlignment: Text.AlignTop
}
}
SettingsToggleRow {
settingKey: "lockPamExternallyManaged"
tags: ["lock", "screen", "pam", "managed", "external", "authentication", "policy"]
text: I18n.tr("Use system PAM authentication", "system PAM policy toggle")
checked: SettingsData.lockPamExternallyManaged
onToggled: checked => SettingsData.set("lockPamExternallyManaged", checked)
}
SettingsToggleRow {
settingKey: "enableFprint"
tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"]
text: I18n.tr("Enable fingerprint authentication")
description: root.lockFprintControlledByPrimary ? I18n.tr("Managed by the primary PAM source.", "factor managed by PAM source status") : root.lockFingerprintDescription()
descriptionColor: root.lockFprintControlledByPrimary || SettingsData.lockFingerprintReason === "ready" ? Theme.surfaceVariantText : Theme.warning
checked: SettingsData.enableFprint || root.primaryPamHasFprint
enabled: root.lockFprintToggleAvailable && !root.lockFprintControlledByPrimary
onToggled: checked => SettingsData.set("enableFprint", checked)
}
SettingsToggleRow {
settingKey: "enableU2f"
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "fido", "authentication", "hardware"]
text: I18n.tr("Enable security key authentication", "Enable FIDO2/U2F hardware security key for lock screen")
description: root.lockU2fControlledByPrimary ? I18n.tr("Managed by the primary PAM source.", "factor managed by PAM source status") : root.lockU2fDescription()
descriptionColor: root.lockU2fControlledByPrimary || SettingsData.lockU2fReason === "ready" ? Theme.surfaceVariantText : Theme.warning
checked: SettingsData.enableU2f || root.primaryPamHasU2f
enabled: root.lockU2fToggleAvailable && !root.lockU2fControlledByPrimary
onToggled: checked => SettingsData.set("enableU2f", checked)
}
SettingsDropdownRow {
settingKey: "u2fMode"
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "mode", "factor", "second"]
text: I18n.tr("Security key mode", "lock screen U2F security key mode setting")
description: I18n.tr("'Alternative' lets the key unlock on its own. 'Second factor' requires password or fingerprint first, then the key.", "lock screen U2F security key mode setting")
visible: SettingsData.enableU2f && !root.lockU2fControlledByPrimary
options: [I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method"), I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint")]
currentValue: SettingsData.u2fMode === "and" ? I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint") : I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method")
onValueChanged: value => {
if (value === I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint"))
SettingsData.set("u2fMode", "and");
else
SettingsData.set("u2fMode", "or");
}
}
SettingsDropdownRow {
settingKey: "lockU2fPamPath"
tags: ["lock", "screen", "pam", "u2f", "security", "key", "source", "service"]
text: I18n.tr("Security Key PAM Source", "lock screen dedicated U2F PAM source setting")
description: SettingsData.lockU2fPamPath !== "" ? SettingsData.lockU2fPamPath : I18n.tr("Auto uses an installed or bundled key-only service.", "lock screen dedicated U2F PAM source setting")
visible: !root.lockU2fControlledByPrimary
options: [root.authAutoLabel, root.authCustomLabel]
currentValue: root.u2fAuthCurrentValue
onValueChanged: value => {
if (value === root.authAutoLabel) {
root.u2fShowCustom = false;
root.applyAutoU2fSource();
return;
}
root.u2fShowCustom = true;
}
}
Row {
width: parent.width
spacing: Theme.spacingS
visible: !root.lockU2fControlledByPrimary && (root.u2fShowCustom || root.u2fAuthCurrentValue === root.authCustomLabel)
DankTextField {
id: customU2fPamField
width: parent.width - validateU2fPamButton.width - Theme.spacingS
placeholderText: "/etc/pam.d/dankshell-u2f"
text: SettingsData.lockU2fPamPath
backgroundColor: Theme.surfaceContainerHighest
}
DankButton {
id: validateU2fPamButton
text: I18n.tr("Apply Changes", "validate and apply custom U2F PAM authentication source")
enabled: !root.u2fValidateRunning && customU2fPamField.text.trim() !== ""
onClicked: root.validateAndApplyU2fSource(customU2fPamField.text.trim())
}
}
Rectangle {
width: parent.width
height: Math.min(160, u2fStatusText.implicitHeight + Theme.spacingM * 2)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
visible: !root.lockU2fControlledByPrimary && root.u2fValidateMessage !== ""
StyledText {
id: u2fStatusText
anchors.fill: parent
anchors.margins: Theme.spacingM
text: root.u2fValidateMessage
font.pixelSize: Theme.fontSizeSmall
font.family: "monospace"
color: !root.u2fValidateOk ? Theme.error : (root.u2fValidateWarn ? Theme.warning : Theme.surfaceVariantText)
wrapMode: Text.Wrap
verticalAlignment: Text.AlignTop
}
}
}
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "lock" iconName: "lock"
title: I18n.tr("Lock Screen behaviour") title: I18n.tr("Behavior")
settingKey: "lockBehavior" settingKey: "lockBehavior"
StyledText { StyledText {
@@ -263,53 +634,6 @@ Item {
checked: SettingsData.lockAtStartup checked: SettingsData.lockAtStartup
onToggled: checked => SettingsData.set("lockAtStartup", checked) onToggled: checked => SettingsData.set("lockAtStartup", checked)
} }
StyledText {
text: I18n.tr("Lock screen authentication changes apply automatically and may open a terminal when sudo authentication is required.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
wrapMode: Text.Wrap
topPadding: Theme.spacingS
}
SettingsToggleRow {
settingKey: "enableFprint"
tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"]
text: I18n.tr("Enable fingerprint authentication")
description: root.lockFingerprintDescription()
descriptionColor: SettingsData.lockFingerprintReason === "ready" ? Theme.surfaceVariantText : Theme.warning
checked: SettingsData.enableFprint
enabled: root.lockFprintToggleAvailable
onToggled: checked => SettingsData.set("enableFprint", checked)
}
SettingsToggleRow {
settingKey: "enableU2f"
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "fido", "authentication", "hardware"]
text: I18n.tr("Enable security key authentication", "Enable FIDO2/U2F hardware security key for lock screen")
description: root.lockU2fDescription()
descriptionColor: SettingsData.lockU2fReason === "ready" ? Theme.surfaceVariantText : Theme.warning
checked: SettingsData.enableU2f
enabled: root.lockU2fToggleAvailable
onToggled: checked => SettingsData.set("enableU2f", checked)
}
SettingsDropdownRow {
settingKey: "u2fMode"
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "mode", "factor", "second"]
text: I18n.tr("Security key mode", "lock screen U2F security key mode setting")
description: I18n.tr("'Alternative' lets the key unlock on its own. 'Second factor' requires password or fingerprint first, then the key.", "lock screen U2F security key mode setting")
visible: SettingsData.enableU2f
options: [I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method"), I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint")]
currentValue: SettingsData.u2fMode === "and" ? I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint") : I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method")
onValueChanged: value => {
if (value === I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint"))
SettingsData.set("u2fMode", "and");
else
SettingsData.set("u2fMode", "or");
}
}
} }
SettingsCard { SettingsCard {
@@ -396,7 +720,7 @@ Item {
SettingsCard { SettingsCard {
width: parent.width width: parent.width
iconName: "monitor" iconName: "monitor"
title: I18n.tr("Lock Screen Display") title: I18n.tr("Display Assignment")
settingKey: "lockDisplay" settingKey: "lockDisplay"
StyledText { StyledText {
@@ -300,6 +300,15 @@ Item {
onToggled: checked => SettingsData.set("notificationCompactMode", checked) onToggled: checked => SettingsData.set("notificationCompactMode", checked)
} }
SettingsToggleRow {
settingKey: "notificationForegroundLayers"
tags: ["notification", "foreground", "layers", "surface", "blur", "glass", "contrast", "cards"]
text: I18n.tr("Foreground Layers")
description: I18n.tr("Show foreground surfaces on notification cards")
checked: SettingsData.notificationForegroundLayers ?? true
onToggled: checked => SettingsData.set("notificationForegroundLayers", checked)
}
SettingsToggleRow { SettingsToggleRow {
settingKey: "notificationShowTimeoutBar" settingKey: "notificationShowTimeoutBar"
tags: ["notification", "timeout", "progress", "bar", "timer", "countdown"] tags: ["notification", "timeout", "progress", "bar", "timer", "countdown"]
+40 -52
View File
@@ -24,6 +24,8 @@ Item {
property string matugenPreviewRequestKey: "" property string matugenPreviewRequestKey: ""
property var installedRegistryThemes: [] property var installedRegistryThemes: []
property var templateDetection: [] property var templateDetection: []
readonly property var neovimDarkBaseThemes: ["aquarium", "ashes", "aylin", "ayu_dark", "bearded-arc", "carbonfox", "catppuccin", "chadracula", "chadracula-evondev", "chadtain", "chocolate", "darcula-dark", "dark_horizon", "decay", "default-dark", "doomchad", "eldritch", "embark", "everblush", "everforest", "falcon", "flexoki", "flouromachine", "gatekeeper", "github_dark", "gruvbox", "gruvchad", "hiberbee", "horizon", "jabuti", "jellybeans", "kanagawa", "kanagawa-dragon", "material-darker", "material-deep-ocean", "melange", "midnight_breeze", "mito-laser", "monekai", "monochrome", "mountain", "neofusion", "nightfox", "nightlamp", "nightowl", "nord", "obsidian-ember", "oceanic-next", "onedark", "onenord", "oxocarbon", "palenight", "pastelDark", "pastelbeans", "penumbra_dark", "poimandres", "radium", "rosepine", "rxyhn", "scaryforest", "seoul256_dark", "solarized_dark", "solarized_osaka", "starlight", "sweetpastel", "tokyodark", "tokyonight", "tomorrow_night", "tundra", "vesper", "vscode_dark", "wombat", "yoru", "zenburn"]
readonly property var neovimLightBaseThemes: ["ayu_light", "blossom_light", "catppuccin-latte", "default-light", "everforest_light", "flex-light", "flexoki-light", "github_light", "gruvbox_light", "material-lighter", "nano-light", "oceanic-light", "one_light", "onenord_light", "penumbra_light", "rosepine-dawn", "seoul256_light", "solarized_light", "sunrise_breeze", "vscode_light"]
readonly property var matugenSchemeColorMap: { readonly property var matugenSchemeColorMap: {
const map = {}; const map = {};
const mode = SessionData.isLightMode ? "light" : "dark"; const mode = SessionData.isLightMode ? "light" : "dark";
@@ -1734,16 +1736,6 @@ Item {
} }
} }
} }
SettingsToggleRow {
tab: "theme"
tags: ["foreground", "layers", "contrast", "surface", "blur", "glass", "frosted"]
settingKey: "blurForegroundLayers"
text: I18n.tr("Foreground Layers")
description: I18n.tr("Show foreground surfaces on panels for stronger contrast")
checked: SettingsData.blurForegroundLayers ?? true
onToggled: checked => SettingsData.set("blurForegroundLayers", checked)
}
SettingsSliderRow { SettingsSliderRow {
tab: "theme" tab: "theme"
tags: ["surface", "popup", "transparency", "opacity", "modal"] tags: ["surface", "popup", "transparency", "opacity", "modal"]
@@ -1759,12 +1751,47 @@ Item {
onSliderValueChanged: newValue => SettingsData.set("popupTransparency", newValue / 100) onSliderValueChanged: newValue => SettingsData.set("popupTransparency", newValue / 100)
} }
SettingsToggleRow {
tab: "theme"
tags: ["foreground", "layers", "contrast", "surface", "blur", "glass", "frosted"]
settingKey: "blurForegroundLayers"
text: I18n.tr("Foreground Layers")
description: I18n.tr("Show foreground surfaces on panels for stronger contrast")
checked: SettingsData.blurForegroundLayers ?? true
onToggled: checked => SettingsData.set("blurForegroundLayers", checked)
}
SettingsSliderRow {
tab: "theme"
tags: ["foreground", "layers", "outline", "border", "cards", "widgets", "notifications", "control center"]
settingKey: "blurLayerOutlineOpacity"
text: I18n.tr("Layer Outline Opacity")
description: I18n.tr("Controls outlines around foreground cards, pills, and notification cards")
value: Math.round((SettingsData.blurLayerOutlineOpacity ?? 0.12) * 100)
minimum: 0
maximum: 40
unit: "%"
defaultValue: 12
onSliderValueChanged: newValue => SettingsData.set("blurLayerOutlineOpacity", newValue / 100)
}
SettingsToggleRow {
tab: "theme"
tags: ["surface", "popup", "modal", "border", "outline", "edge"]
settingKey: "blurBorderEnabled"
text: I18n.tr("Surface Border Outline")
description: I18n.tr("Outline around shell surfaces")
checked: SettingsData.blurBorderEnabled ?? true
onToggled: checked => SettingsData.set("blurBorderEnabled", checked)
}
SettingsDropdownRow { SettingsDropdownRow {
tab: "theme" tab: "theme"
tags: ["surface", "popup", "modal", "border", "outline", "edge"] tags: ["surface", "popup", "modal", "border", "outline", "edge"]
settingKey: "blurBorderColor" settingKey: "blurBorderColor"
text: I18n.tr("Surface Border Color") text: I18n.tr("Surface Border Color")
description: I18n.tr("Border color around popouts, modals, and other shell surfaces") description: I18n.tr("Border color around popouts, modals, and other shell surfaces")
visible: SettingsData.blurBorderEnabled ?? true
options: [I18n.tr("Outline", "surface border color"), I18n.tr("Primary", "surface border color"), I18n.tr("Secondary", "surface border color"), I18n.tr("Text Color", "surface border color"), I18n.tr("Custom", "surface border color")] options: [I18n.tr("Outline", "surface border color"), I18n.tr("Primary", "surface border color"), I18n.tr("Secondary", "surface border color"), I18n.tr("Text Color", "surface border color"), I18n.tr("Custom", "surface border color")]
optionColorMap: ({ optionColorMap: ({
[I18n.tr("Outline", "surface border color")]: Theme.outline, [I18n.tr("Outline", "surface border color")]: Theme.outline,
@@ -1809,6 +1836,7 @@ Item {
settingKey: "blurBorderOpacity" settingKey: "blurBorderOpacity"
text: I18n.tr("Surface Border Opacity") text: I18n.tr("Surface Border Opacity")
description: I18n.tr("Controls the outline of popouts, modals, and other shell surfaces") description: I18n.tr("Controls the outline of popouts, modals, and other shell surfaces")
visible: SettingsData.blurBorderEnabled ?? true
value: Math.round((SettingsData.blurBorderOpacity ?? 0.35) * 100) value: Math.round((SettingsData.blurBorderOpacity ?? 0.35) * 100)
minimum: 0 minimum: 0
maximum: 100 maximum: 100
@@ -1817,20 +1845,6 @@ Item {
onSliderValueChanged: newValue => SettingsData.set("blurBorderOpacity", newValue / 100) onSliderValueChanged: newValue => SettingsData.set("blurBorderOpacity", newValue / 100)
} }
SettingsSliderRow {
tab: "theme"
tags: ["foreground", "layers", "outline", "border", "cards", "widgets", "notifications", "control center"]
settingKey: "blurLayerOutlineOpacity"
text: I18n.tr("Layer Outline Opacity")
description: I18n.tr("Controls outlines around foreground cards, pills, and notification cards")
value: Math.round((SettingsData.blurLayerOutlineOpacity ?? 0.12) * 100)
minimum: 0
maximum: 40
unit: "%"
defaultValue: 12
onSliderValueChanged: newValue => SettingsData.set("blurLayerOutlineOpacity", newValue / 100)
}
SettingsSliderRow { SettingsSliderRow {
tab: "theme" tab: "theme"
tags: ["corner", "radius", "rounded", "square"] tags: ["corner", "radius", "rounded", "square"]
@@ -2104,32 +2118,6 @@ Item {
} }
} }
SettingsCard {
tab: "theme"
tags: ["modal", "darken", "background", "overlay"]
title: I18n.tr("Modal Background")
settingKey: "modalBackground"
iconName: "layers"
SettingsControlledByFrame {
visible: themeColorsTab.frameModeActive
parentModal: themeColorsTab.parentModal
settingLabel: I18n.tr("Darken Modal Background")
reason: I18n.tr("Disabled by Frame Mode")
}
SettingsToggleRow {
tab: "theme"
tags: ["modal", "darken", "background", "overlay"]
settingKey: "modalDarkenBackground"
text: I18n.tr("Darken Modal Background")
description: I18n.tr("Show darkened overlay behind modal dialogs")
visible: !themeColorsTab.frameModeActive
checked: SettingsData.modalDarkenBackground
onToggled: checked => SettingsData.set("modalDarkenBackground", checked)
}
}
SettingsCard { SettingsCard {
tab: "theme" tab: "theme"
tags: ["applications", "portal", "dark", "terminal"] tags: ["applications", "portal", "dark", "terminal"]
@@ -2680,7 +2668,7 @@ Item {
description: "Base to derive dark theme from" description: "Base to derive dark theme from"
visible: neovimThemeToggle.visible && neovimThemeToggle.checked visible: neovimThemeToggle.visible && neovimThemeToggle.checked
currentValue: SettingsData.matugenTemplateNeovimSettings?.dark?.baseTheme ?? "github_dark" currentValue: SettingsData.matugenTemplateNeovimSettings?.dark?.baseTheme ?? "github_dark"
options: ["aquarium", "ashes", "aylin", "ayu_dark", "bearded-arc", "carbonfox", "catppuccin", "chadracula", "chadracula-evondev", "chadtain", "chocolate", "darcula-dark", "dark_horizon", "decay", "default-dark", "doomchad", "eldritch", "embark", "everblush", "everforest", "falcon", "flexoki", "flouromachine", "gatekeeper", "github_dark", "gruvbox", "gruvchad", "hiberbee", "horizon", "jabuti", "jellybeans", "kanagawa", "kanagawa-dragon", "material-darker", "material-deep-ocean", "melange", "midnight_breeze", "mito-laser", "monekai", "monochrome", "mountain", "neofusion", "nightfox", "nightlamp", "nightowl", "nord", "obsidian-ember", "oceanic-next", "onedark", "onenord", "oxocarbon", "palenight", "pastelDark", "pastelbeans", "penumbra_dark", "poimandres", "radium", "rosepine", "rxyhn", "scaryforest", "seoul256_dark", "solarized_dark", "solarized_osaka", "starlight", "sweetpastel", "tokyodark", "tokyonight", "tomorrow_night", "tundra", "vesper", "vscode_dark", "wombat", "yoru", "zenburn"] options: themeColorsTab.neovimDarkBaseThemes.concat(themeColorsTab.neovimLightBaseThemes)
enableFuzzySearch: true enableFuzzySearch: true
onValueChanged: value => { onValueChanged: value => {
const settings = SettingsData.matugenTemplateNeovimSettings; const settings = SettingsData.matugenTemplateNeovimSettings;
@@ -2697,7 +2685,7 @@ Item {
description: "Base to derive light theme from" description: "Base to derive light theme from"
visible: neovimThemeToggle.visible && neovimThemeToggle.checked visible: neovimThemeToggle.visible && neovimThemeToggle.checked
currentValue: SettingsData.matugenTemplateNeovimSettings?.light?.baseTheme ?? "github_light" currentValue: SettingsData.matugenTemplateNeovimSettings?.light?.baseTheme ?? "github_light"
options: ["ayu_light", "blossom_light", "catppuccin-latte", "default-light", "everforest_light", "flex-light", "flexoki-light", "github_light", "gruvbox_light", "material-lighter", "nano-light", "oceanic-light", "one_light", "onenord_light", "penumbra_light", "rosepine-dawn", "seoul256_light", "solarized_light", "sunrise_breeze", "vscode_light"] options: themeColorsTab.neovimLightBaseThemes.concat(themeColorsTab.neovimDarkBaseThemes)
enableFuzzySearch: true enableFuzzySearch: true
onValueChanged: value => { onValueChanged: value => {
const settings = SettingsData.matugenTemplateNeovimSettings; const settings = SettingsData.matugenTemplateNeovimSettings;
+9 -7
View File
@@ -1199,16 +1199,18 @@ Item {
width: parent.width - Theme.spacingM * 2 width: parent.width - Theme.spacingM * 2
} }
DankButtonGroup { DankFilterChips {
id: transitionGroup id: transitionGroup
width: parent.width - Theme.spacingM * 2 width: parent.width - Theme.spacingM * 2
selectionMode: "multi" multiSelect: true
model: SessionData.availableWallpaperTransitions.filter(t => t !== "none") model: SessionData.availableWallpaperTransitions.filter(t => t !== "none").map(t => ({
initialSelection: SessionData.includedTransitions "value": t,
currentSelection: SessionData.includedTransitions "label": t.replace(/\b\w/g, c => c.toUpperCase())
}))
selectedValues: SessionData.includedTransitions
onSelectionChanged: (index, selected) => { onSelectionToggled: (index, selected) => {
const transition = model[index]; const transition = model[index].value;
let newIncluded = SessionData.includedTransitions.slice(); let newIncluded = SessionData.includedTransitions.slice();
if (selected && !newIncluded.includes(transition)) { if (selected && !newIncluded.includes(transition)) {
+19 -12
View File
@@ -557,20 +557,19 @@ Item {
return sectionId === "left" ? leftSection : sectionId === "center" ? centerSection : sectionId === "right" ? rightSection : null; return sectionId === "left" ? leftSection : sectionId === "center" ? centerSection : sectionId === "right" ? rightSection : null;
} }
// Id-based reorder; rebuilds from authoritative objects so every prop (incl. hideWhenIdle) survives // Id-based reorder; rebuilds from authoritative objects so every prop (incl. hideWhenIdle) survives.
// Consumes one entry per occurrence so duplicate ids (e.g. two spacers) don't collapse, and
// appends unconsumed entries so nothing is silently dropped from the config array.
function reorderSection(sectionId, orderedIds) { function reorderSection(sectionId, orderedIds) {
var current = getWidgetsForSection(sectionId); var remaining = getWidgetsForSection(sectionId).slice();
var byId = {};
current.forEach(w => {
var id = (typeof w === "string" ? w : w.id);
byId[id] = w;
});
var reordered = []; var reordered = [];
orderedIds.forEach(id => { orderedIds.forEach(id => {
if (byId[id] !== undefined) var idx = remaining.findIndex(w => (typeof w === "string" ? w : w.id) === id);
reordered.push(byId[id]); if (idx < 0)
return;
reordered.push(remaining.splice(idx, 1)[0]);
}); });
setWidgetsForSection(sectionId, reordered); setWidgetsForSection(sectionId, reordered.concat(remaining));
} }
// Move a widget across sections (or within); committed as one atomic bar-config save // Move a widget across sections (or within); committed as one atomic bar-config save
@@ -979,8 +978,16 @@ Item {
var isString = typeof widget === "string"; var isString = typeof widget === "string";
var widgetId = isString ? widget : widget.id; var widgetId = isString ? widget : widget.id;
var widgetDef = baseWidgetDefinitions.find(w => w.id === widgetId); var widgetDef = baseWidgetDefinitions.find(w => w.id === widgetId);
if (!widgetDef) if (!widgetDef) {
return; // Skipping entries would desync row indices from the config array (issue #2844)
widgetDef = {
"id": widgetId,
"text": widgetId || I18n.tr("Unknown"),
"description": "",
"icon": "extension",
"warning": I18n.tr("Unavailable")
};
}
var item = Object.assign({}, widgetDef); var item = Object.assign({}, widgetDef);
item.enabled = isString ? true : widget.enabled; item.enabled = isString ? true : widget.enabled;
+14 -3
View File
@@ -37,6 +37,8 @@ Variants {
color: "transparent" color: "transparent"
visible: root.contentReady && !root.surfaceBounce
updatesEnabled: root.renderActive || root._settleFrames > 0 updatesEnabled: root.renderActive || root._settleFrames > 0
mask: Region { mask: Region {
@@ -64,6 +66,9 @@ Variants {
property string actualTransitionType: transitionType property string actualTransitionType: transitionType
property bool isInitialized: false property bool isInitialized: false
property bool contentReady: false
property bool surfaceBounce: false
property string scrollMode: SettingsData.wallpaperFillMode property string scrollMode: SettingsData.wallpaperFillMode
property bool scrollingEnabled: scrollMode === "Scrolling" property bool scrollingEnabled: scrollMode === "Scrolling"
property int currentWorkspaceIndex: 0 property int currentWorkspaceIndex: 0
@@ -263,9 +268,9 @@ Variants {
interval: 0 interval: 0
repeat: false repeat: false
onTriggered: { onTriggered: {
wallpaperWindow.visible = false; root.surfaceBounce = true;
Qt.callLater(() => { Qt.callLater(() => {
wallpaperWindow.visible = true; root.surfaceBounce = false;
}); });
} }
} }
@@ -506,6 +511,9 @@ Variants {
Component.onCompleted: { Component.onCompleted: {
isInitialized = true; isInitialized = true;
if (!source || isColorSource) {
contentReady = true;
}
if (scrollingEnabled) { if (scrollingEnabled) {
updateWorkspaceData(); updateWorkspaceData();
@@ -574,8 +582,10 @@ Variants {
root.effectActive = false; root.effectActive = false;
root.screenScale = CompositorService.getScreenScale(modelData); root.screenScale = CompositorService.getScreenScale(modelData);
// No status change coming to clear the flag // No status change coming to clear the flag
if (!newSource || currentWallpaper.source.toString() === newSource) if (!newSource || currentWallpaper.source.toString() === newSource) {
root.changePending = false; root.changePending = false;
root.contentReady = true;
}
currentWallpaper.source = newSource; currentWallpaper.source = newSource;
nextWallpaper.source = ""; nextWallpaper.source = "";
@@ -746,6 +756,7 @@ Variants {
} }
if (status === Image.Ready || status === Image.Error) { if (status === Image.Ready || status === Image.Error) {
root.changePending = false; root.changePending = false;
root.contentReady = true;
} }
} }
@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import QtQuick.Layouts
import Quickshell import Quickshell
import Quickshell.Hyprland import Quickshell.Hyprland
import qs.Common import qs.Common
@@ -80,7 +79,6 @@ Item {
readonly property int displayWorkspaceCount: displayedWorkspaceIds.length readonly property int displayWorkspaceCount: displayedWorkspaceIds.length
readonly property int effectiveColumns: SettingsData.overviewColumns readonly property int effectiveColumns: SettingsData.overviewColumns
readonly property int effectiveRows: Math.max(SettingsData.overviewRows, Math.ceil(displayWorkspaceCount / effectiveColumns))
function getWorkspaceMonitorName(workspaceId) { function getWorkspaceMonitorName(workspaceId) {
if (!allWorkspaces || !workspaceId) if (!allWorkspaces || !workspaceId)
@@ -107,21 +105,53 @@ Item {
} }
} }
function getWorkspaceViewportBounds(workspaceId) { function monitorIpcForWorkspace(workspaceId) {
const workspace = allWorkspaces?.find(ws => ws?.id === workspaceId); const workspace = allWorkspaces?.find(ws => ws?.id === workspaceId);
const mon = workspace?.monitor?.lastIpcObject || monitor?.lastIpcObject || {}; return workspace?.monitor?.lastIpcObject ?? monitor?.lastIpcObject ?? null;
const reserved = mon.reserved || [0, 0, 0, 0]; }
const x = (mon.x ?? 0) + (reserved[0] ?? 0); function monitorLogicalSize(ipc) {
const y = (mon.y ?? 0) + (reserved[1] ?? 0); if (!ipc || !ipc.width || !ipc.height)
const width = Math.max((mon.width ?? monitorPhysicalWidth) - (reserved[0] ?? 0) - (reserved[2] ?? 0), 1); return {
const height = Math.max((mon.height ?? monitorPhysicalHeight) - (reserved[1] ?? 0) - (reserved[3] ?? 0), 1); "width": monitorPhysicalWidth,
const scale = Math.min(root.workspaceImplicitWidth / width, root.workspaceImplicitHeight / height); "height": monitorPhysicalHeight
};
const monScale = ipc.scale > 0 ? ipc.scale : 1;
const rotated = ((ipc.transform ?? 0) % 2) === 1;
return {
"width": (rotated ? ipc.height : ipc.width) / monScale,
"height": (rotated ? ipc.width : ipc.height) / monScale
};
}
function cellForWorkspace(workspaceId) {
const cell = gridLayout.cells.find(c => c.id === workspaceId);
if (cell)
return cell;
return {
"id": workspaceId,
"x": 0,
"y": 0,
"width": workspaceImplicitWidth,
"height": workspaceImplicitHeight
};
}
function getWorkspaceViewportBounds(workspaceId, cellWidth, cellHeight) {
const ipc = monitorIpcForWorkspace(workspaceId) ?? {};
const logical = monitorLogicalSize(ipc);
const reserved = ipc.reserved || [0, 0, 0, 0];
const x = (ipc.x ?? 0) + (reserved[0] ?? 0);
const y = (ipc.y ?? 0) + (reserved[1] ?? 0);
const width = Math.max(logical.width - (reserved[0] ?? 0) - (reserved[2] ?? 0), 1);
const height = Math.max(logical.height - (reserved[1] ?? 0) - (reserved[3] ?? 0), 1);
return { return {
"x": x, "x": x,
"y": y, "y": y,
"scale": scale "scale": Math.min(cellWidth / width, cellHeight / height)
}; };
} }
@@ -143,6 +173,56 @@ Item {
property int draggingFromWorkspace: -1 property int draggingFromWorkspace: -1
property int draggingTargetWorkspace: -1 property int draggingTargetWorkspace: -1
readonly property var gridLayout: {
const ids = displayedWorkspaceIds;
const columns = effectiveColumns;
if (!ids || ids.length === 0 || columns < 1)
return {
"cells": [],
"width": 0,
"height": 0
};
const rows = [];
for (let i = 0; i < ids.length; i += columns) {
rows.push(ids.slice(i, i + columns).map(id => {
const logical = monitorLogicalSize(monitorIpcForWorkspace(id));
return {
"id": id,
"width": logical.width * scale,
"height": logical.height * scale
};
}));
}
const rowWidth = row => row.reduce((acc, cell) => acc + cell.width, 0) + workspaceSpacing * (row.length - 1);
const totalWidth = rows.reduce((acc, row) => Math.max(acc, rowWidth(row)), 0);
const cells = [];
let cursorY = 0;
for (const row of rows) {
const rowHeight = row.reduce((acc, cell) => Math.max(acc, cell.height), 0);
let cursorX = (totalWidth - rowWidth(row)) / 2;
for (const cell of row) {
cells.push({
"id": cell.id,
"x": cursorX,
"y": cursorY + (rowHeight - cell.height) / 2,
"width": cell.width,
"height": cell.height
});
cursorX += cell.width + workspaceSpacing;
}
cursorY += rowHeight + workspaceSpacing;
}
return {
"cells": cells,
"width": totalWidth,
"height": cursorY - workspaceSpacing
};
}
implicitWidth: overviewBackground.implicitWidth + Theme.spacingL * 2 implicitWidth: overviewBackground.implicitWidth + Theme.spacingL * 2
implicitHeight: overviewBackground.implicitHeight + Theme.spacingL * 2 implicitHeight: overviewBackground.implicitHeight + Theme.spacingL * 2
@@ -166,8 +246,8 @@ Item {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingL anchors.margins: Theme.spacingL
implicitWidth: workspaceColumnLayout.implicitWidth + padding * 2 implicitWidth: workspaceGrid.implicitWidth + padding * 2
implicitHeight: workspaceColumnLayout.implicitHeight + padding * 2 implicitHeight: workspaceGrid.implicitHeight + padding * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainer color: Theme.surfaceContainer
@@ -182,84 +262,78 @@ Item {
shadowEnabled: Theme.elevationEnabled shadowEnabled: Theme.elevationEnabled
} }
ColumnLayout { Item {
id: workspaceColumnLayout id: workspaceGrid
z: root.workspaceZ z: root.workspaceZ
anchors.centerIn: parent anchors.centerIn: parent
spacing: workspaceSpacing implicitWidth: root.gridLayout.width
implicitHeight: root.gridLayout.height
Repeater { Repeater {
model: root.effectiveRows model: root.gridLayout.cells.length
delegate: RowLayout {
id: row
property int rowIndex: index
spacing: workspaceSpacing
Repeater { Rectangle {
model: root.effectiveColumns id: workspace
Rectangle { required property int index
id: workspace readonly property var cell: root.gridLayout.cells[index] ?? null
property int colIndex: index property int workspaceValue: cell?.id ?? -1
property int workspaceIndex: rowIndex * root.effectiveColumns + colIndex property bool workspaceExists: (root.allWorkspaceIds && workspaceValue > 0) ? root.allWorkspaceIds.includes(workspaceValue) : false
property int workspaceValue: (root.displayedWorkspaceIds && workspaceIndex < root.displayedWorkspaceIds.length) ? root.displayedWorkspaceIds[workspaceIndex] : -1 property var workspaceObj: (workspaceExists && Hyprland.workspaces?.values) ? Hyprland.workspaces.values.find(ws => ws?.id === workspaceValue) : null
property bool workspaceExists: (root.allWorkspaceIds && workspaceValue > 0) ? root.allWorkspaceIds.includes(workspaceValue) : false property bool isActive: workspaceObj?.active ?? false
property var workspaceObj: (workspaceExists && Hyprland.workspaces?.values) ? Hyprland.workspaces.values.find(ws => ws?.id === workspaceValue) : null property bool isOnThisMonitor: (workspaceObj && root.monitor) ? (workspaceObj.monitor?.name === root.monitor.name) : true
property bool isActive: workspaceObj?.active ?? false property bool hasWindows: (workspaceValue > 0) ? root.workspaceHasWindows(workspaceValue) : false
property bool isOnThisMonitor: (workspaceObj && root.monitor) ? (workspaceObj.monitor?.name === root.monitor.name) : true property color defaultWorkspaceColor: workspaceExists ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, 0.3)
property bool hasWindows: (workspaceValue > 0) ? root.workspaceHasWindows(workspaceValue) : false property color hoveredWorkspaceColor: Qt.lighter(defaultWorkspaceColor, 1.1)
property string workspaceMonitorName: (workspaceValue > 0) ? root.getWorkspaceMonitorName(workspaceValue) : "" property color hoveredBorderColor: Theme.surfaceVariant
property color defaultWorkspaceColor: workspaceExists ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, 0.3) property bool hoveredWhileDragging: false
property color hoveredWorkspaceColor: Qt.lighter(defaultWorkspaceColor, 1.1) property bool shouldShowActiveIndicator: isActive && isOnThisMonitor && hasWindows
property color hoveredBorderColor: Theme.surfaceVariant
property bool hoveredWhileDragging: false
property bool shouldShowActiveIndicator: isActive && isOnThisMonitor && hasWindows
visible: workspaceValue !== -1 visible: workspaceValue !== -1
implicitWidth: root.workspaceImplicitWidth x: cell?.x ?? 0
implicitHeight: root.workspaceImplicitHeight y: cell?.y ?? 0
color: hoveredWhileDragging ? hoveredWorkspaceColor : defaultWorkspaceColor width: cell?.width ?? 0
radius: Theme.cornerRadius height: cell?.height ?? 0
border.width: 2 color: hoveredWhileDragging ? hoveredWorkspaceColor : defaultWorkspaceColor
border.color: hoveredWhileDragging ? hoveredBorderColor : (shouldShowActiveIndicator ? root.activeBorderColor : Theme.withAlpha(root.activeBorderColor, 0)) radius: Theme.cornerRadius
border.width: 2
border.color: hoveredWhileDragging ? hoveredBorderColor : (shouldShowActiveIndicator ? root.activeBorderColor : Theme.withAlpha(root.activeBorderColor, 0))
StyledText { StyledText {
anchors.centerIn: parent anchors.centerIn: parent
text: workspaceValue text: workspace.workspaceValue
font.pixelSize: Theme.fontSizeXLarge * 6 font.pixelSize: Theme.fontSizeXLarge * 6
font.weight: Font.DemiBold font.weight: Font.DemiBold
color: Theme.withAlpha(Theme.surfaceText, workspaceExists ? 0.2 : 0.1) color: Theme.withAlpha(Theme.surfaceText, workspace.workspaceExists ? 0.2 : 0.1)
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: workspaceArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton
onClicked: {
if (root.draggingTargetWorkspace === -1) {
root.overviewOpen = false;
HyprlandService.focusWorkspace(workspace.workspaceValue);
} }
}
}
MouseArea { DropArea {
id: workspaceArea anchors.fill: parent
anchors.fill: parent onEntered: {
acceptedButtons: Qt.LeftButton root.draggingTargetWorkspace = workspace.workspaceValue;
onClicked: { if (root.draggingFromWorkspace == root.draggingTargetWorkspace)
if (root.draggingTargetWorkspace === -1) { return;
root.overviewOpen = false; workspace.hoveredWhileDragging = true;
HyprlandService.focusWorkspace(workspaceValue); }
} onExited: {
} workspace.hoveredWhileDragging = false;
} if (root.draggingTargetWorkspace == workspace.workspaceValue)
root.draggingTargetWorkspace = -1;
DropArea {
anchors.fill: parent
onEntered: {
root.draggingTargetWorkspace = workspaceValue;
if (root.draggingFromWorkspace == root.draggingTargetWorkspace)
return;
hoveredWhileDragging = true;
}
onExited: {
hoveredWhileDragging = false;
if (root.draggingTargetWorkspace == workspaceValue)
root.draggingTargetWorkspace = -1;
}
}
} }
} }
} }
@@ -269,8 +343,8 @@ Item {
Item { Item {
id: windowSpace id: windowSpace
anchors.centerIn: parent anchors.centerIn: parent
implicitWidth: workspaceColumnLayout.implicitWidth implicitWidth: workspaceGrid.implicitWidth
implicitHeight: workspaceColumnLayout.implicitHeight implicitHeight: workspaceGrid.implicitHeight
Repeater { Repeater {
model: ScriptModel { model: ScriptModel {
@@ -306,41 +380,21 @@ Item {
overviewOpen: root.overviewOpen overviewOpen: root.overviewOpen
readonly property int windowWorkspaceId: modelData?.workspace?.id ?? -1 readonly property int windowWorkspaceId: modelData?.workspace?.id ?? -1
readonly property var workspaceCell: root.cellForWorkspace(windowWorkspaceId)
function getWorkspaceIndex() { readonly property var workspaceBounds: root.getWorkspaceViewportBounds(windowWorkspaceId, workspaceCell.width, workspaceCell.height)
if (!root.displayedWorkspaceIds || root.displayedWorkspaceIds.length === 0)
return 0;
if (!windowWorkspaceId || windowWorkspaceId < 0)
return 0;
try {
for (let i = 0; i < root.displayedWorkspaceIds.length; i++) {
if (root.displayedWorkspaceIds[i] === windowWorkspaceId) {
return i;
}
}
return 0;
} catch (e) {
return 0;
}
}
readonly property int workspaceIndex: getWorkspaceIndex()
readonly property int workspaceColIndex: workspaceIndex % root.effectiveColumns
readonly property int workspaceRowIndex: Math.floor(workspaceIndex / root.effectiveColumns)
readonly property var workspaceBounds: root.getWorkspaceViewportBounds(windowWorkspaceId)
toplevel: modelData toplevel: modelData
scale: root.scale scale: root.scale
monitorDpr: root.dpr monitorDpr: root.dpr
availableWorkspaceWidth: root.workspaceImplicitWidth availableWorkspaceWidth: workspaceCell.width
availableWorkspaceHeight: root.workspaceImplicitHeight availableWorkspaceHeight: workspaceCell.height
contentOriginX: workspaceBounds.x contentOriginX: workspaceBounds.x
contentOriginY: workspaceBounds.y contentOriginY: workspaceBounds.y
contentScale: workspaceBounds.scale contentScale: workspaceBounds.scale
widgetMonitorId: root.monitor.id widgetMonitorId: root.monitor.id
xOffset: (root.workspaceImplicitWidth + workspaceSpacing) * workspaceColIndex xOffset: workspaceCell.x
yOffset: (root.workspaceImplicitHeight + workspaceSpacing) * workspaceRowIndex yOffset: workspaceCell.y
z: atInitPosition ? root.windowZ : root.windowDraggingZ z: atInitPosition ? root.windowZ : root.windowDraggingZ
property bool atInitPosition: (initX == x && initY == y) property bool atInitPosition: (initX == x && initY == y)
@@ -409,54 +463,44 @@ Item {
Item { Item {
id: monitorLabelSpace id: monitorLabelSpace
anchors.centerIn: parent anchors.centerIn: parent
implicitWidth: workspaceColumnLayout.implicitWidth implicitWidth: workspaceGrid.implicitWidth
implicitHeight: workspaceColumnLayout.implicitHeight implicitHeight: workspaceGrid.implicitHeight
z: root.monitorLabelZ z: root.monitorLabelZ
Repeater { Repeater {
model: root.effectiveRows model: root.gridLayout.cells.length
delegate: Item { delegate: Item {
id: labelRow id: labelItem
property int rowIndex: index required property int index
y: (root.workspaceImplicitHeight + workspaceSpacing) * rowIndex readonly property var cell: root.gridLayout.cells[index] ?? null
width: parent.width property int workspaceValue: cell?.id ?? -1
height: root.workspaceImplicitHeight property bool workspaceExists: (root.allWorkspaceIds && workspaceValue > 0) ? root.allWorkspaceIds.includes(workspaceValue) : false
property string workspaceMonitorName: (workspaceValue > 0) ? root.getWorkspaceMonitorName(workspaceValue) : ""
Repeater { x: cell?.x ?? 0
model: root.effectiveColumns y: cell?.y ?? 0
delegate: Item { width: cell?.width ?? 0
id: labelItem height: cell?.height ?? 0
property int colIndex: index
property int workspaceIndex: labelRow.rowIndex * root.effectiveColumns + colIndex
property int workspaceValue: (root.displayedWorkspaceIds && workspaceIndex < root.displayedWorkspaceIds.length) ? root.displayedWorkspaceIds[workspaceIndex] : -1
property bool workspaceExists: (root.allWorkspaceIds && workspaceValue > 0) ? root.allWorkspaceIds.includes(workspaceValue) : false
property string workspaceMonitorName: (workspaceValue > 0) ? root.getWorkspaceMonitorName(workspaceValue) : ""
x: (root.workspaceImplicitWidth + workspaceSpacing) * colIndex Rectangle {
width: root.workspaceImplicitWidth anchors.right: parent.right
height: root.workspaceImplicitHeight anchors.top: parent.top
anchors.margins: Theme.spacingS
width: monitorNameText.contentWidth + Theme.spacingS * 2
height: monitorNameText.contentHeight + Theme.spacingXS * 2
radius: Theme.cornerRadius
color: Theme.surface
visible: labelItem.workspaceExists && labelItem.workspaceMonitorName !== ""
Rectangle { StyledText {
anchors.right: parent.right id: monitorNameText
anchors.top: parent.top anchors.centerIn: parent
anchors.margins: Theme.spacingS text: labelItem.workspaceMonitorName
width: monitorNameText.contentWidth + Theme.spacingS * 2 font.pixelSize: Theme.fontSizeSmall
height: monitorNameText.contentHeight + Theme.spacingXS * 2 font.weight: Font.Medium
radius: Theme.cornerRadius color: Theme.surfaceText
color: Theme.surface horizontalAlignment: Text.AlignHCenter
visible: labelItem.workspaceExists && labelItem.workspaceMonitorName !== "" verticalAlignment: Text.AlignVCenter
StyledText {
id: monitorNameText
anchors.centerIn: parent
text: labelItem.workspaceMonitorName
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
} }
} }
} }
+7 -5
View File
@@ -847,7 +847,7 @@ EOFCONFIG
const maxVol = root.sinkMaxVolume; const maxVol = root.sinkMaxVolume;
const clampedVolume = Math.max(0, Math.min(maxVol, percentage)); const clampedVolume = Math.max(0, Math.min(maxVol, percentage));
Quickshell.execDetached(["wpctl", "set-volume", "-l", String(maxVol / 100), "@DEFAULT_AUDIO_SINK@", String(clampedVolume / 100)]); root.sink.audio.volume = clampedVolume / 100;
return `Volume set to ${clampedVolume}%`; return `Volume set to ${clampedVolume}%`;
} }
@@ -857,14 +857,16 @@ EOFCONFIG
} }
function adjustDefaultSinkVolume(step, direction) { function adjustDefaultSinkVolume(step, direction) {
const audio = root.sink.audio;
const maxVol = root.sinkMaxVolume; const maxVol = root.sinkMaxVolume;
const stepValue = outputVolumeStep(step); const stepValue = outputVolumeStep(step);
const currentVolume = Math.round(root.sink.audio.volume * 100); const currentVolume = Math.round(audio.volume * 100);
const newVolume = Math.max(0, Math.min(maxVol, currentVolume + direction * stepValue)); const newVolume = Math.max(0, Math.min(maxVol, currentVolume + direction * stepValue));
const suffix = direction > 0 ? "+" : "-";
Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"]); if (audio.muted)
Quickshell.execDetached(["wpctl", "set-volume", "-l", String(maxVol / 100), "@DEFAULT_AUDIO_SINK@", `${stepValue}%${suffix}`]); audio.muted = false;
audio.volume = newVolume / 100;
return newVolume; return newVolume;
} }
+3 -5
View File
@@ -12,8 +12,6 @@ Singleton {
property bool suppressSound: true property bool suppressSound: true
property bool previousPluggedState: false property bool previousPluggedState: false
readonly property var scale: 100 / SettingsData.batteryChargeLimit
Timer { Timer {
id: startupTimer id: startupTimer
interval: 500 interval: 500
@@ -78,7 +76,7 @@ Singleton {
return 0; return 0;
if (batteryCapacity === 0) { if (batteryCapacity === 0) {
if (usePreferred && preferredDeviceKnown) { if (usePreferred && preferredDeviceKnown) {
const val = Math.min(100, Math.round(preferredDevice.percentage * 100 * scale)); const val = Math.min(100, Math.round(preferredDevice.percentage * 100));
_lastBatteryLevel = val; _lastBatteryLevel = val;
return val; return val;
} }
@@ -88,7 +86,7 @@ Singleton {
if (validBatteries.length === 0) if (validBatteries.length === 0)
return _lastBatteryLevel; return _lastBatteryLevel;
const avgPercentage = validBatteries.reduce((sum, b) => sum + b.percentage, 0) / validBatteries.length; const avgPercentage = validBatteries.reduce((sum, b) => sum + b.percentage, 0) / validBatteries.length;
const val = Math.min(100, Math.round(avgPercentage * 100 * scale)); const val = Math.min(100, Math.round(avgPercentage * 100));
_lastBatteryLevel = val; _lastBatteryLevel = val;
return val; return val;
} }
@@ -96,7 +94,7 @@ Singleton {
const cap = batteryCapacity; const cap = batteryCapacity;
if (cap === 0) if (cap === 0)
return _lastBatteryLevel; return _lastBatteryLevel;
const val = Math.min(100, Math.round((energy * 100) / cap * scale)); const val = Math.min(100, Math.round((energy * 100) / cap));
_lastBatteryLevel = val; _lastBatteryLevel = val;
return val; return val;
} }
+4 -2
View File
@@ -17,6 +17,8 @@ Singleton {
// These settings predate non-blurred surface borders, so keep their keys for compatibility. // These settings predate non-blurred surface borders, so keep their keys for compatibility.
readonly property color borderColor: { readonly property color borderColor: {
if (!(SettingsData.blurBorderEnabled ?? true))
return "transparent";
const opacity = SettingsData.blurBorderOpacity ?? 0.35; const opacity = SettingsData.blurBorderOpacity ?? 0.35;
switch (SettingsData.blurBorderColor ?? "outline") { switch (SettingsData.blurBorderColor ?? "outline") {
case "primary": case "primary":
@@ -26,12 +28,12 @@ Singleton {
case "surfaceText": case "surfaceText":
return Theme.withAlpha(Theme.surfaceText, opacity); return Theme.withAlpha(Theme.surfaceText, opacity);
case "custom": case "custom":
return Theme.withAlpha(SettingsData.blurBorderCustomColor ?? "#ffffff", opacity); return Theme.withAlpha(Qt.color(SettingsData.blurBorderCustomColor ?? "#ffffff"), opacity);
default: default:
return Theme.withAlpha(Theme.outline, opacity); return Theme.withAlpha(Theme.outline, opacity);
} }
} }
readonly property int borderWidth: 1 readonly property int borderWidth: (SettingsData.blurBorderEnabled ?? true) ? 1 : 0
function hoverColor(baseColor, hoverAlpha) { function hoverColor(baseColor, hoverAlpha) {
if (!enabled) if (!enabled)
+1 -1
View File
@@ -13,7 +13,7 @@ Singleton {
readonly property var log: Log.scoped("ChangelogService") readonly property var log: Log.scoped("ChangelogService")
readonly property string currentVersion: "1.5" readonly property string currentVersion: "1.5"
readonly property bool changelogEnabled: false readonly property bool changelogEnabled: true
readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell" readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell"
readonly property string changelogMarkerPath: configDir + "/.changelog-" + currentVersion readonly property string changelogMarkerPath: configDir + "/.changelog-" + currentVersion
+120 -110
View File
@@ -28,7 +28,6 @@ Singleton {
readonly property string hyprlandSignature: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") readonly property string hyprlandSignature: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE")
readonly property string niriSocket: Quickshell.env("NIRI_SOCKET") readonly property string niriSocket: Quickshell.env("NIRI_SOCKET")
readonly property string swaySocket: Quickshell.env("SWAYSOCK") readonly property string swaySocket: Quickshell.env("SWAYSOCK")
readonly property string scrollSocket: Quickshell.env("SWAYSOCK")
readonly property string miracleSocket: Quickshell.env("MIRACLESOCK") readonly property string miracleSocket: Quickshell.env("MIRACLESOCK")
readonly property string labwcPid: Quickshell.env("LABWC_PID") readonly property string labwcPid: Quickshell.env("LABWC_PID")
readonly property string mangoSignature: Quickshell.env("MANGO_INSTANCE_SIGNATURE") readonly property string mangoSignature: Quickshell.env("MANGO_INSTANCE_SIGNATURE")
@@ -512,7 +511,7 @@ Singleton {
return MangoService.filterCurrentDisplay(toplevels, screenName); return MangoService.filterCurrentDisplay(toplevels, screenName);
if (isHyprland) if (isHyprland)
return filterHyprlandCurrentDisplaySafe(toplevels, screenName); return filterHyprlandCurrentDisplaySafe(toplevels, screenName);
return toplevels; return toplevels.filter(t => _toplevelOnScreen(t, screenName));
} }
function _screenName(screenOrName) { function _screenName(screenOrName) {
@@ -943,122 +942,133 @@ Singleton {
} }
} }
// Primary detection asks the kernel which process owns the $WAYLAND_DISPLAY
// socket the compositor quickshell is actually connected to. Env vars like
// HYPRLAND_INSTANCE_SIGNATURE / MANGO_INSTANCE_SIGNATURE can leak into the
// systemd user environment from previous sessions and lie. Unset
// WAYLAND_DISPLAY falls back to "wayland-0", mirroring wl_display_connect.
// /proc/net/unix: field 6 is state (01 = listening), 7 inode, 8 bound path.
function detectCompositor() { function detectCompositor() {
if (mangoSignature && mangoSignature.length > 0) { const script = 'sock="${WAYLAND_DISPLAY:-wayland-0}"; case "$sock" in /*) ;; *) sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/$sock" ;; esac; inode=$(awk -v p="$sock" \'$8 == p && $6 == 1 {print $7; exit}\' /proc/net/unix); [ -n "$inode" ] || exit 1; fd=$(find /proc/[0-9]*/fd/ -mindepth 1 -maxdepth 1 -lname "socket:\\[$inode\\]" 2>/dev/null | head -n1); [ -n "$fd" ] || exit 1; pid="${fd#/proc/}"; cat "/proc/${pid%%/*}/comm"';
isHyprland = false; Proc.runCommand("waylandSocketOwner", ["sh", "-c", script], (output, exitCode) => {
isNiri = false; const comm = (exitCode === 0 && output) ? output.trim().toLowerCase() : "";
isMango = true; const name = _compositorNameFromComm(comm);
isSway = false; if (name) {
isScroll = false; _applyCompositor(name);
isMiracle = false; log.info("Detected", name, "from Wayland socket owner:", comm);
isLabwc = false; return;
compositor = "mango"; }
log.info("Detected MangoWM via MANGO_INSTANCE_SIGNATURE"); if (comm)
return; log.info("Unrecognized Wayland socket owner:", comm, "- falling back to env detection");
} _detectFromEnv(0);
}, 0, 3000);
}
if (hyprlandSignature && hyprlandSignature.length > 0 && !niriSocket && !swaySocket && !scrollSocket && !miracleSocket && !labwcPid) { function _compositorNameFromComm(comm) {
isHyprland = true; switch (comm) {
isNiri = false; case "niri":
isMango = false; return "niri";
isSway = false; case "hyprland":
isScroll = false; return "hyprland";
isMiracle = false; case "sway":
isLabwc = false; return "sway";
compositor = "hyprland"; case "scroll":
log.info("Detected Hyprland"); return "scroll";
return; case "mango":
return "mango";
case "miracle-wm":
return "miracle";
case "labwc":
return "labwc";
default:
return "";
} }
}
if (niriSocket && niriSocket.length > 0) { function _applyCompositor(name) {
Proc.runCommand("niriSocketCheck", ["test", "-S", niriSocket], (output, exitCode) => { isHyprland = name === "hyprland";
if (exitCode === 0) { isNiri = name === "niri";
isNiri = true; isMango = name === "mango";
isHyprland = false; isSway = name === "sway";
isMango = false; isScroll = name === "scroll";
isSway = false; isMiracle = name === "miracle";
isScroll = false; isLabwc = name === "labwc";
isMiracle = false; compositor = name;
isLabwc = false; compositorDetected = true;
compositor = "niri"; if (isNiri)
log.info("Detected Niri with socket:", niriSocket); NiriService.generateNiriBlurrule();
NiriService.generateNiriBlurrule(); }
// Fallback when the socket owner can't be resolved (no ss, unrecognized
// comm). Same priority order as before, but every candidate must prove
// liveness; a dead socket/PID falls through to the next candidate instead
// of winning on a stale env var.
function _envDetectionCandidates() {
const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR") || "";
return [
{
name: "mango",
present: !!mangoSignature,
test: ["test", "-S", mangoSignature],
detail: "MANGO_INSTANCE_SIGNATURE " + mangoSignature
},
{
name: "niri",
present: !!niriSocket,
test: ["test", "-S", niriSocket],
detail: "NIRI_SOCKET " + niriSocket
},
{
name: "miracle",
present: !!miracleSocket,
test: ["test", "-S", miracleSocket],
detail: "MIRACLESOCK " + miracleSocket
},
{
name: "sway",
present: !!swaySocket,
test: ["test", "-S", swaySocket],
resolve: () => {
const desktop = String(Quickshell.env("XDG_CURRENT_DESKTOP") || "").toLowerCase();
return desktop.includes("sway") ? "sway" : "scroll";
},
detail: "SWAYSOCK " + swaySocket
},
{
name: "labwc",
present: !!labwcPid,
test: ["sh", "-c", "[ \"$(cat /proc/\"$LABWC_PID\"/comm 2>/dev/null)\" = labwc ]"],
detail: "LABWC_PID " + labwcPid
},
{
name: "hyprland",
present: !!hyprlandSignature,
test: ["test", "-S", runtimeDir + "/hypr/" + hyprlandSignature + "/.socket.sock"],
detail: "HYPRLAND_INSTANCE_SIGNATURE " + hyprlandSignature
}
];
}
function _detectFromEnv(index) {
const candidates = _envDetectionCandidates();
for (let i = index; i < candidates.length; i++) {
const c = candidates[i];
if (!c.present)
continue;
const next = i + 1;
Proc.runCommand(c.name + "SocketCheck", c.test, (output, exitCode) => {
if (exitCode !== 0) {
log.warn(c.detail, "is set but not alive, skipping");
_detectFromEnv(next);
return;
} }
const name = c.resolve ? c.resolve() : c.name;
_applyCompositor(name);
log.info("Detected", name, "via", c.detail);
}, 0); }, 0);
return; return;
} }
_applyCompositor("unknown");
if (swaySocket && swaySocket.length > 0 && !scrollSocket && scrollSocket.length == 0 && !miracleSocket) {
Proc.runCommand("swaySocketCheck", ["test", "-S", swaySocket], (output, exitCode) => {
if (exitCode === 0) {
isNiri = false;
isHyprland = false;
isSway = true;
isScroll = false;
isMiracle = false;
isLabwc = false;
compositor = "sway";
log.info("Detected Sway with socket:", swaySocket);
}
}, 0);
return;
}
if (miracleSocket && miracleSocket.length > 0) {
Proc.runCommand("miracleSocketCheck", ["test", "-S", miracleSocket], (output, exitCode) => {
if (exitCode === 0) {
isNiri = false;
isHyprland = false;
isMango = false;
isSway = false;
isScroll = false;
isMiracle = true;
isLabwc = false;
compositor = "miracle";
log.info("Detected Miracle WM with socket:", miracleSocket);
}
}, 0);
return;
}
if (scrollSocket && scrollSocket.length > 0 && !miracleSocket) {
Proc.runCommand("scrollSocketCheck", ["test", "-S", scrollSocket], (output, exitCode) => {
if (exitCode === 0) {
isNiri = false;
isHyprland = false;
isMango = false;
isSway = false;
isScroll = true;
isMiracle = false;
isLabwc = false;
compositor = "scroll";
log.info("Detected Scroll with socket:", scrollSocket);
}
}, 0);
return;
}
if (labwcPid && labwcPid.length > 0) {
isHyprland = false;
isNiri = false;
isMango = false;
isSway = false;
isScroll = false;
isMiracle = false;
isLabwc = true;
compositor = "labwc";
log.info("Detected LabWC with PID:", labwcPid);
return;
}
isHyprland = false;
isNiri = false;
isMango = false;
isSway = false;
isScroll = false;
isMiracle = false;
isLabwc = false;
compositor = "unknown";
log.warn("No compositor detected"); log.warn("No compositor detected");
} }
+12 -1
View File
@@ -67,6 +67,7 @@ Singleton {
property var lockComponent: null property var lockComponent: null
property bool monitorsOff: false property bool monitorsOff: false
property bool isShellLocked: false property bool isShellLocked: false
property bool lockPowerOffRequested: false
function reapplyDpmsIfNeeded() { function reapplyDpmsIfNeeded() {
if (monitorsOff) if (monitorsOff)
@@ -79,6 +80,8 @@ Singleton {
respectInhibitors: root.respectInhibitors respectInhibitors: root.respectInhibitors
enabled: false enabled: false
onIsIdleChanged: { onIsIdleChanged: {
if (!enabled)
return;
if (isIdle) { if (isIdle) {
if (SettingsData.fadeToDpmsEnabled) { if (SettingsData.fadeToDpmsEnabled) {
root.fadeToDpmsRequested(); root.fadeToDpmsRequested();
@@ -100,6 +103,8 @@ Singleton {
respectInhibitors: root.respectInhibitors respectInhibitors: root.respectInhibitors
enabled: false enabled: false
onIsIdleChanged: { onIsIdleChanged: {
if (!enabled)
return;
if (isIdle) { if (isIdle) {
root.requestMonitorOff(); root.requestMonitorOff();
} else { } else {
@@ -114,6 +119,8 @@ Singleton {
respectInhibitors: root.respectInhibitors respectInhibitors: root.respectInhibitors
enabled: false enabled: false
onIsIdleChanged: { onIsIdleChanged: {
if (!enabled)
return;
if (isIdle) { if (isIdle) {
if (SettingsData.fadeToLockEnabled) { if (SettingsData.fadeToLockEnabled) {
root.fadeToLockRequested(); root.fadeToLockRequested();
@@ -134,6 +141,8 @@ Singleton {
respectInhibitors: root.respectInhibitors respectInhibitors: root.respectInhibitors
enabled: false enabled: false
onIsIdleChanged: { onIsIdleChanged: {
if (!enabled)
return;
if (isIdle) if (isIdle)
root.requestSuspend(); root.requestSuspend();
} }
@@ -146,8 +155,10 @@ Singleton {
id: lockWakeMonitor id: lockWakeMonitor
timeout: 1 timeout: 1
respectInhibitors: false respectInhibitors: false
enabled: root.enabled && root.isShellLocked && root.monitorsOff && SettingsData.lockScreenPowerOffMonitorsOnLock enabled: root.enabled && root.isShellLocked && root.monitorsOff && (SettingsData.lockScreenPowerOffMonitorsOnLock || root.lockPowerOffRequested)
onIsIdleChanged: { onIsIdleChanged: {
if (!enabled)
return;
if (!isIdle && root.monitorsOff) if (!isIdle && root.monitorsOff)
root.requestMonitorOn(); root.requestMonitorOn();
} }
+1 -1
View File
@@ -467,7 +467,7 @@ Singleton {
if (now - root._lastWatchedReloadAt < 700) if (now - root._lastWatchedReloadAt < 700)
return; return;
root._lastWatchedReloadAt = now; root._lastWatchedReloadAt = now;
root.reloadConfig(true, false); root.reloadConfig(false, false);
} }
function reloadConfig(showToast, suppressWatch) { function reloadConfig(showToast, suppressWatch) {
+71 -14
View File
@@ -19,7 +19,8 @@ Singleton {
const desktopEntry = ("desktopEntry" in p && p.desktopEntry) ? String(p.desktopEntry).toLowerCase() : ""; const desktopEntry = ("desktopEntry" in p && p.desktopEntry) ? String(p.desktopEntry).toLowerCase() : "";
return !excluded.some(ex => { return !excluded.some(ex => {
const exLower = String(ex).toLowerCase().trim(); const exLower = String(ex).toLowerCase().trim();
if (!exLower) return false; if (!exLower)
return false;
// 1. Substring match // 1. Substring match
if (identity.includes(exLower) || desktopEntry.includes(exLower)) if (identity.includes(exLower) || desktopEntry.includes(exLower))
@@ -43,17 +44,20 @@ Singleton {
} }
property MprisPlayer activePlayer: null property MprisPlayer activePlayer: null
property real activePlayerStableLength: 0 property real activePlayerStableLength: 0
// Chromium can report blank metadata between tracks
property string stableTitle: ""
property string stableArtist: ""
Connections { Connections {
target: root.activePlayer target: root.activePlayer
function onTrackTitleChanged() { function onTrackTitleChanged() {
root.activePlayerStableLength = (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) ? root.activePlayer.length : 0; root.activePlayerStableLength = (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) ? root.activePlayer.length : 0;
if (root.isIdle(root.activePlayer)) root._syncStableMeta();
root._resolveActivePlayer(); root._checkIdle();
} }
function onTrackArtistChanged() { function onTrackArtistChanged() {
if (root.isIdle(root.activePlayer)) root._syncStableMeta();
root._resolveActivePlayer(); root._checkIdle();
} }
function onLengthChanged() { function onLengthChanged() {
if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) { if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) {
@@ -61,13 +65,53 @@ Singleton {
} }
} }
function onPlaybackStateChanged() { function onPlaybackStateChanged() {
if (root.isIdle(root.activePlayer)) root._syncStableMeta();
root._resolveActivePlayer(); root._checkIdle();
} }
} }
onActivePlayerChanged: { onActivePlayerChanged: {
activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0; activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0;
stableTitle = activePlayer?.trackTitle || "";
stableArtist = activePlayer?.trackArtist || "";
_checkIdle();
}
function _syncStableMeta(): void {
const p = activePlayer;
if (!p) {
stableTitle = "";
stableArtist = "";
return;
}
if (isFirefoxYoutubeHoverPreview(p))
return;
if (p.trackTitle)
stableTitle = p.trackTitle;
if (p.trackArtist)
stableArtist = p.trackArtist;
}
// Chromium reports stopped media w/blank metadata, resolve by checking idle status
Timer {
id: _idleGraceTimer
interval: 1223
onTriggered: {
if (!root.isIdle(root.activePlayer))
return;
root.stableTitle = "";
root.stableArtist = "";
root._resolveActivePlayer();
}
}
function _checkIdle(): void {
if (!isIdle(activePlayer)) {
_idleGraceTimer.stop();
return;
}
if (!_idleGraceTimer.running)
_idleGraceTimer.start();
} }
onAvailablePlayersChanged: _resolveActivePlayer() onAvailablePlayersChanged: _resolveActivePlayer()
@@ -86,21 +130,28 @@ Singleton {
} }
function isIdle(player: MprisPlayer): bool { function isIdle(player: MprisPlayer): bool {
return player return player && player.playbackState === MprisPlaybackState.Stopped && !player.trackTitle && !player.trackArtist;
&& player.playbackState === MprisPlaybackState.Stopped
&& !player.trackTitle
&& !player.trackArtist;
} }
function _resolveActivePlayer(): void { function _resolveActivePlayer(): void {
// A playing player always wins; otherwise keep the selection stable w/idle
const playing = availablePlayers.find(p => p.isPlaying); const playing = availablePlayers.find(p => p.isPlaying);
if (playing) { if (playing) {
activePlayer = playing; if (activePlayer !== playing) {
_persistIdentity(playing.identity); activePlayer = playing;
_persistIdentity(playing.identity);
}
return; return;
} }
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0 && !isIdle(activePlayer)) if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0 && (!isIdle(activePlayer) || _idleGraceTimer.running))
return; return;
if (activePlayer && availablePlayers.indexOf(activePlayer) < 0) {
const successor = availablePlayers.find(p => p.identity === activePlayer.identity);
if (successor) {
activePlayer = successor;
return;
}
}
const savedId = SessionData.lastPlayerIdentity; const savedId = SessionData.lastPlayerIdentity;
if (savedId) { if (savedId) {
const match = availablePlayers.find(p => p.identity === savedId); const match = availablePlayers.find(p => p.identity === savedId);
@@ -150,4 +201,10 @@ Singleton {
else if (activePlayer.canGoPrevious) else if (activePlayer.canGoPrevious)
activePlayer.previous(); activePlayer.previous();
} }
function next(): void {
const player = activePlayer;
if (player?.canGoNext)
player.next();
}
} }
@@ -0,0 +1,3 @@
import Quickshell.Services.Polkit
PolkitAgent {}
+6 -4
View File
@@ -3,7 +3,6 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Services.Polkit
Singleton { Singleton {
id: root id: root
@@ -12,10 +11,13 @@ Singleton {
readonly property bool disablePolkitIntegration: Quickshell.env("DMS_DISABLE_POLKIT") === "1" readonly property bool disablePolkitIntegration: Quickshell.env("DMS_DISABLE_POLKIT") === "1"
readonly property bool polkitAvailable: !disablePolkitIntegration readonly property bool polkitAvailable: !disablePolkitIntegration
readonly property alias agent: polkitAgentInstance readonly property var agent: polkitAgentLoader.item
PolkitAgent { Loader {
id: polkitAgentInstance id: polkitAgentLoader
active: root.polkitAvailable
asynchronous: false
source: "PolkitAgentInstance.qml"
} }
Component.onCompleted: { Component.onCompleted: {
+31 -38
View File
@@ -3,7 +3,6 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import qs.Common import qs.Common
import qs.Services import qs.Services
@@ -34,6 +33,18 @@ Singleton {
readonly property int updateCount: availableUpdates.length readonly property int updateCount: availableUpdates.length
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0 readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
readonly property bool useCustomCommand: SettingsData.updaterUseCustomCommand && (SettingsData.updaterCustomCommand || "").trim().length > 0
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
// AUR/Flatpak packages stay ignorable holding those cannot break the repo dependency graph.
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay"].includes(pkgManager)
function canIgnorePackage(pkg) {
if (!pkg)
return false;
return systemHoldsAllowed || pkg.repo !== "system";
}
Connections { Connections {
target: DMSService target: DMSService
@@ -108,9 +119,11 @@ Singleton {
if (!data) { if (!data) {
return; return;
} }
backends = data.backends || [];
const systemBackend = backends.find(b => b.repo === "system" || b.repo === "ostree");
pkgManager = systemBackend ? systemBackend.id : (backends.length > 0 ? backends[0].id : "");
_rawUpdates = data.packages || []; _rawUpdates = data.packages || [];
availableUpdates = _filterUpdates(_rawUpdates); availableUpdates = _filterUpdates(_rawUpdates);
backends = data.backends || [];
distribution = data.distro || ""; distribution = data.distro || "";
distributionPretty = data.distroPretty || ""; distributionPretty = data.distroPretty || "";
distributionSupported = (backends.length > 0); distributionSupported = (backends.length > 0);
@@ -145,13 +158,6 @@ Singleton {
errorCode = ""; errorCode = "";
errorHint = ""; errorHint = "";
} }
if (backends.length > 0) {
const sys = backends.find(b => b.repo === "system" || b.repo === "ostree");
pkgManager = sys ? sys.id : backends[0].id;
} else {
pkgManager = "";
}
} }
function _filterUpdates(pkgs) { function _filterUpdates(pkgs) {
@@ -159,6 +165,8 @@ Singleton {
return (pkgs || []).filter(p => { return (pkgs || []).filter(p => {
if (!SettingsData.updaterAllowAUR && p.repo === "aur") if (!SettingsData.updaterAllowAUR && p.repo === "aur")
return false; return false;
if (!canIgnorePackage(p))
return true;
return ignored.indexOf(p.name) === -1; return ignored.indexOf(p.name) === -1;
}); });
} }
@@ -177,17 +185,27 @@ Singleton {
SettingsData.set("updaterIgnoredPackages", list); SettingsData.set("updaterIgnoredPackages", list);
} }
function unignorePackage(name) {
if (!name)
return;
const list = (SettingsData.updaterIgnoredPackages || []).filter(p => p !== name);
SettingsData.set("updaterIgnoredPackages", list);
}
function checkForUpdates() { function checkForUpdates() {
DMSService.sysupdateRefresh(false, null); DMSService.sysupdateRefresh(false, null);
} }
function runUpdates(opts) { function runUpdates(opts) {
const params = opts || {}; const params = opts || {};
if (SettingsData.updaterUseCustomCommand && SettingsData.updaterCustomCommand.length > 0) {
_runCustomTerminalCommand();
return;
}
params.ignored = SettingsData.updaterIgnoredPackages || []; params.ignored = SettingsData.updaterIgnoredPackages || [];
if (useCustomCommand) {
params.customCommand = SettingsData.updaterCustomCommand.trim();
const termArgs = (SettingsData.updaterTerminalAdditionalParams || "").trim();
if (termArgs.length > 0) {
params.terminalArgs = termArgs.split(/\s+/);
}
}
DMSService.sysupdateUpgrade(params, null); DMSService.sysupdateUpgrade(params, null);
} }
@@ -199,30 +217,6 @@ Singleton {
DMSService.sysupdateSetInterval(seconds, null); DMSService.sysupdateSetInterval(seconds, null);
} }
function _runCustomTerminalCommand() {
const terminal = SessionData.resolveTerminal();
if (!terminal || terminal.length === 0) {
ToastService.showError(I18n.tr("No terminal configured"), I18n.tr("Pick a terminal in Settings → Launcher (or set $TERMINAL)."));
return;
}
const updateCommand = `${SettingsData.updaterCustomCommand} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
const termClass = SettingsData.updaterTerminalAdditionalParams || "";
var argv = [terminal];
if (termClass.length > 0) {
argv = argv.concat(termClass.split(" "));
}
argv.push("-e");
argv.push("sh");
argv.push("-c");
argv.push(updateCommand);
customRunner.command = argv;
customRunner.running = true;
}
Process {
id: customRunner
}
property bool _startupCheckDone: false property bool _startupCheckDone: false
function _maybeStartupCheck() { function _maybeStartupCheck() {
@@ -262,5 +256,4 @@ Singleton {
} }
DMSService.sysupdateRelease(null); DMSService.sysupdateRelease(null);
} }
} }
+34 -31
View File
@@ -29,19 +29,17 @@ Singleton {
function getArtworkUrl(player) { function getArtworkUrl(player) {
if (!player) return ""; if (!player) return "";
// 1. If native trackArtUrl is present and valid
let artUrl = player.trackArtUrl || ""; let artUrl = player.trackArtUrl || "";
if (artUrl !== "") { if (artUrl !== "") {
return artUrl; return artUrl;
} }
// 2. Fallback to raw metadata mpris:artUrl if present
if (player.metadata && player.metadata["mpris:artUrl"]) { if (player.metadata && player.metadata["mpris:artUrl"]) {
artUrl = player.metadata["mpris:artUrl"].toString(); artUrl = player.metadata["mpris:artUrl"].toString();
if (artUrl !== "") return artUrl; if (artUrl !== "") return artUrl;
} }
// 3. Fallback for YouTube from xesam:url // YouTube publishes no artUrl; derive the thumbnail from the video id.
if (player.metadata && player.metadata["xesam:url"]) { if (player.metadata && player.metadata["xesam:url"]) {
const url = player.metadata["xesam:url"].toString(); const url = player.metadata["xesam:url"].toString();
if (url.includes("youtube.com") || url.includes("youtu.be")) { if (url.includes("youtube.com") || url.includes("youtu.be")) {
@@ -56,13 +54,13 @@ Singleton {
return ""; return "";
} }
function _commit(u) { function _commit(u, artKey, srcUrl) {
resolvedArtUrl = u; resolvedArtUrl = u;
_committedArtKey = u !== "" ? _pendingArtKey : ""; _committedArtKey = u !== "" ? artKey : "";
_committedSrcUrl = u !== "" ? _lastArtUrl : ""; _committedSrcUrl = u !== "" ? srcUrl : "";
} }
function loadArtwork(url) { function loadArtwork(url, artKey, requestSerial) {
if (!url || url === "") { if (!url || url === "") {
// Keep stale art; only blank once the empty url debounce settles. // Keep stale art; only blank once the empty url debounce settles.
_lastArtUrl = ""; _lastArtUrl = "";
@@ -71,9 +69,11 @@ Singleton {
return; return;
} }
_clearDebounce.stop(); _clearDebounce.stop();
if (url === _lastArtUrl) // Same url must re-issue under a new serial; the bump cancelled the in-flight load.
if (url === _lastArtUrl && requestSerial === _lastIssuedSerial)
return; return;
_lastArtUrl = url; _lastArtUrl = url;
_lastIssuedSerial = requestSerial;
if (url.startsWith("http://") || url.startsWith("https://")) { if (url.startsWith("http://") || url.startsWith("https://")) {
loading = true; loading = true;
@@ -83,18 +83,17 @@ Singleton {
const filePath = cacheDir + "/remote_" + hash; const filePath = cacheDir + "/remote_" + hash;
const localFileUrl = "file://" + filePath; const localFileUrl = "file://" + filePath;
// 1. First, check if the file already exists locally
Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => { Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => {
if (_lastArtUrl !== targetUrl) if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
return; return;
if (exitCode === 0) { if (exitCode === 0) {
_commit(localFileUrl); _commit(localFileUrl, artKey, targetUrl);
loading = false; loading = false;
} else { } else {
const dlCmd = "mkdir -p \"$(dirname \"$1\")\" && curl -f -s -L -o \"$1\" \"$2\" && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }"; const dlCmd = "mkdir -p \"$(dirname \"$1\")\" && curl -f -s -L -o \"$1\" \"$2\" && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }";
// 2. Check if this is a YouTube URL to do high quality 16:9 fallback // YouTube: try the 16:9 maxres thumbnail before falling back.
if (targetUrl.includes("img.youtube.com/vi/")) { if (targetUrl.includes("img.youtube.com/vi/")) {
const videoId = targetUrl.split("/vi/")[1].split("/")[0]; const videoId = targetUrl.split("/vi/")[1].split("/")[0];
const maxresUrl = "https://img.youtube.com/vi/" + videoId + "/maxresdefault.jpg"; const maxresUrl = "https://img.youtube.com/vi/" + videoId + "/maxresdefault.jpg";
@@ -102,30 +101,29 @@ Singleton {
const tmpPath = filePath + ".tmp"; const tmpPath = filePath + ".tmp";
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, maxresUrl, filePath], (maxOutput, maxExitCode) => { Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, maxresUrl, filePath], (maxOutput, maxExitCode) => {
if (_lastArtUrl !== targetUrl) if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
return; return;
if (maxExitCode === 0) { if (maxExitCode === 0) {
_commit(localFileUrl); _commit(localFileUrl, artKey, targetUrl);
loading = false; loading = false;
} else { } else {
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => { Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => {
if (_lastArtUrl !== targetUrl) if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
return; return;
_commit(mqExitCode === 0 ? localFileUrl : targetUrl); _commit(mqExitCode === 0 ? localFileUrl : targetUrl, artKey, targetUrl);
loading = false; loading = false;
}, 50, 15000); }, 50, 15000);
} }
}, 50, 15000); }, 50, 15000);
} else { } else {
// Standard curl download for other remote URLs (e.g. SoundCloud)
const tmpPath = filePath + ".tmp"; const tmpPath = filePath + ".tmp";
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => { Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => {
if (_lastArtUrl !== targetUrl) if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
return; return;
_commit(dlExitCode === 0 ? localFileUrl : targetUrl); _commit(dlExitCode === 0 ? localFileUrl : targetUrl, artKey, targetUrl);
loading = false; loading = false;
}, 50, 15000); }, 50, 15000);
} }
@@ -141,7 +139,7 @@ Singleton {
// Cover lands after metadata, so poll; commit a content-addressed copy so identical bytes keep an identical url // Cover lands after metadata, so poll; commit a content-addressed copy so identical bytes keep an identical url
const script = "f=\"$1\"; d=\"$2\"; i=0; while [ ! -f \"$f\" ] && [ \"$i\" -lt 20 ]; do sleep 0.15; i=$((i + 1)); done; [ -f \"$f\" ] || exit 1; s=$(sha1sum \"$f\" | cut -c1-40); if [ ! -f \"$d/art_$s\" ]; then mkdir -p \"$d\" && cp \"$f\" \"$d/art_$s.tmp\" && mv \"$d/art_$s.tmp\" \"$d/art_$s\" || exit 1; fi; echo \"$s\""; const script = "f=\"$1\"; d=\"$2\"; i=0; while [ ! -f \"$f\" ] && [ \"$i\" -lt 20 ]; do sleep 0.15; i=$((i + 1)); done; [ -f \"$f\" ] || exit 1; s=$(sha1sum \"$f\" | cut -c1-40); if [ ! -f \"$d/art_$s\" ]; then mkdir -p \"$d\" && cp \"$f\" \"$d/art_$s.tmp\" && mv \"$d/art_$s.tmp\" \"$d/art_$s\" || exit 1; fi; echo \"$s\"";
Proc.runCommand(null, ["sh", "-c", script, "sh", filePath, cacheDir], (output, exitCode) => { Proc.runCommand(null, ["sh", "-c", script, "sh", filePath, cacheDir], (output, exitCode) => {
if (_lastArtUrl !== localUrl) if (_lastArtUrl !== localUrl || _requestSerial !== requestSerial)
return; return;
loading = false; loading = false;
if (exitCode !== 0) if (exitCode !== 0)
@@ -149,7 +147,7 @@ Singleton {
const sha = (output || "").trim(); const sha = (output || "").trim();
if (_artHashDenylist.indexOf(sha) !== -1) if (_artHashDenylist.indexOf(sha) !== -1)
return; return;
_commit("file://" + cacheDir + "/art_" + sha); _commit("file://" + cacheDir + "/art_" + sha, artKey, localUrl);
}, 50, 5000); }, 50, 5000);
} }
@@ -158,7 +156,7 @@ Singleton {
interval: 800 interval: 800
onTriggered: { onTriggered: {
if (root._lastArtUrl === "") if (root._lastArtUrl === "")
root._commit(""); root._commit("", "", "");
} }
} }
@@ -167,6 +165,8 @@ Singleton {
property string _committedArtKey: "" property string _committedArtKey: ""
property string _pendingArtKey: "" property string _pendingArtKey: ""
property string _committedSrcUrl: "" property string _committedSrcUrl: ""
property int _requestSerial: 0
property int _lastIssuedSerial: -1
onActivePlayerChanged: _updateArtUrl() onActivePlayerChanged: _updateArtUrl()
@@ -182,9 +182,10 @@ Singleton {
const p = activePlayer; const p = activePlayer;
if (!p) if (!p)
return ""; return "";
// +title/artist: KDEconnect reuses one trackid forever; album excluded: Chrome delivers it late, orphaning the lock // dbusName is constant per player; uniqueId is per-track and would churn the key.
const playerId = p.dbusName || p.identity || "";
const tid = p.metadata && p.metadata["mpris:trackid"] ? p.metadata["mpris:trackid"].toString() : ""; const tid = p.metadata && p.metadata["mpris:trackid"] ? p.metadata["mpris:trackid"].toString() : "";
return tid + " " + (p.trackTitle || "") + " " + (p.trackArtist || ""); return playerId + " " + tid + " " + (p.trackTitle || "") + " " + (p.trackArtist || "");
} }
function artReadyFor(player) { function artReadyFor(player) {
@@ -194,17 +195,19 @@ Singleton {
function _updateArtUrl() { function _updateArtUrl() {
const key = _trackKey(); const key = _trackKey();
// Skip once real art is committed for this track (dedup Chrome's multi-size if (key !== _pendingArtKey) {
// re-publish). The lock is set in _commit(), never optimistically, so a rejected _requestSerial++;
// placeholder or a short-circuited duplicate url can't wedge the real cover out. loading = false;
if (key !== "" && key === _committedArtKey) }
return;
_pendingArtKey = key; _pendingArtKey = key;
const url = getArtworkUrl(activePlayer); const url = getArtworkUrl(activePlayer);
// Ignore Chrome's same-track thumbnail size updates.
if (key !== "" && key === _committedArtKey)
return;
if (key !== "" && url !== "" && url === _committedSrcUrl) { if (key !== "" && url !== "" && url === _committedSrcUrl) {
_committedArtKey = key; // Chrome can publish track metadata before its new artwork URL.
return; return;
} }
loadArtwork(url); loadArtwork(url, key, _requestSerial);
} }
} }
+17 -1
View File
@@ -116,12 +116,28 @@ Item {
property var hyprlandOverviewLoaderRef: hyprlandOverviewLoader property var hyprlandOverviewLoaderRef: hyprlandOverviewLoader
// Horizontal bars must claim their exclusive zones first, so vertical bars wait for every enabled horizontal bar to load
readonly property int horizontalWanted: SettingsData.barConfigs.filter(c => (c.enabled ?? false) && c.position !== SettingsData.Position.Left && c.position !== SettingsData.Position.Right).length
property int horizontalReady: 0
function recountHorizontalReady() {
let ready = 0;
for (let i = 0; i < count; i++) {
const loader = itemAt(i);
if (loader?.item && !loader.isVertical)
ready++;
}
horizontalReady = ready;
}
delegate: Loader { delegate: Loader {
id: barLoader id: barLoader
required property var modelData required property var modelData
property var barConfig: SettingsData.barConfigs.find(cfg => cfg.id === modelData.id) || null property var barConfig: SettingsData.barConfigs.find(cfg => cfg.id === modelData.id) || null
active: root.barSurfacesLoaded && (barConfig?.enabled ?? false) readonly property bool isVertical: modelData.position === SettingsData.Position.Left || modelData.position === SettingsData.Position.Right
active: root.barSurfacesLoaded && (barConfig?.enabled ?? false) && (!isVertical || dankBarRepeater.horizontalReady >= dankBarRepeater.horizontalWanted)
asynchronous: false asynchronous: false
onItemChanged: dankBarRepeater.recountHorizontalReady()
sourceComponent: DankBar { sourceComponent: DankBar {
barConfig: barLoader.barConfig barConfig: barLoader.barConfig
+1 -1
View File
@@ -1 +1 @@
v1.6-beta v1.5.2
+6 -10
View File
@@ -182,16 +182,12 @@ Item {
} }
} }
FrameAnimation { // Timer, not FrameAnimation a running animation commits frames every vsync (#2863)
running: blobEffect.visible Timer {
property real pending: 0 running: blobEffect.visible && root.onScreen
onTriggered: { interval: 33
pending += frameTime; repeat: true
if (pending < 0.03) onTriggered: root.stepBlob(0.033)
return;
root.stepBlob(Math.min(pending, 0.05));
pending = 0;
}
} }
ShaderEffect { ShaderEffect {

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