1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00

Compare commits

..

112 Commits

Author SHA1 Message Date
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
75 changed files with 4229 additions and 1253 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -51,7 +51,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
+11 -2
View File
@@ -43,7 +43,7 @@ jobs:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -55,6 +55,15 @@ jobs:
fetch-depth: 0
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)
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
@@ -83,7 +92,7 @@ jobs:
- name: Dispatch Release workflow
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
GH_TOKEN: ${{ github.token }}
run: |
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
-f tag="${{ steps.derive.outputs.tag }}" \
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
+18 -4
View File
@@ -91,21 +91,30 @@ var authListServicesCmd = &cobra.Command{
var authValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate a PAM service file for use as the DMS lock-screen password stack",
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password stack. Exits 1 when the file is not usable.",
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 != "":
result = sharedpam.ValidateLockscreenPamService(service)
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamService(service)
} else {
result = sharedpam.ValidateLockscreenPamService(service)
}
case !filepath.IsAbs(path):
result = sharedpam.LockscreenPamValidation{
Path: path,
@@ -114,7 +123,11 @@ var authValidateCmd = &cobra.Command{
Errors: []string{"--path must be an absolute file path"},
}
default:
result = sharedpam.ValidateLockscreenPamPath(path)
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamPath(path)
} else {
result = sharedpam.ValidateLockscreenPamPath(path)
}
}
if asJSON {
@@ -159,6 +172,7 @@ func init() {
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")
}
+39 -4
View File
@@ -51,8 +51,9 @@ Modes:
full - Capture the focused output
all - Capture all outputs combined
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
scroll - Select a region, then scroll to capture a stitched tall image
Output format (--format):
png - PNG format (default)
@@ -72,7 +73,9 @@ Examples:
dms screenshot --no-confirm # Region capture on mouse release
dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON`,
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{
@@ -110,10 +113,33 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{
Use: "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,
}
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{
Use: "list",
Short: "List available outputs",
@@ -143,7 +169,10 @@ func init() {
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(ssScrollCmd)
screenshotCmd.AddCommand(ssFullCmd)
screenshotCmd.AddCommand(ssAllCmd)
screenshotCmd.AddCommand(ssOutputCmd)
@@ -249,7 +278,7 @@ func runScreenshot(config screenshot.Config) {
// Region select needs the keyboard; drop popout grabs for its duration.
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 {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
@@ -437,6 +466,12 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
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) {
config := getScreenshotConfig(screenshot.ModeFullScreen)
runScreenshot(config)
+7 -2
View File
@@ -124,7 +124,9 @@ func GetLogger() *Logger {
logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path)
logMu.Lock()
_ = setLogFile(logger, path)
logMu.Unlock()
}
})
return logger
@@ -145,15 +147,18 @@ func SetLevel(level string) {
// profile when stderr is a TTY and route the file through ansiStripWriter so
// the file stays plain while stderr keeps its colors.
func SetLogFile(path string) error {
l := GetLogger()
logMu.Lock()
defer logMu.Unlock()
return setLogFile(l, path)
}
func setLogFile(l *Logger, path string) error {
if logFile != nil {
logFile.Close()
logFile = nil
}
l := GetLogger()
if path == "" {
l.SetOutput(logStderr)
applyColorProfile(l, logStderr)
+71 -1
View File
@@ -646,6 +646,7 @@ type lockscreenPamAnalysis struct {
inlineFingerprint bool
inlineU2f bool
modules []string
authModules []string
unknownDirectives []string
err error
}
@@ -730,6 +731,9 @@ func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack
}
if !foundModule && strings.HasSuffix(field, ".so") {
acc.modules = append(acc.modules, field)
if lineType == "auth" {
acc.authModules = append(acc.authModules, field)
}
foundModule = true
}
}
@@ -774,6 +778,14 @@ 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{},
@@ -838,6 +850,64 @@ func validateLockscreenPam(serviceName string, path string, deps lockscreenPamVa
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)
@@ -895,7 +965,7 @@ func buildManagedLockscreenU2FPamContent() string {
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
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
}
+80 -2
View File
@@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
if err != nil {
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") {
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") {
t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs)
}
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)
@@ -994,6 +994,84 @@ func TestValidateLockscreenPam(t *testing.T) {
})
}
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) {
@@ -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")
}
}
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
}
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
niriSocket := os.Getenv("NIRI_SOCKET")
swaySocket := os.Getenv("SWAYSOCK")
scrollSocket := os.Getenv("SCROLLSOCK")
miracleSocket := os.Getenv("MIRACLESOCK")
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE")
candidates := []struct {
socket string
needsStat bool
compositor Compositor
}{
{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 {
case mangoSocket != "":
if _, err := os.Stat(mangoSocket); err == nil {
detectedCompositor = CompositorMango
return detectedCompositor
// A stale env var from a previous session must not mask the live compositor
for _, c := range candidates {
if c.socket == "" {
continue
}
case niriSocket != "":
if _, err := os.Stat(niriSocket); err == nil {
detectedCompositor = CompositorNiri
return detectedCompositor
if c.needsStat {
if _, err := os.Stat(c.socket); err != nil {
continue
}
}
case scrollSocket != "":
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
detectedCompositor = c.compositor
return detectedCompositor
}
@@ -90,7 +79,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
case CompositorMango:
return getMangoActiveWindow()
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
}
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 {
enc := png.Encoder{CompressionLevel: png.BestSpeed}
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
}
+27 -8
View File
@@ -95,6 +95,9 @@ type RegionSelector struct {
showCapturedCursor bool
shiftHeld bool
phase selectorPhase
scroll *scrollSession
running bool
cancelled bool
result Region
@@ -167,11 +170,15 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
r.running = true
for r.running {
if err := r.ctx.Dispatch(); err != nil {
if err := r.dispatchOrTick(); err != nil {
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 {
return nil, r.cancelled, nil
}
@@ -186,6 +193,10 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
scale = s
}
}
if r.scroll != nil {
yInverted = false
format = uint32(r.scroll.format)
}
return &CaptureResult{
Buffer: r.capturedBuffer,
@@ -696,7 +707,9 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
}
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 {
log.Error("create render slot wl_buffer failed", "err", err)
pool.Destroy()
@@ -738,8 +751,9 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
x1 := float64(r.preSelect.X-os.output.x) * scaleX
y1 := float64(r.preSelect.Y-os.output.y) * scaleY
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY
// selection edges are inclusive; the exclusive width edge is one device px past it
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.dragging = false
@@ -769,10 +783,13 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
return
}
slot.shm.CopyFrom(srcBuf)
// Draw overlay (dimming + selection) into this slot
r.drawOverlay(os, slot.shm)
switch r.phase {
case phaseScroll:
r.drawScrollOverlay(os, slot.shm)
default:
slot.shm.CopyFrom(srcBuf)
r.drawOverlay(os, slot.shm)
}
if os.viewport != nil {
_ = os.wlSurface.SetBufferScale(1)
@@ -808,6 +825,8 @@ func (r *RegionSelector) cleanup() {
r.cursorBuffer.Close()
}
r.cleanupScroll()
for _, os := range r.surfaces {
for _, slot := range os.slots {
if slot == nil {
+49 -24
View File
@@ -94,6 +94,20 @@ func (r *RegionSelector) setupPointerHandlers() {
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 {
case 0x110: // BTN_LEFT
switch e.State {
@@ -135,6 +149,17 @@ func (r *RegionSelector) setupKeyboardHandlers() {
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 {
case 1:
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 {
r.running = false
return
return nil, 0, 0, 0, 0
}
os := r.selection.surface
srcBuf := r.getSourceBuffer(os)
if srcBuf == nil {
r.running = false
return
return nil, 0, 0, 0, 0
}
x1, y1 := r.selection.anchorX, r.selection.anchorY
@@ -181,24 +204,10 @@ func (r *RegionSelector) finishSelection() {
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
}
bx1 := int(x1 * scaleX)
by1 := int(y1 * scaleY)
bx2 := int(x2 * scaleX)
by2 := int(y2 * scaleY)
// 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
}
bx1 := clamp(int(x1*scaleX), 0, srcBuf.Width)
by1 := clamp(int(y1*scaleY), 0, srcBuf.Height)
bx2 := clamp(int(x2*scaleX), 0, srcBuf.Width)
by2 := clamp(int(y2*scaleY), 0, srcBuf.Height)
w, h := bx2-bx1+1, by2-by1+1
if r.shiftHeld && w != h {
@@ -215,7 +224,23 @@ func (r *RegionSelector) finishSelection() {
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)
if err != nil {
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
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++ {
off := y * stride
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+1] = uint8(int(data[i+1]) * 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+1] = srcData[si+1]
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)
}
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) {
if r.selection.dragging {
return
+5 -1
View File
@@ -92,7 +92,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
switch s.config.Mode {
case ModeLastRegion:
return s.captureLastRegion()
case ModeRegion:
case ModeRegion, ModeScroll:
return s.captureRegion()
case ModeWindow:
return s.captureWindow()
@@ -145,6 +145,10 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorNiri {
return s.captureNiriWindow()
}
geom, err := GetActiveWindow()
if err != nil {
return nil, err
+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
ModeOutput
ModeLastRegion
ModeScroll
)
type Format int
@@ -62,6 +63,7 @@ type Config struct {
SaveFile bool
Notify bool
Stdout bool
IntervalMs int
}
func DefaultConfig() Config {
@@ -744,6 +744,32 @@ func (b *IWDBackend) DisconnectWiFi() error {
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 {
b.stateMutex.RLock()
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)
}
if !autoconnect {
b.abortInFlightConnection(ssid)
}
b.updateState()
if b.onStateChange != nil {
@@ -359,6 +359,24 @@ func (b *NetworkManagerBackend) DisconnectWiFi() error {
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 {
conn, err := b.findConnection(ssid)
if err != nil {
@@ -963,6 +981,10 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
return fmt.Errorf("failed to update connection: %w", err)
}
if !autoconnect {
b.abortInFlightConnection(ssid)
}
b.updateWiFiNetworks()
if b.onStateChange != nil {
@@ -50,11 +50,42 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
}
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"}
if len(opts.Ignored) > 0 {
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
}
// 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 {
@@ -112,7 +143,7 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
}
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
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 {
+14 -13
View File
@@ -119,7 +119,7 @@ func findTerminal(override string) string {
return ""
}
func wrapInTerminal(term, title, shellCmd string) []string {
func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
const appID = "com.danklinux.dms"
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'`,
@@ -129,24 +129,25 @@ func wrapInTerminal(term, title, shellCmd string) []string {
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer
var argv []string
execFlag := "-e"
switch term {
case "kitty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "alacritty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "kitty", "alacritty", "wezterm":
argv = []string{term, "--class", appID, "-T", title}
case "foot":
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
argv = []string{term, "--app-id=" + appID, "--title=" + title}
case "ghostty":
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
argv = []string{term, "--class=" + appID, "--title=" + title}
case "xterm":
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
argv = []string{term, "-class", appID, "-T", title}
case "konsole":
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
argv = []string{term, "-p", "tabtitle=" + title}
case "gnome-terminal":
return []string{term, "--title=" + title, "--", "sh", "-c", full}
argv = []string{term, "--title=" + title}
execFlag = "--"
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),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""),
TerminalArgs: stringSliceOpt(req.Params, "terminalArgs"),
Ignored: stringSliceOpt(req.Params, "ignored"),
}
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 != "" {
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
m.runCustomUpgrade(ctx, opts)
return
}
@@ -345,6 +345,9 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock()
}
if isPacmanFamily(m.selection.System) {
opts.Ignored = dropPacmanRepoIgnores(opts.Ignored, opts.Targets)
}
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
backends := upgradeBackends(m.selection, opts)
@@ -389,8 +392,8 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
m.finishSuccessfulUpgrade(true)
}
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
term := findTerminal(terminalOverride)
func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
term := findTerminal(opts.Terminal)
if term == "" {
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
return
@@ -407,7 +410,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
m.markDirty()
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 {
code := ErrCodeBackendFailed
switch {
@@ -425,6 +428,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
}
m.finishSuccessfulUpgrade(false)
m.runRefresh(context.Background(), false)
}
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
+1
View File
@@ -80,6 +80,7 @@ type UpgradeOptions struct {
AttachStdio bool
CustomCommand string
Terminal string
TerminalArgs []string
Targets []Package
Ignored []string
}
@@ -50,9 +50,9 @@ func TestUpgradeCommandBuilders(t *testing.T) {
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"}}),
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"},
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
},
{
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) {
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
+1 -1
View File
@@ -1 +1 @@
Marble Tabby
The Wolverine
+9 -1
View File
@@ -11,6 +11,8 @@ Singleton {
property var appUsageRanking: {}
property bool _saving: false
property bool _hasLoaded: false
property bool _parseError: false
Component.onCompleted: {
loadSettings();
@@ -21,15 +23,21 @@ Singleton {
}
function parseSettings(content) {
_parseError = false;
try {
if (content && content.trim()) {
var settings = JSON.parse(content);
appUsageRanking = settings.appUsageRanking || {};
}
} catch (e) {}
_hasLoaded = true;
} catch (e) {
_parseError = true;
}
}
function saveSettings() {
if (_parseError || !_hasLoaded)
return;
settingsFile.setText(JSON.stringify({
"appUsageRanking": appUsageRanking
}, 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 close", label: "Keybinds Cheatsheet: Close" },
{ 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 inhibit toggle", label: "Idle Inhibit: Toggle" },
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
+5
View File
@@ -240,6 +240,8 @@ Singleton {
onBlurForegroundLayersChanged: saveSettings()
property real blurLayerOutlineOpacity: 0.12
onBlurLayerOutlineOpacityChanged: saveSettings()
property bool blurBorderEnabled: true
onBlurBorderEnabledChanged: saveSettings()
property string blurBorderColor: "outline"
onBlurBorderColorChanged: saveSettings()
property string blurBorderCustomColor: "#ffffff"
@@ -859,6 +861,7 @@ Singleton {
property bool notificationOverlayEnabled: false
property bool notificationPopupShadowEnabled: true
property bool notificationPopupPrivacyMode: false
property bool notificationForegroundLayers: true
property int overviewRows: 2
property int overviewColumns: 5
property real overviewScale: 0.16
@@ -898,6 +901,8 @@ Singleton {
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 int lockScreenNotificationMode: 0
+4
View File
@@ -580,11 +580,15 @@ Singleton {
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool blurForegroundLayers: 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 readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
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 layerOutlineOpacity: blurLayerOutlineOpacity
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
+15 -2
View File
@@ -63,6 +63,7 @@ Singleton {
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
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 greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
@@ -176,7 +177,7 @@ Singleton {
readonly property bool lockU2fReady: {
if (forcedU2fAvailable !== null)
return forcedU2fAvailable;
return lockU2fCustomConfigDetected || homeU2fKeysDetected;
return lockU2fCustomSourceDetected || lockU2fCustomConfigDetected || homeU2fKeysDetected;
}
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"]
function detectAuthCapabilities() {
// FileView cannot watch paths that do not exist yet, so reload the U2F PAM
dankshellU2fPamWatcher.reload();
u2fKeysWatcher.reload();
if (forcedFprintAvailable === null) {
fingerprintProbeFinalized = false;
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
@@ -599,7 +604,7 @@ Singleton {
let details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
ToastService.showInfo(I18n.tr("Authentication changes applied."), details, "", "auth-sync");
ToastService.showInfo(I18n.tr("Authentication changes applied"), details, "", "auth-sync");
root.detectAuthCapabilities();
root.finishAuthApply();
return;
@@ -722,14 +727,22 @@ Singleton {
FileView {
id: dankshellU2fPamWatcher
path: "/etc/pam.d/dankshell-u2f"
watchChanges: true
printErrors: false
onLoaded: root.dankshellU2fPamText = text()
onLoadFailed: root.dankshellU2fPamText = ""
}
FileView {
id: customU2fPamWatcher
path: root.settingsRoot?.lockU2fPamPath || ""
printErrors: false
}
FileView {
id: u2fKeysWatcher
path: root.u2fKeysPath
watchChanges: true
printErrors: false
onLoaded: root.u2fKeysText = text()
onLoadFailed: root.u2fKeysText = ""
@@ -70,6 +70,7 @@ var SPEC = {
blurEnabled: { def: false },
blurForegroundLayers: { def: true },
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
blurBorderEnabled: { def: true },
blurBorderColor: { def: "outline" },
blurBorderCustomColor: { def: "#ffffff" },
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
@@ -437,6 +438,7 @@ var SPEC = {
notificationOverlayEnabled: { def: false },
notificationPopupShadowEnabled: { def: true },
notificationPopupPrivacyMode: { def: false },
notificationForegroundLayers: { def: true },
overviewRows: { def: 2, persist: false },
overviewColumns: { def: 5, persist: false },
overviewScale: { def: 0.16, persist: false },
@@ -459,6 +461,8 @@ var SPEC = {
lockPamPath: { def: "" },
lockPamInlineFprint: { def: false },
lockPamInlineU2f: { def: false },
lockPamExternallyManaged: { def: false },
lockU2fPamPath: { def: "" },
lockScreenInactiveColor: { def: "#000000" },
lockScreenNotificationMode: { def: 0 },
lockScreenVideoEnabled: { def: false },
+53 -66
View File
@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
@@ -53,6 +55,7 @@ Item {
delegate: Loader {
id: fadeWindowLoader
required property var modelData
readonly property FadeToLockWindow loadedWindow: item as FadeToLockWindow
active: SettingsData.fadeToLockEnabled
asynchronous: false
@@ -64,29 +67,29 @@ Item {
}
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 {
target: IdleService
enabled: fadeWindowLoader.item !== null
enabled: fadeWindowLoader.loadedWindow !== null
function onFadeToLockRequested() {
if (fadeWindowLoader.item) {
fadeWindowLoader.item.startFade();
if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.loadedWindow.startFade();
}
}
function onCancelFadeToLock() {
if (fadeWindowLoader.item) {
fadeWindowLoader.item.cancelFade();
if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.loadedWindow.cancelFade();
}
}
function onDismissFadeToLock() {
if (fadeWindowLoader.item) {
fadeWindowLoader.item.dismiss();
if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.loadedWindow.dismiss();
}
}
}
@@ -99,6 +102,7 @@ Item {
delegate: Loader {
id: fadeDpmsWindowLoader
required property var modelData
readonly property FadeToDpmsWindow loadedWindow: item as FadeToDpmsWindow
active: SettingsData.fadeToDpmsEnabled
asynchronous: false
@@ -110,30 +114,30 @@ Item {
}
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 {
target: IdleService
enabled: fadeDpmsWindowLoader.item !== null
enabled: fadeDpmsWindowLoader.loadedWindow !== null
function onFadeToDpmsRequested() {
if (fadeDpmsWindowLoader.item) {
fadeDpmsWindowLoader.item.startFade();
if (fadeDpmsWindowLoader.loadedWindow) {
fadeDpmsWindowLoader.loadedWindow.startFade();
}
}
function onCancelFadeToDpms() {
if (fadeDpmsWindowLoader.item) {
fadeDpmsWindowLoader.item.cancelFade();
if (fadeDpmsWindowLoader.loadedWindow) {
fadeDpmsWindowLoader.loadedWindow.cancelFade();
}
}
function onRequestMonitorOn() {
if (!fadeDpmsWindowLoader.item)
if (!fadeDpmsWindowLoader.loadedWindow)
return;
fadeDpmsWindowLoader.item.cancelFade();
fadeDpmsWindowLoader.loadedWindow.cancelFade();
}
}
}
@@ -351,9 +355,7 @@ Item {
Variants {
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
delegate: NotificationPopupManager {
modelData: item
}
delegate: NotificationPopupManager {}
}
LazyLoader {
@@ -387,6 +389,7 @@ Item {
LazyLoader {
id: wifiPasswordModalLoader
active: false
readonly property WifiPasswordModal loadedModal: item as WifiPasswordModal
Component.onCompleted: {
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
@@ -421,6 +424,7 @@ Item {
LazyLoader {
id: polkitAuthModalLoader
active: false
readonly property PolkitAuthModal loadedModal: item as PolkitAuthModal
PolkitAuthModal {
id: polkitAuthModal
@@ -439,8 +443,8 @@ Item {
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
return;
polkitAuthModalLoader.active = true;
if (polkitAuthModalLoader.item)
polkitAuthModalLoader.item.show();
if (polkitAuthModalLoader.loadedModal)
polkitAuthModalLoader.loadedModal.show();
}
}
@@ -459,20 +463,20 @@ Item {
target: NetworkService
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
const alreadyShown = wifiPasswordModalLoader.item && wifiPasswordModalLoader.item.shouldBeVisible;
if (alreadyShown && token === lastCredentialsToken)
const alreadyShown = wifiPasswordModalLoader.loadedModal && wifiPasswordModalLoader.loadedModal.shouldBeVisible;
if (alreadyShown && token === root.lastCredentialsToken)
return;
wifiPasswordModalLoader.active = true;
if (!wifiPasswordModalLoader.item)
if (!wifiPasswordModalLoader.loadedModal)
return;
if (alreadyShown && lastCredentialsToken !== "" && lastCredentialsToken !== token)
NetworkService.cancelCredentials(lastCredentialsToken);
if (alreadyShown && root.lastCredentialsToken !== "" && root.lastCredentialsToken !== token)
NetworkService.cancelCredentials(root.lastCredentialsToken);
lastCredentialsToken = token;
lastCredentialsTime = Date.now();
wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
root.lastCredentialsToken = token;
root.lastCredentialsTime = Date.now();
wifiPasswordModalLoader.loadedModal.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
}
}
@@ -769,10 +773,10 @@ Item {
}
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) {
log.warn("Invalid app picker request data");
root.log.warn("Invalid app picker request data");
return;
}
@@ -889,7 +893,6 @@ Item {
delegate: DankSlideout {
id: notepadSlideout
modelData: item
title: I18n.tr("Notepad")
slideoutWidth: 480
expandable: true
@@ -985,8 +988,8 @@ Item {
onSwitchUserRequested: {
switchUserModalLoader.active = true;
Qt.callLater(() => {
if (switchUserModalLoader.item)
switchUserModalLoader.item.showFromPowerMenu();
if (switchUserModalLoader.loadedModal)
switchUserModalLoader.loadedModal.showFromPowerMenu();
});
}
@@ -1000,6 +1003,7 @@ Item {
id: switchUserModalLoader
active: false
readonly property SwitchUserModal loadedModal: item as SwitchUserModal
SwitchUserModal {
id: switchUserModal
@@ -1055,7 +1059,6 @@ Item {
model: SettingsData.getFilteredScreens("toast")
delegate: Toast {
modelData: item
visible: ToastService.toastVisible
}
}
@@ -1070,73 +1073,55 @@ Item {
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: VolumeOSD {
modelData: item
}
delegate: VolumeOSD {}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MediaVolumeOSD {
modelData: item
}
delegate: MediaVolumeOSD {}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MediaPlaybackOSD {
modelData: item
}
delegate: MediaPlaybackOSD {}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MicVolumeOSD {
modelData: item
}
delegate: MicVolumeOSD {}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: BrightnessOSD {
modelData: item
}
delegate: BrightnessOSD {}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: IdleInhibitorOSD {
modelData: item
}
delegate: IdleInhibitorOSD {}
}
Variants {
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
delegate: PowerProfileOSD {
modelData: item
}
delegate: PowerProfileOSD {}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: CapsLockOSD {
modelData: item
}
delegate: CapsLockOSD {}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: AudioOutputOSD {
modelData: item
}
delegate: AudioOutputOSD {}
}
}
}
@@ -1153,6 +1138,7 @@ Item {
Loader {
id: greeterLoader
active: false
readonly property GreeterModal loadedModal: item as GreeterModal
sourceComponent: GreeterModal {
onGreeterCompleted: greeterLoader.active = false
Component.onCompleted: show()
@@ -1161,8 +1147,8 @@ Item {
Connections {
target: FirstLaunchService
function onGreeterRequested() {
if (greeterLoader.active && greeterLoader.item) {
greeterLoader.item.show();
if (greeterLoader.active && greeterLoader.loadedModal) {
greeterLoader.loadedModal.show();
return;
}
greeterLoader.active = true;
@@ -1173,6 +1159,7 @@ Item {
Loader {
id: changelogLoader
active: false
readonly property ChangelogModal loadedModal: item as ChangelogModal
sourceComponent: ChangelogModal {
onChangelogDismissed: changelogLoader.active = false
Component.onCompleted: show()
@@ -1181,8 +1168,8 @@ Item {
Connections {
target: ChangelogService
function onChangelogRequested() {
if (changelogLoader.active && changelogLoader.item) {
changelogLoader.item.show();
if (changelogLoader.active && changelogLoader.loadedModal) {
changelogLoader.loadedModal.show();
return;
}
changelogLoader.active = true;
@@ -214,7 +214,7 @@ Item {
Item {
id: listClip
anchors.fill: parent
anchors.topMargin: BlurService.enabled && stickyHeader.visible ? 32 : 0
anchors.topMargin: stickyHeader.visible ? 32 : 0
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
clip: true
@@ -417,7 +417,7 @@ Item {
anchors.top: parent.top
height: 32
z: 101
color: Theme.floatingSurface
color: "transparent"
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
+136 -236
View File
@@ -1,5 +1,4 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.Common
@@ -57,60 +56,96 @@ Variants {
}
property string source: SessionData.getMonitorWallpaper(modelData.name) || ""
property bool isColorSource: source.startsWith("#")
readonly property bool isColorSource: source.startsWith("#")
readonly property string displaySource: {
if (!source || isColorSource)
return "";
return source.startsWith("file://") ? source : encodeFileUrl(source);
}
property bool contentReady: false
property bool surfaceBounce: false
Connections {
target: SessionData
function onIsLightModeChanged() {
if (SessionData.perModeWallpaper) {
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
if (newSource !== root.source) {
root.source = newSource;
}
}
}
}
// Live stack is captured into frozenLayer once stable, then unloaded; if the capture stalls the stack just stays live.
property bool liveActive: false
property bool frozenValid: false
property string _frozenSource: ""
property bool loadFailed: false
property int _freezeWaitFrames: 0
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;
}
}
readonly property var backingWindow: Window.window
readonly property bool renderActive: !source || liveActive || _freezeWaitFrames > 0
property int _settleFrames: 3
readonly property int maxTextureSize: 8192
readonly property int textureWidth: Math.min(modelData.width, maxTextureSize)
readonly property int textureHeight: Math.min(modelData.height, maxTextureSize)
Component.onCompleted: {
isInitialized = true;
if (!source || isColorSource) {
if (!displaySource) {
contentReady = true;
return;
}
liveActive = true;
}
property bool isInitialized: false
property real transitionProgress: 0
readonly property bool transitioning: transitionAnimation.running
property bool effectActive: false
property bool useNextForEffect: false
readonly property var backingWindow: Window.window
readonly property bool renderActive: !source || effectActive || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading
property int _settleFrames: 3
onDisplaySourceChanged: {
invalidate();
loadFailed = false;
_freezeWaitFrames = 0;
if (!displaySource) {
liveActive = false;
frozenValid = false;
_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() {
_settleFrames = 3;
@@ -157,29 +192,35 @@ Variants {
root._settleFrames--;
root._wedgeBounced = false;
wedgeWatchdog.stop();
if (root._freezeWaitFrames > 0 && --root._freezeWaitFrames === 0)
root.completeFreeze();
}
function onVisibleChanged() {
root.invalidate();
}
function onWidthChanged() {
root.invalidate();
root.regenerate();
}
function onHeightChanged() {
root.invalidate();
root.regenerate();
}
function onResourcesLost() {
root.frozenValid = false;
root.regenerate();
}
}
Connections {
target: Quickshell
function onScreensChanged() {
root.invalidate();
root.regenerate();
}
}
Connections {
target: SettingsData
function onWallpaperFillModeChanged() {
root.invalidate();
root.regenerate();
}
function onEffectiveWallpaperBackgroundColorChanged() {
root.invalidate();
@@ -188,18 +229,26 @@ Variants {
Connections {
target: SessionData
function onIsLightModeChanged() {
if (SessionData.perModeWallpaper) {
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
if (newSource !== root.source) {
root.source = newSource;
}
}
}
function onMonitorWallpaperFillModesChanged() {
root.invalidate();
root.regenerate();
}
function onPerMonitorWallpaperChanged() {
root.invalidate();
root.regenerate();
}
}
// Theme changes repaint DankBackdrop but nothing else wakes the render loop
Connections {
target: Theme
enabled: root.isColorSource || currentWallpaper.status === Image.Error
enabled: root.isColorSource || root.loadFailed
function onPrimaryChanged() {
root.invalidate();
}
@@ -217,89 +266,23 @@ Variants {
}
}
function handleTransitionLoadError(failedSource) {
log.warn("failed to load candidate wallpaper for", modelData.name + ":", failedSource);
transitionDelayTimer.stop();
transitionAnimation.stop();
root.useNextForEffect = false;
root.effectActive = false;
root.transitionProgress = 0.0;
nextWallpaper.source = "";
}
onSourceChanged: {
invalidate();
if (!source || source.startsWith("#")) {
setWallpaperImmediate("");
return;
Connections {
target: liveLoader.item
function onBecameDisplayable() {
root.handleDisplayable();
}
const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source);
if (!isInitialized || !currentWallpaper.source) {
setWallpaperImmediate(formattedSource);
isInitialized = true;
return;
function onStableChanged() {
if (liveLoader.item.stable)
root.scheduleFreeze();
}
if (CompositorService.isNiri && SessionData.isSwitchingMode) {
setWallpaperImmediate(formattedSource);
return;
function onTransitioningChanged() {
root.invalidate();
}
changeWallpaper(formattedSource);
}
function setWallpaperImmediate(newSource) {
transitionDelayTimer.stop();
transitionAnimation.stop();
root.transitionProgress = 0.0;
root.effectActive = false;
if (!newSource)
root.contentReady = true;
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 {
anchors.fill: parent
active: !root.source || root.isColorSource || currentWallpaper.status === Image.Error
active: !root.source || root.isColorSource || root.loadFailed
asynchronous: true
sourceComponent: DankBackdrop {
@@ -307,126 +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);
}
if (status === Image.Ready || status === Image.Error) {
root.contentReady = true;
}
}
}
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 {
id: srcNext
sourceItem: root.effectActive ? nextWallpaper : null
hideSource: root.effectActive
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
id: frozenLayer
anchors.fill: parent
sourceItem: liveContainer
live: false
mipmap: false
recursive: false
smooth: true
visible: root.frozenValid || root.liveActive
textureSize: Qt.size(root.textureWidth, root.textureHeight)
}
Item {
id: blurredLayer
id: liveContainer
anchors.fill: parent
visible: root.liveActive
MultiEffect {
Loader {
id: liveLoader
anchors.fill: parent
source: currentWallpaper
visible: currentWallpaper.source !== ""
blurEnabled: true
blur: 0.8
blurMax: 75
opacity: 1 - root.transitionProgress
autoPaddingEnabled: false
}
active: root.liveActive
asynchronous: 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
}
}
// Cached images reach Ready synchronously during creation, before Connections retargets
onLoaded: {
if (item.displayableNow)
root.handleDisplayable();
if (item.stable)
root.scheduleFreeze();
}
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;
sourceComponent: BlurredWallpaperLive {
wallpaperSource: root.displaySource
initialSource: root._frozenSource
screenName: modelData.name
blurTextureSize: Qt.size(root.textureWidth, root.textureHeight)
}
}
}
}
+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;
}
}
}
@@ -74,7 +74,7 @@ DankPopout {
color: "transparent"
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)
@@ -272,7 +272,7 @@ DankPopout {
includeAUR: SettingsData.updaterAllowAUR,
terminal: SessionData.terminalOverride
};
if (updaterPanel.hasTerminalBackend) {
if (updaterPanel.upgradeRunsInTerminal) {
systemUpdatePopout._reopenAfterUpgrade = true;
SystemUpdateService.runUpdates(opts);
systemUpdatePopout.close();
@@ -336,7 +336,10 @@ DankPopout {
StyledText {
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
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
@@ -361,7 +364,10 @@ DankPopout {
DankListView {
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
visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
clip: true
@@ -467,18 +473,128 @@ DankPopout {
iconName: "visibility_off"
iconSize: 16
iconColor: Theme.surfaceVariantText
visible: rowHoverHandler.hovered
visible: rowHoverHandler.hovered && SystemUpdateService.canIgnorePackage(packageRow.modelData)
tooltipText: I18n.tr("Ignore this package")
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 {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
visible: SystemUpdateService.isUpgrading && updaterPanel.hasTerminalBackend
visible: SystemUpdateService.isUpgrading && updaterPanel.upgradeRunsInTerminal
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
@@ -509,7 +625,7 @@ DankPopout {
DankFlickable {
anchors.fill: parent
anchors.margins: Theme.spacingM
visible: SystemUpdateService.isUpgrading && !updaterPanel.hasTerminalBackend
visible: SystemUpdateService.isUpgrading && !updaterPanel.upgradeRunsInTerminal
contentWidth: width
contentHeight: logText.implicitHeight
clip: true
+60 -65
View File
@@ -13,33 +13,6 @@ BasePill {
readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer)
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: {
if (!activePlayer?.identity)
return false;
@@ -310,10 +283,10 @@ BasePill {
readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari")
property string displayText: {
if (!activePlayer || !root._stableTitle)
if (!activePlayer || !MprisController.stableTitle)
return "";
const title = isWebMedia ? root._stableTitle : (root._stableTitle || "Unknown Track");
const subtitle = isWebMedia ? (root._stableArtist || cachedIdentity) : (root._stableArtist || "");
const title = MprisController.stableTitle;
const subtitle = isWebMedia ? (MprisController.stableArtist || cachedIdentity) : MprisController.stableArtist;
return subtitle.length > 0 ? title + " • " + subtitle : title;
}
@@ -344,9 +317,45 @@ BasePill {
id: mediaText
readonly property bool onScreen: Window.window?.visible ?? false
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 int scrollDirection: 1
property real scrollHoldMs: 2000
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
text: textContainer.displayText
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
@@ -356,52 +365,38 @@ BasePill {
opacity: 1
onTextChanged: {
scrollOffset = 0;
resetScroll();
textShift = 0;
scrollTimer.reset();
textChangeAnimation.restart();
}
// Timer stepping, not NumberAnimation a running animation commits frames every vsync (#2863)
// Timer stepping, not NumberAnimation a running animation commits frames every vsync (#2863).
// When cava frames are already driving renders, scroll steps ride those ticks instead
// two unsynchronized tick sources nearly double the surface commit rate (#2863).
Timer {
id: scrollTimer
readonly property real maxOffset: Math.max(0, mediaText.implicitWidth - textContainer.width + 5)
property int direction: 1
property int holdTicks: 33
interval: 60
repeat: true
running: mediaText.needsScrolling && textContainer.visible && mediaText.onScreen && root._isPlaying
function reset() {
mediaText.scrollOffset = 0;
direction = 1;
holdTicks = 33;
}
onRunningChanged: {
if (!running)
reset();
}
running: mediaText.scrollActive
onTriggered: {
if (holdTicks > 0) {
holdTicks--;
if (cavaTickWatch.running)
return;
}
const next = mediaText.scrollOffset + direction;
if (next >= maxOffset) {
mediaText.scrollOffset = maxOffset;
direction = -1;
holdTicks = 33;
return;
}
if (next <= 0) {
mediaText.scrollOffset = 0;
direction = 1;
holdTicks = 33;
return;
}
mediaText.scrollOffset = next;
mediaText.stepScroll(60);
}
}
Timer {
id: cavaTickWatch
interval: 150
}
Connections {
target: CavaService
enabled: mediaText.scrollActive && SettingsData.audioVisualizerEnabled && CavaService.cavaAvailable
function onValuesChanged() {
cavaTickWatch.restart();
mediaText.stepScroll(40);
}
}
@@ -1752,7 +1752,11 @@ BasePill {
id: trayMenuContainer
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 alignedHeight: Theme.px(rawHeight, menuWindow.dpr)
@@ -1852,236 +1856,243 @@ BasePill {
}
}
Column {
id: menuColumn
DankFlickable {
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
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: Theme.spacingS
spacing: 1
Column {
id: menuColumn
Rectangle {
visible: entryStack.count === 0
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
width: menuFlickable.width
spacing: 1
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
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);
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 - (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 {
id: itemArea
id: visibilityToggleArea
anchors.fill: parent
hoverEnabled: true
enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false)
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!menuEntry || menuEntry.isSeparator)
return;
if (menuEntry.hasChildren) {
menuRoot.showSubMenu(menuEntry);
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);
}
if (typeof menuEntry.activate === "function") {
menuEntry.activate();
} else if (typeof menuEntry.triggered === "function") {
menuEntry.triggered();
}
pendingActionCloseTimer.restart();
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.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
visible: !menuEntry?.isSeparator
Rectangle {
width: 16
height: 16
DankIcon {
name: "arrow_back"
size: Theme.iconSizeSmall
color: Theme.widgetTextColor
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 {
text: menuEntry?.text || ""
text: I18n.tr("Back")
font.pixelSize: Theme.fontSizeSmall
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
elide: Text.ElideRight
color: Theme.widgetTextColor
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 {
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
MouseArea {
id: itemArea
anchors.fill: parent
hoverEnabled: true
enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false)
cursorShape: Qt.PointingHandCursor
DankIcon {
anchors.centerIn: parent
name: "chevron_right"
size: 14
color: Theme.widgetTextColor
visible: menuEntry?.hasChildren ?? false
onClicked: {
if (!menuEntry || menuEntry.isSeparator)
return;
if (menuEntry.hasChildren) {
menuRoot.showSubMenu(menuEntry);
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 var activePlayer: null
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 bool isRightEdge: false
property var targetWindow: null
@@ -64,6 +66,22 @@ Item {
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: {
switch (dropdownType) {
case 1:
@@ -144,6 +162,7 @@ Item {
hoverEnabled: true
onEntered: panelAreaEntered()
onExited: panelAreaExited()
onWheel: wheelEvent => root.volumeWheel(wheelEvent)
}
Item {
@@ -203,6 +222,7 @@ Item {
onEntered: panelAreaEntered()
onExited: panelAreaExited()
onWheel: wheelEvent => root.volumeWheel(wheelEvent)
onPressed: mouse => updateVolume(mouse)
onPositionChanged: mouse => {
if (pressed)
@@ -428,7 +448,7 @@ Item {
id: playersPanel
visible: dropdownType === 3
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
y: anchorPos.y - height / 2
radius: Theme.cornerRadius * 2
@@ -485,7 +505,7 @@ Item {
anchors.margins: Theme.spacingM
StyledText {
text: I18n.tr("Media Players (") + (allPlayers?.length || 0) + ")"
text: I18n.tr("Media Players (") + (root.selectablePlayers?.length || 0) + ")"
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
@@ -506,7 +526,7 @@ Item {
spacing: Theme.spacingS
Repeater {
model: allPlayers || []
model: root.selectablePlayers || []
delegate: Rectangle {
required property var modelData
required property int index
@@ -582,5 +602,4 @@ Item {
}
}
}
}
+63 -68
View File
@@ -47,8 +47,6 @@ Item {
playersExpanded = false;
}
readonly property bool isRightEdge: {
if (barPosition === SettingsData.Position.Right)
return true;
@@ -342,54 +340,6 @@ Item {
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 {
id: layerA
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 {
anchors.centerIn: parent
spacing: Theme.spacingM
@@ -469,7 +467,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
StyledText {
text: activePlayer?.trackTitle || I18n.tr("Unknown Track")
text: MprisController.stableTitle || I18n.tr("Unknown Track")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
@@ -481,7 +479,7 @@ Item {
}
StyledText {
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
text: MprisController.stableArtist || I18n.tr("Unknown Artist")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
width: parent.width
@@ -799,16 +797,17 @@ Item {
cursorShape: Qt.PointingHandCursor
onClicked: {
if (playersExpanded) {
if (allPlayers && allPlayers.length > 1) {
const players = (root.allPlayers || []).filter(p => p && !MprisController.isIdle(p));
if (players.length > 1) {
let currentIndex = -1;
for (let i = 0; i < allPlayers.length; i++) {
if (allPlayers[i] === activePlayer) {
for (let i = 0; i < players.length; i++) {
if (players[i] === root.activePlayer) {
currentIndex = i;
break;
}
}
const nextIndex = (currentIndex + 1) % allPlayers.length;
MprisController.setActivePlayer(allPlayers[nextIndex]);
const nextIndex = (currentIndex + 1) % players.length;
MprisController.setActivePlayer(players[nextIndex]);
}
return;
}
@@ -885,19 +884,15 @@ Item {
onClicked: {
toggleMute();
}
property real wheelAccum: 0
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;
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);
}
}
}
@@ -88,7 +88,7 @@ Card {
topPadding: Theme.spacingL
StyledText {
text: activePlayer?.trackTitle || I18n.tr("Unknown")
text: MprisController.stableTitle || I18n.tr("Unknown")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
@@ -99,7 +99,7 @@ Card {
}
StyledText {
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
text: MprisController.stableArtist || I18n.tr("Unknown Artist")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: parent.width
+22 -2
View File
@@ -936,7 +936,7 @@ Item {
id: dailyLoader
anchors.fill: parent
sourceComponent: dailyComponent
active: root.visible && root.available
active: root.visible && root.available && width > 0 && height > 0
visible: !root.showHourly
asynchronous: true
opacity: 0
@@ -953,7 +953,7 @@ Item {
id: hourlyLoader
anchors.fill: parent
sourceComponent: hourlyComponent
active: root.visible && root.available
active: root.visible && root.available && width > 0 && height > 0
visible: root.showHourly
asynchronous: true
opacity: 0
@@ -1038,6 +1038,7 @@ Item {
MouseArea {
anchors.fill: parent
property real hWheelAccum: 0
onWheel: wheel => {
if (wheel.modifiers & Qt.ShiftModifier) {
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
@@ -1049,6 +1050,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;
}
}
@@ -1096,6 +1106,7 @@ Item {
MouseArea {
anchors.fill: parent
property real hWheelAccum: 0
onWheel: wheel => {
if (wheel.modifiers & Qt.ShiftModifier) {
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
@@ -1107,6 +1118,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;
}
}
+3 -3
View File
@@ -73,14 +73,14 @@ Item {
readonly property bool greeterPamHasFprint: greeterPamStackHasFprint && (!fprintdProbeComplete || fprintdHasDevice)
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
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 string externalAuthStatusMessage: {
if (!externalAuthInProgress)
return "";
if (greeterPamStackHasFprint && greeterPamHasU2f)
if (greeterPamHasFprint && greeterPamHasU2f)
return I18n.tr("Awaiting fingerprint or security key authentication");
if (greeterPamStackHasFprint)
if (greeterPamHasFprint)
return I18n.tr("Awaiting fingerprint authentication");
return I18n.tr("Awaiting security key authentication");
}
+131 -18
View File
@@ -13,6 +13,12 @@ Scope {
property string sharedPasswordBuffer: ""
property bool shouldLock: false
onSharedPasswordBufferChanged: {
if (!powerOffFadeTimer.running)
return;
cancelPowerOffFade();
}
onShouldLockChanged: {
IdleService.isShellLocked = shouldLock;
if (shouldLock && lockPowerOffArmed) {
@@ -27,12 +33,8 @@ Scope {
onTriggered: {
if (sessionLock.locked && lockPowerOffArmed) {
pendingLock = false;
IdleService.monitorsOff = true;
CompositorService.powerOffMonitors();
lockWakeAllowed = false;
lockWakeDebounce.restart();
lockPowerOffArmed = false;
dpmsReapplyTimer.start();
beginPowerOff();
}
}
}
@@ -42,6 +44,38 @@ Scope {
property bool lockPowerOffArmed: false
property bool lockWakeAllowed: 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: {
IdleService.lockComponent = this;
@@ -65,6 +99,7 @@ Scope {
}
function spawnCustomLocker() {
IdleService.lockPowerOffRequested = false;
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]);
// The custom locker manages its own surface; DMS never engages
// WlSessionLock here, so isShellLocked stays false and the fade
@@ -90,7 +125,7 @@ Scope {
return;
lockInitiatedLocally = true;
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
lockPowerOffArmed = powerOffOnLock;
if (!SessionService.active && SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration) {
pendingLock = true;
@@ -102,6 +137,16 @@ Scope {
notifyLoginctl(true);
}
function lockAndOutputsOff() {
IdleService.lockPowerOffRequested = true;
if (sessionLock.locked) {
beginPowerOff();
return;
}
lockPowerOffArmed = true;
lock();
}
function unlock() {
if (!shouldLock)
return;
@@ -115,6 +160,8 @@ Scope {
pendingLock = false;
shouldLock = false;
customLockerSpawned = false;
resetPowerOffFade();
IdleService.lockPowerOffRequested = false;
}
function activate() {
@@ -135,7 +182,7 @@ Scope {
return;
}
lockInitiatedLocally = false;
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
lockPowerOffArmed = powerOffOnLock;
shouldLock = true;
}
@@ -155,7 +202,7 @@ Scope {
if (SessionService.active && pendingLock) {
pendingLock = false;
lockInitiatedLocally = true;
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
lockPowerOffArmed = powerOffOnLock;
shouldLock = true;
return;
}
@@ -163,7 +210,7 @@ Scope {
if (handleLoginctlCustomLock())
return;
lockInitiatedLocally = false;
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
lockPowerOffArmed = powerOffOnLock;
shouldLock = true;
}
}
@@ -175,6 +222,11 @@ Scope {
function onLockRequested() {
lock();
}
function onMonitorsOffChanged() {
if (!IdleService.monitorsOff)
root.resetPowerOffFade();
}
}
Pam {
@@ -214,6 +266,46 @@ Scope {
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();
}
}
}
}
}
@@ -224,22 +316,19 @@ Scope {
notifyLockedHint(sessionLock.locked);
if (sessionLock.locked) {
pendingLock = false;
if (lockPowerOffArmed && SettingsData.lockScreenPowerOffMonitorsOnLock) {
IdleService.monitorsOff = true;
CompositorService.powerOffMonitors();
lockWakeAllowed = false;
lockWakeDebounce.restart();
}
if (lockPowerOffArmed && powerOffOnLock)
beginPowerOff();
lockPowerOffArmed = false;
dpmsReapplyTimer.start();
return;
}
lockWakeAllowed = false;
if (IdleService.monitorsOff && SettingsData.lockScreenPowerOffMonitorsOnLock) {
resetPowerOffFade();
if (IdleService.monitorsOff && powerOffOnLock) {
IdleService.monitorsOff = false;
CompositorService.powerOnMonitors();
}
IdleService.lockPowerOffRequested = false;
}
}
@@ -254,6 +343,10 @@ Scope {
root.lock();
}
function lockAndOutputsOff() {
root.lockAndOutputsOff();
}
function unlock() {
root.unlock();
}
@@ -282,6 +375,26 @@ 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: dpmsReapplyTimer
interval: 100
@@ -296,7 +409,7 @@ Scope {
onTriggered: {
if (!sessionLock.locked)
return;
if (!SettingsData.lockScreenPowerOffMonitorsOnLock)
if (!powerOffOnLock)
return;
if (!IdleService.monitorsOff) {
lockWakeAllowed = true;
+47 -22
View File
@@ -71,7 +71,7 @@ Scope {
}
function proceedAfterPrimaryAuth(): void {
if (SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) {
if (!root.u2fSuppressedByPrimaryPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) {
u2f.startForSecondFactor();
} else {
completeUnlock();
@@ -91,8 +91,9 @@ Scope {
}
readonly property bool customPamActive: SettingsData.lockPamPath !== "" && customPamWatcher.loaded
readonly property bool fprintSuppressedByCustomPam: customPamActive && SettingsData.lockPamInlineFprint
readonly property bool u2fSuppressedByCustomPam: customPamActive && SettingsData.lockPamInlineU2f
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
@@ -109,16 +110,17 @@ Scope {
}
FileView {
id: nixosMarker
id: u2fConfigWatcher
path: "/etc/NIXOS"
path: "/etc/pam.d/dankshell-u2f"
watchChanges: true
printErrors: false
}
FileView {
id: u2fConfigWatcher
id: customU2fPamWatcher
path: "/etc/pam.d/dankshell-u2f"
path: SettingsData.lockU2fPamPath !== "" ? SettingsData.lockU2fPamPath : ""
printErrors: false
}
@@ -145,26 +147,23 @@ Scope {
}
function ensureUserPamConfig(): void {
if (root.runningFromNixStore || resolveUserPam.running)
if (SettingsData.lockPamExternallyManaged || resolveUserPam.running)
return;
resolveUserPam.running = true;
}
Component.onCompleted: ensureUserPamConfig()
// Detects Nix-installed DMS on non-NixOS systems
readonly property bool runningFromNixStore: Quickshell.shellDir.startsWith("/nix/store/")
PamContext {
id: passwd
config: {
if (root.customPamActive)
return SettingsData.lockPamPath.slice(SettingsData.lockPamPath.lastIndexOf("/") + 1);
if (SettingsData.lockPamExternallyManaged)
return "login";
if (dankshellConfigWatcher.loaded)
return "dankshell";
if (nixosMarker.loaded || root.runningFromNixStore)
return "login";
if (userPamWatcher.loaded)
return "dankshell";
return "login";
@@ -174,9 +173,9 @@ Scope {
const idx = SettingsData.lockPamPath.lastIndexOf("/");
return idx > 0 ? SettingsData.lockPamPath.slice(0, idx) : "/";
}
if (dankshellConfigWatcher.loaded)
if (SettingsData.lockPamExternallyManaged)
return "/etc/pam.d";
if (nixosMarker.loaded || root.runningFromNixStore)
if (dankshellConfigWatcher.loaded)
return "/etc/pam.d";
if (userPamWatcher.loaded)
return root.userPamDir;
@@ -265,7 +264,7 @@ Scope {
property int errorTries
function checkAvail(): void {
if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByCustomPam) {
if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam) {
abort();
return;
}
@@ -325,7 +324,7 @@ Scope {
property bool available: SettingsData.lockU2fReady
function checkAvail(): void {
if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByCustomPam) {
if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByPrimaryPam) {
abort();
return;
}
@@ -335,7 +334,7 @@ Scope {
}
function startForSecondFactor(): void {
if (!available || !SettingsData.enableU2f || root.u2fSuppressedByCustomPam) {
if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam) {
root.completeUnlock();
return;
}
@@ -348,7 +347,7 @@ Scope {
}
function startForAlternativeAuth(): void {
if (!available || !SettingsData.enableU2f || root.u2fSuppressedByCustomPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active)
if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active)
return;
abort();
root.u2fPending = true;
@@ -358,8 +357,18 @@ Scope {
start();
}
config: u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f"
configDirectory: u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam"
config: {
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: {
if (message.toLowerCase().includes("touch"))
@@ -478,8 +487,11 @@ Scope {
root.attemptInfoMessages = [];
root.lockoutAnnouncedThisAttempt = false;
root.resetAuthFlows();
if (!dankshellConfigWatcher.loaded && !nixosMarker.loaded && !userPamWatcher.loaded)
if (!SettingsData.lockPamExternallyManaged && !dankshellConfigWatcher.loaded && !userPamWatcher.loaded)
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();
u2f.checkAvail();
}
@@ -516,6 +528,19 @@ Scope {
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 {
if (root.lockSecured) {
u2f.abort();
@@ -49,7 +49,7 @@ Rectangle {
color: {
if (isSelected && keyboardNavigationActive)
return Theme.primaryPressed;
return Theme.floatingSurfaceHigh;
return Theme.notificationFloatingSurfaceHigh;
}
border.color: {
if (isSelected && keyboardNavigationActive)
@@ -153,7 +153,7 @@ Rectangle {
if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
return Theme.primaryHoverLight;
}
return Theme.floatingSurfaceHigh;
return Theme.notificationFloatingSurfaceHigh;
}
border.color: {
if (isGroupSelected && keyboardNavigationActive) {
@@ -583,7 +583,7 @@ Rectangle {
return expandedBaseHeight;
}
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.width: 1
@@ -676,7 +676,7 @@ PanelWindow {
sourceWidth: Math.max(0, content.width - (content.cardInset * 2))
sourceHeight: Math.max(0, content.height - (content.cardInset * 2))
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)
borderWidth: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? 2 : 0
}
+22 -22
View File
@@ -19,39 +19,39 @@ Item {
function greeterFingerprintDescription() {
if (SettingsData.greeterPamExternallyManaged)
return "greetd PAM is externally managed";
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");
switch (SettingsData.greeterFingerprintReason) {
case "ready":
return I18n.tr("Authentication changes apply automatically.", "greeter auth setting description");
return I18n.tr("Authentication changes apply automatically", "greeter auth setting description");
case "missing_enrollment":
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");
case "missing_reader":
return I18n.tr("No fingerprint reader detected.", "fingerprint setting status");
return I18n.tr("No fingerprint reader detected", "fingerprint setting status");
case "missing_pam_support":
return I18n.tr("Not available — install fprintd and pam_fprintd, or configure greetd PAM.", "greeter fingerprint login setting");
default:
return I18n.tr("Fingerprint availability could not be confirmed.", "fingerprint setting status");
return I18n.tr("Fingerprint availability could not be confirmed", "fingerprint setting status");
}
}
function greeterU2fDescription() {
if (SettingsData.greeterPamExternallyManaged)
return "greetd PAM is externally managed";
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");
switch (SettingsData.greeterU2fReason) {
case "ready":
return I18n.tr("Authentication changes apply automatically.", "greeter auth setting description");
return I18n.tr("Authentication changes apply automatically", "greeter auth setting description");
case "missing_key_registration":
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");
case "missing_pam_support":
return I18n.tr("Not available — install or configure pam_u2f, or configure greetd PAM.", "greeter security key login setting");
default:
return I18n.tr("Security-key availability could not be confirmed.", "security key setting status");
return I18n.tr("Security-key availability could not be confirmed", "security key setting status");
}
}
@@ -305,7 +305,7 @@ Item {
onExited: exitCode => {
root.greeterSyncRunning = false;
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;
SettingsData.clearGreeterSyncPending();
return;
@@ -396,11 +396,11 @@ Item {
SettingsCard {
width: parent.width
iconName: "info"
title: I18n.tr("Greeter Status")
title: I18n.tr("Status")
settingKey: "greeterStatus"
StyledText {
text: I18n.tr("Sync applies your theme and settings to the login screen. Other users should run dms greeter sync --profile instead of a full sync. Authentication changes apply automatically.")
text: I18n.tr("Sync applies your theme and settings to the login screen. Shared users should run dms greeter sync --profile instead of a primary user sync.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
@@ -470,11 +470,11 @@ Item {
SettingsCard {
width: parent.width
iconName: "fingerprint"
title: I18n.tr("Login Authentication")
title: I18n.tr("Authentication")
settingKey: "greeterAuth"
StyledText {
text: I18n.tr("Enable fingerprint or security key for DMS Greeter. Authentication changes apply automatically.")
text: I18n.tr("Enable fingerprint or security key for DMS Greeter")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
@@ -485,8 +485,8 @@ Item {
SettingsToggleRow {
settingKey: "greeterPamExternallyManaged"
tags: ["greeter", "pam", "managed", "external", "greetd", "auth"]
text: "greetd PAM is externally managed"
description: "DMS removes its managed block from /etc/pam.d/greetd and stops writing to it"
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 write services", "greeter system PAM toggle description")
checked: SettingsData.greeterPamExternallyManaged
onToggled: checked => SettingsData.set("greeterPamExternallyManaged", checked)
}
@@ -517,7 +517,7 @@ Item {
SettingsCard {
width: parent.width
iconName: "palette"
title: I18n.tr("Greeter Appearance")
title: I18n.tr("Appearance")
settingKey: "greeterAppearance"
StyledText {
@@ -553,12 +553,12 @@ Item {
settingKey: "greeterLockDateFormat"
tags: ["greeter", "date", "format"]
text: I18n.tr("Date Format")
description: I18n.tr("Greeter only — format for the date on the login screen")
description: I18n.tr("Format the date on the login screen")
options: root._lockDateFormatPresets.map(p => p.label)
currentValue: {
var current = (SettingsData.greeterLockDateFormat !== undefined && SettingsData.greeterLockDateFormat !== "") ? SettingsData.greeterLockDateFormat : SettingsData.lockDateFormat || "";
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 => {
var preset = root._lockDateFormatPresets.find(p => p.label === value);
@@ -577,7 +577,7 @@ Item {
}
StyledText {
text: I18n.tr("Use a custom image for the login screen, or leave empty to use your desktop wallpaper.")
text: I18n.tr("Use a custom image for the login screen, or leave empty to use desktop wallpaper")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
@@ -601,11 +601,11 @@ Item {
SettingsCard {
width: parent.width
iconName: "history"
title: I18n.tr("Greeter Behavior")
title: I18n.tr("Behavior")
settingKey: "greeterBehavior"
StyledText {
text: I18n.tr("Convenience options for the login screen. Sync to apply.")
text: I18n.tr("Convenience options for the login screen")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
@@ -649,7 +649,7 @@ Item {
settingKey: "greeterDeps"
StyledText {
text: I18n.tr("Requires greetd, dms-greeter, and your user in the greeter group (plus fprintd/pam_fprintd for fingerprint, pam_u2f for security keys). Auth changes apply automatically and may open a terminal for sudo.")
text: I18n.tr("Requires greetd, dms-greeter, and your user in the greeter group (plus fprintd/pam_fprintd for fingerprint, pam_u2f for security keys).")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
@@ -658,7 +658,7 @@ Item {
}
StyledText {
text: I18n.tr("Installation and PAM setup: see the ") + "<a href=\"https://danklinux.com/docs/dankgreeter/installation\" style=\"text-decoration:none; color:" + Theme.primary + ";\">DankGreeter docs</a> " + I18n.tr("or run ") + "'dms greeter install'."
text: I18n.tr("Installation and PAM setup are documented in the ") + "<a href=\"https://danklinux.com/docs/dankgreeter/installation\" style=\"text-decoration:none; color:" + Theme.primary + ";\">DankGreeter docs.</a> "
textFormat: Text.RichText
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
@@ -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 {
width: parent.width
iconName: "open_in_new"
+197 -71
View File
@@ -11,6 +11,10 @@ Item {
readonly property bool lockFprintToggleAvailable: SettingsData.lockFingerprintCanEnable || SettingsData.enableFprint
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
@@ -19,6 +23,12 @@ Item {
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")
@@ -37,6 +47,8 @@ Item {
return svc ? authServiceLabel(svc) : authCustomLabel;
}
readonly property string u2fAuthCurrentValue: SettingsData.lockU2fPamPath === "" ? authAutoLabel : authCustomLabel
function refreshAuthServices() {
authListServicesProcess.running = true;
}
@@ -62,31 +74,50 @@ Item {
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() {
switch (SettingsData.lockFingerprintReason) {
case "ready":
return I18n.tr("Use fingerprint authentication for the lock screen.", "lock screen fingerprint setting");
return I18n.tr("Use fingerprint authentication for the lock screen", "lock screen fingerprint setting");
case "missing_enrollment":
return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and enroll later.", "lock screen fingerprint setting");
case "missing_reader":
return I18n.tr("No fingerprint reader detected.", "fingerprint setting status");
return I18n.tr("No fingerprint reader detected", "fingerprint setting status");
case "missing_pam_support":
return I18n.tr("Not available install fprintd and pam_fprintd.", "lock screen fingerprint setting");
return I18n.tr("Not available - install fprintd and pam_fprintd", "lock screen fingerprint setting");
default:
return I18n.tr("Fingerprint availability could not be confirmed.", "fingerprint setting status");
return I18n.tr("Fingerprint availability could not be confirmed", "fingerprint setting status");
}
}
function lockU2fDescription() {
switch (SettingsData.lockU2fReason) {
case "ready":
return 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":
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");
case "missing_pam_support":
return I18n.tr("Not available install or configure pam_u2f.", "lock screen security key setting");
return I18n.tr("Not available - install or configure pam_u2f", "lock screen security key setting");
default:
return I18n.tr("Security-key availability could not be confirmed.", "security key setting status");
return I18n.tr("Security-key availability could not be confirmed", "security key setting status");
}
}
@@ -161,12 +192,12 @@ Item {
} catch (e) {}
if (!data) {
root.authValidateMessage = "validation failed — is dms in PATH?";
root.authValidateMessage = I18n.tr("Config validation failed");
return;
}
if (!data.valid) {
const errs = Array.isArray(data.errors) ? data.errors : [];
root.authValidateMessage = ["not applied:", ...errs].join("\n");
root.authValidateMessage = [I18n.tr("Config validation failed"), ...errs].join("\n");
return;
}
@@ -176,7 +207,46 @@ Item {
const warns = Array.isArray(data.warnings) ? data.warnings : [];
root.authValidateOk = true;
root.authValidateWarn = warns.length > 0;
root.authValidateMessage = warns.length > 0 ? ["applied with warnings:", ...warns].join("\n") : "applied";
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();
}
}
@@ -196,7 +266,7 @@ Item {
SettingsCard {
width: parent.width
iconName: "lock"
title: I18n.tr("Lock Screen layout")
title: I18n.tr("Layout")
settingKey: "lockLayout"
SettingsToggleRow {
@@ -275,7 +345,7 @@ Item {
SettingsCard {
width: parent.width
iconName: "palette"
title: I18n.tr("Lock Screen Appearance")
title: I18n.tr("Appearance")
settingKey: "lockAppearance"
StyledText {
@@ -326,14 +396,22 @@ Item {
SettingsCard {
width: parent.width
iconName: "key"
title: "Authentication Source"
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: "Authentication Source"
description: SettingsData.lockPamPath !== "" ? SettingsData.lockPamPath : "Which PAM service the lock screen uses to authenticate"
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 => {
@@ -394,21 +472,116 @@ Item {
}
}
StyledText {
visible: (SettingsData.lockPamInlineFprint && SettingsData.enableFprint) || (SettingsData.lockPamInlineU2f && SettingsData.enableU2f)
text: "The pinned PAM stack already prompts for fingerprint or security key, so DMS skips its own prompts"
font.pixelSize: Theme.fontSizeSmall
color: Theme.warning
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
wrapMode: Text.Wrap
topPadding: Theme.spacingS
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 {
width: parent.width
iconName: "lock"
title: I18n.tr("Lock Screen behaviour")
title: I18n.tr("Behavior")
settingKey: "lockBehavior"
StyledText {
@@ -461,53 +634,6 @@ Item {
checked: SettingsData.lockAtStartup
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 {
@@ -594,7 +720,7 @@ Item {
SettingsCard {
width: parent.width
iconName: "monitor"
title: I18n.tr("Lock Screen Display")
title: I18n.tr("Display Assignment")
settingKey: "lockDisplay"
StyledText {
@@ -300,6 +300,15 @@ Item {
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 {
settingKey: "notificationShowTimeoutBar"
tags: ["notification", "timeout", "progress", "bar", "timer", "countdown"]
+36 -50
View File
@@ -1734,16 +1734,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 {
tab: "theme"
tags: ["surface", "popup", "transparency", "opacity", "modal"]
@@ -1759,12 +1749,47 @@ Item {
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 {
tab: "theme"
tags: ["surface", "popup", "modal", "border", "outline", "edge"]
settingKey: "blurBorderColor"
text: I18n.tr("Surface Border Color")
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")]
optionColorMap: ({
[I18n.tr("Outline", "surface border color")]: Theme.outline,
@@ -1809,6 +1834,7 @@ Item {
settingKey: "blurBorderOpacity"
text: I18n.tr("Surface Border Opacity")
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)
minimum: 0
maximum: 100
@@ -1817,20 +1843,6 @@ Item {
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 {
tab: "theme"
tags: ["corner", "radius", "rounded", "square"]
@@ -2104,32 +2116,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 {
tab: "theme"
tags: ["applications", "portal", "dark", "terminal"]
+4 -2
View File
@@ -17,6 +17,8 @@ Singleton {
// These settings predate non-blurred surface borders, so keep their keys for compatibility.
readonly property color borderColor: {
if (!(SettingsData.blurBorderEnabled ?? true))
return "transparent";
const opacity = SettingsData.blurBorderOpacity ?? 0.35;
switch (SettingsData.blurBorderColor ?? "outline") {
case "primary":
@@ -26,12 +28,12 @@ Singleton {
case "surfaceText":
return Theme.withAlpha(Theme.surfaceText, opacity);
case "custom":
return Theme.withAlpha(SettingsData.blurBorderCustomColor ?? "#ffffff", opacity);
return Theme.withAlpha(Qt.color(SettingsData.blurBorderCustomColor ?? "#ffffff"), opacity);
default:
return Theme.withAlpha(Theme.outline, opacity);
}
}
readonly property int borderWidth: 1
readonly property int borderWidth: (SettingsData.blurBorderEnabled ?? true) ? 1 : 0
function hoverColor(baseColor, hoverAlpha) {
if (!enabled)
+1 -1
View File
@@ -13,7 +13,7 @@ Singleton {
readonly property var log: Log.scoped("ChangelogService")
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 changelogMarkerPath: configDir + "/.changelog-" + currentVersion
+2 -1
View File
@@ -67,6 +67,7 @@ Singleton {
property var lockComponent: null
property bool monitorsOff: false
property bool isShellLocked: false
property bool lockPowerOffRequested: false
function reapplyDpmsIfNeeded() {
if (monitorsOff)
@@ -146,7 +147,7 @@ Singleton {
id: lockWakeMonitor
timeout: 1
respectInhibitors: false
enabled: root.enabled && root.isShellLocked && root.monitorsOff && SettingsData.lockScreenPowerOffMonitorsOnLock
enabled: root.enabled && root.isShellLocked && root.monitorsOff && (SettingsData.lockScreenPowerOffMonitorsOnLock || root.lockPowerOffRequested)
onIsIdleChanged: {
if (!isIdle && root.monitorsOff)
root.requestMonitorOn();
+82 -10
View File
@@ -19,7 +19,8 @@ Singleton {
const desktopEntry = ("desktopEntry" in p && p.desktopEntry) ? String(p.desktopEntry).toLowerCase() : "";
return !excluded.some(ex => {
const exLower = String(ex).toLowerCase().trim();
if (!exLower) return false;
if (!exLower)
return false;
// 1. Substring match
if (identity.includes(exLower) || desktopEntry.includes(exLower))
@@ -43,43 +44,114 @@ Singleton {
}
property MprisPlayer activePlayer: null
property real activePlayerStableLength: 0
// Chromium can report blank metadata between tracks
property string stableTitle: ""
property string stableArtist: ""
Connections {
target: root.activePlayer
function onTrackTitleChanged() {
root.activePlayerStableLength = (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) ? root.activePlayer.length : 0;
root._syncStableMeta();
root._checkIdle();
}
function onTrackArtistChanged() {
root._syncStableMeta();
root._checkIdle();
}
function onLengthChanged() {
if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) {
root.activePlayerStableLength = root.activePlayer.length;
}
}
function onPlaybackStateChanged() {
root._syncStableMeta();
root._checkIdle();
}
}
onActivePlayerChanged: {
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()
Component.onCompleted: _resolveActivePlayer()
Instantiator {
model: root.availablePlayers
delegate: Connections {
required property MprisPlayer modelData
target: modelData
function onIsPlayingChanged() {
if (modelData.isPlaying)
root._resolveActivePlayer();
}
}
}
function isIdle(player: MprisPlayer): bool {
return player
&& player.playbackState === MprisPlaybackState.Stopped
&& !player.trackTitle
&& !player.trackArtist;
return player && player.playbackState === MprisPlaybackState.Stopped && !player.trackTitle && !player.trackArtist;
}
function _resolveActivePlayer(): void {
// Keep the selected player stable across transient metadata changes.
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0)
return;
// A playing player always wins; otherwise keep the selection stable w/idle
const playing = availablePlayers.find(p => p.isPlaying);
if (playing) {
activePlayer = playing;
_persistIdentity(playing.identity);
if (activePlayer !== playing) {
activePlayer = playing;
_persistIdentity(playing.identity);
}
return;
}
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0 && (!isIdle(activePlayer) || _idleGraceTimer.running))
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;
if (savedId) {
const match = availablePlayers.find(p => p.identity === savedId);
+31 -38
View File
@@ -3,7 +3,6 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
@@ -34,6 +33,18 @@ Singleton {
readonly property int updateCount: availableUpdates.length
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 {
target: DMSService
@@ -108,9 +119,11 @@ Singleton {
if (!data) {
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 || [];
availableUpdates = _filterUpdates(_rawUpdates);
backends = data.backends || [];
distribution = data.distro || "";
distributionPretty = data.distroPretty || "";
distributionSupported = (backends.length > 0);
@@ -145,13 +158,6 @@ Singleton {
errorCode = "";
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) {
@@ -159,6 +165,8 @@ Singleton {
return (pkgs || []).filter(p => {
if (!SettingsData.updaterAllowAUR && p.repo === "aur")
return false;
if (!canIgnorePackage(p))
return true;
return ignored.indexOf(p.name) === -1;
});
}
@@ -177,17 +185,27 @@ Singleton {
SettingsData.set("updaterIgnoredPackages", list);
}
function unignorePackage(name) {
if (!name)
return;
const list = (SettingsData.updaterIgnoredPackages || []).filter(p => p !== name);
SettingsData.set("updaterIgnoredPackages", list);
}
function checkForUpdates() {
DMSService.sysupdateRefresh(false, null);
}
function runUpdates(opts) {
const params = opts || {};
if (SettingsData.updaterUseCustomCommand && SettingsData.updaterCustomCommand.length > 0) {
_runCustomTerminalCommand();
return;
}
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);
}
@@ -199,30 +217,6 @@ Singleton {
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
function _maybeStartupCheck() {
@@ -262,5 +256,4 @@ Singleton {
}
DMSService.sysupdateRelease(null);
}
}
+8 -9
View File
@@ -29,19 +29,17 @@ Singleton {
function getArtworkUrl(player) {
if (!player) return "";
// 1. If native trackArtUrl is present and valid
let artUrl = player.trackArtUrl || "";
if (artUrl !== "") {
return artUrl;
}
// 2. Fallback to raw metadata mpris:artUrl if present
if (player.metadata && player.metadata["mpris:artUrl"]) {
artUrl = player.metadata["mpris:artUrl"].toString();
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"]) {
const url = player.metadata["xesam:url"].toString();
if (url.includes("youtube.com") || url.includes("youtu.be")) {
@@ -71,9 +69,11 @@ Singleton {
return;
}
_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;
_lastArtUrl = url;
_lastIssuedSerial = requestSerial;
if (url.startsWith("http://") || url.startsWith("https://")) {
loading = true;
@@ -83,7 +83,6 @@ Singleton {
const filePath = cacheDir + "/remote_" + hash;
const localFileUrl = "file://" + filePath;
// 1. First, check if the file already exists locally
Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => {
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
return;
@@ -94,7 +93,7 @@ Singleton {
} else {
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/")) {
const videoId = targetUrl.split("/vi/")[1].split("/")[0];
const maxresUrl = "https://img.youtube.com/vi/" + videoId + "/maxresdefault.jpg";
@@ -119,7 +118,6 @@ Singleton {
}
}, 50, 15000);
} else {
// Standard curl download for other remote URLs (e.g. SoundCloud)
const tmpPath = filePath + ".tmp";
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => {
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
@@ -168,6 +166,7 @@ Singleton {
property string _pendingArtKey: ""
property string _committedSrcUrl: ""
property int _requestSerial: 0
property int _lastIssuedSerial: -1
onActivePlayerChanged: _updateArtUrl()
@@ -183,8 +182,8 @@ Singleton {
const p = activePlayer;
if (!p)
return "";
// Scope artwork ownership to the MPRIS object and track.
const playerId = p.uniqueId || p.identity || "";
// 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() : "";
return playerId + " " + tid + " " + (p.trackTitle || "") + " " + (p.trackArtist || "");
}
+17 -1
View File
@@ -116,12 +116,28 @@ Item {
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 {
id: barLoader
required property var modelData
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
onItemChanged: dankBarRepeater.recountHorizontalReady()
sourceComponent: DankBar {
barConfig: barLoader.barConfig
+1 -1
View File
@@ -1 +1 @@
v1.6-beta
v1.5.1
+10 -2
View File
@@ -174,8 +174,15 @@ Item {
_pendingOpen = false;
_pendingOpenTimer.stop();
transientSurfaceTracker?.closeAll?.();
if (impl.item)
if (impl.item) {
impl.item.close();
return;
}
PopoutManager.hidePopout(root);
if (!shouldBeVisible)
return;
shouldBeVisible = false;
popoutClosed();
}
function cancelHoverDismiss() {
@@ -315,7 +322,8 @@ Item {
it.hoverDismissEnabled = Qt.binding(() => root.hoverDismissEnabled);
it.hoverDismissSuspended = Qt.binding(() => root.effectiveHoverDismissSuspended);
it.shouldBeVisible = root.shouldBeVisible;
if (root.shouldBeVisible && !_pendingOpen)
root.shouldBeVisible = false;
if (root._primeContent && typeof it.primeContent === "function")
it.primeContent();
if (_pendingOpen)
@@ -923,6 +923,13 @@ Item {
visible: false
color: "transparent"
onVisibleChanged: {
if (visible || !Qt.inputMethod)
return;
Qt.inputMethod.hide();
Qt.inputMethod.reset();
}
PopoutHoverDismiss {
id: hoverDismissController
anchors.fill: parent
@@ -630,6 +630,13 @@ Item {
color: "transparent"
readonly property bool closeVisualActive: root.shouldBeVisible || root.isClosing
onVisibleChanged: {
if (visible || !Qt.inputMethod)
return;
Qt.inputMethod.hide();
Qt.inputMethod.reset();
}
PopoutHoverDismiss {
id: hoverDismissController
anchors.fill: parent
+1 -1
View File
@@ -174,7 +174,7 @@ PanelWindow {
id: contentRect
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
layer.smooth: false
layer.textureSize: Qt.size(width * root.dpr, height * root.dpr)
layer.textureSize: Qt.size(0, 0)
opacity: 1
readonly property color slideoutSurfaceColor: root.customTransparency >= 0 ? Theme.withAlpha(Theme.surfaceContainer, root.customTransparency) : Theme.popupLayerColor(Theme.surfaceContainer)
+4
View File
@@ -70,6 +70,10 @@ Item {
iconImage.source = "file:///run/current-system/profile/share/icons/hicolor/scalable/apps/guix-icon.svg"
return
}
if (logo === "zirconium") {
iconImage.source = "file:///usr/share/zirconium/pixmaps/logo-z.svg"
return
}
iconImage.source = Quickshell.iconPath(logo, true)
}, 0)
}, 0)
+335 -230
View File
@@ -2374,6 +2374,29 @@
],
"icon": "extension"
},
{
"section": "modalDarkenBackground",
"label": "Darken Modal Background",
"tabIndex": 9,
"category": "Launcher",
"keywords": [
"background",
"behind",
"dark mode",
"darken",
"darkened",
"dialogs",
"drawer",
"launcher",
"menu",
"modal",
"night",
"overlay",
"show",
"start"
],
"description": "Show darkened overlay behind modal dialogs"
},
{
"section": "launcherStyle",
"label": "Default Launcher",
@@ -2614,6 +2637,30 @@
],
"icon": "apps"
},
{
"section": "modalBackground",
"label": "Modal Background",
"tabIndex": 9,
"category": "Launcher",
"keywords": [
"background",
"behind",
"dark mode",
"darken",
"darkened",
"dialogs",
"drawer",
"launcher",
"menu",
"modal",
"night",
"overlay",
"show",
"start"
],
"icon": "layers",
"description": "Show darkened overlay behind modal dialogs"
},
{
"section": "spotlightCloseNiriOverview",
"label": "Niri Integration",
@@ -3383,31 +3430,6 @@
],
"description": "Base to derive dark theme from"
},
{
"section": "modalDarkenBackground",
"label": "Darken Modal Background",
"tabIndex": 10,
"category": "Theme & Colors",
"keywords": [
"appearance",
"background",
"behind",
"colors",
"dark mode",
"darken",
"darkened",
"dialogs",
"look",
"modal",
"night",
"overlay",
"scheme",
"show",
"style",
"theme"
],
"description": "Show darkened overlay behind modal dialogs"
},
{
"section": "matugenTemplateEmacs",
"label": "Emacs",
@@ -3827,32 +3849,6 @@
"icon": "auto_awesome",
"conditionKey": "matugenAvailable"
},
{
"section": "modalBackground",
"label": "Modal Background",
"tabIndex": 10,
"category": "Theme & Colors",
"keywords": [
"appearance",
"background",
"behind",
"colors",
"dark mode",
"darken",
"darkened",
"dialogs",
"look",
"modal",
"night",
"overlay",
"scheme",
"show",
"style",
"theme"
],
"icon": "layers",
"description": "Show darkened overlay behind modal dialogs"
},
{
"section": "modalElevationEnabled",
"label": "Modal Shadows",
@@ -4142,6 +4138,30 @@
],
"description": "Controls the outline of popouts, modals, and other shell surfaces"
},
{
"section": "blurBorderEnabled",
"label": "Surface Border Outline",
"tabIndex": 10,
"category": "Theme & Colors",
"keywords": [
"appearance",
"around",
"border",
"colors",
"edge",
"look",
"modal",
"outline",
"popup",
"scheme",
"shell",
"style",
"surface",
"surfaces",
"theme"
],
"description": "Outline around shell surfaces"
},
{
"section": "popupTransparency",
"label": "Surface Opacity",
@@ -4635,22 +4655,49 @@
]
},
{
"section": "lockAuthSource",
"label": "Authentication Source",
"section": "lockAppearance",
"label": "Appearance",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"appearance",
"clock",
"date",
"font",
"lock",
"lockscreen",
"login",
"password",
"screen",
"security",
"time",
"typography",
"watch"
],
"icon": "palette",
"description": "Font used for the clock and date on the lock screen"
},
{
"section": "lockAuthSource",
"label": "Authentication",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"authenticate",
"authentication",
"lock",
"lockscreen",
"login",
"pam",
"password",
"screen",
"security",
"service",
"source"
"source",
"uses"
],
"icon": "key"
"icon": "key",
"description": "Which PAM service the lock screen uses to authenticate"
},
{
"section": "lockPamPath",
@@ -4658,16 +4705,20 @@
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"authenticate",
"authentication",
"lock",
"lockscreen",
"login",
"pam",
"password",
"screen",
"security",
"service",
"source"
]
"source",
"uses"
],
"description": "Which PAM service the lock screen uses to authenticate"
},
{
"section": "lockScreenVideoCycling",
@@ -4694,6 +4745,48 @@
],
"description": "Pick a different random video each time from the same folder"
},
{
"section": "lockBehavior",
"label": "Behavior",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"behavior",
"bind",
"dbus",
"disable",
"external",
"integration",
"lock",
"lockscreen",
"login",
"loginctl",
"password",
"screen",
"security",
"signals"
],
"icon": "lock",
"description": "Bind lock screen to dbus signals from loginctl. Disable if using an external lock screen"
},
{
"section": "lockDisplay",
"label": "Display Assignment",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"assignment",
"display",
"lock",
"login",
"monitor",
"output",
"password",
"screen",
"security"
],
"icon": "monitor"
},
{
"section": "lockScreenVideoEnabled",
"label": "Enable Video Screensaver",
@@ -4728,10 +4821,14 @@
"fprint",
"lock",
"login",
"managed",
"password",
"primary",
"screen",
"security"
]
"security",
"source"
],
"description": "Managed by the primary PAM source"
},
{
"section": "loginctlLockIntegration",
@@ -4769,12 +4866,41 @@
"key",
"lock",
"login",
"managed",
"password",
"primary",
"screen",
"security",
"source",
"u2f",
"yubikey"
]
],
"description": "Managed by the primary PAM source"
},
{
"section": "lockLayout",
"label": "Layout",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"actions",
"appear",
"field",
"hidden",
"layout",
"lock",
"login",
"password",
"power",
"pressed",
"reboot",
"screen",
"security",
"shutdown",
"soon"
],
"icon": "lock",
"description": "If the field is hidden, it will appear as soon as a key is pressed."
},
{
"section": "_tab_11",
@@ -4791,97 +4917,6 @@
],
"icon": "lock"
},
{
"section": "lockAppearance",
"label": "Lock Screen Appearance",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"appearance",
"clock",
"date",
"font",
"lock",
"lockscreen",
"login",
"password",
"screen",
"security",
"time",
"typography",
"watch"
],
"icon": "palette",
"description": "Font used for the clock and date on the lock screen"
},
{
"section": "lockDisplay",
"label": "Lock Screen Display",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"display",
"lock",
"lockscreen",
"login",
"monitor",
"output",
"password",
"screen",
"security"
],
"icon": "monitor"
},
{
"section": "lockBehavior",
"label": "Lock Screen behaviour",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"behaviour",
"bind",
"dbus",
"disable",
"external",
"integration",
"lock",
"lockscreen",
"login",
"loginctl",
"password",
"screen",
"security",
"signals"
],
"icon": "lock",
"description": "Bind lock screen to dbus signals from loginctl. Disable if using an external lock screen"
},
{
"section": "lockLayout",
"label": "Lock Screen layout",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"actions",
"appear",
"field",
"hidden",
"layout",
"lock",
"lockscreen",
"login",
"password",
"power",
"pressed",
"reboot",
"screen",
"security",
"shutdown",
"soon"
],
"icon": "lock",
"description": "If the field is hidden, it will appear as soon as a key is pressed."
},
{
"section": "lockAtStartup",
"label": "Lock at startup",
@@ -5005,6 +5040,29 @@
],
"description": "Turn off all displays immediately when the lock screen activates"
},
{
"section": "lockU2fPamPath",
"label": "Security Key PAM Source",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"auto",
"bundled",
"installed",
"key",
"lock",
"login",
"pam",
"password",
"screen",
"security",
"service",
"source",
"u2f",
"uses"
],
"description": "Auto uses an installed or bundled key-only service."
},
{
"section": "u2fMode",
"label": "Security key mode",
@@ -5159,6 +5217,25 @@
"time"
]
},
{
"section": "lockPamExternallyManaged",
"label": "Use system PAM authentication",
"tabIndex": 11,
"category": "Lock Screen",
"keywords": [
"authentication",
"external",
"lock",
"login",
"managed",
"pam",
"password",
"policy",
"screen",
"security",
"system"
]
},
{
"section": "videoScreensaver",
"label": "Video Screensaver",
@@ -6048,6 +6125,31 @@
],
"description": "Show notification popups only on the currently focused monitor"
},
{
"section": "notificationForegroundLayers",
"label": "Foreground Layers",
"tabIndex": 17,
"category": "Notifications",
"keywords": [
"alert",
"alerts",
"blur",
"cards",
"contrast",
"foreground",
"glass",
"layers",
"messages",
"notif",
"notification",
"notifications",
"show",
"surface",
"surfaces",
"toast"
],
"description": "Show foreground surfaces on notification cards"
},
{
"section": "notificationHistoryMaxAgeDays",
"label": "History Retention",
@@ -7804,6 +7906,48 @@
],
"description": "Change the locale used for date and time formatting, independent of the interface language."
},
{
"section": "greeterAppearance",
"label": "Appearance",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"appearance",
"display manager",
"font",
"greetd",
"greeter",
"login",
"screen",
"typography"
],
"icon": "palette",
"description": "Font used on the login screen"
},
{
"section": "greeterAuth",
"label": "Authentication",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"auth",
"authentication",
"block",
"display manager",
"external",
"greetd",
"greeter",
"login",
"managed",
"pam",
"removes",
"services",
"stops",
"write"
],
"icon": "fingerprint",
"description": "DMS removes its managed block from /etc/pam.d/greetd and stops write services"
},
{
"section": "greeterAutoLogin",
"label": "Auto-login on startup",
@@ -7837,6 +7981,25 @@
],
"description": "Skip the greeter password after boot until you sign out. Lock screen unlock is unchanged. Takes effect on the next reboot after sync."
},
{
"section": "greeterBehavior",
"label": "Behavior",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"behavior",
"display manager",
"greetd",
"greeter",
"last",
"login",
"remember",
"select",
"session"
],
"icon": "history",
"description": "Pre-select the last used session on the greeter"
},
{
"section": "greeterLockDateFormat",
"label": "Date Format",
@@ -7851,7 +8014,7 @@
"login",
"screen"
],
"description": "Greeter only — format for the date on the login screen"
"description": "Format the date on the login screen"
},
{
"section": "greeterDeps",
@@ -7914,57 +8077,6 @@
],
"icon": "login"
},
{
"section": "greeterAppearance",
"label": "Greeter Appearance",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"appearance",
"display manager",
"font",
"greetd",
"greeter",
"login",
"screen",
"typography"
],
"icon": "palette",
"description": "Font used on the login screen"
},
{
"section": "greeterBehavior",
"label": "Greeter Behavior",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"behavior",
"display manager",
"greetd",
"greeter",
"last",
"login",
"remember",
"select",
"session"
],
"icon": "history",
"description": "Pre-select the last used session on the greeter"
},
{
"section": "greeterStatus",
"label": "Greeter Status",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"display manager",
"greetd",
"greeter",
"login",
"status"
],
"icon": "info"
},
{
"section": "greeterFontFamily",
"label": "Greeter font",
@@ -7981,29 +8093,6 @@
],
"description": "Font used on the login screen"
},
{
"section": "greeterAuth",
"label": "Login Authentication",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"auth",
"authentication",
"block",
"display manager",
"external",
"greetd",
"greeter",
"login",
"managed",
"pam",
"removes",
"stops",
"writing"
],
"icon": "fingerprint",
"description": "DMS removes its managed block from /etc/pam.d/greetd and stops writing to it"
},
{
"section": "greeterRememberLastSession",
"label": "Remember last session",
@@ -8040,27 +8129,43 @@
],
"description": "Pre-fill the last successful username on the greeter"
},
{
"section": "greeterStatus",
"label": "Status",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"display manager",
"greetd",
"greeter",
"login",
"status"
],
"icon": "info"
},
{
"section": "greeterPamExternallyManaged",
"label": "greetd PAM is externally managed",
"label": "Use system PAM authentication",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"auth",
"authentication",
"block",
"display manager",
"external",
"externally",
"greetd",
"greeter",
"login",
"managed",
"pam",
"removes",
"services",
"stops",
"writing"
"system",
"write"
],
"description": "DMS removes its managed block from /etc/pam.d/greetd and stops writing to it"
"description": "DMS removes its managed block from /etc/pam.d/greetd and stops write services"
},
{
"section": "muxType",
+1 -1
View File
@@ -48,7 +48,7 @@ for n in $refs; do
gh issue comment "$n" --repo "$REPO" --body "$(cat <<EOF
:package: A fix referencing this issue shipped in [**${TAG}**](${RELEASE_URL}).
Please retest on the new release when you get a chanceif it's resolved, this issue can be closed. Thanks!
A New Release has been deployed! Please retest when you get a chance. If it's resolved, this issue can be closed. ~ Cheers, the DMS Team!
EOF
)"
echo "notified #${n}${title}"