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

Compare commits

...

163 Commits

Author SHA1 Message Date
bbedward 4fb6a17e1b launcher: fix regex escaping launch args
fixes #2961
port 1.5

(cherry picked from commit a710d6d7cc)
2026-07-30 18:35:04 +00:00
bbedward e771e3b675 greeter: lua for hypr fallback 2026-07-30 14:31:39 -04:00
bbedward b1b7aa7aa2 Revert "dock: use required properties in dock item delegate, fixes context injection on Qt 6.8 (#2926)"
This reverts commit b8e2ce1da8.
2026-07-30 14:30:51 -04:00
bbedward c128793239 qs/socket: improve resilience of socket detection and connection
related #2369
port 1.5

(cherry picked from commit 64461c534f)
2026-07-27 17:41:17 +00:00
dms-ci[bot] 069ddab041 bump VERSION to v1.5.3 2026-07-27 00:17:35 +00:00
bbedward 45cf6ecefd sysupdate: fix gnome-terminal title
(cherry picked from commit c367153bac)
2026-07-26 19:51:46 -04:00
bbedward 7058b00091 cups: fix sub/unsub race
port 1.5

(cherry picked from commit bab2078dfd)
2026-07-26 23:43:53 +00:00
bbedward 6ba4b79039 port: record hand-ported master equivalents
(cherry picked from commit e9bc0169f6)
(cherry picked from commit 9b7d3c64fe)
(cherry picked from commit ea03fb2788)
(cherry picked from commit 0815e48465)
(cherry picked from commit 3938e60ce4)
(cherry picked from commit 3c688cfbd3)
(cherry picked from commit e63d210358)
(cherry picked from commit fa094db127)
2026-07-26 19:32:23 -04:00
bbedward b8e2ce1da8 dock: use required properties in dock item delegate, fixes context injection on Qt 6.8 (#2926)
(cherry picked from commit fa629fbf99)
2026-07-26 19:15:44 -04:00
bbedward c58d55db7d bluetooth: add soft rfkill unblock, wait for adapter to become available
instead of forfeiting subscription

fixes #2922
fixes #1537

(cherry picked from commit 77a357109a)
2026-07-26 19:15:02 -04:00
bbedward e15db00714 cc: migrate some settings to cache
port 1.5

(cherry picked from commit e9bc0169f6)
2026-07-26 21:46:22 +00:00
bbedward 906eba5c17 battery: auto-apply power profile at startup when DMS managed
fixes #2935
port 1.5

(cherry picked from commit 59c83d19e4)
2026-07-26 19:27:56 +00:00
bbedward d2b61b91ef dbar: fix click-through mask of center section
fixes #2938

(cherry picked from commit 9b7d3c64fe)
2026-07-26 15:22:03 -04:00
bbedward 6c27b92445 dbar/notifications: fix right click context menu position and add middle
click to DnD

(cherry picked from commit 1f94c3cbd4)
2026-07-26 14:52:39 -04:00
bbedward a64bd80cc9 window-rules/niri: make floating a tri-state
(cherry picked from commit 814871d982)
2026-07-26 14:34:50 -04:00
feng-yifan 661dc27d26 fix(settings): reset AppBrowserPopup model on close to avoid crash (#2925)
Reopening the autostart "Browse" picker crashed in
QQmlIncubatorPrivate::incubate. Drop the ListView model on hide()
and rebind it on show() so each open starts with a fresh
QQmlDelegateModel instead of reusing a stale incubation queue.

(cherry picked from commit 3182c70857)
2026-07-26 14:34:30 -04:00
bbedward 3d5a6fb9d7 display config: preserve config when turning off on niri
fixes #2939
port 1.5

(cherry picked from commit 24cb3d19a0)
2026-07-26 18:26:18 +00:00
bbedward c2a5397d8a notification: clip region on animation exit
related #2917
port 1.5

(cherry picked from commit f57f254855)
2026-07-24 18:36:07 +00:00
bbedward 9b5538323d lock: guard dpms window 2026-07-24 14:13:43 -04:00
bbedward 8321d28d18 greeter: don't override manually selected session with saved/defaul 2026-07-24 11:47:59 -04:00
bbedward 39278860c8 cups: fix initialization race
port 1.5

(cherry picked from commit ea03fb2788)
2026-07-24 10:58:41 -04:00
bbedward f455456ff1 dock: limit input mask to body, disable hover headroom unless enabled
fixes #2923

(cherry picked from commit 0815e48465)
2026-07-24 10:35:40 -04:00
bbedward 5a80a32951 network: fix networkPreference reverting from eth to wifi
fixes #2919
port 1.5

(cherry picked from commit 42060366cb)
2026-07-24 14:14:08 +00:00
Aldi Prasetyo 3cedd56f23 fix(minimal-install): add hyprland packages to minimal install preference (#2872)
(I guess we'll find out if this breaks any minimal script to desktop runs)

(cherry picked from commit ff71502083)
2026-07-24 09:31:46 -04:00
Legend-017 dc1ad5b329 Fix(spotlight): improve file search query restoration and mode switching (#2887)
* fix(spotlight): fix empty results on restoring last file search query

- When "remember last query" is enabled and the last query is file search, it shows "no results" until the query is manually modified in full launcher mode.

* fix(spotlight): properly restore last selected mode(tab) when enabled in full launcher mode

* fix(spotlight): allow file trigger in plugins mode(tab)

* fix(spotlight): restore previous mode when clearing '/' file search prefix

When the '/' prefix is removed from the query, the controller now correctly reverts to the previous mode instead of remaining stuck in "files" mode.

* fix(spotlight): code review fix

(cherry picked from commit 367ad5f69a)
2026-07-24 09:31:32 -04:00
Thomas Kroll 25c12a14a9 fix(lock): hold sleep inhibitor until compositor confirms lock (#2907)
lock-before-suspend released the logind sleep delay inhibitor as soon as Qt
reported the lock rendered (afterAnimating/afterRendering). That signal fires
before the compositor has committed/presented the ext-session-lock surface, so
the machine could freeze ~1 frame later with the desktop still the last
presented frame. On resume the desktop was briefly visible before the lock
appeared (both suspend and hibernate).

Gate lockerReady on WlSessionLock.secure (the ext-session-lock `locked` event),
so the inhibitor is held until the compositor confirms the session is locked and
the desktop is hidden. LockSurface passes the WlSessionLock down as sessionLock;
the readiness check returns early until secure, and re-fires on secureChanged.
The Go-side fallback timer in loginctl monitor remains as the backstop.

Reproduced on niri / s2idle with 3 outputs: debug log showed lockerReady sent
18 ms before `PM: suspend entry`; with this change the desktop no longer flashes
on resume.

Signed-off-by: Thomas Kroll <99196436+tkroll-ionos@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 237fb57d5e)
2026-07-24 09:31:23 -04:00
bbedward b72b276fb4 lock/pam: attempt to fix fprintd retry spam after resume
(cherry picked from commit 958672badd)
2026-07-24 09:30:57 -04:00
bbedward f89fb67316 settings: fix close IPC
fixes #2912
port 1.5

(cherry picked from commit 5854327c28)
2026-07-23 18:10:19 +00:00
bbedward f457e04cb1 calendar/dcal: fix flatpak socket detection
port 1.5

(cherry picked from commit d818067bee)
2026-07-23 14:14:49 +00:00
bbedward 23daca0b57 location: prevent calls when weather auto-locate is disabled
port 1.5

(cherry picked from commit fe561f2b5d)
2026-07-22 03:36:01 +00:00
bbedward accdd0972c popout: fix lifecycle of blur with various popouts
fixes #2901
port 1.5

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

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

fixes #2890
port 1.5

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

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

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

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

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

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

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

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

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

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

port 1.5

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

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

port 1.5

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

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

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

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

Related: #2874
Port 1.5

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

Related: #2874
Port 1.5

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

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

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

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

Closes #2446
port 1.5

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

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

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

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

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

related #2873

port 1.5

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

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

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

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

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

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

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

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

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

Related: #2874
Port 1.5

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

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

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

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

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

related #2869

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

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

Port 1.5

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

port 1.5

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

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

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

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

port 1.5

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

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

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

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

Fixes #2853
Port 1.5

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

port 1.5

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

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

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

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

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

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

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

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

port 1.5

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

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

port 1.5

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

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

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

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

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

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

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

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

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

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

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

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

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

* apply code review comments

---------

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

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

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

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

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

port 1.5

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

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

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

port 1.5

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

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

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

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

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

Port 1.5

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

Port 1.5

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

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

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

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

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

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

Port 1.5

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

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

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

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

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

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

Port 1.5

---------

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

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

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

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

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

Closes #2831

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

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

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

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

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

Port 1.5

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

Related #2788

Port 1.5

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

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

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

Closes #2814

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

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

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

port 1.5

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

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

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

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

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

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

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

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

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

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

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

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

(cherry picked from commit c0eeed4e89)
2026-07-09 16:54:26 +00:00
purian23 067d01f678 workflow: update porting logic
(cherry picked from commit cb0dc9c68d)
2026-07-09 12:53:05 -04:00
purian23 95c8cc80f6 Add GitHub workflows for release management
(cherry picked from commit 8008238ca0)
2026-07-09 12:53:05 -04:00
purian23 b8f216dc0f fix(workflow): void stable packages
(cherry picked from commit a48cce59d4)
2026-07-09 12:53:05 -04:00
bbedward e84e63fa3a default apps: add configuration for geo: URIs
port: 1.5
(cherry picked from commit d0a4c1c56e)
2026-07-09 16:24:19 +00:00
bbedward 6d08b91004 fix(notifications): handle sound-name hint
(cherry picked from commit 0439d017b9)
2026-07-09 12:07:35 -04:00
315 changed files with 29022 additions and 5708 deletions
+23
View File
@@ -0,0 +1,23 @@
# Categories for GitHub's auto-generated release notes
changelog:
exclude:
authors:
- github-actions[bot]
- dependabot[bot]
labels:
- internal
categories:
- title: Breaking Changes
labels: [breaking]
- title: Features
labels: [feature]
- title: Fixes
labels: [fix]
- title: Packaging
labels: [packaging]
- title: Internationalization
labels: [i18n]
- title: Documentation
labels: [docs]
- title: Other Changes
labels: ["*"]
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "usage: $0 download|publish" >&2
exit 2
}
require_env() {
local name
for name in "$@"; do
if [[ -z "${!name:-}" ]]; then
echo "error: $name is required" >&2
exit 2
fi
done
}
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
aws_r2() {
aws --endpoint-url "$R2_ENDPOINT" "$@"
}
download() {
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
aws_r2 s3 sync \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
"$REPOSITORY_DIR/current/" \
--only-show-errors
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
}
build_manifest() {
local packages='[]'
local file filename pkgver name version revision sha size
for file in "$REPOSITORY_DIR"/current/*.xbps; do
filename="$(basename "$file")"
pkgver="$(xbps-uhelper binpkgver "$filename")"
name="$(xbps-uhelper getpkgname "$pkgver")"
version="$(xbps-uhelper getpkgversion "$pkgver")"
revision="$(xbps-uhelper getpkgrevision "$pkgver")"
version="${version%_"${revision}"}"
sha="$(sha256sum "$file" | cut -d' ' -f1)"
size="$(stat -c '%s' "$file")"
packages="$(jq \
--arg name "$name" \
--arg version "$version" \
--arg revision "$revision" \
--arg filename "$filename" \
--arg sha256 "$sha" \
--argjson size "$size" \
'. + [{name: $name, version: $version, revision: $revision, filename: $filename, sha256: $sha256, size: $size}]' \
<<<"$packages")"
done
jq -n \
--arg repository "$R2_PREFIX" \
--arg source_commit "$SOURCE_COMMIT" \
--arg published_at "$PUBLISHED_AT" \
--argjson packages "$packages" \
'{schema: 1, repository: $repository, source_commit: $source_commit, published_at: $published_at, packages: $packages}' \
> "$REPOSITORY_DIR/current/manifest.json"
}
verify_immutable_packages() {
local file previous
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
previous="$REPOSITORY_DIR/previous-current/$(basename "$file")"
if [[ -f "$previous" ]] && ! cmp -s "$previous" "$file"; then
echo "error: refusing to replace immutable object $(basename "$file")" >&2
echo "bump the XBPS revision or version before publishing a changed build" >&2
exit 1
fi
done
}
archive_retired() {
local old filename
local archive_prefix="archive/${R2_PREFIX}/${PUBLISHED_AT//:/-}"
shopt -s nullglob
for old in "$REPOSITORY_DIR"/previous-current/*.xbps "$REPOSITORY_DIR"/previous-current/*.sig2; do
filename="$(basename "$old")"
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
aws_r2 s3 cp \
"$old" \
"s3://${R2_BUCKET}/${archive_prefix}/${filename}" \
--cache-control 'private,no-store' \
--only-show-errors
fi
done
}
upload_current() {
local file filename old
# Versioned package objects must exist before repodata can reference them.
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
filename="$(basename "$file")"
aws_r2 s3 cp \
"$file" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
--cache-control 'public,max-age=31536000,immutable' \
--only-show-errors
done
aws_r2 s3 cp \
"$REPOSITORY_DIR/current/x86_64-repodata" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/x86_64-repodata" \
--cache-control 'no-cache' \
--only-show-errors
# The manifest is the publication marker and is always uploaded last.
aws_r2 s3 cp \
"$REPOSITORY_DIR/current/manifest.json" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/manifest.json" \
--cache-control 'no-cache' \
--only-show-errors
# Once the new index and marker are live, remove objects no longer referenced.
shopt -s nullglob
for old in "$REPOSITORY_DIR"/previous-current/*; do
filename="$(basename "$old")"
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
aws_r2 s3 rm \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
--only-show-errors
fi
done
}
publish() {
require_env SOURCE_COMMIT
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
export PUBLISHED_AT
shopt -s nullglob
local packages=("$REPOSITORY_DIR"/current/*.xbps)
if (( ${#packages[@]} == 0 )); then
echo "error: refusing to publish an empty XBPS repository" >&2
exit 1
fi
[[ -s "$REPOSITORY_DIR/current/x86_64-repodata" ]] || {
echo "error: x86_64-repodata is missing or empty" >&2
exit 1
}
for file in "${packages[@]}"; do
[[ -s "${file}.sig2" ]] || {
echo "error: signature is missing for $(basename "$file")" >&2
exit 1
}
done
verify_immutable_packages
build_manifest
archive_retired
upload_current
}
case "${1:-}" in
download) download ;;
publish) publish ;;
*) usage ;;
esac
+55 -2
View File
@@ -7,14 +7,17 @@ on:
permissions:
contents: write
issues: write
jobs:
update-stable:
# skip prerelease tags
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -28,4 +31,54 @@ jobs:
- name: Push to stable branch
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force
run: |
set -euo pipefail
# don't roll stable backwards
if git fetch origin stable --quiet 2>/dev/null; then
stable_tag=$(git describe --tags --abbrev=0 FETCH_HEAD 2>/dev/null || echo "v0.0.0")
newest=$(printf '%s\n%s\n' "$stable_tag" "${GITHUB_REF_NAME}" | sort -V | tail -1)
if [ "$newest" != "${GITHUB_REF_NAME}" ]; then
echo "skipping: ${GITHUB_REF_NAME} is older than stable (${stable_tag})"
exit 0
fi
fi
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:refs/heads/stable --force
cut-release-branch:
# create stable-X.Y at each vX.Y.0 tag
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Create release branch
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
if [[ ! "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then
echo "not a vX.Y.0 tag, no release branch to cut"
exit 0
fi
branch="stable-${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
if git ls-remote --exit-code origin "refs/heads/${branch}" >/dev/null 2>&1; then
echo "${branch} already exists"
exit 0
fi
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:refs/heads/${branch}"
echo "created ${branch} at ${GITHUB_REF_NAME}"
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" gh label create "port ${branch}" \
--repo "${{ github.repository }}" \
--description "Auto-port merged PR to ${branch}" --color 0e8a16 ||
echo "label 'port ${branch}' may already exist"
+1 -1
View File
@@ -8,7 +8,7 @@ on:
- "core/**"
- ".github/workflows/go-ci.yml"
pull_request:
branches: [master, main]
branches: [master, main, "stable-*"]
paths:
- "core/**"
- ".github/workflows/go-ci.yml"
+1 -1
View File
@@ -2,7 +2,7 @@ name: Nix flake and NixOS tests
on:
pull_request:
branches: [master, main]
branches: [master, main, "stable-*"]
paths:
- "flake.*"
- "distro/nix/**"
+107
View File
@@ -0,0 +1,107 @@
name: Point release
# Cuts vX.Y.Z from stable-X.Y: runs the port audit (warn-only), bumps
# quickshell/VERSION, tags, and dispatches the Release workflow. Distro
# builds are dispatched separately.
on:
workflow_dispatch:
inputs:
version:
description: "Point release version (e.g. 1.5.1)"
required: true
type: string
notify_issues:
description: "Comment on open related/fixes issues asking reporters to retest"
required: false
type: boolean
default: true
permissions:
contents: write
actions: write
concurrency:
group: point-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
env:
VERSION: ${{ inputs.version }}
steps:
- name: Validate version and derive branch
id: derive
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version must be X.Y.Z (got '$VERSION')"; exit 1
fi
echo "branch=stable-${VERSION%.*}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout release branch
uses: actions/checkout@v6
with:
ref: ${{ steps.derive.outputs.branch }}
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 }}
run: |
bash scripts/port-audit.sh "${{ steps.derive.outputs.branch }}" ||
echo "::warning::port audit failed; continuing"
- name: Bump VERSION, tag, and push
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.derive.outputs.tag }}
BRANCH: ${{ steps.derive.outputs.branch }}
run: |
set -euo pipefail
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::tag ${TAG} already exists"; exit 1
fi
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
echo "${TAG}" > quickshell/VERSION
git add quickshell/VERSION
git commit -m "bump VERSION to ${TAG}"
git tag "${TAG}"
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH}" "refs/tags/${TAG}"
- name: Dispatch Release workflow
env:
GH_TOKEN: ${{ github.token }}
run: |
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
-f tag="${{ steps.derive.outputs.tag }}" \
-f notify_issues="${{ inputs.notify_issues }}"
- name: Next steps
run: |
{
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
echo ""
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published."
} >> "$GITHUB_STEP_SUMMARY"
+30
View File
@@ -0,0 +1,30 @@
name: Port audit
# On-demand report of master commits not yet ported to a release branch.
# Updates the "Port status: <branch>" tracking issue and the step summary.
on:
workflow_dispatch:
inputs:
target:
description: "Release branch to audit (default: newest release/*)"
required: false
type: string
permissions:
contents: read
issues: write
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run audit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/port-audit.sh ${{ inputs.target }} --issue
+101
View File
@@ -0,0 +1,101 @@
name: Port to release branch
# Ports flagged commits from master onto stable-X.Y branches:
# - "port 1.5" flag in a commit message pushed to master — own line or
# mid-line; separators : / - or space; "stable-1.5" also accepted;
# comma lists ("Port: 1.5, 1.4") work in the own-line form
# - "port stable-1.5" label on a merged PR
# Conflicts are reported to the "Port status: <branch>" tracking issue.
on:
push:
branches: [master]
pull_request_target:
types: [closed, labeled]
permissions:
contents: write
issues: write
concurrency:
group: port-engine
cancel-in-progress: false
jobs:
port:
name: Port flagged commits
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Base-repo code only; PR head code is never checked out or executed.
- name: Checkout
uses: actions/checkout@v6
with:
ref: master
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Port trailer-flagged commits
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
COMMITS: ${{ toJSON(github.event.commits) }}
run: |
set -euo pipefail
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
git cat-file -e "$sha" 2>/dev/null || continue
# skip merge commits (handled by the label path)
[ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -eq 0 ] || continue
# own-line form (supports comma lists), validated version-shaped
t1=$(git log -1 --format=%B "$sha" |
{ grep -iE '^Port[:/ -]' || true; } | sed -E 's|^port[-:/ ]+||I' | tr ',' '\n' |
sed 's/[[:space:]]//g; /^$/d' | sed -E 's,^stable-,,I' |
{ grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)?$' || true; })
# mid-line form, e.g. "fix thing fixes #2802 port 1.5"
t2=$(git log -1 --format=%B "$sha" |
{ grep -oiE '\bport[-: /]+(stable-)?[0-9]+\.[0-9]+(\.[0-9]+)?\b' || true; } |
sed -E 's|^port[-:/ ]+||I' | sed -E 's,^stable-,,I')
targets=$(printf '%s\n%s\n' "$t1" "$t2" | sed '/^$/d' | sort -u)
for ver in $targets; do
echo "::group::port $sha -> stable-$ver"
bash scripts/port.sh "stable-$ver" "$sha"
echo "::endgroup::"
done
done
- name: Port label-flagged PR
if: >
github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true &&
(github.event.action == 'closed' ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'port ')))
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
sed -E 's,^stable-,,' | sort -u)
[ -n "$targets" ] || { echo "no port labels, nothing to do"; exit 0; }
[ -n "$MERGE_SHA" ] || { echo "::error::PR has no merge commit sha"; exit 1; }
for ver in $targets; do
echo "::group::port PR #${PORT_SOURCE_PR} ($MERGE_SHA) -> stable-$ver"
bash scripts/port.sh "stable-$ver" "$MERGE_SHA"
echo "::endgroup::"
done
+1 -1
View File
@@ -3,7 +3,7 @@ name: Pre-commit Checks
on:
push:
pull_request:
branches: [master, main]
branches: [master, main, "stable-*"]
jobs:
pre-commit-check:
runs-on: ubuntu-latest
+36 -4
View File
@@ -7,10 +7,16 @@ on:
description: "Tag to release (e.g., v1.0.1)"
required: true
type: string
notify_issues:
description: "Comment on open related/fixes issues asking reporters to retest"
required: false
type: boolean
default: false
permissions:
contents: write
actions: write
issues: write
concurrency:
group: release-${{ inputs.tag }}
@@ -205,13 +211,23 @@ jobs:
- name: Generate Changelog
id: changelog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
else
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
CHANGELOG=""
if [ -n "$PREVIOUS_TAG" ]; then
# PR-based notes with author credits; falls back to raw git log below
CHANGELOG=$(python3 scripts/release-notes.py "${PREVIOUS_TAG}..${TAG}" --format github --bare 2>/dev/null || true)
fi
if [ -z "$CHANGELOG" ]; then
echo "release-notes.py unavailable or empty, using git log fallback"
if [ -z "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
else
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
fi
fi
cat > RELEASE_BODY.md << 'EOF'
@@ -417,3 +433,19 @@ jobs:
prerelease: ${{ contains(env.TAG, '-') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Ask reporters on still-open "related/fixes #N" issues to retest.
# Opt-in via notify_issues (Point release defaults it on). Skipped for
# prereleases. Preview: scripts/notify-issues.sh <prev> <tag> --dry-run
- name: Notify open issues to retest
if: ${{ inputs.notify_issues && !contains(env.TAG, '-') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)
if [ -z "$PREVIOUS_TAG" ]; then
echo "no previous tag; skipping issue notify"
exit 0
fi
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
+111 -39
View File
@@ -3,10 +3,15 @@ name: Void Linux XBPS Repository
on:
schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
release:
types: [published]
# release trigger disabled; dispatch manually after a release
# release:
# types: [published]
workflow_dispatch:
inputs:
version:
description: 'Stable release tag/version to build (e.g. v1.5.0 or 1.5.0). Leave blank to use the selected ref/template version'
type: string
required: false
force_rebuild:
description: 'Force rebuilding packages even if they already exist in the repository'
type: boolean
@@ -29,30 +34,63 @@ on:
default: true
permissions:
contents: write
contents: read
concurrency:
group: void-xbps-${{ github.ref }}
cancel-in-progress: false
jobs:
preflight:
name: Check for unpublished changes
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
steps:
- name: Check published commit
id: check
env:
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json
run: |
SHOULD_BUILD=true
if [ "${{ github.event_name }}" = "schedule" ]; then
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)"
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
SHOULD_BUILD=false
echo "${{ github.sha }} is already published; skipping the scheduled build."
fi
fi
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
build-and-deploy:
name: Build & Deploy XBPS packages
runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
needs: preflight
if: github.repository == 'AvengeMedia/DankMaterialShell' && needs.preflight.outputs.should_build == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: true
R2_BUCKET: danklinux-void
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
R2_PREFIX: dms
REPOSITORY_DIR: ${{ github.workspace }}/r2-repo
SOURCE_COMMIT: ${{ github.sha }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
fetch-depth: 0
- name: Checkout gh-pages branch
- name: Record checked-out source commit
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Download current R2 repository
run: |
git clone --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages-repo || {
echo "⚠️ gh-pages branch not found or empty, initializing a new one..."
mkdir gh-pages-repo
cd gh-pages-repo
git init
git checkout -b gh-pages
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
cd ..
}
aws --version
bash .github/scripts/publish-void-r2.sh download
- name: Install XBPS Static Utilities
run: |
@@ -126,19 +164,19 @@ jobs:
- name: Configure repositories
run: |
# Append the repository to repos-remote templates so xbps-src translates it automatically
echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
# Add any existing compiled packages to the build cache directory to avoid rebuilds
if [ -d "gh-pages-repo/current" ]; then
if [ -d "r2-repo/current" ]; then
mkdir -p void-packages/hostdir/binpkgs
cp -L gh-pages-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
cp -L r2-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
fi
- name: Build packages
run: |
mkdir -p gh-pages-repo/current
mkdir -p r2-repo/current
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
@@ -157,6 +195,25 @@ jobs:
BUILD_GIT="false"
fi
RELEASE_TAG=""
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_TAG="${{ github.event.release.tag_name }}"
elif [ -n "${{ github.event.inputs.version || '' }}" ]; then
RELEASE_TAG="${{ github.event.inputs.version }}"
elif [ "${{ github.ref_type }}" = "tag" ]; then
RELEASE_TAG="${{ github.ref_name }}"
fi
if [ -n "$RELEASE_TAG" ]; then
RELEASE_VER="${RELEASE_TAG#v}"
ARCHIVE_TAG="v${RELEASE_VER}"
echo "Stable release tag: $ARCHIVE_TAG"
else
RELEASE_VER=""
ARCHIVE_TAG=""
echo "Stable release tag: (template version)"
fi
echo "=== Starting Builds ==="
echo "DMS stable build enabled: $BUILD_DMS"
echo "Greeter stable build enabled: $BUILD_GREETER"
@@ -165,6 +222,19 @@ jobs:
cd void-packages
if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then
echo "🔧 Updating stable templates for $ARCHIVE_TAG"
TARBALL="$(mktemp)"
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/archive/refs/tags/${ARCHIVE_TAG}.tar.gz"
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
rm -f "$TARBALL"
for pkg in dms dms-greeter; do
sed -i "s/^version=.*/version=${RELEASE_VER}/" "srcpkgs/${pkg}/template"
sed -i "s/^checksum=.*/checksum=${RELEASE_CHECKSUM}/" "srcpkgs/${pkg}/template"
done
fi
# 1. Build dms-git (development package)
if [ "$BUILD_GIT" = "true" ]; then
# Calculate dynamic git version (tag.commits.hash)
@@ -178,7 +248,7 @@ jobs:
mkdir -p "$SRC_CACHE"
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
--exclude=void-packages \
--exclude=gh-pages-repo \
--exclude=r2-repo \
--exclude=.git \
--exclude=danklinux \
-C .. .
@@ -192,13 +262,13 @@ jobs:
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
if [ -f "../gh-pages-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
if [ -f "../r2-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
else
echo "🔨 Compiling dms-git..."
./xbps-src pkg dms-git
rm -f "../gh-pages-repo/current/${EXPECTED_GIT_FILE}"
cp -L hostdir/binpkgs/dms-git-*.xbps ../gh-pages-repo/current/
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}"
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/
fi
fi
@@ -208,13 +278,13 @@ jobs:
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
if [ -f "../gh-pages-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
if [ -f "../r2-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
else
echo "🔨 Compiling dms ($STABLE_VER)..."
./xbps-src pkg dms
rm -f "../gh-pages-repo/current/${EXPECTED_DMS_FILE}"
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../gh-pages-repo/current/
rm -f "../r2-repo/current/${EXPECTED_DMS_FILE}"
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/
fi
fi
@@ -224,22 +294,21 @@ jobs:
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
if [ -f "../gh-pages-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
if [ -f "../r2-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build."
else
echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
./xbps-src pkg dms-greeter
rm -f "../gh-pages-repo/current/${EXPECTED_GREETER_FILE}"
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../gh-pages-repo/current/
rm -f "../r2-repo/current/${EXPECTED_GREETER_FILE}"
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../r2-repo/current/
fi
fi
- name: Index and sign repository
run: |
cd gh-pages-repo/current
cd r2-repo/current
# Clean up any stale or dangling signature files to prevent O_CREAT ENOENT errors
rm -f *.sig2 *.sig
rm -f *.sig
# Guard: nothing to index if no .xbps files exist
if ! ls *.xbps 1>/dev/null 2>&1; then
@@ -249,6 +318,14 @@ jobs:
# Regenerate repo index
xbps-rindex -a $(pwd)/*.xbps
xbps-rindex --remove-obsoletes $(pwd)
# Remove signatures left behind by obsolete packages. Signatures for
# unchanged immutable packages are retained byte-for-byte.
for sig in *.sig2; do
[ -e "$sig" ] || break
[ -f "${sig%.sig2}" ] || rm -f "$sig"
done
# Sign repository
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
@@ -259,10 +336,5 @@ jobs:
rm -f /tmp/xbps_privkey.pem
- name: Deploy to gh-pages branch
run: |
cd gh-pages-repo
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add current/
git diff --quiet && git diff --staged --quiet || (git commit -m "Update XBPS packages [skip ci]" && git push origin gh-pages)
- name: Publish repository to R2
run: bash .github/scripts/publish-void-r2.sh publish
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
+3
View File
@@ -126,3 +126,6 @@ __pycache__
distro/void/temp/
distro/void/hostdir/
distro/void/masterdir*/
# Often gets built
core/dms
+3 -3
View File
@@ -6,8 +6,8 @@ BINARY_NAME=dms
CORE_DIR=core
BUILD_DIR=$(CORE_DIR)/bin
PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin
DATA_DIR=$(PREFIX)/share
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin
DATA_DIR=$(DESTDIR)$(PREFIX)/share
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
@@ -62,7 +62,7 @@ install-systemd:
@echo "Installing systemd user service..."
@mkdir -p $(SYSTEMD_USER_DIR)
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
@sed 's|/usr/bin/dms|$(PREFIX)/bin/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
+1 -1
View File
@@ -206,7 +206,7 @@ Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
Requires PPA support. Most packages built from source (slow first install).
**Debian**
Debian 13+ (Trixie). niri only, no Hyprland support. Builds from source.
Debian 13+ (Trixie), testing, and sid. Stable quickshell from Debian (trixie-backports on 13). Other companions via OBS.
**openSUSE**
Most packages available in standard repos. Minimal building required.
+136
View File
@@ -1,7 +1,10 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -35,9 +38,142 @@ var authSyncCmd = &cobra.Command{
},
}
var authResolveLockCmd = &cobra.Command{
Use: "resolve-lock",
Short: "Generate the lock-screen PAM config from the system auth stack",
Long: "Resolve the distribution's PAM auth stack into a self-contained lock-screen config under the user state directory.\n" +
"Runs unprivileged (reads /etc/pam.d, writes to the user's state dir) and is used by the shell as a fallback when /etc/pam.d/dankshell is not managed.\n" +
"Prints the path of the generated file.",
Run: func(cmd *cobra.Command, args []string) {
quiet, _ := cmd.Flags().GetBool("quiet")
logFunc := func(msg string) {
if !quiet {
fmt.Println(msg)
}
}
path, err := sharedpam.WriteUserLockscreenPamConfig(logFunc)
if err != nil {
log.Fatalf("Error resolving lock-screen PAM config: %v", err)
}
fmt.Println(path)
},
}
var authListServicesCmd = &cobra.Command{
Use: "list-services",
Short: "List candidate lock-screen PAM services available on this system",
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
Run: func(cmd *cobra.Command, args []string) {
asJSON, _ := cmd.Flags().GetBool("json")
services := sharedpam.ListLockscreenPamServices()
if asJSON {
payload := struct {
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
}{Services: services}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
log.Fatalf("Error encoding services: %v", err)
}
fmt.Println(string(data))
return
}
if len(services) == 0 {
fmt.Println("No candidate lock-screen PAM services found.")
return
}
for _, s := range services {
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
}
},
}
var authValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate a PAM service file for use by the DMS lock screen",
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
Run: func(cmd *cobra.Command, args []string) {
path, _ := cmd.Flags().GetString("path")
service, _ := cmd.Flags().GetString("service")
purpose, _ := cmd.Flags().GetString("purpose")
asJSON, _ := cmd.Flags().GetBool("json")
if (path == "") == (service == "") {
log.Fatalf("Error: exactly one of --path or --service is required")
}
if purpose != "password" && purpose != "u2f" {
log.Fatalf("Error: --purpose must be password or u2f")
}
var result sharedpam.LockscreenPamValidation
switch {
case service != "":
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamService(service)
} else {
result = sharedpam.ValidateLockscreenPamService(service)
}
case !filepath.IsAbs(path):
result = sharedpam.LockscreenPamValidation{
Path: path,
MissingModules: []string{},
Warnings: []string{},
Errors: []string{"--path must be an absolute file path"},
}
default:
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamPath(path)
} else {
result = sharedpam.ValidateLockscreenPamPath(path)
}
}
if asJSON {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatalf("Error encoding validation: %v", err)
}
fmt.Println(string(data))
} else {
printLockscreenPamValidation(result)
}
if !result.Valid {
os.Exit(1)
}
},
}
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
fmt.Printf("Path: %s\n", result.Path)
fmt.Printf("Valid: %t\n", result.Valid)
fmt.Printf("Has auth: %t\n", result.HasAuth)
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
if len(result.MissingModules) > 0 {
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
}
for _, w := range result.Warnings {
fmt.Println("⚠ " + w)
}
for _, e := range result.Errors {
fmt.Println("✗ " + e)
}
}
func init() {
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
authListServicesCmd.Flags().Bool("json", false, "Output as JSON")
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
}
func syncAuth(nonInteractive bool) error {
+1
View File
@@ -759,6 +759,7 @@ func getCommonCommands() []*cobra.Command {
greeterCmd,
setupCmd,
colorCmd,
qrCmd,
screenshotCmd,
notifyActionCmd,
notifyCmd,
+24
View File
@@ -320,6 +320,7 @@ func checkEnvironmentVars() []checkResult {
var results []checkResult
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkEnvVar("QS_ICON_THEME")...)
results = append(results, checkXDGMenuPrefix()...)
return results
}
@@ -334,6 +335,29 @@ func checkEnvVar(name string) []checkResult {
return nil
}
func checkXDGMenuPrefix() []checkResult {
menuPrefix := os.Getenv("XDG_MENU_PREFIX")
if menuPrefix != "" {
if checkXDGMenuFile(menuPrefix) {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}}
}
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/%sapplications.menu exists.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
}
if _, err := exec.LookPath("keditfiletype"); err == nil {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}}
}
if doctorVerbose {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, "Not set", "", doctorDocsURL + "#xdg-menu-prefix"}}
}
return nil
}
func checkXDGMenuFile(prefix string) bool {
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
_, err := os.Stat(menuPath)
return err == nil
}
func readOSRelease() map[string]string {
result := make(map[string]string)
data, err := os.ReadFile("/etc/os-release")
+23 -6
View File
@@ -1081,8 +1081,11 @@ func ensureGreetdEnabled() error {
if !runitServiceInstalled("greetd") {
return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir)
}
// Seat + runtime-dir setup that logind handles automatically on systemd.
ensureRunitSeat("_greeter")
if greeter.IsVoidLinux() {
ensureVoidLogindGreeter("_greeter")
} else {
ensureRunitSeat("_greeter")
}
ensureGreetdPamRundir()
if err := enableRunitService("greetd"); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err)
@@ -1261,6 +1264,9 @@ func enableGreeter(nonInteractive bool) error {
if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil {
fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir)
}
if err := greeter.EnsureVoidLogindGreetdCommand(logFunc, ""); err != nil {
return err
}
if err := ensureGraphicalTarget(); err != nil {
return err
@@ -1475,12 +1481,23 @@ func extractGreeterWrapperFromCommand(command string) string {
if len(tokens) == 0 {
return ""
}
wrapper := strings.Trim(tokens[0], "\"")
wrapperIndex := 0
if filepath.Base(strings.Trim(tokens[0], "\"")) == "env" {
wrapperIndex = 1
for wrapperIndex < len(tokens) && strings.Contains(tokens[wrapperIndex], "=") {
wrapperIndex++
}
}
if wrapperIndex >= len(tokens) {
return ""
}
wrapper := strings.Trim(tokens[wrapperIndex], "\"")
if wrapper == "" {
return ""
}
if len(tokens) > 1 {
next := strings.Trim(tokens[1], "\"")
if wrapperIndex+1 < len(tokens) {
next := strings.Trim(tokens[wrapperIndex+1], "\"")
if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") {
return fmt.Sprintf("%s (script: %s)", wrapper, next)
}
@@ -1535,7 +1552,7 @@ func packageInstallHint() string {
case distros.FamilyArch:
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
case distros.FamilyVoid:
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://avengemedia.github.io/DankMaterialShell/current' | sudo tee /etc/xbps.d/dms.conf)"
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf)"
default:
return "Run 'dms greeter install' to install greeter"
}
+7
View File
@@ -101,6 +101,13 @@ func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
}
}
func TestExtractGreeterWrapperFromEnvCommand(t *testing.T) {
command := "env LIBSEAT_BACKEND=logind DMS_VOID=1 /usr/bin/dms-greeter --command niri"
if got := extractGreeterWrapperFromCommand(command); got != "/usr/bin/dms-greeter" {
t.Fatalf("extractGreeterWrapperFromCommand() = %q, want %q", got, "/usr/bin/dms-greeter")
}
}
func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) {
origGreeterIsNixOSFn := greeterIsNixOSFn
greeterIsNixOSFn = func() bool { return true }
+265
View File
@@ -0,0 +1,265 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)
var (
qrEcc string
qrVersion int
qrOutput string
qrStdout bool
qrClipboard bool
qrCopyText bool
qrRender bool
qrNoRender bool
qrInvert bool
qrQuietZone int
qrModuleSize int
qrFg string
qrBg string
qrTransparent bool
qrBorder int
qrShape string
qrLogo string
qrLogoScale int
qrWifiPassword string
qrWifiSecurity string
qrWifiHidden bool
)
var qrCmd = &cobra.Command{
Use: "qr [text]",
Short: "Generate QR codes",
Long: `Generate a QR code from text, stdin, or a WiFi network.
By default the code is rendered to the terminal when stdout is a TTY, or
written as PNG bytes to stdout when piped. Use flags to also copy to the
clipboard, save a PNG, or tune encoding and colors.
Input:
dms qr "https://example.com" # encode an argument
echo -n "data" | dms qr # encode stdin
dms qr - # encode stdin explicitly
Output (combine freely):
dms qr "text" --clipboard # copy PNG image to clipboard
dms qr "text" --copy-text # copy the source text to clipboard
dms qr "text" -o code.png # save a PNG file
dms qr "text" > code.png # PNG to stdout (piped)
dms qr "text" --render # force terminal render
Encoding & style:
--ecc L|M|Q|H # error correction (default M)
--qr-version 10 # force symbol version (1-40)
--module-size 12 --fg '#000' ... # PNG sizing and colors
--shape circle # round modules
--logo icon.png # center logo (bumps --ecc to H)
--invert # flip colors for light terminals
WiFi:
dms qr wifi MySSID -p secret # build from an explicit password
dms qr wifi MySSID # pull the saved secret from the shell`,
Args: cobra.ArbitraryArgs,
Run: runQR,
}
var qrWifiCmd = &cobra.Command{
Use: "wifi <ssid>",
Short: "Generate a WiFi QR code",
Long: `Generate a QR code that joins a WiFi network when scanned.
With --password the code is built entirely offline. Without it, the saved
credentials are fetched from the running DMS shell (like the network panel).`,
Args: cobra.ExactArgs(1),
Run: runQRWifi,
}
func init() {
qrCmd.PersistentFlags().StringVar(&qrEcc, "ecc", "", "Error correction level: L, M, Q, H (default M, or H with --logo)")
qrCmd.PersistentFlags().IntVar(&qrVersion, "qr-version", 0, "Force QR symbol version 1-40 (0 = auto)")
qrCmd.PersistentFlags().StringVarP(&qrOutput, "output", "o", "", "Write a PNG to this file")
qrCmd.PersistentFlags().BoolVar(&qrStdout, "stdout", false, "Write PNG bytes to stdout")
qrCmd.PersistentFlags().BoolVar(&qrClipboard, "clipboard", false, "Copy the PNG image to the clipboard")
qrCmd.PersistentFlags().BoolVar(&qrCopyText, "copy-text", false, "Copy the source text to the clipboard")
qrCmd.PersistentFlags().BoolVar(&qrRender, "render", false, "Force terminal rendering")
qrCmd.PersistentFlags().BoolVar(&qrNoRender, "no-render", false, "Never render to the terminal")
qrCmd.PersistentFlags().BoolVar(&qrInvert, "invert", false, "Swap colors (for light terminals)")
qrCmd.PersistentFlags().IntVar(&qrQuietZone, "quiet-zone", 2, "Terminal margin in modules")
qrCmd.PersistentFlags().IntVar(&qrModuleSize, "module-size", 0, "PNG pixels per module (0 = auto)")
qrCmd.PersistentFlags().StringVar(&qrFg, "fg", "", "Dark module color (#RGB or #RRGGBB)")
qrCmd.PersistentFlags().StringVar(&qrBg, "bg", "", "Light module color (#RGB or #RRGGBB)")
qrCmd.PersistentFlags().BoolVar(&qrTransparent, "transparent", false, "Transparent PNG background")
qrCmd.PersistentFlags().IntVar(&qrBorder, "border", -1, "PNG border in pixels (-1 = auto)")
qrCmd.PersistentFlags().StringVar(&qrShape, "shape", "square", "PNG module shape (square, circle)")
qrCmd.PersistentFlags().StringVar(&qrLogo, "logo", "", "Center a PNG/JPEG logo on the PNG output")
qrCmd.PersistentFlags().IntVar(&qrLogoScale, "logo-scale", 0, "Max logo size as 1/N of the code (0 = library default of 5)")
qrWifiCmd.Flags().StringVarP(&qrWifiPassword, "password", "p", "", "WiFi password (offline build)")
qrWifiCmd.Flags().StringVar(&qrWifiSecurity, "security", "WPA", "Security type (WPA, WEP, nopass)")
qrWifiCmd.Flags().BoolVar(&qrWifiHidden, "hidden", false, "Mark the network as hidden")
qrCmd.AddCommand(qrWifiCmd)
}
func runQR(cmd *cobra.Command, args []string) {
text := strings.Join(args, " ")
if text == "" || text == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
fatalf("Error reading stdin: %v", err)
}
text = strings.TrimRight(string(data), "\n")
}
if text == "" {
fatalf("Error: no input (provide text, pipe stdin, or use a subcommand)")
}
emitQR(text)
}
func runQRWifi(cmd *cobra.Command, args []string) {
ssid := args[0]
if qrWifiPassword != "" || strings.EqualFold(qrWifiSecurity, "nopass") {
emitQR(qrcode.WiFiString(qrWifiSecurity, ssid, qrWifiPassword, qrWifiHidden))
return
}
content, err := fetchWifiQRContent(ssid)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintln(os.Stderr, "Hint: pass --password to build the code without the shell.")
os.Exit(1)
}
if qrWifiHidden {
content = strings.TrimSuffix(content, ";") + "H:true;;"
}
emitQR(content)
}
func emitQR(text string) {
renderTerm := shouldRenderTerminal()
pngToStdout := qrStdout || (!renderTerm && qrOutput == "" && !qrClipboard && !qrCopyText)
if pngToStdout || qrOutput != "" || qrClipboard {
png, err := qrcode.RenderPNG(text, qrcode.ImageOptions{
ECC: effectiveEcc(),
Version: qrVersion,
ModuleSize: qrModuleSize,
Fg: qrFg,
Bg: qrBg,
Transparent: qrTransparent,
Border: qrBorder,
Shape: qrShape,
Logo: qrLogo,
LogoScale: qrLogoScale,
})
if err != nil {
fatalf("Error encoding QR: %v", err)
}
emitPNG(png, pngToStdout)
}
if qrCopyText {
if err := clipboard.CopyText(text); err != nil {
fatalf("Error copying text: %v", err)
}
}
if !renderTerm {
return
}
out, err := qrcode.RenderTerminal(text, qrcode.TermOptions{
ECC: effectiveEcc(),
Version: qrVersion,
QuietZone: qrQuietZone,
Invert: qrInvert,
Fg: qrFg,
Bg: qrBg,
})
if err != nil {
fatalf("Error rendering QR: %v", err)
}
dst := os.Stdout
if pngToStdout {
dst = os.Stderr
}
fmt.Fprint(dst, out)
}
func emitPNG(png []byte, toStdout bool) {
if qrOutput != "" {
if err := os.WriteFile(qrOutput, png, 0o644); err != nil {
fatalf("Error writing file: %v", err)
}
fmt.Fprintln(os.Stderr, qrOutput)
}
if toStdout {
os.Stdout.Write(png)
}
if qrClipboard {
if err := clipboard.Copy(png, "image/png"); err != nil {
fatalf("Error copying image: %v", err)
}
}
}
func shouldRenderTerminal() bool {
switch {
case qrNoRender:
return false
case qrRender:
return true
case qrStdout, qrOutput != "", qrClipboard, qrCopyText:
return false
default:
return isatty.IsTerminal(os.Stdout.Fd())
}
}
func effectiveEcc() string {
switch {
case qrEcc != "":
return qrEcc
case qrLogo != "":
return "H"
default:
return "M"
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
func fetchWifiQRContent(ssid string) (string, error) {
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "network.qrcode-content",
Params: map[string]any{"ssid": ssid},
})
if err != nil {
return "", err
}
if resp.Error != "" {
return "", fmt.Errorf("%s", resp.Error)
}
if resp.Result == nil {
return "", fmt.Errorf("empty response")
}
content, ok := (*resp.Result).(string)
if !ok {
return "", fmt.Errorf("unexpected response format")
}
return content, nil
}
+108 -14
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -27,8 +28,19 @@ var (
ssNoConfirm bool
ssReset bool
ssStdout bool
ssJSON bool
)
type screenshotMetadata struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Scale float64 `json:"scale,omitempty"`
Mime string `json:"mime,omitempty"`
Error string `json:"error,omitempty"`
}
var screenshotCmd = &cobra.Command{
Use: "screenshot",
Short: "Capture screenshots",
@@ -39,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)
@@ -59,7 +72,10 @@ Examples:
dms screenshot --no-file # Clipboard only
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 -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
}
var ssRegionCmd = &cobra.Command{
@@ -97,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",
@@ -128,8 +167,12 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.PersistentFlags().BoolVar(&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)
@@ -203,10 +246,39 @@ func setPopoutScreenshotMode(begin bool) {
_ = exec.Command("qs", cmdArgs...).Run()
}
func writeScreenshotJSON(meta screenshotMetadata) {
_ = json.NewEncoder(os.Stdout).Encode(meta)
}
func exitScreenshotError(context string, err error) {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "error", Error: err.Error()})
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Error%s: %v\n", context, err)
os.Exit(1)
}
func formatMime(format screenshot.Format) string {
switch format {
case screenshot.FormatJPEG:
return "image/jpeg"
case screenshot.FormatPPM:
return "image/x-portable-pixmap"
default:
return "image/png"
}
}
func runScreenshot(config screenshot.Config) {
if ssJSON && config.Stdout {
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
os.Exit(1)
}
// Region select needs the keyboard; drop popout grabs for its duration.
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)
@@ -215,11 +287,13 @@ func runScreenshot(config screenshot.Config) {
}()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
exitScreenshotError("", err)
}
if result == nil {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
}
os.Exit(0)
}
@@ -231,8 +305,7 @@ func runScreenshot(config screenshot.Config) {
if config.Stdout {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
os.Exit(1)
exitScreenshotError(" writing to stdout", err)
}
return
}
@@ -252,22 +325,37 @@ func runScreenshot(config screenshot.Config) {
filePath = filepath.Join(outputDir, filename)
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
exitScreenshotError(" writing file", err)
}
if !ssJSON {
fmt.Println(filePath)
}
fmt.Println(filePath)
}
if config.Clipboard {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
os.Exit(1)
exitScreenshotError(" copying to clipboard", err)
}
if !config.SaveFile {
if !ssJSON && !config.SaveFile {
fmt.Println("Copied to clipboard")
}
}
if ssJSON {
scale := result.Scale
if scale <= 0 {
scale = 1.0
}
writeScreenshotJSON(screenshotMetadata{
Status: "success",
Path: filePath,
Width: result.Buffer.Width,
Height: result.Buffer.Height,
Scale: scale,
Mime: formatMime(config.Format),
})
}
if config.Notify {
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
screenshot.SendNotification(screenshot.NotifyResult{
@@ -378,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)
+12 -2
View File
@@ -47,6 +47,7 @@ var (
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIgnore []string
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
)
@@ -58,6 +59,7 @@ func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
systemCmd.AddCommand(systemUpdateCmd)
@@ -192,6 +194,7 @@ func runSystemUpdateApply() {
Targets: pkgs,
IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR,
Ignored: sysUpdateIgnore,
DryRun: sysUpdateDry,
UseSudo: true,
}
@@ -234,12 +237,19 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
}
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR {
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
return pkgs
}
ignored := make(map[string]bool, len(sysUpdateIgnore))
for _, name := range sysUpdateIgnore {
ignored[name] = true
}
out := pkgs[:0]
for _, p := range pkgs {
if p.Repo == sysupdate.RepoAUR {
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
continue
}
if ignored[p.Name] {
continue
}
out = append(out, p)
+26 -3
View File
@@ -6,6 +6,7 @@ import (
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
@@ -83,9 +84,31 @@ func ensureRunitSeat(greeterUser string) {
}
}
// ensureGreetdPamRundir adds pam_rundir to the greetd PAM stack so the post-login
// session gets an XDG_RUNTIME_DIR on systems without logind (Void with seatd).
// Appended outside DMS's managed auth block so it survives `dms greeter sync`.
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
func ensureVoidLogindGreeter(greeterUser string) {
for _, service := range []string{"dbus", "elogind"} {
if err := enableRunitService(service); err != nil {
fmt.Printf(" ⚠ could not enable %s: %v\n", service, err)
} else {
fmt.Printf(" ✓ %s enabled\n", service)
}
}
greeter.EnsureVoidGreetdRunScript(func(msg string) { fmt.Println(" " + msg) }, "")
if runitServiceEnabled("seatd") {
if err := disableRunitService("seatd"); err != nil {
fmt.Printf(" ⚠ could not disable seatd: %v\n", err)
} else {
fmt.Println(" ✓ seatd disabled (elogind manages the seat)")
}
}
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "video,input", greeterUser); err != nil {
fmt.Printf(" ⚠ could not add %s to video/input groups: %v\n", greeterUser, err)
} else {
fmt.Printf(" ✓ %s added to video/input groups (elogind manages the seat)\n", greeterUser)
}
}
// ensureGreetdPamRundir provides XDG_RUNTIME_DIR to runit greeter sessions.
func ensureGreetdPamRundir() {
const pamPath = "/etc/pam.d/greetd"
data, err := os.ReadFile(pamPath)
+1 -1
View File
@@ -20,7 +20,7 @@ func init() {
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
authCmd.AddCommand(authSyncCmd)
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
+1 -1
View File
@@ -20,7 +20,7 @@ func init() {
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
authCmd.AddCommand(authSyncCmd)
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...)
+10 -2
View File
@@ -92,6 +92,14 @@ func appendLogEnv(env []string) []string {
return env
}
func withDMSExecutable(env []string) []string {
selfPath, err := os.Executable()
if err != nil {
return env
}
return append(env, "DMS_EXECUTABLE="+selfPath)
}
func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run")
return err == nil
@@ -207,7 +215,7 @@ func runShellInteractive(session bool) {
log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
cmd.Env = withDMSExecutable(append(os.Environ(), "DMS_SOCKET="+socketPath))
if os.Getenv("QT_LOGGING_RULES") == "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
@@ -461,7 +469,7 @@ func runShellDaemon(session bool) {
log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
cmd.Env = withDMSExecutable(append(os.Environ(), "DMS_SOCKET="+socketPath))
if os.Getenv("QT_LOGGING_RULES") == "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
+2 -1
View File
@@ -11,7 +11,8 @@ end)
hl.config({
input = {
kb_layout = "us",
-- empty inherits XKB_DEFAULT_LAYOUT (libxkbcommon), falls back to "us"
kb_layout = "",
numlock_by_default = true,
follow_mouse = 0,
touchpad = {
+18 -18
View File
@@ -51,24 +51,24 @@ binds {
XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute";
}
XF86AudioPause allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPlay allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPrev allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "previous";
}
XF86AudioNext allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "next";
}
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "increment" "3";
}
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
}
XF86AudioPause allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPlay allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPrev allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "previous";
}
XF86AudioNext allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "next";
}
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "increment" "3";
}
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
}
// === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true {
+18 -1
View File
@@ -133,6 +133,11 @@ func mergedAssociations() *MimeAssociations {
return merged
}
// isSafeIniField rejects values that would corrupt a key=value line in mimeapps.list
func isSafeIniField(s string) bool {
return !strings.ContainsAny(s, "\n\r[]")
}
func writeUserMimeapps(update func(*MimeAssociations)) error {
mimeappsWriteMu.Lock()
defer mimeappsWriteMu.Unlock()
@@ -152,6 +157,7 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
var writeErr error
writeSection := func(name string, entries map[string]string) {
fmt.Fprintf(w, "[%s]\n", name)
keys := make([]string, 0, len(entries))
@@ -160,7 +166,14 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
v := entries[k]
if !isSafeIniField(k) || !isSafeIniField(v) {
if writeErr == nil {
writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v)
}
continue
}
fmt.Fprintf(w, "%s=%s\n", k, v)
}
fmt.Fprintln(w)
}
@@ -177,6 +190,10 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
writeSection(groupAdded, flatten(assoc.Added))
writeSection(groupRemoved, flatten(assoc.Removed))
if writeErr != nil {
return writeErr
}
if err := w.Flush(); err != nil {
return err
}
+58 -1
View File
@@ -161,7 +161,60 @@ func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) P
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
// Debian 13 ships stable quickshell in trixie-backports only.
if d.debianNeedsQuickshellBackports() {
return PackageMapping{Name: "quickshell/trixie-backports", Repository: RepoTypeSystem}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
}
func (d *DebianDistribution) debianNeedsQuickshellBackports() bool {
osInfo, err := GetOSInfo()
if err != nil {
return false
}
return osInfo.VersionID == "13" || strings.EqualFold(osInfo.VersionCodename, "trixie")
}
func (d *DebianDistribution) ensureQuickshellBackports(ctx context.Context, systemPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
needsBackports := false
for _, pkg := range systemPkgs {
if strings.Contains(pkg, "trixie-backports") {
needsBackports = true
break
}
}
if !needsBackports {
return nil
}
policyOut, err := exec.CommandContext(ctx, "apt-cache", "policy").Output()
if err == nil && strings.Contains(string(policyOut), "trixie-backports") {
d.log("trixie-backports already configured")
return nil
}
listFile := "/etc/apt/sources.list.d/trixie-backports.list"
repoLine := "deb http://deb.debian.org/debian trixie-backports main contrib non-free non-free-firmware"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.30,
Step: "Enabling trixie-backports for quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
LogOutput: "Debian 13 ships quickshell in trixie-backports",
}
addCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
if err := d.runWithProgress(addCmd, progressChan, PhaseSystemPackages, 0.30, 0.31); err != nil {
return err
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
return d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.31, 0.33)
}
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
@@ -266,6 +319,10 @@ func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies [
}
}
if err := d.ensureQuickshellBackports(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable trixie-backports for quickshell: %w", err)
}
// System Packages
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
+1 -1
View File
@@ -7,7 +7,7 @@ type minimalInstallGroup struct {
func shouldPreferMinimalInstall(pkg string) bool {
switch pkg {
case "niri", "niri-git":
case "niri", "niri-git", "hyprland", "hyprland-git":
return true
default:
return false
+9 -2
View File
@@ -13,8 +13,8 @@ import (
)
const (
VoidDMSRepo = "https://avengemedia.github.io/DankMaterialShell/current"
VoidDankLinuxRepo = "https://avengemedia.github.io/DankLinux/current"
VoidDMSRepo = "https://void.danklinux.com/dms/current"
VoidDankLinuxRepo = "https://void.danklinux.com/danklinux/current"
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
voidRunitSvDir = "/etc/sv"
@@ -72,6 +72,7 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, v.detectAccountsService())
dependencies = append(dependencies, v.detectDBus())
dependencies = append(dependencies, v.detectElogind())
dependencies = append(dependencies, v.detectMesaDri())
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, v.detectHyprlandTools()...)
@@ -142,6 +143,10 @@ func (v *VoidDistribution) detectElogind() deps.Dependency {
return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl"))
}
func (v *VoidDistribution) detectMesaDri() deps.Dependency {
return v.detectPackage("mesa-dri", "Mesa DRI/EGL drivers (GPU rendering; compositors find no outputs without it)", v.packageInstalled("mesa-dri"))
}
func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency {
return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite"))
}
@@ -172,6 +177,7 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"dbus": {Name: "dbus", Repository: RepoTypeSystem},
"elogind": {Name: "elogind", Repository: RepoTypeSystem},
"mesa-dri": {Name: "mesa-dri", Repository: RepoTypeSystem},
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
@@ -305,6 +311,7 @@ func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPasswo
return nil
}
// D-Bus activation alone starts elogind without its wrapper mounts; the runit service is required.
for _, service := range []string{"dbus", "elogind"} {
if !v.runitServiceInstalled(service) {
v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service))
+1 -1
View File
@@ -132,7 +132,7 @@ func (c *GeoClueClient) startSignalPump() error {
if err := c.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(c.clientPath),
dbus.WithMatchInterface(dbusGeoClueClientInterface),
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
dbus.WithMatchMember("LocationUpdated"),
); err != nil {
return err
}
+149 -11
View File
@@ -115,6 +115,52 @@ func ensureRunitSeat(greeterUser, sudoPassword string, logFunc func(string)) {
}
}
// VoidGreetdRunScript orders greetd after dbus/elogind so the first greeter session can't race elogind's runtime-dir setup.
const VoidGreetdRunScript = `#!/bin/sh
sv check dbus >/dev/null || exit 1
sv check elogind >/dev/null || exit 1
exec greetd
`
// EnsureVoidGreetdRunScript rewrites /etc/sv/greetd/run with dbus/elogind ordering (greetd updates restore stock; enable re-asserts).
func EnsureVoidGreetdRunScript(logFunc func(string), sudoPassword string) {
const runPath = "/etc/sv/greetd/run"
if data, err := os.ReadFile(runPath); err == nil && strings.Contains(string(data), "sv check elogind") {
logFunc("✓ greetd run script already waits for elogind")
return
}
script := fmt.Sprintf("cat > %s <<'EOF'\n%sEOF\nchmod 755 %s", runPath, VoidGreetdRunScript, runPath)
if err := privesc.Run(context.Background(), sudoPassword, "sh", "-c", script); err != nil {
logFunc(fmt.Sprintf("⚠ could not update %s: %v", runPath, err))
return
}
logFunc("✓ greetd run script now waits for dbus/elogind")
}
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
func ensureVoidLogindGreeter(greeterUser, sudoPassword string, logFunc func(string)) {
for _, service := range []string{"dbus", "elogind"} {
if err := enableRunitService(service, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ could not enable %s: %v", service, err))
} else {
logFunc(fmt.Sprintf("✓ %s enabled", service))
}
}
EnsureVoidGreetdRunScript(logFunc, sudoPassword)
if runitServiceEnabled("seatd") {
if err := disableRunitService("seatd", sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ could not disable seatd: %v", err))
} else {
logFunc("✓ seatd disabled (elogind manages the seat)")
}
}
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", "video,input", greeterUser); err != nil {
logFunc(fmt.Sprintf("⚠ could not add %s to video/input groups: %v", greeterUser, err))
} else {
logFunc(fmt.Sprintf("✓ %s added to video/input groups (elogind manages the seat)", greeterUser))
}
}
func ensureGreetdPamRundir(sudoPassword string, logFunc func(string)) {
const pamPath = "/etc/pam.d/greetd"
data, err := os.ReadFile(pamPath)
@@ -483,11 +529,23 @@ func execFromDesktopFile(path string) (string, error) {
if err != nil {
return "", err
}
inDesktopEntry := false
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Exec=") {
return strings.TrimSpace(trimmed[len("Exec="):]), nil
switch {
case trimmed == "" || strings.HasPrefix(trimmed, "#"):
continue
case strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"):
inDesktopEntry = trimmed == "[Desktop Entry]"
continue
case !inDesktopEntry:
continue
}
key, value, found := strings.Cut(trimmed, "=")
if !found || strings.TrimSpace(key) != "Exec" {
continue
}
return strings.TrimSpace(value), nil
}
return "", fmt.Errorf("no Exec= line found in %s", path)
}
@@ -990,7 +1048,7 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper)
installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git")
case distros.FamilyVoid:
failHint = "⚠ dms-greeter install failed. Add the DMS XBPS repo manually:\necho 'repository=https://avengemedia.github.io/DankMaterialShell/current' | sudo tee /etc/xbps.d/dms.conf\nsudo xbps-install -Sy dms-greeter"
failHint = "⚠ dms-greeter install failed. Add the DMS XBPS repo manually:\necho 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf\nsudo xbps-install -Sy dms-greeter"
logFunc("Adding DMS XBPS repository...")
if err := ensureVoidXBPSRepo(ctx, sudoPassword, "dms", distros.VoidDMSRepo); err != nil {
logFunc(fmt.Sprintf("⚠ Failed to add DMS XBPS repository: %v", err))
@@ -1740,6 +1798,10 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
}
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
if err := EnsureVoidLogindGreetdCommand(logFunc, sudoPassword); err != nil {
return err
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
@@ -2271,13 +2333,7 @@ vt = 1
return fmt.Errorf("failed to read greetd config: %w", err)
}
wrapperCmd := resolveGreeterWrapperPath()
compositorLower := strings.ToLower(compositor)
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir)
if dmsPath != "" {
commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath)
}
commandValue := buildGreetdCommand(resolveGreeterWrapperPath(), compositor, dmsPath, IsVoidLinux())
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
@@ -2289,6 +2345,84 @@ vt = 1
return nil
}
func buildGreetdCommand(wrapperCmd, compositor, dmsPath string, useVoidLogind bool) string {
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, strings.ToLower(compositor), GreeterCacheDir)
if dmsPath != "" {
commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath)
}
if useVoidLogind {
commandValue = "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + commandValue
}
return commandValue
}
// EnsureVoidLogindGreetdCommand migrates DMS greeter commands on Void.
func EnsureVoidLogindGreetdCommand(logFunc func(string), sudoPassword string) error {
if !IsVoidLinux() {
return nil
}
const configPath = "/etc/greetd/config.toml"
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("failed to read greetd config: %w", err)
}
configContent := string(data)
command := extractDefaultSessionCommand(configContent)
if command == "" || !strings.Contains(command, "dms-greeter") {
return nil
}
migratedCommand := voidLogindGreeterCommand(command)
if migratedCommand == command {
return nil
}
greeterUser := extractDefaultSessionUser(configContent)
if greeterUser == "" {
greeterUser = DetectGreeterUser()
}
newConfig := upsertDefaultSession(configContent, greeterUser, fmt.Sprintf(`command = "%s"`, migratedCommand))
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Updated existing Void greeter to use elogind")
}
func extractDefaultSessionCommand(configContent string) string {
inDefaultSession := false
for line := range strings.SplitSeq(configContent, "\n") {
if section, ok := parseTomlSection(line); ok {
inDefaultSession = section == "default_session"
continue
}
if !inDefaultSession {
continue
}
trimmed := stripTomlComment(line)
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
if command := strings.Trim(strings.TrimSpace(parts[1]), `"`); command != "" {
return command
}
}
return ""
}
func voidLogindGreeterCommand(command string) string {
if strings.Contains(command, "LIBSEAT_BACKEND=logind") && strings.Contains(command, "DMS_VOID=1") {
return command
}
return "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + command
}
func stripConfigFlag(command string) string {
for _, flag := range []string{" -C ", " --config "} {
idx := strings.Index(command, flag)
@@ -2430,7 +2564,11 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
if !runitServiceInstalled("greetd") {
return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir)
}
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
if IsVoidLinux() {
ensureVoidLogindGreeter(DetectGreeterUser(), sudoPassword, logFunc)
} else {
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
}
ensureGreetdPamRundir(sudoPassword, logFunc)
if err := enableRunitService("greetd", sudoPassword); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err)
+50
View File
@@ -169,6 +169,56 @@ func TestStripDesktopExecCodes(t *testing.T) {
}
}
func TestBuildGreetdCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
wrapper string
compositor string
dmsPath string
useVoidLogind bool
want string
}{
{
name: "standard command",
wrapper: "/usr/bin/dms-greeter",
compositor: "Niri",
want: "/usr/bin/dms-greeter --command niri --cache-dir /var/cache/dms-greeter",
},
{
name: "void selects elogind and keeps custom DMS path",
wrapper: "/usr/bin/dms-greeter",
compositor: "Niri",
dmsPath: "/usr/share/quickshell/dms-greeter",
useVoidLogind: true,
want: "env LIBSEAT_BACKEND=logind DMS_VOID=1 /usr/bin/dms-greeter --command niri --cache-dir /var/cache/dms-greeter -p /usr/share/quickshell/dms-greeter",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := buildGreetdCommand(tt.wrapper, tt.compositor, tt.dmsPath, tt.useVoidLogind); got != tt.want {
t.Fatalf("buildGreetdCommand() = %q, want %q", got, tt.want)
}
})
}
}
func TestVoidLogindGreeterCommand(t *testing.T) {
t.Parallel()
const oldCommand = "/usr/bin/dms-greeter --command niri -C /etc/greetd/niri.kdl"
const want = "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + oldCommand
if got := voidLogindGreeterCommand(oldCommand); got != want {
t.Fatalf("voidLogindGreeterCommand() = %q, want %q", got, want)
}
if got := voidLogindGreeterCommand(want); got != want {
t.Fatalf("voidLogindGreeterCommand() must be idempotent, got %q", got)
}
}
func TestResolveGreeterAutoLoginState(t *testing.T) {
t.Parallel()
+81 -3
View File
@@ -3,6 +3,7 @@ package greeter
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
@@ -93,18 +94,95 @@ func resolveSessionExecInDirs(sessionID string, dirs []string) (string, error) {
return "", fmt.Errorf("session desktop file %q was not found", id)
}
// parseExecString splits a Desktop Entry Exec= value into argv without
// involving a shell, mirroring quickshell's DesktopEntry::parseExecString
// (string quoting, value escapes, field code stripping).
func parseExecString(execLine string) []string {
var args []string
var cur strings.Builder
inString := false
escape := 0
percent := false
for _, c := range execLine {
switch {
case escape == 0 && c == '\\':
escape = 1
case inString:
switch {
case c == '\\':
escape++
if escape == 4 {
cur.WriteByte('\\')
escape = 0
}
case escape == 2:
cur.WriteRune(c)
escape = 0
case escape != 0:
switch c {
case 's':
cur.WriteByte(' ')
case 'n':
cur.WriteByte('\n')
case 't':
cur.WriteByte('\t')
case 'r':
cur.WriteByte('\r')
default:
cur.WriteRune(c)
}
escape = 0
case c == '"' || c == '\'':
inString = false
default:
cur.WriteRune(c)
}
case escape != 0:
cur.WriteRune(c)
escape = 0
case percent:
if c == '%' {
cur.WriteByte('%')
}
percent = false
case c == '%':
percent = true
case c == '"' || c == '\'':
inString = true
case c == ' ':
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteRune(c)
}
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
func LaunchSessionByID(sessionID string) error {
execLine, err := ResolveSessionExec(sessionID)
if err != nil {
return err
}
execLine = strings.TrimSpace(stripDesktopExecCodes(execLine))
if execLine == "" {
argv := parseExecString(strings.TrimSpace(execLine))
if len(argv) == 0 {
return fmt.Errorf("session %q has an empty Exec command", sessionID)
}
resolved, err := exec.LookPath(argv[0])
if err != nil {
return fmt.Errorf("session %q command %q not found: %w", sessionID, argv[0], err)
}
env := append(os.Environ(), "XDG_SESSION_TYPE=wayland")
return syscall.Exec("/bin/sh", []string{"sh", "-c", "exec " + execLine}, env)
return syscall.Exec(resolved, argv, env)
}
func LaunchSessionFromMemory(cacheDir, homeDir string) error {
@@ -0,0 +1,57 @@
package greeter
import (
"path/filepath"
"reflect"
"testing"
)
func TestParseExecString(t *testing.T) {
t.Parallel()
tests := []struct {
name string
exec string
want []string
}{
{"plain", "niri --session", []string{"niri", "--session"}},
{"extra spaces", "niri --session", []string{"niri", "--session"}},
{"double quoted arg", `env "with space" run`, []string{"env", "with space", "run"}},
{"single quoted arg", `env 'with space' run`, []string{"env", "with space", "run"}},
{"escaped quote in quotes", `sh "say \\"hi\\""`, []string{"sh", `say "hi"`}},
{"field code dropped", "gnome-session %U", []string{"gnome-session"}},
{"field code mid-arg", "app --url=%u --run", []string{"app", "--url=", "--run"}},
{"literal percent", "app 100%% done", []string{"app", "100%", "done"}},
{"shell metachars stay literal", "sh -c $(reboot); echo", []string{"sh", "-c", "$(reboot);", "echo"}},
{"empty", "", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseExecString(tt.exec); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("parseExecString(%q) = %#v, want %#v", tt.exec, got, tt.want)
}
})
}
}
func TestExecFromDesktopFileOnlyReadsDesktopEntryGroup(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "example.desktop")
writeTestFile(t, path, `[Desktop Action other]
Exec=/wrong/binary
[Desktop Entry]
Name=Example
Exec = /right/binary --flag
`)
got, err := execFromDesktopFile(path)
if err != nil {
t.Fatalf("execFromDesktopFile returned error: %v", err)
}
if got != "/right/binary --flag" {
t.Fatalf("execFromDesktopFile = %q, want %q", got, "/right/binary --flag")
}
}
+33 -16
View File
@@ -299,6 +299,9 @@ type hyprlandOverrideBind struct {
Options map[string]any
// Unbind: negative override (hl.unbind only, no rebind).
Unbind bool
// RawLuaAction: Action is a custom hl.* Lua expression round-tripped from an
// existing Lua override; re-emit it verbatim instead of quoting it.
RawLuaAction bool
}
func (h *HyprlandProvider) ensureWritableConfig() error {
@@ -1046,18 +1049,26 @@ func luaActionStringFromHyprlangAction(action string) string {
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return expr
}
return action
// Unrecognized dispatchers are freeform text, not Lua; forward them to
// hyprctl quoted so a stray `"` can't produce broken Lua output.
return luaHyprctlDispatchFunction(action)
}
func luaExprToInternalAction(expr string) string {
// luaExprToInternalAction converts a parsed Lua bind expression back into
// "dispatcher params" text. isRawLua reports that expr matched no known hl.*
// shape and must be re-emitted verbatim as Lua on write-back.
func luaExprToInternalAction(expr string) (action string, isRawLua bool) {
d, p := luaExprToDispatcherParams(expr)
if d == expr && p == "" {
return expr, true
}
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
return "exec " + p
return "exec " + p, false
}
if p != "" {
return d + " " + p
return d + " " + p, false
}
return d
return d, false
}
func luaBindOptions(bind *hyprlandOverrideBind) []string {
@@ -1075,20 +1086,25 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
}
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
key := formatLuaBindKey(bind.Key)
key := strconv.Quote(formatLuaBindKey(bind.Key))
if bind.Unbind {
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
sb.WriteByte('\n')
return
}
expr := luaActionStringFromHyprlangAction(bind.Action)
var expr string
if bind.RawLuaAction {
expr = bind.Action
} else {
expr = luaActionStringFromHyprlangAction(bind.Action)
}
opts := luaBindOptions(bind)
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
sb.WriteByte('\n')
if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
fmt.Fprintf(sb, `hl.bind(%s, %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else {
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
fmt.Fprintf(sb, `hl.bind(%s, %s)`, key, expr)
}
sb.WriteByte('\n')
}
@@ -1104,17 +1120,18 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
}
internalKey := luaKeyComboToInternalKey(kbc)
action := luaExprToInternalAction(actionExpr)
action, isRawLua := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix)
if description == "" {
description = luaLineTrailingComment(line)
}
return &hyprlandOverrideBind{
Key: internalKey,
Action: action,
Description: description,
Flags: flags,
Key: internalKey,
Action: action,
Description: description,
Flags: flags,
RawLuaAction: isRawLua,
}, true
}
@@ -147,9 +147,10 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { descripti
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: "hl.dsp.no_op()",
Description: "Custom Lua",
Key: "Super+u",
Action: "hl.dsp.no_op()",
Description: "Custom Lua",
RawLuaAction: true,
})
want := `hl.unbind("SUPER + U")
@@ -159,6 +160,24 @@ hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
}
}
func TestWriteLuaBindLineQuotesUnrecognizedActionWithoutRawLuaFlag(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: `customdispatcher "),os.execute("id")--`,
})
got := sb.String()
if !strings.Contains(got, "hl.exec_cmd(") {
t.Fatalf("expected unrecognized action to go through the hyprctl-dispatch wrapper, got %q", got)
}
// an unpaired bare quote means the action broke out of its string literal
withoutEscapedQuotes := strings.ReplaceAll(got, `\"`, "")
if n := strings.Count(withoutEscapedQuotes, `"`); n%2 != 0 {
t.Fatalf("action broke out of its string literal (%d unpaired quotes): %q", n, got)
}
}
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
tests := []struct {
action string
@@ -226,15 +245,12 @@ func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
}
}
func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
func TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText(t *testing.T) {
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
want := `hl.dsp.no_op()`
want := `function() hl.exec_cmd("hyprctl dispatch hl.dsp.no_op()") end`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
}
}
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
+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)
+36 -13
View File
@@ -44,6 +44,7 @@ type TemplateDef struct {
ID string
Commands []string
Flatpaks []string
ConfigDirs []string
ConfigFile string
Kind TemplateKind
RunUnconditionally bool
@@ -60,9 +61,9 @@ var templateRegistry = []TemplateDef{
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigDirs: []string{"vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigDirs: []string{"Vencord"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigDirs: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
@@ -260,15 +261,15 @@ func Run(opts Options) error {
return buildErr
}
if opts.SyncModeWithPortal {
syncColorScheme(opts.Mode)
}
if !changed {
log.Info("No color changes detected, skipping refresh")
return ErrNoChanges
}
if opts.SyncModeWithPortal {
syncColorScheme(opts.Mode)
}
log.Info("Done")
return nil
}
@@ -459,9 +460,9 @@ output_path = '%s'
case TemplateKindGTK:
switch opts.Mode {
case ColorModeLight:
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-light.toml")
default:
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-dark.toml")
}
case TemplateKindTerminal:
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
@@ -474,10 +475,10 @@ output_path = '%s'
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
}
default:
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
}
}
@@ -512,13 +513,14 @@ func appendConfig(
cfgFile *os.File,
checkCmd []string,
checkFlatpaks []string,
checkConfigDirs []string,
fileName string,
) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil {
return
}
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) && !configDirExists(checkConfigDirs) {
return
}
data, err := os.ReadFile(configPath)
@@ -599,6 +601,20 @@ func templateSessionActive(tmpl TemplateDef) bool {
return err == nil
}
func configDirExists(names []string) bool {
configHome := utils.XDGConfigHome()
if configHome == "" {
return false
}
for _, name := range names {
info, err := os.Stat(filepath.Join(configHome, name))
if err == nil && info.IsDir() {
return true
}
}
return false
}
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
// Both nil is treated as "skip check" / unconditionally run
if checkCmd == nil && checkFlatpaks == nil {
@@ -1006,6 +1022,13 @@ func syncColorScheme(mode ColorMode) {
scheme = "default"
}
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme"); err == nil {
cur = strings.Trim(cur, "'")
if cur == scheme || (mode == ColorModeLight && cur == "prefer-light") {
return
}
}
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
log.Warnf("Failed to sync color-scheme: %v", err)
}
@@ -1089,7 +1112,7 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
case tmpl.Kind == TemplateKindEmacs:
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
default:
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && templateSessionActive(tmpl)
detected = (appExists(checker, tmpl.Commands, tmpl.Flatpaks) || configDirExists(tmpl.ConfigDirs)) && templateSessionActive(tmpl)
}
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
+95 -8
View File
@@ -38,7 +38,7 @@ func TestAppendConfigBinaryExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
appendConfig(opts, cfgFile, []string{"sh"}, nil, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -82,7 +82,7 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -122,7 +122,7 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -163,7 +163,7 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -203,7 +203,7 @@ func TestAppendConfigBothExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -244,7 +244,7 @@ func TestAppendConfigNeitherExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -281,7 +281,7 @@ func TestAppendConfigNoChecks(t *testing.T) {
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "test.toml")
appendConfig(opts, cfgFile, nil, nil, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -312,7 +312,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
appendConfig(opts, cfgFile, nil, nil, nil, "nonexistent.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -487,3 +487,90 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
}
assert.NotContains(t, string(output), "[templates.dmsmango]")
}
func TestAppendConfigConfigDirExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "vencord config content"
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
configHome := filepath.Join(tempDir, "config")
if err := os.MkdirAll(filepath.Join(configHome, "Vencord"), 0o755); err != nil {
t.Fatalf("failed to create Vencord config dir: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", configHome)
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
assert.Equal(t, testConfig+"\n", string(output))
}
func TestAppendConfigConfigDirDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte("vencord config content"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
configHome := filepath.Join(tempDir, "config")
if err := os.MkdirAll(configHome, 0o755); err != nil {
t.Fatalf("failed to create config home: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", configHome)
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
assert.Empty(t, string(output))
}
+509 -52
View File
@@ -12,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -32,6 +33,32 @@ const (
DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f"
)
// lockscreenPamEntryCandidates are the /etc/pam.d entry-point services tried in
// order. "login" is first so systems that ship it behave exactly as before; the
// rest cover distros (or minimal installs) with no /etc/pam.d/login.
// lockscreenPamBaseDirs mirrors libpam's search order: /etc overrides, then the
// vendor dir (/usr/lib) and the stateless-distro default (/usr/share).
var lockscreenPamBaseDirs = []string{"/etc/pam.d", "/usr/lib/pam.d", "/usr/share/pam.d"}
// Standalone auth+account services, most universal first. login exists almost
// everywhere (util-linux); system-* cover Fedora/Arch/Gentoo/SUSE-Leap.
var lockscreenPamEntryCandidates = []string{
"login",
"system-auth",
"system-login",
"system-local-login",
}
// Fallback for distros with no standalone login service, only shared building
// blocks: openSUSE/Debian (common-*), Alpine/postmarketOS (base-*).
var lockscreenPamSharedIncludePairs = []struct {
auth string
account string
}{
{auth: "common-auth", account: "common-account"},
{auth: "base-auth", account: "base-account"},
}
var includedPamAuthFiles = []string{
"system-auth",
"common-auth",
@@ -43,10 +70,11 @@ var includedPamAuthFiles = []string{
}
type AuthSettings struct {
EnableFprint bool `json:"enableFprint"`
EnableU2f bool `json:"enableU2f"`
GreeterEnableFprint bool `json:"greeterEnableFprint"`
GreeterEnableU2f bool `json:"greeterEnableU2f"`
EnableFprint bool `json:"enableFprint"`
EnableU2f bool `json:"enableU2f"`
GreeterEnableFprint bool `json:"greeterEnableFprint"`
GreeterEnableU2f bool `json:"greeterEnableU2f"`
GreeterPamExternallyManaged bool `json:"greeterPamExternallyManaged"`
}
type SyncAuthOptions struct {
@@ -75,10 +103,50 @@ type lockscreenPamIncludeDirective struct {
}
type lockscreenPamResolver struct {
pamDir string
baseDirs []string
readFile func(string) ([]byte, error)
}
// locate resolves a service/include name across baseDirs (libpam vendor-dir
// fallback). Targets may not escape the base dirs.
func (r lockscreenPamResolver) locate(target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", fmt.Errorf("empty PAM include target")
}
if filepath.IsAbs(target) {
clean := filepath.Clean(target)
for _, dir := range r.baseDirs {
if filepath.Dir(clean) == filepath.Clean(dir) {
return clean, nil
}
}
return "", fmt.Errorf("unsupported PAM include outside PAM dirs: %s", target)
}
clean := filepath.Clean(target)
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid PAM include target: %s", target)
}
var firstErr error
for _, dir := range r.baseDirs {
path := filepath.Join(filepath.Clean(dir), clean)
if _, err := r.readFile(path); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
return path, nil
}
if firstErr == nil {
firstErr = os.ErrNotExist
}
return "", firstErr
}
func defaultSyncDeps() syncDeps {
return syncDeps{
pamDir: "/etc/pam.d",
@@ -169,6 +237,14 @@ func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options S
return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err)
}
if settings.GreeterPamExternallyManaged {
if err := removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, deps); err != nil {
return err
}
logFunc(" /etc/pam.d/greetd is externally managed. Skipping DMS greeter PAM sync.")
return nil
}
if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil {
return err
}
@@ -375,32 +451,10 @@ func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string)
return lockscreenPamIncludeDirective{}, false
}
func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) {
if strings.TrimSpace(target) == "" {
return "", fmt.Errorf("empty PAM include target")
}
cleanPamDir := filepath.Clean(pamDir)
if filepath.IsAbs(target) {
cleanTarget := filepath.Clean(target)
if filepath.Dir(cleanTarget) != cleanPamDir {
return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target)
}
return cleanTarget, nil
}
cleanTarget := filepath.Clean(target)
if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid PAM include target: %s", target)
}
return filepath.Join(cleanPamDir, cleanTarget), nil
}
func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) {
path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName)
path, err := r.locate(serviceName)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to read PAM file %s: %w", serviceName, err)
}
for _, seen := range stack {
@@ -458,29 +512,73 @@ func (r lockscreenPamResolver) resolveService(serviceName string, filterType str
return resolved, nil
}
func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) {
resolver := lockscreenPamResolver{
pamDir: pamDir,
readFile: readFile,
}
resolvedLines, err := resolver.resolveService("login", "", nil)
if err != nil {
return "", err
}
if len(resolvedLines) == 0 {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
}
hasAuth := false
for _, line := range resolvedLines {
func resolvedLinesHaveAuth(lines []string) bool {
for _, line := range lines {
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
hasAuth = true
break
return true
}
}
if !hasAuth {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
return false
}
func (r lockscreenPamResolver) resolveLines() ([]string, error) {
var lastErr error
// Standalone login-like services: an existing one is authoritative.
for _, service := range lockscreenPamEntryCandidates {
if _, err := r.locate(service); err != nil {
lastErr = err
continue
}
lines, err := r.resolveService(service, "", nil)
if err != nil {
return nil, err
}
if !resolvedLinesHaveAuth(lines) {
return nil, fmt.Errorf("no auth directives remained after filtering %s", service)
}
return lines, nil
}
// Shared building blocks for distros without a login service (openSUSE,
// Alpine): stitch the auth stanza to the account stanza when present.
for _, pair := range lockscreenPamSharedIncludePairs {
if _, err := r.locate(pair.auth); err != nil {
lastErr = err
continue
}
authLines, err := r.resolveService(pair.auth, "auth", nil)
if err != nil {
return nil, err
}
if !resolvedLinesHaveAuth(authLines) {
lastErr = fmt.Errorf("no auth directives remained after filtering %s", pair.auth)
continue
}
resolved := append([]string{}, authLines...)
if _, err := r.locate(pair.account); err == nil {
acctLines, err := r.resolveService(pair.account, "account", nil)
if err != nil {
return nil, err
}
resolved = append(resolved, acctLines...)
}
return resolved, nil
}
if lastErr != nil {
return nil, fmt.Errorf("no usable PAM auth service found: %w", lastErr)
}
return nil, fmt.Errorf("no usable PAM auth service found")
}
func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) ([]byte, error)) (string, error) {
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
resolvedLines, err := resolver.resolveLines()
if err != nil {
return "", err
}
var b strings.Builder
@@ -494,6 +592,365 @@ func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]by
return b.String(), nil
}
var lockscreenPamCandidateServices = []string{
"login",
"system-auth",
"system-login",
"system-local-login",
"common-auth",
"base-auth",
}
type LockscreenPamServiceInfo struct {
Name string `json:"name"`
Dir string `json:"dir"`
Path string `json:"path"`
HasAuth bool `json:"hasAuth"`
InlineFingerprint bool `json:"inlineFingerprint"`
InlineU2f bool `json:"inlineU2f"`
}
type LockscreenPamValidation struct {
Valid bool `json:"valid"`
Path string `json:"path"`
HasAuth bool `json:"hasAuth"`
InlineFingerprint bool `json:"inlineFingerprint"`
InlineU2f bool `json:"inlineU2f"`
MissingModules []string `json:"missingModules"`
Warnings []string `json:"warnings"`
Errors []string `json:"errors"`
}
type lockscreenPamValidateDeps struct {
baseDirs []string
readFile func(string) ([]byte, error)
stat func(string) (os.FileInfo, error)
pamModuleExists func(string) bool
}
func defaultValidateDeps() lockscreenPamValidateDeps {
return lockscreenPamValidateDeps{
baseDirs: lockscreenPamBaseDirs,
readFile: os.ReadFile,
stat: os.Stat,
pamModuleExists: pamModuleExists,
}
}
// lockscreenPamAnalysis is a non-destructive walk of a PAM service. Unlike
// resolveService it detects (rather than strips) pam_fprintd/pam_u2f and
// records unknown directives instead of hard-failing on them.
type lockscreenPamAnalysis struct {
lines []string
hasAuth bool
inlineFingerprint bool
inlineU2f bool
modules []string
authModules []string
unknownDirectives []string
err error
}
func (r lockscreenPamResolver) analyzePath(path string) lockscreenPamAnalysis {
var acc lockscreenPamAnalysis
if err := r.analyzeInto(filepath.Clean(path), "", nil, &acc); err != nil {
acc.err = err
}
return acc
}
func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack []string, acc *lockscreenPamAnalysis) error {
for _, seen := range stack {
if seen == path {
chain := append(append([]string{}, stack...), path)
display := make([]string, 0, len(chain))
for _, item := range chain {
display = append(display, filepath.Base(item))
}
return fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
}
}
data, err := r.readFile(path)
if err != nil {
return fmt.Errorf("failed to read PAM file %s: %w", path, err)
}
for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
rawLine = strings.TrimRight(rawLine, "\r")
trimmed := strings.TrimSpace(rawLine)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
lineType := pamDirectiveType(trimmed)
if filterType != "" && lineType != "" && lineType != filterType {
continue
}
nestedPath := include.target
if filepath.IsAbs(nestedPath) {
nestedPath = filepath.Clean(nestedPath)
} else {
located, err := r.locate(include.target)
if err != nil {
return fmt.Errorf("failed to read PAM file %s: %w", include.target, err)
}
nestedPath = located
}
if err := r.analyzeInto(nestedPath, include.filterType, append(stack, path), acc); err != nil {
return err
}
continue
}
lineType := pamDirectiveType(trimmed)
if lineType == "" {
acc.unknownDirectives = append(acc.unknownDirectives, trimmed)
continue
}
if filterType != "" && lineType != filterType {
continue
}
acc.lines = append(acc.lines, rawLine)
if lineType == "auth" {
acc.hasAuth = true
}
foundModule := false
for _, field := range strings.Fields(trimmed) {
if strings.HasPrefix(field, "#") {
break
}
if strings.Contains(field, "pam_fprintd") {
acc.inlineFingerprint = true
}
if strings.Contains(field, "pam_u2f") {
acc.inlineU2f = true
}
if !foundModule && strings.HasSuffix(field, ".so") {
acc.modules = append(acc.modules, field)
if lineType == "auth" {
acc.authModules = append(acc.authModules, field)
}
foundModule = true
}
}
}
return nil
}
// Earlier base dir wins per name (libpam precedence).
func ListLockscreenPamServices() []LockscreenPamServiceInfo {
return listLockscreenPamServices(lockscreenPamBaseDirs, os.ReadFile)
}
func listLockscreenPamServices(baseDirs []string, readFile func(string) ([]byte, error)) []LockscreenPamServiceInfo {
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
out := make([]LockscreenPamServiceInfo, 0, len(lockscreenPamCandidateServices))
for _, name := range lockscreenPamCandidateServices {
path, err := resolver.locate(name)
if err != nil {
continue
}
info := LockscreenPamServiceInfo{
Name: name,
Dir: filepath.Dir(path),
Path: path,
}
if analysis := resolver.analyzePath(path); analysis.err == nil {
info.HasAuth = analysis.hasAuth
info.InlineFingerprint = analysis.inlineFingerprint
info.InlineU2f = analysis.inlineU2f
}
out = append(out, info)
}
return out
}
func ValidateLockscreenPamService(name string) LockscreenPamValidation {
return validateLockscreenPam(name, "", defaultValidateDeps())
}
func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
return validateLockscreenPam("", path, defaultValidateDeps())
}
func ValidateLockscreenU2fPamService(name string) LockscreenPamValidation {
return validateLockscreenU2fPam(name, "", defaultValidateDeps())
}
func ValidateLockscreenU2fPamPath(path string) LockscreenPamValidation {
return validateLockscreenU2fPam("", path, defaultValidateDeps())
}
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
result := LockscreenPamValidation{
MissingModules: []string{},
Warnings: []string{},
Errors: []string{},
}
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
var analysis lockscreenPamAnalysis
if path != "" {
result.Path = path
analysis = resolver.analyzePath(path)
} else {
located, err := resolver.locate(serviceName)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("PAM service %q not found: %v", serviceName, err))
return result
}
result.Path = located
analysis = resolver.analyzePath(located)
}
if analysis.err != nil {
result.Errors = append(result.Errors, analysis.err.Error())
return result
}
result.HasAuth = analysis.hasAuth
result.InlineFingerprint = analysis.inlineFingerprint
result.InlineU2f = analysis.inlineU2f
if !analysis.hasAuth {
result.Errors = append(result.Errors, "no auth directives found after include resolution")
}
for _, directive := range analysis.unknownDirectives {
result.Warnings = append(result.Warnings, "unsupported PAM directive (libpam may still handle it at runtime): "+directive)
}
seen := map[string]bool{}
for _, ref := range analysis.modules {
name := filepath.Base(ref)
if seen[name] {
continue
}
seen[name] = true
if moduleReferenceExists(ref, deps) {
continue
}
result.MissingModules = append(result.MissingModules, name)
result.Warnings = append(result.Warnings, "referenced PAM module not found: "+name)
}
if analysis.inlineFingerprint {
result.Warnings = append(result.Warnings, "pam_fprintd is present in the resolved stack; may double-prompt with DMS's separate fingerprint context")
}
if analysis.inlineU2f {
result.Warnings = append(result.Warnings, "pam_u2f is present in the resolved stack; may double-prompt with DMS's separate U2F context")
}
result.Valid = len(result.Errors) == 0
return result
}
func validateLockscreenU2fPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
result := validateLockscreenPam(serviceName, path, deps)
if result.Path == "" {
return result
}
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
analysis := resolver.analyzePath(result.Path)
if analysis.err != nil {
return result
}
filteredWarnings := result.Warnings[:0]
for _, warning := range result.Warnings {
if strings.Contains(warning, "pam_u2f is present") && strings.Contains(warning, "double-prompt") {
continue
}
filteredWarnings = append(filteredWarnings, warning)
}
result.Warnings = filteredWarnings
hasU2fAuth := false
unsafeModules := []string{}
unsafeSeen := map[string]bool{}
for _, ref := range analysis.authModules {
name := filepath.Base(ref)
if name == "pam_u2f.so" {
hasU2fAuth = true
continue
}
switch name {
case "pam_env.so", "pam_faildelay.so", "pam_nologin.so":
continue
default:
if !unsafeSeen[name] {
unsafeSeen[name] = true
unsafeModules = append(unsafeModules, name)
}
}
}
if !hasU2fAuth {
result.Errors = append(result.Errors, "no pam_u2f auth directive found; select a dedicated security-key PAM service")
}
for _, name := range unsafeModules {
result.Errors = append(result.Errors, fmt.Sprintf("additional auth module %s is not allowed in a dedicated security-key PAM service", name))
}
for _, name := range result.MissingModules {
if strings.Contains(name, "pam_u2f") {
result.Errors = append(result.Errors, fmt.Sprintf("%s is not installed or its configured path is unavailable", name))
break
}
}
result.Valid = len(result.Errors) == 0
return result
}
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
if filepath.IsAbs(ref) {
_, err := deps.stat(ref)
return err == nil
}
return deps.pamModuleExists(ref)
}
const UserLockscreenPamService = "dankshell"
func UserLockscreenPamDir() string {
return filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "pam")
}
// WriteUserLockscreenPamConfig resolves the distro's real auth stack into a
// self-contained lock-screen service under the user state dir, unprivileged
// (reads world-readable PAM dirs, writes the user's own state dir). Rewrites
// only on change to avoid inotify churn. Returns the written path.
func WriteUserLockscreenPamConfig(logFunc func(string)) (string, error) {
content, err := buildManagedLockscreenPamContent(lockscreenPamBaseDirs, os.ReadFile)
if err != nil {
return "", fmt.Errorf("failed to resolve system PAM auth stack: %w", err)
}
dir := UserLockscreenPamDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("failed to create %s: %w", dir, err)
}
path := filepath.Join(dir, UserLockscreenPamService)
if existing, err := os.ReadFile(path); err == nil && string(existing) == content {
return path, nil
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return "", fmt.Errorf("failed to write %s: %w", path, err)
}
if logFunc != nil {
logFunc("✓ Wrote lock-screen PAM config " + path)
}
return path, nil
}
func buildManagedLockscreenU2FPamContent() string {
var b strings.Builder
b.WriteString("#%PAM-1.0\n")
@@ -508,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
}
@@ -522,7 +979,7 @@ func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string,
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
}
content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile)
content, err := buildManagedLockscreenPamContent([]string{deps.pamDir}, deps.readFile)
if err != nil {
return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err)
}
@@ -725,7 +1182,7 @@ func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, set
if wantFprint || wantU2f {
blockLines := []string{GreeterPamManagedBlockStart}
if wantFprint {
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5")
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=2 timeout=10")
}
if wantU2f {
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
+544 -5
View File
@@ -246,6 +246,31 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
"session optional pam_lastlog.so silent": 1,
},
},
{
name: "falls back to system-auth when login is absent",
files: map[string]string{
"system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so try_first_pass nullok\naccount required pam_unix.so\n",
},
wantContains: []string{
"auth sufficient pam_unix.so try_first_pass nullok",
"account required pam_unix.so",
},
},
{
name: "no usable service when none of the candidates exist",
files: map[string]string{
"other": "#%PAM-1.0\nauth required pam_deny.so\n",
},
wantErr: "no usable PAM auth service found",
},
{
name: "existing login with bad include is authoritative and does not fall back",
files: map[string]string{
"login": "#%PAM-1.0\nauth include missing-auth\n",
"system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n",
},
wantErr: "failed to read PAM file",
},
{
name: "missing include fails",
files: map[string]string{
@@ -281,7 +306,7 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
env.writePamFile(t, name, content)
}
content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
@@ -314,6 +339,192 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
}
}
// Real /etc/pam.d layouts of the non-Arch-shaped distros (#2789).
func TestBuildManagedLockscreenPamContent_DistroShapes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
files map[string]string
wantContains []string
wantNotContains []string
}{
{
// openSUSE: `include` (not @include), common-auth symlinked to
// common-auth-pc (here just a plain file), bracketed securetty
// control, keyring modules, pam_sss.
name: "openSUSE include + common-auth + bracket control",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth requisite pam_nologin.so\n" +
"auth [user_unknown=ignore success=ok ignore=ignore auth_err=die default=bad] pam_securetty.so\n" +
"auth include common-auth\n" +
"account include common-account\n" +
"session required pam_loginuid.so\n" +
"session include common-session\n",
"common-auth": "auth required pam_env.so\n" +
"auth optional pam_gnome_keyring.so\n" +
"auth sufficient pam_unix.so try_first_pass\n" +
"auth required pam_sss.so use_first_pass\n",
"common-account": "account required pam_unix.so try_first_pass\naccount sufficient pam_localuser.so\n",
"common-session": "session optional pam_gnome_keyring.so auto_start\n",
},
wantContains: []string{
"pam_securetty.so",
"auth sufficient pam_unix.so try_first_pass",
"auth required pam_sss.so use_first_pass",
"account required pam_unix.so try_first_pass",
},
},
{
name: "openSUSE without login stitches common-auth and common-account",
files: map[string]string{
"common-auth": "auth required pam_env.so\n" +
"auth optional pam_gnome_keyring.so\n" +
"auth sufficient pam_unix.so try_first_pass\n" +
"auth required pam_sss.so use_first_pass\n",
"common-account": "account required pam_unix.so try_first_pass\n" +
"account sufficient pam_localuser.so\n" +
"account required pam_sss.so use_first_pass\n",
},
wantContains: []string{
"auth sufficient pam_unix.so try_first_pass",
"auth required pam_sss.so use_first_pass",
"account required pam_unix.so try_first_pass",
"account required pam_sss.so use_first_pass",
},
},
{
name: "openSUSE with only common-auth resolves auth-only",
files: map[string]string{
"common-auth": "auth sufficient pam_unix.so try_first_pass\nauth required pam_deny.so\n",
},
wantContains: []string{"auth sufficient pam_unix.so try_first_pass"},
wantNotContains: []string{
"account",
},
},
{
name: "Debian @include common-auth and common-account",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth requisite pam_nologin.so\n" +
"@include common-auth\n" +
"@include common-account\n" +
"session required pam_loginuid.so\n" +
"@include common-session\n",
"common-auth": "auth\t[success=1 default=ignore]\tpam_unix.so nullok\n" +
"auth\trequisite\t\t\tpam_deny.so\n" +
"auth\trequired\t\t\tpam_permit.so\n",
"common-account": "account\t[success=1 new_authtok_reqd=done default=ignore]\tpam_unix.so\naccount\trequisite\t\t\tpam_deny.so\n",
"common-session": "session\t[default=1]\t\t\tpam_permit.so\n",
},
wantContains: []string{
"auth\t[success=1 default=ignore]\tpam_unix.so nullok",
"account\t[success=1 new_authtok_reqd=done default=ignore]\tpam_unix.so",
},
},
{
name: "NixOS flat login with absolute paths and dash directives",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth required /nix/store/abc-pam/lib/security/pam_unix.so likeauth nullok try_first_pass\n" +
"auth sufficient /nix/store/abc-pam-u2f/lib/security/pam_u2f.so\n" +
"-auth optional /nix/store/abc-kbd/lib/security/pam_gnome_keyring.so\n" +
"account required /nix/store/abc-pam/lib/security/pam_unix.so\n" +
"-session optional /nix/store/abc-sd/lib/security/pam_systemd.so\n",
},
wantContains: []string{
"auth required /nix/store/abc-pam/lib/security/pam_unix.so likeauth nullok try_first_pass",
"-auth optional /nix/store/abc-kbd/lib/security/pam_gnome_keyring.so",
"account required /nix/store/abc-pam/lib/security/pam_unix.so",
},
wantNotContains: []string{"pam_u2f"},
},
{
name: "Gentoo deep include chain login->system-local-login->system-login->system-auth",
files: map[string]string{
"login": "#%PAM-1.0\nauth\tinclude\t\tsystem-local-login\naccount\tinclude\t\tsystem-local-login\n",
"system-local-login": "auth\trequired\tpam_group.so\nauth\tinclude\t\tsystem-login\naccount\tinclude\t\tsystem-login\n",
"system-login": "auth\tinclude\t\tsystem-auth\naccount\tinclude\t\tsystem-auth\n",
"system-auth": "auth\trequired\tpam_env.so\nauth\tsufficient\tpam_unix.so try_first_pass likeauth nullok\nauth\trequired\tpam_deny.so\naccount\trequired\tpam_unix.so\n",
},
wantContains: []string{
"auth\trequired\tpam_group.so",
"auth\tsufficient\tpam_unix.so try_first_pass likeauth nullok",
"account\trequired\tpam_unix.so",
},
},
{
name: "no login, entry falls through to system-auth",
files: map[string]string{
"system-auth": "#%PAM-1.0\n" +
"auth required pam_env.so\n" +
"auth sufficient pam_unix.so nullok\n" +
"auth sufficient pam_sss.so forward_pass\n" +
"auth required pam_deny.so\n" +
"account required pam_unix.so\n" +
"account [default=bad success=ok user_unknown=ignore] pam_sss.so\n",
},
wantContains: []string{
"auth sufficient pam_unix.so nullok",
"auth sufficient pam_sss.so forward_pass",
"account required pam_unix.so",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
for name, content := range tt.files {
env.writePamFile(t, name, content)
}
content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile)
if err != nil {
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
}
if !strings.Contains(content, "auth") {
t.Fatalf("resolved content has no auth line:\n%s", content)
}
for _, want := range tt.wantContains {
if !strings.Contains(content, want) {
t.Errorf("missing expected string %q in output:\n%s", want, content)
}
}
for _, notWant := range tt.wantNotContains {
if strings.Contains(content, notWant) {
t.Errorf("unexpected string %q found in output:\n%s", notWant, content)
}
}
})
}
}
func TestBuildManagedLockscreenPamContent_VendorDirFallback(t *testing.T) {
t.Parallel()
// Stateless/vendored-PAM systems (Clear Linux) ship the stack in
// /usr/lib|share/pam.d with /etc/pam.d empty; includes resolve in that dir.
etcDir := t.TempDir()
vendorDir := t.TempDir()
writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "auth sufficient pam_unix.so nullok\naccount required pam_unix.so\n")
content, err := buildManagedLockscreenPamContent([]string{etcDir, vendorDir}, os.ReadFile)
if err != nil {
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
}
for _, want := range []string{"auth sufficient pam_unix.so nullok", "account required pam_unix.so"} {
if !strings.Contains(content, want) {
t.Errorf("missing %q in output:\n%s", want, content)
}
}
}
func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
t.Parallel()
@@ -400,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)
@@ -512,7 +723,7 @@ func TestSyncGreeterPamConfigWithDeps(t *testing.T) {
got := readFileString(t, env.greetdPath)
for _, want := range []string{
GreeterPamManagedBlockStart,
"auth sufficient pam_fprintd.so max-tries=1 timeout=5",
"auth sufficient pam_fprintd.so max-tries=2 timeout=10",
"auth sufficient pam_u2f.so cue nouserok timeout=10",
GreeterPamManagedBlockEnd,
} {
@@ -575,6 +786,301 @@ func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) {
}
}
func (e *pamTestEnv) validateDeps() lockscreenPamValidateDeps {
return lockscreenPamValidateDeps{
baseDirs: []string{e.pamDir},
readFile: os.ReadFile,
stat: os.Stat,
pamModuleExists: func(module string) bool { return e.availableModules[module] },
}
}
func TestListLockscreenPamServices(t *testing.T) {
t.Parallel()
t.Run("dedupes by name with earlier base dir winning", func(t *testing.T) {
t.Parallel()
etcDir := t.TempDir()
vendorDir := t.TempDir()
// login exists in both dirs; system-auth only in the vendor dir.
writeTestFile(t, filepath.Join(etcDir, "login"), "#%PAM-1.0\nauth required pam_unix.so\naccount required pam_unix.so\n")
writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth required pam_deny.so\n")
writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
services := listLockscreenPamServices([]string{etcDir, vendorDir}, os.ReadFile)
if len(services) != 2 {
t.Fatalf("expected 2 services (login, system-auth), got %d: %+v", len(services), services)
}
byName := map[string]LockscreenPamServiceInfo{}
for _, s := range services {
byName[s.Name] = s
}
login, ok := byName["login"]
if !ok {
t.Fatalf("expected login service, got %+v", services)
}
if login.Dir != etcDir || login.Path != filepath.Join(etcDir, "login") {
t.Fatalf("expected login to resolve in earlier dir %s, got dir=%s path=%s", etcDir, login.Dir, login.Path)
}
if !login.HasAuth {
t.Fatalf("expected login to report hasAuth")
}
if byName["system-auth"].Dir != vendorDir {
t.Fatalf("expected system-auth to resolve in vendor dir, got %s", byName["system-auth"].Dir)
}
})
t.Run("include resolution sets hasAuth and detects inline fprintd/u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
services := listLockscreenPamServices([]string{env.pamDir}, os.ReadFile)
var login LockscreenPamServiceInfo
for _, s := range services {
if s.Name == "login" {
login = s
}
}
if !login.HasAuth {
t.Fatalf("expected hasAuth via resolved include")
}
if !login.InlineFingerprint || !login.InlineU2f {
t.Fatalf("expected inline fingerprint and u2f detection, got %+v", login)
}
})
}
func TestValidateLockscreenPam(t *testing.T) {
t.Parallel()
t.Run("valid service with resolved auth", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("login", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid result, got %+v", result)
}
if !result.HasAuth {
t.Fatalf("expected hasAuth true")
}
if len(result.Errors) != 0 {
t.Fatalf("expected no errors, got %v", result.Errors)
}
})
t.Run("path outside base dirs is read directly", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
outside := filepath.Join(t.TempDir(), "custom-pam")
writeTestFile(t, outside, "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("", outside, env.validateDeps())
if !result.Valid || result.Path != outside {
t.Fatalf("expected valid result for outside path, got %+v", result)
}
})
t.Run("missing module produces warning and missingModules but stays valid", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth required pam_absent.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid despite missing module, got %+v", result)
}
if len(result.MissingModules) != 1 || result.MissingModules[0] != "pam_absent.so" {
t.Fatalf("expected missing pam_absent.so, got %v", result.MissingModules)
}
if !containsSubstr(result.Warnings, "pam_absent.so") {
t.Fatalf("expected warning about missing module, got %v", result.Warnings)
}
})
t.Run("unknown directive is a warning not an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_foo.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nbadtype required pam_foo.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid with unknown directive, got %+v", result)
}
if len(result.Errors) != 0 {
t.Fatalf("expected no errors, got %v", result.Errors)
}
if !containsSubstr(result.Warnings, "unsupported PAM directive") {
t.Fatalf("expected unsupported directive warning, got %v", result.Warnings)
}
})
t.Run("cyclic include is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\n")
env.writePamFile(t, "system-auth", "auth include login\n")
result := validateLockscreenPam("login", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected invalid on cyclic include, got %+v", result)
}
if !containsSubstr(result.Errors, "cyclic PAM include detected") {
t.Fatalf("expected cyclic include error, got %v", result.Errors)
}
})
t.Run("no auth directives is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected invalid when no auth directives, got %+v", result)
}
if !containsSubstr(result.Errors, "no auth directives") {
t.Fatalf("expected no-auth error, got %v", result.Errors)
}
})
t.Run("missing file is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
result := validateLockscreenPam("", filepath.Join(env.pamDir, "does-not-exist"), env.validateDeps())
if result.Valid || len(result.Errors) == 0 {
t.Fatalf("expected invalid for missing file, got %+v", result)
}
})
t.Run("inline fingerprint and u2f produce warnings", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_fprintd.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid, got %+v", result)
}
if !result.InlineFingerprint || !result.InlineU2f {
t.Fatalf("expected inline flags set, got %+v", result)
}
if !containsSubstr(result.Warnings, "pam_fprintd") || !containsSubstr(result.Warnings, "pam_u2f") {
t.Fatalf("expected double-prompt warnings, got %v", result.Warnings)
}
})
}
func TestValidateLockscreenU2fPam(t *testing.T) {
t.Parallel()
t.Run("accepts a dedicated U2F stack with custom options", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue authfile=/etc/u2f-mappings\naccount required pam_permit.so\n")
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid dedicated U2F stack, got %+v", result)
}
if !result.InlineU2f {
t.Fatalf("expected inline U2F detection, got %+v", result)
}
if containsSubstr(result.Warnings, "double-prompt") {
t.Fatalf("dedicated U2F validation should not warn about its expected U2F module: %v", result.Warnings)
}
})
t.Run("rejects a primary login stack that also prompts for a password", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "login", "#%PAM-1.0\nauth required pam_unix.so\nauth required pam_u2f.so cue\naccount required pam_unix.so\n")
result := validateLockscreenU2fPam("login", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected mixed password/U2F stack to be rejected, got %+v", result)
}
if !containsSubstr(result.Errors, "pam_unix.so") || !containsSubstr(result.Errors, "dedicated security-key") {
t.Fatalf("expected actionable mixed-stack error, got %v", result.Errors)
}
})
t.Run("rejects a stack without pam_u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "password-only", "#%PAM-1.0\nauth required pam_unix.so\n")
result := validateLockscreenU2fPam("password-only", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "pam_u2f") {
t.Fatalf("expected missing-U2F error, got %+v", result)
}
})
t.Run("does not accept a similarly named module as pam_u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_u2f_helper.so"] = true
env.writePamFile(t, "not-u2f", "#%PAM-1.0\nauth required pam_u2f_helper.so\n")
result := validateLockscreenU2fPam("not-u2f", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "no pam_u2f auth directive") {
t.Fatalf("expected exact pam_u2f module validation, got %+v", result)
}
})
t.Run("rejects a missing pam_u2f module", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue\n")
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "pam_u2f.so is not installed") {
t.Fatalf("expected missing-module error, got %+v", result)
}
})
}
func containsSubstr(items []string, substr string) bool {
for _, item := range items {
if strings.Contains(item, substr) {
return true
}
}
return false
}
func TestSyncAuthConfigWithDeps(t *testing.T) {
t.Parallel()
@@ -629,7 +1135,7 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
}
greetd := readFileString(t, env.greetdPath)
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") {
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=2 timeout=10") {
t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd)
}
if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") {
@@ -637,6 +1143,39 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
}
})
t.Run("externally managed greetd is stripped and greeter sync skipped", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.writeSettings(t, `{"greeterPamExternallyManaged":true,"greeterEnableFprint":true}`)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\n"+
GreeterPamManagedBlockStart+"\n"+
"auth sufficient pam_fprintd.so max-tries=2 timeout=10\n"+
GreeterPamManagedBlockEnd+"\n")
var logs []string
err := syncAuthConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
greetd := readFileString(t, env.greetdPath)
if strings.Contains(greetd, GreeterPamManagedBlockStart) || strings.Contains(greetd, "pam_fprintd") {
t.Fatalf("expected DMS-managed block stripped from externally managed greetd:\n%s", greetd)
}
if !strings.Contains(greetd, "auth include system-auth") {
t.Fatalf("expected non-DMS greetd lines to remain:\n%s", greetd)
}
if !containsSubstr(logs, "externally managed") {
t.Fatalf("expected externally-managed skip log, got %v", logs)
}
})
t.Run("NixOS remains informational and non-mutating", func(t *testing.T) {
t.Parallel()
+17
View File
@@ -64,7 +64,20 @@ func (m *Manager) findInstalledPath(pluginID string) (string, error) {
return m.findInDir(systemDir, pluginID)
}
// isSafePluginPathComponent rejects ids that aren't a single path component,
// so filepath.Join can't resolve (and later RemoveAll) outside the plugins dir
func isSafePluginPathComponent(s string) bool {
if s == "" || s == "." || s == ".." {
return false
}
return !strings.ContainsAny(s, "/\\")
}
func (m *Manager) findInDir(dir, pluginID string) (string, error) {
if !isSafePluginPathComponent(pluginID) {
return "", fmt.Errorf("invalid plugin id: %q", pluginID)
}
// First, check if folder with exact ID name exists
exactPath := filepath.Join(dir, pluginID)
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
@@ -507,6 +520,10 @@ func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
}
func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
if !isSafePluginPathComponent(idOrName) {
return "", fmt.Errorf("invalid plugin id/name: %q", idOrName)
}
// Check exact folder name match first
exactPath := filepath.Join(dir, idOrName)
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
+16 -10
View File
@@ -135,15 +135,14 @@ func EscapeSingleQuotes(s string) string {
}
// MakeCommand returns a bash command string that runs `command` with the
// detected tool. When the tool supports stdin passwords and password is
// non-empty, the password is piped in. Otherwise the tool is invoked with
// no non-interactive flag so that an interactive TTY prompt is still
// possible for CLI callers.
// detected tool, prompting interactively on a TTY where applicable. The
// sudo-with-password case lives in ExecCommand, which pipes the password via
// stdin so it never lands in argv.
//
// If detection fails, the returned shell string exits 1 with an error
// message so callers that treat the *exec.Cmd as infallible still fail
// deterministically.
func MakeCommand(password, command string) string {
func MakeCommand(command string) string {
t, err := Detect()
if err != nil {
return failingShell(err)
@@ -151,9 +150,6 @@ func MakeCommand(password, command string) string {
switch t {
case ToolSudo:
if password != "" {
return fmt.Sprintf("echo '%s' | sudo -S %s", EscapeSingleQuotes(password), command)
}
return fmt.Sprintf("sudo %s", command)
case ToolDoas:
return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command))
@@ -166,9 +162,19 @@ func MakeCommand(password, command string) string {
// ExecCommand builds an exec.Cmd that runs `command` as root via the
// detected tool. Detection errors surface at Run() time as a failing
// command writing a clear error to stderr.
// command writing a clear error to stderr. A sudo password is piped via
// stdin (sudo -S) so it never appears in argv.
func ExecCommand(ctx context.Context, password, command string) *exec.Cmd {
return exec.CommandContext(ctx, "bash", "-c", MakeCommand(password, command))
t, err := Detect()
if err != nil {
return exec.CommandContext(ctx, "bash", "-c", failingShell(err))
}
if t == ToolSudo && password != "" {
cmd := exec.CommandContext(ctx, "sudo", "-S", "sh", "-c", command)
cmd.Stdin = strings.NewReader(password + "\n")
return cmd
}
return exec.CommandContext(ctx, "bash", "-c", MakeCommand(command))
}
// ExecArgv builds an exec.Cmd that runs argv as root via the detected tool.
@@ -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")
}
}
+282
View File
@@ -0,0 +1,282 @@
package qrcode
import (
"bytes"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"os"
"strings"
qr "github.com/yeqown/go-qrcode/v2"
"github.com/yeqown/go-qrcode/writer/standard"
)
type TermOptions struct {
ECC string
Version int
QuietZone int
Invert bool
Fg string
Bg string
}
type ImageOptions struct {
ECC string
Version int
ModuleSize int
Fg string
Bg string
Transparent bool
Border int
Shape string
Logo string
LogoScale int
}
var wifiEscaper = strings.NewReplacer(`\`, `\\`, `;`, `\;`, `,`, `\,`, `:`, `\:`, `"`, `\"`)
func WiFiString(security, ssid, password string, hidden bool) string {
if security == "" {
security = "WPA"
}
var b strings.Builder
fmt.Fprintf(&b, "WIFI:T:%s;S:%s;", security, wifiEscaper.Replace(ssid))
if !strings.EqualFold(security, "nopass") {
fmt.Fprintf(&b, "P:%s;", wifiEscaper.Replace(password))
}
if hidden {
b.WriteString("H:true;")
}
b.WriteString(";")
return b.String()
}
// Colors are painted explicitly on both halves of each ▀ cell so polarity
// does not depend on the terminal theme.
func RenderTerminal(text string, opt TermOptions) (string, error) {
fg, err := parseColor(opt.Fg, color.RGBA{A: 255})
if err != nil {
return "", err
}
bg, err := parseColor(opt.Bg, color.RGBA{R: 255, G: 255, B: 255, A: 255})
if err != nil {
return "", err
}
if opt.Invert {
fg, bg = bg, fg
}
mat, err := encode(text, opt.ECC, opt.Version)
if err != nil {
return "", err
}
grid := bitmapWithQuietZone(mat, opt.QuietZone)
var b strings.Builder
for y := 0; y < len(grid); y += 2 {
for x := range grid[y] {
top := moduleColor(grid[y][x], fg, bg)
bottom := bg
if y+1 < len(grid) {
bottom = moduleColor(grid[y+1][x], fg, bg)
}
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀", top.R, top.G, top.B, bottom.R, bottom.G, bottom.B)
}
b.WriteString("\x1b[0m\n")
}
return b.String(), nil
}
func RenderPNG(text string, opt ImageOptions) ([]byte, error) {
encOpts, err := encodeOptions(opt.ECC, opt.Version)
if err != nil {
return nil, err
}
imgOpts, err := imageOptions(opt)
if err != nil {
return nil, err
}
q, err := qr.NewWith(text, encOpts...)
if err != nil {
return nil, err
}
var buf bytes.Buffer
w := standard.NewWithWriter(nopCloser{&buf}, imgOpts...)
if err := q.Save(w); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func encodeOptions(ecc string, version int) ([]qr.EncodeOption, error) {
eccOpt, err := eccOption(ecc)
if err != nil {
return nil, err
}
opts := []qr.EncodeOption{eccOpt}
switch {
case version == 0:
case version >= 1 && version <= 40:
opts = append(opts, qr.WithVersion(version))
default:
return nil, fmt.Errorf("QR version must be 1-40, got %d", version)
}
return opts, nil
}
func eccOption(level string) (qr.EncodeOption, error) {
switch strings.ToUpper(level) {
case "", "M":
return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionMedium), nil
case "L":
return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionLow), nil
case "Q":
return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionQuart), nil
case "H":
return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionHighest), nil
default:
return nil, fmt.Errorf("invalid error correction level %q (want L, M, Q, or H)", level)
}
}
func imageOptions(opt ImageOptions) ([]standard.ImageOption, error) {
if opt.ModuleSize < 0 || opt.ModuleSize > 255 {
return nil, fmt.Errorf("module size must be 0-255, got %d", opt.ModuleSize)
}
opts := []standard.ImageOption{standard.WithBuiltinImageEncoder(standard.PNG_FORMAT)}
if opt.ModuleSize > 0 {
opts = append(opts, standard.WithQRWidth(uint8(opt.ModuleSize)))
}
if opt.Border >= 0 {
opts = append(opts, standard.WithBorderWidth(opt.Border))
}
switch {
case opt.Transparent:
opts = append(opts, standard.WithBgTransparent())
case opt.Bg != "":
c, err := parseColor(opt.Bg, color.RGBA{})
if err != nil {
return nil, err
}
opts = append(opts, standard.WithBgColor(c))
}
if opt.Fg != "" {
c, err := parseColor(opt.Fg, color.RGBA{})
if err != nil {
return nil, err
}
opts = append(opts, standard.WithFgColor(c))
}
switch strings.ToLower(opt.Shape) {
case "", "square":
case "circle":
opts = append(opts, standard.WithCircleShape())
default:
return nil, fmt.Errorf("invalid shape %q (want square or circle)", opt.Shape)
}
if opt.Logo != "" {
img, err := loadImage(opt.Logo)
if err != nil {
return nil, err
}
opts = append(opts, standard.WithLogoImage(img))
if opt.LogoScale > 0 {
opts = append(opts, standard.WithLogoSizeMultiplier(opt.LogoScale))
}
}
return opts, nil
}
func encode(text, ecc string, version int) (qr.Matrix, error) {
opts, err := encodeOptions(ecc, version)
if err != nil {
return qr.Matrix{}, err
}
q, err := qr.NewWith(text, opts...)
if err != nil {
return qr.Matrix{}, err
}
mw := &matrixWriter{}
if err := q.Save(mw); err != nil {
return qr.Matrix{}, err
}
return mw.mat, nil
}
func bitmapWithQuietZone(mat qr.Matrix, quiet int) [][]bool {
if quiet < 0 {
quiet = 0
}
src := mat.Bitmap()
h := len(src)
w := 0
if h > 0 {
w = len(src[0])
}
out := make([][]bool, h+quiet*2)
for y := range out {
out[y] = make([]bool, w+quiet*2)
}
for y := range h {
for x := range w {
out[y+quiet][x+quiet] = src[y][x]
}
}
return out
}
func moduleColor(dark bool, fg, bg color.RGBA) color.RGBA {
if dark {
return fg
}
return bg
}
func parseColor(hex string, def color.RGBA) (color.RGBA, error) {
hex = strings.TrimPrefix(strings.TrimSpace(hex), "#")
if hex == "" {
return def, nil
}
if len(hex) == 3 {
hex = fmt.Sprintf("%c%c%c%c%c%c", hex[0], hex[0], hex[1], hex[1], hex[2], hex[2])
}
var r, g, b int
if len(hex) != 6 {
return def, fmt.Errorf("invalid color %q (want #RGB or #RRGGBB)", hex)
}
if _, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b); err != nil {
return def, fmt.Errorf("invalid color %q (want #RGB or #RRGGBB)", hex)
}
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}, nil
}
func loadImage(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
return img, nil
}
type matrixWriter struct{ mat qr.Matrix }
func (w *matrixWriter) Write(m qr.Matrix) error { w.mat = m; return nil }
func (w *matrixWriter) Close() error { return nil }
type nopCloser struct{ *bytes.Buffer }
func (nopCloser) Close() error { return nil }
+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
}
+32 -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,20 +170,32 @@ 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
}
yInverted := false
var format uint32
scale := 1.0
if r.selection.surface != nil {
yInverted = r.selection.surface.yInverted
format = r.selection.surface.screenFormat
if s := r.selection.surface.output.fractionalScale; s > 0 {
scale = s
}
}
if r.scroll != nil {
yInverted = false
format = uint32(r.scroll.format)
}
return &CaptureResult{
@@ -188,6 +203,7 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
Region: r.result,
YInverted: yInverted,
Format: format,
Scale: scale,
}, false, nil
}
@@ -691,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()
@@ -733,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
@@ -764,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)
@@ -803,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
+39 -22
View File
@@ -28,6 +28,21 @@ type CaptureResult struct {
Region Region
YInverted bool
Format uint32
Scale float64
}
func (o *WaylandOutput) effectiveScale() float64 {
scale := o.fractionalScale
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(o.name)
}
if scale <= 0 {
scale = float64(o.scale)
}
if scale <= 0 {
return 1.0
}
return scale
}
type Screenshoter struct {
@@ -77,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()
@@ -130,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
@@ -255,6 +274,7 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
Region: region,
YInverted: false,
Format: result.Format,
Scale: scale,
}, nil
}
@@ -430,6 +450,7 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
Buffer: composite,
Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)},
Format: format,
Scale: maxScale,
}, nil
}
@@ -502,6 +523,7 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
if err != nil {
return nil, err
}
result.Scale = output.effectiveScale()
if result.YInverted {
result.Buffer.FlipVertical()
@@ -604,6 +626,7 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
Region: region,
YInverted: false,
Format: result.Format,
Scale: scale,
}, nil
}
@@ -612,16 +635,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
return s.captureRegionOnTransformedOutput(output, region)
}
scale := output.fractionalScale
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(output.name)
}
if scale <= 0 {
scale = float64(output.scale)
}
if scale <= 0 {
scale = 1.0
}
scale := output.effectiveScale()
localX := int32(float64(region.X-output.x) * scale)
localY := int32(float64(region.Y-output.y) * scale)
@@ -660,7 +674,12 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
return nil, fmt.Errorf("capture region: %w", err)
}
return s.processFrame(frame, region)
result, err := s.processFrame(frame, region)
if err != nil {
return nil, err
}
result.Scale = scale
return result, nil
}
func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
@@ -669,16 +688,7 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
return nil, err
}
scale := output.fractionalScale
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(output.name)
}
if scale <= 0 {
scale = float64(output.scale)
}
if scale <= 0 {
scale = 1.0
}
scale := output.effectiveScale()
localX := int(float64(region.X-output.x) * scale)
localY := int(float64(region.Y-output.y) * scale)
@@ -730,6 +740,7 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
Region: region,
YInverted: false,
Format: result.Format,
Scale: scale,
}, nil
}
@@ -747,12 +758,16 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
bpp := format.BytesPerPixel()
if int(e.Stride) < int(e.Width)*bpp {
log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp)
// bail out here or the dispatch loop waits forever on a ready/failed
// event that never comes (frame.Copy is never called)
failed = true
return
}
var err error
buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
if err != nil {
log.Error("failed to create buffer", "err", err)
failed = true
return
}
buf.Format = format
@@ -771,6 +786,7 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size()))
if err != nil {
log.Error("failed to create pool", "err", err)
failed = true
return
}
@@ -779,6 +795,7 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
pool.Destroy()
pool = nil
log.Error("failed to create wl_buffer", "err", err)
failed = true
return
}
+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 {
+11 -6
View File
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
m.promptBroker = broker
adapter, err := m.findAdapter()
adapter, err := findAdapter(conn)
if err != nil {
conn.Close()
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
return nil, err
}
m.adapterPath = adapter
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
return m, nil
}
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
return "", err
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
}
for path, interfaces := range objects {
@@ -89,7 +89,7 @@ func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
}
}
return "", fmt.Errorf("no adapter found")
return "", ErrNoAdapter
}
func (m *Manager) initialize() error {
@@ -487,6 +487,11 @@ func (m *Manager) StopDiscovery() error {
}
func (m *Manager) SetPowered(powered bool) error {
if powered {
if err := rfkillUnblockBluetooth(); err != nil {
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
}
}
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
}
@@ -0,0 +1,5 @@
package bluez
func rfkillUnblockBluetooth() error {
return nil
}
@@ -0,0 +1,19 @@
package bluez
import "os"
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
func rfkillUnblockBluetooth() error {
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
var event [8]byte
event[4] = 2
event[5] = 3
_, err = f.Write(event[:])
return err
}
+46
View File
@@ -0,0 +1,46 @@
package bluez
import (
"errors"
"fmt"
"github.com/godbus/dbus/v5"
)
var ErrNoAdapter = errors.New("no bluetooth adapter found")
func WaitForAdapter() error {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return err
}
defer conn.Close()
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(objectMgrIface),
dbus.WithMatchMember("InterfacesAdded"),
); err != nil {
return err
}
signals := make(chan *dbus.Signal, 64)
conn.Signal(signals)
if _, err := findAdapter(conn); err == nil {
return nil
}
for sig := range signals {
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
continue
}
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
if !ok {
continue
}
if _, ok := ifaces[adapter1Iface]; ok {
return nil
}
}
return fmt.Errorf("dbus signal stream closed")
}
+12 -10
View File
@@ -138,20 +138,22 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
return
}
filePath := m.EntryToFile(entry)
if filePath != "" {
if err := m.CopyFile(filePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
if entry.AltMimeType == "" {
filePath := m.EntryToFile(entry)
if filePath != "" {
if err := m.CopyFile(filePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, map[string]any{
"success": true,
"filePath": filePath,
})
return
}
models.Respond(conn, req.ID, map[string]any{
"success": true,
"filePath": filePath,
})
return
}
if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
if err := m.SetClipboardEntry(entry); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
+175 -40
View File
@@ -279,7 +279,29 @@ func (m *Manager) setupDataDeviceSync() {
}
w.Close()
go m.readAndStore(r, preferredMime)
altMime := ""
if m.isImageMimeType(preferredMime) && !slices.Contains(mimes, "x-special/gnome-copied-files") {
altMime = selectAltTextMimeType(mimes)
}
if altMime == "" {
go m.readAndStore(r, preferredMime, nil, "")
return
}
altR, altW, err := os.Pipe()
if err != nil {
go m.readAndStore(r, preferredMime, nil, "")
return
}
if err := typedOffer.Receive(altMime, int(altW.Fd())); err != nil {
altR.Close()
altW.Close()
go m.readAndStore(r, preferredMime, nil, "")
return
}
altW.Close()
go m.readAndStore(r, preferredMime, altR, altMime)
})
if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil {
@@ -324,22 +346,42 @@ func (m *Manager) releaseCurrentSource() {
source.Destroy()
}
func (m *Manager) readAndStore(r *os.File, mimeType string) {
defer r.Close()
cfg := m.getConfig()
func readPipeTimeout(r *os.File) []byte {
done := make(chan []byte, 1)
go func() {
data, _ := io.ReadAll(r)
done <- data
}()
var data []byte
select {
case data = <-done:
case data := <-done:
return data
case <-time.After(500 * time.Millisecond):
return
return nil
}
}
func (m *Manager) readAndStore(r *os.File, mimeType string, altR *os.File, altMime string) {
defer r.Close()
cfg := m.getConfig()
altCh := make(chan []byte, 1)
switch altR {
case nil:
altCh <- nil
default:
go func() {
defer altR.Close()
altCh <- readPipeTimeout(altR)
}()
}
data := readPipeTimeout(r)
altData := <-altCh
if len(bytes.TrimSpace(altData)) == 0 || int64(len(altData)) > cfg.MaxEntrySize {
altData, altMime = nil, ""
}
if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize {
@@ -350,14 +392,14 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) {
}
if !cfg.Disabled && m.db != nil {
m.storeClipboardEntry(data, mimeType)
m.storeClipboardEntry(data, mimeType, altData, altMime)
}
m.updateState()
m.notifySubscribers()
}
func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []byte, altMime string) {
if mimeType == "text/uri-list" {
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
data = imgData
@@ -366,11 +408,13 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
}
entry := Entry{
Data: data,
MimeType: mimeType,
Size: len(data),
Timestamp: time.Now(),
IsImage: m.isImageMimeType(mimeType),
Data: data,
MimeType: mimeType,
Size: len(data),
Timestamp: time.Now(),
IsImage: m.isImageMimeType(mimeType),
AltData: altData,
AltMimeType: altMime,
}
switch {
@@ -483,6 +527,12 @@ func encodeEntry(e Entry) ([]byte, error) {
} else {
buf.WriteByte(0)
}
if e.AltMimeType != "" {
binary.Write(buf, binary.BigEndian, uint32(len(e.AltMimeType)))
buf.WriteString(e.AltMimeType)
binary.Write(buf, binary.BigEndian, uint32(len(e.AltData)))
buf.Write(e.AltData)
}
return buf.Bytes(), nil
}
@@ -547,6 +597,21 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
e.Pinned = pinnedByte == 1
}
if buf.Len() >= 4 {
var altMimeLen uint32
binary.Read(buf, binary.BigEndian, &altMimeLen)
altMimeBytes := make([]byte, altMimeLen)
buf.Read(altMimeBytes)
e.AltMimeType = string(altMimeBytes)
var altDataLen uint32
binary.Read(buf, binary.BigEndian, &altDataLen)
if withData {
e.AltData = make([]byte, altDataLen)
buf.Read(e.AltData)
}
}
return e, nil
}
@@ -563,10 +628,27 @@ func computeHash(data []byte) uint64 {
}
func extractHash(data []byte) uint64 {
if len(data) < 9 {
buf := bytes.NewReader(data)
if _, err := buf.Seek(8, io.SeekStart); err != nil {
return 0
}
return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1])
for range 3 { // data, mime type, preview
var length uint32
if binary.Read(buf, binary.BigEndian, &length) != nil {
return 0
}
if _, err := buf.Seek(int64(length), io.SeekCurrent); err != nil {
return 0
}
}
if _, err := buf.Seek(4+8+1, io.SeekCurrent); err != nil { // size, timestamp, isImage
return 0
}
var hash uint64
if binary.Read(buf, binary.BigEndian, &hash) != nil {
return 0
}
return hash
}
func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
@@ -611,6 +693,23 @@ func (m *Manager) selectMimeType(mimes []string) string {
return ""
}
var altTextMimeTypes = []string{
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
}
func selectAltTextMimeType(mimes []string) string {
for _, pref := range altTextMimeTypes {
if slices.Contains(mimes, pref) {
return pref
}
}
return ""
}
func (m *Manager) isImageMimeType(mime string) bool {
return strings.HasPrefix(mime, "image/")
}
@@ -929,13 +1028,15 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
// Create a new unpinned entry with the same data
newEntry := Entry{
Data: pinnedEntry.Data,
MimeType: pinnedEntry.MimeType,
Size: pinnedEntry.Size,
Timestamp: time.Now(),
IsImage: pinnedEntry.IsImage,
Preview: pinnedEntry.Preview,
Pinned: false,
Data: pinnedEntry.Data,
MimeType: pinnedEntry.MimeType,
Size: pinnedEntry.Size,
Timestamp: time.Now(),
IsImage: pinnedEntry.IsImage,
Preview: pinnedEntry.Preview,
Pinned: false,
AltData: pinnedEntry.AltData,
AltMimeType: pinnedEntry.AltMimeType,
}
if err := m.storeEntry(newEntry); err != nil {
@@ -1061,6 +1162,23 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
return nil
}
// SetClipboardEntry takes the selection serving the entry's primary
// representation plus its stored alternate, so history restores keep
// both the text and image sides pasteable.
func (m *Manager) SetClipboardEntry(entry *Entry) error {
if int64(len(entry.Data)) > m.config.MaxEntrySize {
return fmt.Errorf("data too large")
}
offers := clipboardstore.ExpandOffers(slices.Clone(entry.Data), entry.MimeType)
if entry.AltMimeType != "" {
offers = append(offers, clipboardstore.ExpandOffers(slices.Clone(entry.AltData), entry.AltMimeType)...)
}
m.takeSelection(offers)
return nil
}
// takeSelection makes the daemon the selection owner, serving the given
// offers until another client claims the clipboard.
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
@@ -1154,16 +1272,20 @@ func (m *Manager) PasteText() (string, error) {
}
entry := history[0]
if entry.IsImage {
return "", fmt.Errorf("clipboard contains image, not text")
}
fullEntry, err := m.GetEntry(entry.ID)
if err != nil {
return "", err
}
return string(fullEntry.Data), nil
switch {
case !fullEntry.IsImage:
return string(fullEntry.Data), nil
case fullEntry.AltMimeType != "":
return string(fullEntry.AltData), nil
default:
return "", fmt.Errorf("clipboard contains image, not text")
}
}
func (m *Manager) Close() {
@@ -1839,21 +1961,34 @@ func (m *Manager) EntryToFile(entry *Entry) string {
return ""
}
func (m *Manager) dbusConnForFlatpak() (*dbus.Conn, error) {
m.dbusConnMutex.Lock()
defer m.dbusConnMutex.Unlock()
if m.dbusConn != nil {
return m.dbusConn, nil
}
conn, err := dbus.ConnectSessionBus()
if err != nil {
return nil, fmt.Errorf("connect session bus: %w", err)
}
if !conn.SupportsUnixFDs() {
conn.Close()
return nil, fmt.Errorf("D-Bus connection does not support Unix FD passing")
}
m.dbusConn = conn
return conn, nil
}
func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
if _, err := os.Stat(filePath); err != nil {
return "", fmt.Errorf("file not found: %w", err)
}
if m.dbusConn == nil {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return "", fmt.Errorf("connect session bus: %w", err)
}
if !conn.SupportsUnixFDs() {
conn.Close()
return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
}
m.dbusConn = conn
dbusConn, err := m.dbusConnForFlatpak()
if err != nil {
return "", err
}
file, err := os.Open(filePath)
@@ -1862,7 +1997,7 @@ func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
}
fd := int(file.Fd())
portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
portal := dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
var docIds []string
var extra map[string]dbus.Variant
@@ -144,6 +144,72 @@ func TestEncodeDecodeEntry_LargeData(t *testing.T) {
assert.Equal(t, original.Size, decoded.Size)
}
func TestEncodeDecodeEntry_AltRepresentation(t *testing.T) {
original := Entry{
ID: 555,
Data: []byte{0x42, 0x4D, 0x01, 0x02},
MimeType: "image/bmp",
Preview: "[[ image 4 B bmp 85x19 ]]",
Size: 4,
Timestamp: time.Now().Truncate(time.Second),
IsImage: true,
Hash: computeHash([]byte{0x42, 0x4D, 0x01, 0x02}),
Pinned: true,
AltData: []byte("real text from OneNote"),
AltMimeType: "text/plain;charset=utf-8",
}
encoded, err := encodeEntry(original)
assert.NoError(t, err)
decoded, err := decodeEntry(encoded)
assert.NoError(t, err)
assert.Equal(t, original.Data, decoded.Data)
assert.Equal(t, original.MimeType, decoded.MimeType)
assert.True(t, decoded.Pinned)
assert.Equal(t, original.AltData, decoded.AltData)
assert.Equal(t, original.AltMimeType, decoded.AltMimeType)
meta, err := decodeEntryMeta(encoded)
assert.NoError(t, err)
assert.Empty(t, meta.Data)
assert.Equal(t, original.AltMimeType, meta.AltMimeType)
assert.Equal(t, original.Hash, extractHash(encoded))
}
func TestExtractHash_NoAlt(t *testing.T) {
entry := Entry{
ID: 1,
Data: []byte("plain entry"),
MimeType: "text/plain",
Preview: "plain entry",
Size: 11,
Timestamp: time.Now().Truncate(time.Second),
Hash: computeHash([]byte("plain entry")),
}
encoded, err := encodeEntry(entry)
assert.NoError(t, err)
assert.Equal(t, entry.Hash, extractHash(encoded))
}
func TestSelectAltTextMimeType(t *testing.T) {
tests := []struct {
mimes []string
expected string
}{
{[]string{"image/bmp", "TEXT", "text/html", "text/plain", "text/plain;charset=utf-8", "UTF8_STRING"}, "text/plain;charset=utf-8"},
{[]string{"image/png", "UTF8_STRING"}, "UTF8_STRING"},
{[]string{"image/png", "text/html"}, ""},
{[]string{"image/png"}, ""},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, selectAltTextMimeType(tt.mimes))
}
}
func TestStateEqual_BothNil(t *testing.T) {
assert.False(t, stateEqual(nil, nil))
}
+14 -10
View File
@@ -95,15 +95,17 @@ type SearchResult struct {
}
type Entry struct {
ID uint64 `json:"id"`
Data []byte `json:"data,omitempty"`
MimeType string `json:"mimeType"`
Preview string `json:"preview"`
Size int `json:"size"`
Timestamp time.Time `json:"timestamp"`
IsImage bool `json:"isImage"`
Hash uint64 `json:"hash,omitempty"`
Pinned bool `json:"pinned"`
ID uint64 `json:"id"`
Data []byte `json:"data,omitempty"`
MimeType string `json:"mimeType"`
Preview string `json:"preview"`
Size int `json:"size"`
Timestamp time.Time `json:"timestamp"`
IsImage bool `json:"isImage"`
Hash uint64 `json:"hash,omitempty"`
Pinned bool `json:"pinned"`
AltData []byte `json:"altData,omitempty"`
AltMimeType string `json:"altMimeType,omitempty"`
}
type State struct {
@@ -153,7 +155,9 @@ type Manager struct {
notifierWg sync.WaitGroup
lastState *State
dbusConn *dbus.Conn
// lazily created by dbusConnForFlatpak under dbusConnMutex
dbusConn *dbus.Conn
dbusConnMutex sync.Mutex
}
func (m *Manager) GetState() State {
+23 -10
View File
@@ -229,6 +229,9 @@ func (m *Manager) snapshotState() CUPSState {
func (m *Manager) Subscribe(id string) chan CUPSState {
ch := make(chan CUPSState, 64)
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
wasEmpty := true
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
wasEmpty = false
@@ -237,19 +240,25 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
m.subscribers.Store(id, ch)
if wasEmpty && m.subscription != nil {
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
} else {
m.eventWG.Add(1)
go m.eventHandler()
}
if !wasEmpty || m.subscription == nil {
return ch
}
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
return ch
}
m.eventWG.Add(1)
go m.eventHandler()
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
@@ -260,18 +269,22 @@ func (m *Manager) Unsubscribe(id string) {
return false
})
if isEmpty && m.subscription != nil {
m.subscription.Stop()
m.eventWG.Wait()
if !isEmpty || m.subscription == nil {
return
}
m.subscription.Stop()
m.eventWG.Wait()
}
func (m *Manager) Close() {
close(m.stopChan)
m.subLifecycleMu.Lock()
if m.subscription != nil {
m.subscription.Stop()
}
m.subLifecycleMu.Unlock()
m.eventWG.Wait()
m.notifierWg.Wait()
+64
View File
@@ -1,6 +1,9 @@
package cups
import (
"errors"
"fmt"
"sync"
"testing"
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
@@ -75,6 +78,67 @@ func TestManager_Subscribe(t *testing.T) {
assert.Equal(t, 0, count)
}
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
// unsynchronized so overlapping Start/Stop trips the race detector
type stubSubscription struct {
mu sync.Mutex
events chan SubscriptionEvent
conn *int
running bool
}
func (s *stubSubscription) Start() error {
if s.running {
return errors.New("already running")
}
s.running = true
s.mu.Lock()
s.events = make(chan SubscriptionEvent)
s.mu.Unlock()
v := 0
s.conn = &v
*s.conn++
return nil
}
func (s *stubSubscription) Stop() {
if !s.running {
return
}
s.running = false
s.conn = nil
s.mu.Lock()
close(s.events)
s.mu.Unlock()
}
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
s.mu.Lock()
defer s.mu.Unlock()
return s.events
}
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
m.subscription = &stubSubscription{}
var wg sync.WaitGroup
for i := range 8 {
wg.Go(func() {
id := fmt.Sprintf("client-%d", i)
for range 50 {
m.Subscribe(id)
m.Unsubscribe(id)
}
})
}
wg.Wait()
}
func TestManager_Close(t *testing.T) {
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+12
View File
@@ -37,6 +37,9 @@ func (sm *SubscriptionManager) Start() error {
return fmt.Errorf("subscription manager already running")
}
sm.running = true
// replace the channel closed by the previous Stop(); doing it here rather
// than in Stop() guarantees a lagging eventHandler still observes the close
sm.eventChan = make(chan SubscriptionEvent, 100)
sm.mu.Unlock()
subID, err := sm.createSubscription()
@@ -206,6 +209,8 @@ func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEven
}
func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent {
sm.mu.Lock()
defer sm.mu.Unlock()
return sm.eventChan
}
@@ -228,6 +233,13 @@ func (sm *SubscriptionManager) Stop() {
}
sm.stopChan = make(chan struct{})
// the writer (notificationLoop) joined above, so closing is safe; without
// this close Manager.eventHandler never returns and Unsubscribe deadlocks
// on eventWG.Wait(). Start() allocates the replacement.
sm.mu.Lock()
close(sm.eventChan)
sm.mu.Unlock()
}
func (sm *SubscriptionManager) cancelSubscription() {
@@ -38,6 +38,8 @@ func (sm *DBusSubscriptionManager) Start() error {
return fmt.Errorf("subscription manager already running")
}
sm.running = true
// replaced here rather than in Stop(); see SubscriptionManager.Start()
sm.eventChan = make(chan SubscriptionEvent, 100)
sm.mu.Unlock()
conn, err := dbus.ConnectSystemBus()
@@ -252,6 +254,8 @@ func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) Subscriptio
}
func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent {
sm.mu.Lock()
defer sm.mu.Unlock()
return sm.eventChan
}
@@ -278,6 +282,12 @@ func (sm *DBusSubscriptionManager) Stop() {
}
sm.stopChan = make(chan struct{})
// the writer (dbusListenerLoop) joined above, so closing is safe; see
// SubscriptionManager.Stop()
sm.mu.Lock()
close(sm.eventChan)
sm.mu.Unlock()
}
func (sm *DBusSubscriptionManager) cancelSubscription() {
+1
View File
@@ -79,6 +79,7 @@ type Manager struct {
client CUPSClientInterface
pkHelper PkHelper
subscription SubscriptionManagerInterface
subLifecycleMu sync.Mutex
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan CUPSState]
stopChan chan struct{}
+4
View File
@@ -201,6 +201,10 @@ func handleListNames(conn net.Conn, req models.Request, m *Manager) {
}
func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) {
if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
clientID = id
}
bus, err := params.String(req.Params, "bus")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
+19 -4
View File
@@ -137,22 +137,25 @@ func (m *Manager) consumeSelfEcho(value uint32) bool {
}
func (m *Manager) watchSettingsChanges() {
conn, err := dbus.ConnectSessionBus()
if err != nil {
log.Warnf("color-scheme watcher: session bus connect: %v", err)
// reuse the shared session connection; a dedicated one was unreachable
// from Close() and leaked with this goroutine
if m.sessionConn == nil {
return
}
conn := m.sessionConn
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(dbusPortalSettingsInterface),
dbus.WithMatchMember("SettingChanged"),
); err != nil {
log.Warnf("Failed to watch portal settings changes: %v", err)
conn.Close()
return
}
signals := make(chan *dbus.Signal, 64)
m.stateMutex.Lock()
m.settingsSignals = signals
m.stateMutex.Unlock()
conn.Signal(signals)
for sig := range signals {
@@ -309,6 +312,18 @@ func (m *Manager) Close() {
m.systemConn.Close()
}
if m.sessionConn != nil {
m.sessionConn.RemoveMatchSignal(
dbus.WithMatchInterface(dbusPortalSettingsInterface),
dbus.WithMatchMember("SettingChanged"),
)
m.stateMutex.Lock()
signals := m.settingsSignals
m.settingsSignals = nil
m.stateMutex.Unlock()
if signals != nil {
m.sessionConn.RemoveSignal(signals)
close(signals)
}
m.sessionConn.Close()
}
}
@@ -71,4 +71,6 @@ type Manager struct {
screensaverGnomeClaimed bool
selfEchoMu sync.Mutex
selfEchoes []colorSchemeEcho
// registered on sessionConn by watchSettingsChanges; guarded by stateMutex
settingsSignals chan *dbus.Signal
}
+24 -11
View File
@@ -22,13 +22,14 @@ type ConnectionStateChecker interface {
}
type IWDAgent struct {
conn *dbus.Conn
objPath dbus.ObjectPath
prompts PromptBroker
onUserCanceled func()
onPromptRetry func(ssid string)
lastRequestSSID string
stateChecker ConnectionStateChecker
conn *dbus.Conn
objPath dbus.ObjectPath
prompts PromptBroker
onUserCanceled func()
onPromptRetry func(ssid string)
takePendingSecret func(ssid string) (string, bool)
lastRequestSSID string
stateChecker ConnectionStateChecker
}
const iwdAgentIntrospectXML = `
@@ -119,6 +120,13 @@ func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Err
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
}
if a.takePendingSecret != nil {
if psk, ok := a.takePendingSecret(ssid); ok {
a.lastRequestSSID = ssid
return psk, nil
}
}
if a.prompts == nil {
if a.onUserCanceled != nil {
a.onUserCanceled()
@@ -126,19 +134,24 @@ func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Err
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
}
if a.lastRequestSSID == ssid {
if a.onPromptRetry != nil {
a.onPromptRetry(ssid)
}
retry := a.lastRequestSSID == ssid
if retry && a.onPromptRetry != nil {
a.onPromptRetry(ssid)
}
a.lastRequestSSID = ssid
reason := ""
if retry {
reason = "wrong-password"
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
token, err := a.prompts.Ask(ctx, PromptRequest{
SSID: ssid,
Fields: []string{"psk"},
Reason: reason,
})
if err != nil {
if a.onUserCanceled != nil {
@@ -24,6 +24,7 @@ const (
type connectAttempt struct {
ssid string
netPath dbus.ObjectPath
saved bool
start time.Time
deadline time.Time
sawAuthish bool
@@ -53,6 +54,37 @@ type IWDBackend struct {
attemptMutex sync.RWMutex
recentScans map[string]time.Time
recentScansMu sync.Mutex
pendingPSK *pendingReplacementPSK
pendingPSKMu sync.Mutex
}
type pendingReplacementPSK struct {
ssid string
psk string
expires time.Time
}
func (b *IWDBackend) storePendingPSK(ssid, psk string) {
b.pendingPSKMu.Lock()
b.pendingPSK = &pendingReplacementPSK{
ssid: ssid,
psk: psk,
expires: time.Now().Add(30 * time.Second),
}
b.pendingPSKMu.Unlock()
}
func (b *IWDBackend) takePendingPSK(ssid string) (string, bool) {
b.pendingPSKMu.Lock()
defer b.pendingPSKMu.Unlock()
pending := b.pendingPSK
if pending == nil || pending.ssid != ssid || time.Now().After(pending.expires) {
return "", false
}
b.pendingPSK = nil
return pending.psk, true
}
func NewIWDBackend() (*IWDBackend, error) {
@@ -18,6 +18,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
}
agent.onUserCanceled = b.OnUserCanceledPrompt
agent.onPromptRetry = b.OnPromptRetry
agent.takePendingSecret = b.takePendingPSK
b.iwdAgent = agent
}
@@ -1,6 +1,7 @@
package network
import (
"context"
"testing"
"time"
@@ -314,6 +315,133 @@ func TestConnectAttempt_Finalization(t *testing.T) {
backend.stateMutex.RUnlock()
}
func TestIWDBackend_PendingPSK(t *testing.T) {
backend, _ := NewIWDBackend()
_, ok := backend.takePendingPSK("Home")
assert.False(t, ok)
backend.storePendingPSK("Home", "newpass")
_, ok = backend.takePendingPSK("Other")
assert.False(t, ok, "pending PSK should not match a different SSID")
psk, ok := backend.takePendingPSK("Home")
assert.True(t, ok)
assert.Equal(t, "newpass", psk)
_, ok = backend.takePendingPSK("Home")
assert.False(t, ok, "pending PSK should be consumed on take")
backend.storePendingPSK("Home", "newpass")
backend.pendingPSKMu.Lock()
backend.pendingPSK.expires = time.Now().Add(-time.Second)
backend.pendingPSKMu.Unlock()
_, ok = backend.takePendingPSK("Home")
assert.False(t, ok, "expired pending PSK should not be returned")
}
type fakePromptBroker struct {
asked chan PromptRequest
reply PromptReply
replyErr error
}
func (f *fakePromptBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
f.asked <- req
return "token", nil
}
func (f *fakePromptBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
return f.reply, f.replyErr
}
func (f *fakePromptBroker) Resolve(token string, reply PromptReply) error { return nil }
func (f *fakePromptBroker) Cancel(path string, setting string) error { return nil }
func TestIWDBackend_BadCredentialsSavedNetwork_PromptsReplacement(t *testing.T) {
backend, _ := NewIWDBackend()
backend.state = &BackendState{}
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{Cancel: true},
}
backend.promptBroker = broker
att := &connectAttempt{
ssid: "Home",
netPath: "/test",
saved: true,
start: time.Now(),
deadline: time.Now().Add(15 * time.Second),
}
backend.finalizeAttempt(att, "bad-credentials")
select {
case req := <-broker.asked:
assert.Equal(t, "Home", req.SSID)
assert.Equal(t, "wrong-password", req.Reason)
assert.Equal(t, []string{"psk"}, req.Fields)
case <-time.After(time.Second):
t.Fatal("expected replacement credentials prompt for saved network")
}
}
func TestIWDBackend_BadCredentialsUnsavedNetwork_NoReplacementPrompt(t *testing.T) {
backend, _ := NewIWDBackend()
backend.state = &BackendState{}
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{Cancel: true},
}
backend.promptBroker = broker
att := &connectAttempt{
ssid: "Home",
netPath: "/test",
start: time.Now(),
deadline: time.Now().Add(15 * time.Second),
}
backend.finalizeAttempt(att, "bad-credentials")
select {
case <-broker.asked:
t.Fatal("unsaved network should not trigger a replacement prompt")
case <-time.After(100 * time.Millisecond):
}
}
func TestIWDBackend_BadCredentialsAfterPromptRetry_NoReplacementPrompt(t *testing.T) {
backend, _ := NewIWDBackend()
backend.state = &BackendState{}
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{Cancel: true},
}
backend.promptBroker = broker
att := &connectAttempt{
ssid: "Home",
netPath: "/test",
saved: true,
sawPromptRetry: true,
start: time.Now(),
deadline: time.Now().Add(15 * time.Second),
}
backend.finalizeAttempt(att, "bad-credentials")
select {
case <-broker.asked:
t.Fatal("attempt that already prompted should not trigger a replacement prompt")
case <-time.After(100 * time.Millisecond):
}
}
func TestConnectAttempt_DoubleFinalization(t *testing.T) {
backend, _ := NewIWDBackend()
backend.state = &BackendState{}
@@ -1,10 +1,12 @@
package network
import (
"context"
"fmt"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5"
)
@@ -476,6 +478,73 @@ func (b *IWDBackend) finalizeAttempt(att *connectAttempt, code string) {
if b.onStateChange != nil {
b.onStateChange()
}
if code == errdefs.ErrBadCredentials {
b.maybeReplaceSavedPSK(att)
}
}
func (b *IWDBackend) maybeReplaceSavedPSK(att *connectAttempt) {
if b.promptBroker == nil || !att.saved {
return
}
att.mu.Lock()
prompted := att.sawPromptRetry
att.mu.Unlock()
if prompted {
return
}
b.sigWG.Add(1)
go func() {
defer b.sigWG.Done()
b.requestReplacementPSK(att.ssid)
}()
}
func (b *IWDBackend) requestReplacementPSK(ssid string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
go func() {
select {
case <-b.stopChan:
cancel()
case <-ctx.Done():
}
}()
token, err := b.promptBroker.Ask(ctx, PromptRequest{
SSID: ssid,
SettingName: "802-11-wireless-security",
Fields: []string{"psk"},
Reason: "wrong-password",
})
if err != nil {
log.Warnf("failed to request replacement credentials for %s: %v", ssid, err)
return
}
reply, err := b.promptBroker.Wait(ctx, token)
if err != nil || reply.Cancel {
return
}
psk, ok := reply.Secrets["psk"]
if !ok || psk == "" {
return
}
if err := b.ForgetWiFiNetwork(ssid); err != nil {
log.Warnf("failed to forget %s before credential replacement: %v", ssid, err)
}
b.storePendingPSK(ssid, psk)
if err := b.ConnectWiFi(ConnectionRequest{SSID: ssid}); err != nil {
log.Warnf("failed to reconnect %s with replacement credentials: %v", ssid, err)
}
}
func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) {
@@ -560,7 +629,7 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
return fmt.Errorf("no WiFi device available")
}
networkPath, err := b.findNetworkPath(req.SSID)
networkPath, saved, err := b.findNetworkPath(req.SSID)
if err != nil {
b.setConnectError(errdefs.ErrNoSuchSSID)
if b.onStateChange != nil {
@@ -572,6 +641,7 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
att := &connectAttempt{
ssid: req.SSID,
netPath: networkPath,
saved: saved,
start: time.Now(),
deadline: time.Now().Add(15 * time.Second),
}
@@ -619,26 +689,39 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
return nil
}
func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, error) {
func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, bool, error) {
obj := b.conn.Object(iwdBusName, iwdObjectPath)
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
if err != nil {
return "", err
return "", false, err
}
var netPath dbus.ObjectPath
saved := false
for path, interfaces := range objects {
if netProps, ok := interfaces[iwdNetworkInterface]; ok {
if nameVar, ok := netProps["Name"]; ok {
if name, ok := nameVar.Value().(string); ok && name == ssid {
return path, nil
netPath = path
}
}
}
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
if nameVar, ok := knownProps["Name"]; ok {
if name, ok := nameVar.Value().(string); ok && name == ssid {
saved = true
}
}
}
}
return "", fmt.Errorf("network not found")
if netPath == "" {
return "", false, fmt.Errorf("network not found")
}
return netPath, saved, nil
}
func (b *IWDBackend) DisconnectWiFi() error {
@@ -661,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
@@ -729,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 {
@@ -2,6 +2,7 @@ package network
import (
"fmt"
"maps"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -57,6 +58,11 @@ type NetworkManagerBackend struct {
wifiDev any
wifiDevices map[string]*wifiDeviceInfo
// devMutex guards ethernetDevices/wifiDevices (written by the signal pump,
// read by request handlers). Not reentrant — never hold it across calls
// into other backend methods.
devMutex sync.RWMutex
dbusConn *dbus.Conn
signals chan *dbus.Signal
sigWG sync.WaitGroup
@@ -185,12 +191,12 @@ func (b *NetworkManagerBackend) Initialize() error {
}
hwAddr, _ := w.GetPropertyHwAddress()
b.ethernetDevices[iface] = &ethernetDeviceInfo{
b.setEthernetDeviceInfo(iface, &ethernetDeviceInfo{
device: dev,
wired: w,
name: iface,
hwAddress: hwAddr,
}
})
if b.ethernetDevice == nil {
b.ethernetDevice = dev
@@ -214,12 +220,12 @@ func (b *NetworkManagerBackend) Initialize() error {
}
hwAddr, _ := w.GetPropertyHwAddress()
b.wifiDevices[iface] = &wifiDeviceInfo{
b.setWifiDeviceInfo(iface, &wifiDeviceInfo{
device: dev,
wireless: w,
name: iface,
hwAddress: hwAddr,
}
})
if b.wifiDevice == nil {
b.wifiDevice = dev
@@ -267,6 +273,80 @@ func (b *NetworkManagerBackend) Initialize() error {
return nil
}
func (b *NetworkManagerBackend) ethernetDevicesSnapshot() map[string]*ethernetDeviceInfo {
b.devMutex.RLock()
defer b.devMutex.RUnlock()
out := make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices))
maps.Copy(out, b.ethernetDevices)
return out
}
func (b *NetworkManagerBackend) wifiDevicesSnapshot() map[string]*wifiDeviceInfo {
b.devMutex.RLock()
defer b.devMutex.RUnlock()
out := make(map[string]*wifiDeviceInfo, len(b.wifiDevices))
maps.Copy(out, b.wifiDevices)
return out
}
func (b *NetworkManagerBackend) ethernetDeviceByIface(iface string) (*ethernetDeviceInfo, bool) {
b.devMutex.RLock()
defer b.devMutex.RUnlock()
info, ok := b.ethernetDevices[iface]
return info, ok
}
func (b *NetworkManagerBackend) wifiDeviceByIface(iface string) (*wifiDeviceInfo, bool) {
b.devMutex.RLock()
defer b.devMutex.RUnlock()
info, ok := b.wifiDevices[iface]
return info, ok
}
func (b *NetworkManagerBackend) setEthernetDeviceInfo(iface string, info *ethernetDeviceInfo) {
b.devMutex.Lock()
b.ethernetDevices[iface] = info
b.devMutex.Unlock()
}
func (b *NetworkManagerBackend) setWifiDeviceInfo(iface string, info *wifiDeviceInfo) {
b.devMutex.Lock()
b.wifiDevices[iface] = info
b.devMutex.Unlock()
}
// removeEthernetDeviceByPath deletes the device and returns a snapshot of
// what's left so the caller can pick a replacement without holding devMutex
func (b *NetworkManagerBackend) removeEthernetDeviceByPath(path dbus.ObjectPath) (removed *ethernetDeviceInfo, remaining map[string]*ethernetDeviceInfo, found bool) {
b.devMutex.Lock()
defer b.devMutex.Unlock()
for iface, info := range b.ethernetDevices {
if info.device.GetPath() != path {
continue
}
delete(b.ethernetDevices, iface)
remaining = make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices))
maps.Copy(remaining, b.ethernetDevices)
return info, remaining, true
}
return nil, nil, false
}
func (b *NetworkManagerBackend) removeWifiDeviceByPath(path dbus.ObjectPath) (removed *wifiDeviceInfo, remaining map[string]*wifiDeviceInfo, found bool) {
b.devMutex.Lock()
defer b.devMutex.Unlock()
for iface, info := range b.wifiDevices {
if info.device.GetPath() != path {
continue
}
delete(b.wifiDevices, iface)
remaining = make(map[string]*wifiDeviceInfo, len(b.wifiDevices))
maps.Copy(remaining, b.wifiDevices)
return info, remaining, true
}
return nil, nil, false
}
func (b *NetworkManagerBackend) Close() {
close(b.stopChan)
b.StopMonitoring()
@@ -323,7 +323,7 @@ func (b *NetworkManagerBackend) GetEthernetDevices() []EthernetDevice {
}
func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
info, ok := b.ethernetDevices[device]
info, ok := b.ethernetDeviceByIface(device)
if !ok {
return fmt.Errorf("ethernet device %s not found", device)
}
@@ -345,9 +345,10 @@ func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
}
func (b *NetworkManagerBackend) updateAllEthernetDevices() {
devices := make([]EthernetDevice, 0, len(b.ethernetDevices))
ethernetDevices := b.ethernetDevicesSnapshot()
devices := make([]EthernetDevice, 0, len(ethernetDevices))
for name, info := range b.ethernetDevices {
for name, info := range ethernetDevices {
state, _ := info.device.GetPropertyState()
connected := state == gonetworkmanager.NmDeviceStateActivated
driver, _ := info.device.GetPropertyDriver()
@@ -112,7 +112,7 @@ func (b *NetworkManagerBackend) startSignalPump() error {
return err
}
for _, info := range b.wifiDevices {
for _, info := range b.wifiDevicesSnapshot() {
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
@@ -124,7 +124,7 @@ func (b *NetworkManagerBackend) startSignalPump() error {
}
}
for _, info := range b.ethernetDevices {
for _, info := range b.ethernetDevicesSnapshot() {
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
@@ -227,7 +227,7 @@ func (b *NetworkManagerBackend) stopSignalPump() {
dbus.WithMatchMember("StateChanged"),
)
for _, info := range b.wifiDevices {
for _, info := range b.wifiDevicesSnapshot() {
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
@@ -235,7 +235,7 @@ func (b *NetworkManagerBackend) stopSignalPump() {
)
}
for _, info := range b.ethernetDevices {
for _, info := range b.ethernetDevicesSnapshot() {
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
@@ -550,12 +550,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
}
hwAddr, _ := w.GetPropertyHwAddress()
b.ethernetDevices[iface] = &ethernetDeviceInfo{
b.setEthernetDeviceInfo(iface, &ethernetDeviceInfo{
device: dev,
wired: w,
name: iface,
hwAddress: hwAddr,
}
})
if b.ethernetDevice == nil {
b.ethernetDevice = dev
@@ -573,12 +573,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
}
hwAddr, _ := w.GetPropertyHwAddress()
b.wifiDevices[iface] = &wifiDeviceInfo{
b.setWifiDeviceInfo(iface, &wifiDeviceInfo{
device: dev,
wireless: w,
name: iface,
hwAddress: hwAddr,
}
})
if b.wifiDevice == nil {
b.wifiDevice = dev
@@ -603,57 +603,49 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath)
)
}
for iface, info := range b.ethernetDevices {
if info.device.GetPath() == devicePath {
delete(b.ethernetDevices, iface)
if b.ethernetDevice != nil {
dev := b.ethernetDevice.(gonetworkmanager.Device)
if dev.GetPath() == devicePath {
b.ethernetDevice = nil
for _, remaining := range b.ethernetDevices {
b.ethernetDevice = remaining.device
break
}
if _, remaining, found := b.removeEthernetDeviceByPath(devicePath); found {
if b.ethernetDevice != nil {
dev := b.ethernetDevice.(gonetworkmanager.Device)
if dev.GetPath() == devicePath {
b.ethernetDevice = nil
for _, r := range remaining {
b.ethernetDevice = r.device
break
}
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.listEthernetConnections()
b.updatePrimaryConnection()
if b.onStateChange != nil {
b.onStateChange()
}
return
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.listEthernetConnections()
b.updatePrimaryConnection()
if b.onStateChange != nil {
b.onStateChange()
}
return
}
for iface, info := range b.wifiDevices {
if info.device.GetPath() == devicePath {
delete(b.wifiDevices, iface)
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
if dev.GetPath() == devicePath {
b.wifiDevice = nil
b.wifiDev = nil
for _, remaining := range b.wifiDevices {
b.wifiDevice = remaining.device
b.wifiDev = remaining.wireless
break
}
if _, remaining, found := b.removeWifiDeviceByPath(devicePath); found {
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
if dev.GetPath() == devicePath {
b.wifiDevice = nil
b.wifiDev = nil
for _, r := range remaining {
b.wifiDevice = r.device
b.wifiDev = r.wireless
break
}
}
b.updateAllWiFiDevices()
b.updateWiFiState()
if b.onStateChange != nil {
b.onStateChange()
}
return
}
b.updateAllWiFiDevices()
b.updateWiFiState()
if b.onStateChange != nil {
b.onStateChange()
}
return
}
}
@@ -76,7 +76,7 @@ func (b *NetworkManagerBackend) updateEthernetState() error {
var connectedIP string
var anyConnected bool
for name, info := range b.ethernetDevices {
for name, info := range b.ethernetDevicesSnapshot() {
state, err := info.device.GetPropertyState()
if err != nil {
continue
@@ -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 {
@@ -973,7 +995,7 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
}
func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
devInfo, ok := b.wifiDevices[device]
devInfo, ok := b.wifiDeviceByIface(device)
if !ok {
return fmt.Errorf("WiFi device not found: %s", device)
}
@@ -995,7 +1017,7 @@ func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
}
func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error {
devInfo, ok := b.wifiDevices[device]
devInfo, ok := b.wifiDeviceByIface(device)
if !ok {
return fmt.Errorf("WiFi device not found: %s", device)
}
@@ -1047,7 +1069,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
wifiConnected := b.state.WiFiConnected
b.stateMutex.RUnlock()
for name, devInfo := range b.wifiDevices {
for name, devInfo := range b.wifiDevicesSnapshot() {
state, _ := devInfo.device.GetPropertyState()
connected := state == gonetworkmanager.NmDeviceStateActivated
@@ -1211,7 +1233,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) {
if deviceName != "" {
devInfo, ok := b.wifiDevices[deviceName]
devInfo, ok := b.wifiDeviceByIface(deviceName)
if !ok {
return nil, fmt.Errorf("WiFi device not found: %s", deviceName)
}
@@ -1224,7 +1246,7 @@ func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*
dev := b.wifiDevice.(gonetworkmanager.Device)
iface, _ := dev.GetPropertyInterface()
if devInfo, ok := b.wifiDevices[iface]; ok {
if devInfo, ok := b.wifiDeviceByIface(iface); ok {
return devInfo, nil
}
+18
View File
@@ -43,6 +43,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleGetNetworkInfo(conn, req, manager)
case "network.qrcode":
handleGetNetworkQRCode(conn, req, manager)
case "network.qrcode-content":
handleGetNetworkQRCodeContent(conn, req, manager)
case "network.delete-qrcode":
handleDeleteQRCode(conn, req, manager)
case "network.ethernet.info":
@@ -341,6 +343,22 @@ func handleGetNetworkQRCode(conn net.Conn, req models.Request, manager *Manager)
models.Respond(conn, req.ID, content)
}
func handleGetNetworkQRCodeContent(conn net.Conn, req models.Request, manager *Manager) {
ssid, err := params.String(req.Params, "ssid")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
content, err := manager.GetWiFiQRContent(ssid)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, content)
}
func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) {
path, err := params.String(req.Params, "path")
if err != nil {
+4
View File
@@ -473,6 +473,10 @@ func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, err
return m.backend.GetWiFiNetworkDetails(ssid)
}
func (m *Manager) GetWiFiQRContent(ssid string) (string, error) {
return m.backend.GetWiFiQRCodeContent(ssid)
}
func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) {
content, err := m.backend.GetWiFiQRCodeContent(ssid)
if err != nil {
+3 -1
View File
@@ -5,12 +5,14 @@ import (
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
)
const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-"
func FormatWiFiQRString(securityType, ssid, password string) string {
return fmt.Sprintf("WIFI:T:%s;S:%s;P:%s;;", securityType, ssid, password)
return qrcode.WiFiString(securityType, ssid, password, false)
}
func qrCodePaths(ssid string) (themed, normal string) {
+3 -2
View File
@@ -117,11 +117,12 @@ func RouteRequest(conn net.Conn, req models.Request) {
}
if strings.HasPrefix(req.Method, "cups.") {
if cupsManager == nil {
mgr, err := ensureCupsManager()
if err != nil {
models.RespondError(conn, req.ID, "CUPS manager not initialized")
return
}
cups.HandleRequest(conn, req, cupsManager)
cups.HandleRequest(conn, req, mgr)
return
}
+106 -40
View File
@@ -8,10 +8,10 @@ import (
"net"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -41,7 +41,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
const APIVersion = 27
const APIVersion = 28
var CLIVersion = "dev"
@@ -84,8 +84,8 @@ var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client"
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
var cupsSubscribers syncmap.Map[string, bool]
var cupsSubscriberCount atomic.Int32
var cupsMu sync.Mutex
var cupsSubscriberCount int
func getSocketDir() string {
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
@@ -242,17 +242,55 @@ func InitializeAppPickerManager() error {
return nil
}
func InitializeCupsManager() error {
func initializeCupsManagerLocked() (bool, error) {
if cupsManager != nil {
return false, nil
}
manager, err := cups.NewManager()
if err != nil {
log.Warnf("Failed to initialize cups manager: %v", err)
return err
return false, err
}
cupsManager = manager
log.Info("CUPS manager initialized")
return nil
return true, nil
}
func ensureCupsManager() (*cups.Manager, error) {
cupsMu.Lock()
created, err := initializeCupsManagerLocked()
mgr := cupsManager
cupsMu.Unlock()
if err != nil {
return nil, err
}
if created {
notifyCapabilityChange()
}
return mgr, nil
}
func cupsAvailable() bool {
cupsMu.Lock()
defer cupsMu.Unlock()
return cupsManager != nil
}
func releaseCupsSubscriber() {
cupsMu.Lock()
cupsSubscriberCount--
var mgr *cups.Manager
if cupsSubscriberCount == 0 && cupsManager != nil {
mgr = cupsManager
cupsManager = nil
}
cupsMu.Unlock()
if mgr == nil {
return
}
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
mgr.Close()
notifyCapabilityChange()
}
func InitializeBrightnessManager() error {
@@ -398,6 +436,11 @@ func InitializeSysUpdateManager() error {
func handleConnection(conn net.Conn) {
defer conn.Close()
defer func() {
if r := recover(); r != nil {
log.Errorf("handleConnection panic recovered: panic=%v\n%s", r, debug.Stack())
}
}()
caps := getCapabilities()
capsData, _ := json.Marshal(caps)
@@ -415,10 +458,21 @@ func handleConnection(conn net.Conn) {
continue
}
go RouteRequest(conn, req)
go routeRequestRecovered(conn, req)
}
}
// routeRequestRecovered keeps a panicking handler from taking down the whole daemon
func routeRequestRecovered(conn net.Conn, req models.Request) {
defer func() {
if r := recover(); r != nil {
log.Errorf("RouteRequest panic recovered: method=%s panic=%v\n%s", req.Method, r, debug.Stack())
models.RespondError(conn, req.ID, "internal server error")
}
}()
RouteRequest(conn, req)
}
func getCapabilities() Capabilities {
caps := []string{"plugins"}
@@ -446,7 +500,7 @@ func getCapabilities() Capabilities {
caps = append(caps, "browser")
}
if cupsManager != nil {
if cupsAvailable() {
caps = append(caps, "cups")
}
@@ -516,7 +570,7 @@ func getServerInfo() ServerInfo {
caps = append(caps, "browser")
}
if cupsManager != nil {
if cupsAvailable() {
caps = append(caps, "cups")
}
@@ -581,6 +635,11 @@ func notifyCapabilityChange() {
func handleSubscribe(conn net.Conn, req models.Request) {
clientID := fmt.Sprintf("meta-client-%p", conn)
dbusClient := dbusClientID
if id, ok := models.Get[string](req, "clientId"); ok && id != "" {
dbusClient = id
}
var services []string
if servicesParam, ok := models.Get[[]any](req, "services"); ok {
for _, s := range servicesParam {
@@ -975,38 +1034,31 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}
if shouldSubscribe("cups") {
cupsSubscribers.Store(clientID+"-cups", true)
count := cupsSubscriberCount.Add(1)
cupsMu.Lock()
cupsSubscriberCount++
created, err := initializeCupsManagerLocked()
mgr := cupsManager
cupsMu.Unlock()
if count == 1 {
if err := InitializeCupsManager(); err != nil {
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
} else {
notifyCapabilityChange()
}
if err != nil {
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
} else if created {
notifyCapabilityChange()
}
if cupsManager != nil {
if mgr == nil {
releaseCupsSubscriber()
} else {
wg.Add(1)
cupsChan := cupsManager.Subscribe(clientID + "-cups")
cupsChan := mgr.Subscribe(clientID + "-cups")
go func() {
defer wg.Done()
defer func() {
cupsManager.Unsubscribe(clientID + "-cups")
cupsSubscribers.Delete(clientID + "-cups")
count := cupsSubscriberCount.Add(-1)
if count == 0 {
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
if cupsManager != nil {
cupsManager.Close()
cupsManager = nil
notifyCapabilityChange()
}
}
mgr.Unsubscribe(clientID + "-cups")
releaseCupsSubscriber()
}()
initialState := cupsManager.GetState()
initialState := mgr.GetState()
select {
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
case <-stopChan:
@@ -1249,10 +1301,10 @@ func handleSubscribe(conn net.Conn, req models.Request) {
if shouldSubscribe("dbus") && dbusManager != nil {
wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
dbusChan := dbusManager.SubscribeSignals(dbusClient)
go func() {
defer wg.Done()
defer dbusManager.UnsubscribeSignals(dbusClientID)
defer dbusManager.UnsubscribeSignals(dbusClient)
for {
select {
@@ -1316,9 +1368,12 @@ func cleanupManagers() {
if appPickerManager != nil {
appPickerManager.Close()
}
cupsMu.Lock()
if cupsManager != nil {
cupsManager.Close()
cupsManager = nil
}
cupsMu.Unlock()
if brightnessManager != nil {
brightnessManager.Close()
}
@@ -1676,10 +1731,21 @@ func Start(printDocs bool) error {
}()
go func() {
if err := InitializeBluezManager(); err != nil {
for {
err := InitializeBluezManager()
if err == nil {
notifyCapabilityChange()
return
}
log.Warnf("Bluez manager unavailable: %v", err)
} else {
notifyCapabilityChange()
if !errors.Is(err, bluez.ErrNoAdapter) {
return
}
if err := bluez.WaitForAdapter(); err != nil {
log.Warnf("Bluetooth adapter watch failed: %v", err)
return
}
log.Info("Bluetooth adapter appeared, initializing bluez manager")
}
}()
+47 -2
View File
@@ -2,6 +2,7 @@ package sysupdate
import (
"context"
"fmt"
"os/exec"
"regexp"
"strings"
@@ -31,7 +32,36 @@ func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
if err != nil {
return nil, err
}
return parseAptUpgradable(string(out)), nil
return filterAptHeld(parseAptUpgradable(string(out)), aptHeldPackages(ctx)), nil
}
// aptHeldPackages returns held packages, which apt-get upgrade never applies.
func aptHeldPackages(ctx context.Context) map[string]bool {
out, err := exec.CommandContext(ctx, "apt-mark", "showhold").Output()
if err != nil {
return nil
}
held := make(map[string]bool)
for line := range strings.SplitSeq(string(out), "\n") {
if name := strings.TrimSpace(line); name != "" {
held[name] = true
}
}
return held
}
func filterAptHeld(pkgs []Package, held map[string]bool) []Package {
if len(held) == 0 {
return pkgs
}
out := pkgs[:0]
for _, p := range pkgs {
if held[p.Name] {
continue
}
out = append(out, p)
}
return out
}
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
@@ -52,7 +82,22 @@ func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(
}
func aptUpgradeArgv(bin string, opts UpgradeOptions) []string {
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
ignored := shellSafeNames(opts.Ignored)
if len(ignored) == 0 {
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
}
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "sh", "-c", aptHoldScript(bin, ignored))
}
// aptHoldScript holds ignored packages only for the upgrade, leaving pre-existing user holds untouched.
func aptHoldScript(bin string, ignored []string) string {
names := strings.Join(ignored, " ")
return fmt.Sprintf(
`new=""; for p in %s; do apt-mark showhold | grep -qx "$p" || new="$new $p"; done; `+
`[ -n "$new" ] && apt-mark hold $new; `+
`%s upgrade -y; rc=$?; `+
`[ -n "$new" ] && apt-mark unhold $new; exit $rc`,
names, bin)
}
func parseAptUpgradable(text string) []Package {
@@ -70,3 +70,22 @@ libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
})
}
}
func TestFilterAptHeld(t *testing.T) {
pkgs := []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt"},
{Name: "linux-image-generic", Repo: RepoSystem, Backend: "apt"},
{Name: "zsh", Repo: RepoSystem, Backend: "apt"},
}
got := filterAptHeld(append([]Package(nil), pkgs...), map[string]bool{"linux-image-generic": true})
want := []Package{pkgs[0], pkgs[2]}
if !reflect.DeepEqual(got, want) {
t.Errorf("filterAptHeld() = %#v\nwant %#v", got, want)
}
unfiltered := filterAptHeld(append([]Package(nil), pkgs...), nil)
if !reflect.DeepEqual(unfiltered, pkgs) {
t.Errorf("filterAptHeld(nil held) = %#v\nwant %#v", unfiltered, pkgs)
}
}
+19 -1
View File
@@ -3,6 +3,7 @@ package sysupdate
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
@@ -52,7 +53,11 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun
}
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y")
argv := []string{bin, "upgrade", "--refresh", "-y"}
if len(opts.Ignored) > 0 {
argv = append(argv, "--exclude="+strings.Join(opts.Ignored, ","))
}
return privilegedArgv(opts, argv...)
}
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
@@ -65,9 +70,22 @@ func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
return string(out), nil
}
if detail := lastNonEmptyLine(string(out)); detail != "" {
return "", fmt.Errorf("%w: %s", err, detail)
}
return "", err
}
func lastNonEmptyLine(text string) string {
lines := strings.Split(text, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return line
}
}
return ""
}
func dnfCheckUpdatesArgv(bin string) []string {
subcommand := "check-update"
if bin == "dnf5" {
@@ -95,11 +95,21 @@ func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine f
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
return Run(ctx, flatpakUpgradeArgv(opts), RunOptions{OnLine: onLine})
}
func flatpakUpgradeArgv() []string {
return []string{"flatpak", "update", "-y", "--noninteractive"}
func flatpakUpgradeArgv(opts UpgradeOptions) []string {
argv := []string{"flatpak", "update", "-y", "--noninteractive"}
if len(opts.Ignored) == 0 {
return argv
}
// No exclude flag; update the already-filtered refs explicitly.
for _, p := range opts.Targets {
if p.Repo == RepoFlatpak && p.Ref != "" {
argv = append(argv, p.Ref)
}
}
return argv
}
func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package {
@@ -53,6 +53,41 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
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
}
}
type archHelperBackend struct {
id string
}
@@ -99,23 +134,27 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
return nil
}
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...)
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored)...)
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ")
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) []string {
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
if !includeAUR {
argv = append(argv, "--repo")
}
ignored = shellSafeNames(ignored)
if len(ignored) > 0 {
argv = append(argv, "--ignore", strings.Join(ignored, ","))
}
return argv
}
@@ -248,6 +287,10 @@ func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
if line == "" {
continue
}
// pacman -Qu / paru -Qua flag IgnorePkg entries with a trailing marker
if strings.HasSuffix(line, "[ignored]") {
continue
}
m := archUpdateLine.FindStringSubmatch(line)
if m == nil {
continue
@@ -92,6 +92,17 @@ foo`,
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "skips IgnorePkg entries",
input: `bat 0.26.0-1 -> 0.26.1-2
linux 6.18.0-1 -> 6.18.1-1 [ignored]
discord 0.0.108-1 -> 0.0.109-1 [ignored]`,
backendID: "pacman",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "extra whitespace tolerated",
input: " bat 0.26.0-1 -> 0.26.1-2 ",
@@ -4,7 +4,9 @@ import (
"context"
"encoding/xml"
"errors"
"fmt"
"os/exec"
"strings"
)
func init() {
@@ -81,5 +83,20 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu
}
func zypperUpgradeArgv(opts UpgradeOptions) []string {
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
ignored := shellSafeNames(opts.Ignored)
if len(ignored) == 0 {
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
}
return privilegedArgv(opts, "sh", "-c", zypperLockScript(ignored))
}
// zypperLockScript locks ignored packages only for the update, leaving pre-existing user locks untouched.
func zypperLockScript(ignored []string) string {
names := strings.Join(ignored, " ")
return fmt.Sprintf(
`new=""; for p in %s; do grep -qsE "^solvable_name:[[:space:]]*$p$" /etc/zypp/locks || new="$new $p"; done; `+
`[ -n "$new" ] && zypper --non-interactive al $new; `+
`zypper --non-interactive update; rc=$?; `+
`[ -n "$new" ] && zypper --non-interactive rl $new; exit $rc`,
names)
}

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