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

Compare commits

...

418 Commits

Author SHA1 Message Date
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
bbedward bdfd565b72 fix words 2026-07-08 12:53:54 -04:00
bbedward f3b698590c bump VERSION to 1.5.0 2026-07-08 12:13:41 -04:00
bbedward 7826d42ce4 i18n: sync terms and remove term freeze 2026-07-08 12:13:14 -04:00
bbedward 6cc30c289b dankinstall/arch: add dms-greeter-bin package 2026-07-08 12:12:20 -04:00
bbedward 43cc8e69d1 window rules: fix gating of window-rules to supported compositors 2026-07-08 12:10:05 -04:00
bbedward a62ae336e0 media: improve album art handling 2026-07-08 12:01:33 -04:00
bbedward e94af2a7aa dankinstall: add danksearch and dankcalendar as optional deps 2026-07-08 10:01:56 -04:00
bbedward bb9e69e0c1 lock: add support for readline/emacs style modifiers
fixes #2777
2026-07-08 09:16:35 -04:00
purian23 1cc9218ff6 fix(frame): update hairline seam in notification joined surface 2026-07-07 21:54:37 -04:00
Lucas 56712f46fa Add optional=true to niri configs (#2776)
* nix: update flake.lock

* nix/niri: use optional=true in includes

* nix: fix tests

* niri: update embedded config to add optional=true
2026-07-07 21:40:43 -04:00
bbedward 93168ee073 qs: launcher optimizations and weather tab optimizations 2026-07-07 21:16:09 -04:00
purian23 1724aedd49 fix(AudioService): update the default audio sink device handling
- Fixes #2764
2026-07-07 20:17:40 -04:00
bbedward d799175c07 media: tweak seek bar accent color 2026-07-07 18:36:38 -04:00
bbedward ee6f7b4798 qs/media: replace FBOs with ClippingRectangle and rewrite seekbar as a
shader
2026-07-07 18:16:26 -04:00
bbedward 0511cd19df qs: numerous performance optimizations
- ClippingRectangle usages
- Asynchronous loader usages
- Replace cava visualizers with shaders
2026-07-07 16:11:11 -04:00
bbedward 71b1901ab0 notepad: add inline tab renaming and bump tab size 2026-07-07 11:05:14 -04:00
purian23 32e2d96e55 fix(popout): reintroduce texture sizing during animation to prevent content shift 2026-07-07 10:01:05 -04:00
bbedward 647766b9fa dankinstall: xray false for niri in default config 2026-07-07 09:38:45 -04:00
bbedward 0cd8974110 i18n: add Korean 2026-07-07 09:32:15 -04:00
bbedward 0c4c5fc146 changelog: enable for 1.5 2026-07-07 09:16:54 -04:00
bbedward b447e16374 launcher: dont cache clipboard results and fix image previews 2026-07-06 23:57:27 -04:00
purian23 0bb8353a33 refactor(blurSync): reimplement blur sync in popouts & modals 2026-07-06 23:52:57 -04:00
purian23 19a7dcf17d feat: (void-linux): add dankinstall support for auto installs 2026-07-06 23:01:16 -04:00
bbedward 8a1acb63c9 launcher: improve clipboard preview performance
related #2769
2026-07-06 18:15:49 -04:00
bbedward 6f298d3f52 workspace: fix hiding index numbers on sway
fixes #2768
2026-07-06 16:20:31 -04:00
bbedward 3e481a566b wallpaper: more resilience to updatesEnabled missing expose/update
events
2026-07-06 15:57:19 -04:00
bbedward 405ea708b3 wallpaper: optimize caching and performance of wallpaper tab 2026-07-06 13:52:31 -04:00
bbedward cc0bec2682 dash: fix clock index hardcoded at tab 0 2026-07-06 13:06:30 -04:00
bbedward 240adfa28f calendar: support tasks/VTODO from dank calendar 2026-07-06 12:40:41 -04:00
bbedward c175f4823a hyprland: workaround for focus re-grabbing a closing surface
related #2577
2026-07-06 11:45:38 -04:00
bbedward 35613ebba8 worksapces: attempt to fix hyprland workspace stability in ScriptModel
related #2754
2026-07-06 11:27:49 -04:00
purian23 583e46f2f0 fix(imageCache): improve cache management & file handling 2026-07-06 00:35:00 -04:00
purian23 590555dfc6 fix(dankdash): update tabIndex reordering for media & weather widgets
- Fixes #2762
2026-07-06 00:33:42 -04:00
bbedward c11169eb4c matugen: only write accent-color if changed 2026-07-05 23:13:49 -04:00
bbedward bddcdb3f99 core/completions: handle QS timeouts instead of hanging
related #1404
2026-07-05 23:12:30 -04:00
purian23 6ec67cefd0 add(settings): enable focused window icon display by default 2026-07-05 22:45:35 -04:00
David Mireles 8d9d7ff0ef fix(notifications): dismiss popup reliably on user-initiated close paths (#2761)
The notification popup's close button (X), action button clicks, and the
cardClick body's else branch all set notificationData.popup = false
directly. This relies on wrapperConn.onPopupChanged firing startExit(),
which can be interrupted by four races:

1. enterDelay (160 ms Timer) starts notificationData.timer after the
   user clicks, on an already-orphan wrapper.
2. The dismiss Timer keeps running post-click and flips popup again.
3. wrapperConn.target is set imperatively in onNotificationDataChanged
   (line 293) and may be stale after NotificationPopupManager._sync()
   reorders wrappers.
4. exiting or _isDestroying stuck true gates wrapperConn.enabled.

Introduce a single helper dismissPopupReliably() that stops the timer,
sets popup = false, and kicks off startExit() via Qt.callLater as a
belt-and-suspenders fallback. Apply to the three vulnerable handlers.

This matches the existing upstream pattern used in the hover, contextMenu,
and Component.onDestruction paths (which all stop the timer before
mutating popup state).

Closes #2760
2026-07-05 22:01:31 -04:00
Scott McKendry acc39ceb16 fix(settings): missing vpn confirm delete modal (#2759) 2026-07-05 22:00:57 -04:00
bbedward 43863a86fb workspaces: use stable references for placeholders in ScriptModel to fix
animation jitter
related #2754
2026-07-05 21:59:52 -04:00
bbedward 36ad34a555 keybinds: fix lua parsing on Hyprland for non-static configuration
fixes #2753
2026-07-05 17:16:55 -04:00
bbedward 78e823e23a workspaces: skip placeholders in findClosestWorkspace
related #2754
2026-07-05 11:26:38 -04:00
bbedward b6a27dc713 wallpaper: rewrite CachingImage and make it work with file browser
fixes #2756
2026-07-05 11:17:21 -04:00
bbedward 79dfd34ca2 control center: fix height expansion animation being out of sync 2026-07-05 10:42:15 -04:00
bbedward eedba0e8c9 vpn: fix tls openvpn connection types
related #1344
2026-07-05 01:35:01 -04:00
bbedward 2b12292895 Revert "dankbar: keep blur enabled at opacity-0"
This reverts commit 8a5e7c78fb.

fixes #2750
2026-07-05 00:20:31 -04:00
bbedward dd7f4f3ac5 audio/bluez: update device aliases at runtime when renamed
fixes #1628
2026-07-04 23:00:22 -04:00
bbedward 3df801e3b2 keyboard layout: add support for sway
fixes #1681
2026-07-04 22:55:50 -04:00
purian23 74d220767d fix(greeter): update user cursor theme support
- Fixes #2054
2026-07-04 22:52:16 -04:00
bbedward a3fba8fcb6 matugen: make GTK4 live-refresh opt-in, off by default. Bump the settle
timeout
fixes #2552
2026-07-04 22:38:49 -04:00
purian23 33384e4d5d fix(niri): update custom keyboard shortcut parsing properties
Fixes #2187
2026-07-04 22:25:53 -04:00
purian23 78f2ea4642 fix(Pywalfox): update distro detection handling
- Fixes #2220
2026-07-04 22:17:55 -04:00
purian23 7dffe85e94 fix(iwd): update rfkill to allow backend management
- Fixes #2221
2026-07-04 21:57:27 -04:00
purian23 c5c3105469 fix(Bluetooth): update codec selection and error handling
Fixes #2240
2026-07-04 21:48:34 -04:00
bbedward 8f100960cf theme: fix gtk3 checkboxes in light mode
fixes #1555
2026-07-04 21:28:08 -04:00
bbedward 8a5e7c78fb dankbar: keep blur enabled at opacity-0 2026-07-04 20:57:07 -04:00
bbedward 8a85ab06a1 portal: prevent feedback loop with the gtk4 hack to make it react to
color scheme changes
2026-07-04 20:43:32 -04:00
bbedward 30ceda97a0 brightness: avoid targeting inactive panels
fixes #1127
2026-07-04 20:43:32 -04:00
purian23 fefc0afa6f fix(SystemTrayBar): improve menu position boundaries
- Fixes #2252
2026-07-04 20:24:59 -04:00
bbedward b01042d530 brightness/ddc: avoid re-probing already identified devices
related #2049
2026-07-04 20:12:27 -04:00
bbedward 7355cc54b2 display config: persist applied layout to auto profile and skip no-op re-apply on startup
related #2526
2026-07-04 20:03:11 -04:00
purian23 187b7ba8ff feat(Settings): add option to show icon for focused windows
- Closes #2122
2026-07-04 18:42:40 -04:00
purian23 c0e7c1b2b4 feat(Battery): add horizontal battery pill style option in widget settings 2026-07-04 18:41:52 -04:00
bbedward ea0c235d6e notepad: make dentry NoDisplay 2026-07-04 17:00:43 -04:00
bbedward 57a04cf409 i18n: add mechanism for blocking new terms 2026-07-04 16:51:28 -04:00
bbedward fd99558ce5 clipboard: implement virtual-keyboard-unstable-v1 to replace wtype for
pasting entries
2026-07-04 16:24:14 -04:00
purian23 81a4d3b4e0 feat(Notepad): add notepad desktop entry & IPC commands file handling 2026-07-04 16:10:56 -04:00
Scott McKendry bfda5f2d66 fix(lockscreen): clock off-center with single hour digit (#2745) 2026-07-04 15:18:34 -04:00
bbedward 65c4167ab2 display config: fix mixed ID/model identifiers
fixes #2736
2026-07-04 13:24:53 -04:00
bbedward e7bd8e4002 greeter: fix weather location sync
fixes #2303
2026-07-04 13:06:49 -04:00
bbedward 25a1b125b7 keyboard layout: add consistent helper to map layout codes to display
fixes #1849
2026-07-04 11:57:17 -04:00
bbedward d766c0773c theme: fix setting color mode when no wallpaper is set in auto mode
fixes #2239
2026-07-04 11:40:27 -04:00
Huỳnh Thiện Lộc 5d12962fd6 fix(clipboard): fix action buttons hover cursor shape (#2744) 2026-07-04 11:31:39 -04:00
bbedward 9911698237 lock: synchronize password inputs between displays and fix video
screensave/password focus
fixes #1933
2026-07-04 11:29:41 -04:00
Huỳnh Thiện Lộc 7e095415a7 refactor(battery): align status layout and add individual notification types (#2743)
* refactor(battery): restore status list layout and add individual notification types

* settings/battery: fix RTL display of top tablular-like view

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-07-04 11:20:44 -04:00
bbedward 68d5d7c5ed wallpaper: persist cycling state to disk
fixes #1010
2026-07-04 11:17:13 -04:00
Scott McKendry 7b1c542585 feat: lockscreen screen filter (#2740)
this makes use of the existing getFilteredScreens function to allow
users to specify which displays the lockscreen should appear on. prior
to this change, it was "all" or just one.
2026-07-04 11:04:59 -04:00
Kangheng Liu 52ed719489 fix: add missing vertical anchor for cava/music icon (#2742) 2026-07-04 10:58:55 -04:00
bbedward 6e508d416d core/wayland: fix fatal error handling when the socket becomes
unavailable
2026-07-04 10:52:52 -04:00
bbedward 518cc93276 weather: fix location search with han characters
fixes #2274
2026-07-04 10:48:08 -04:00
bbedward 8cb4e21716 animations: fix regression eliding button group text caused by recently
RTL fixes
fixes #2741
2026-07-04 10:44:02 -04:00
bbedward 19b44b1782 settings: make display config identify only show when interacting 2026-07-04 10:30:24 -04:00
purian23 0509694d78 fix(Notifications): update flexible image caching in DankCircularImage
- Fixes #2480
2026-07-04 01:26:56 -04:00
purian23 da44e07cf7 fix(International keyboards): update key mapping w/symbolic & shifted fallback support
- Fixes #2497
2026-07-04 01:13:49 -04:00
purian23 b34941e3b8 fix(keybinds): respect niri mod-key when capturing binds
Hardens #2224
2026-07-04 00:50:58 -04:00
purian23 c554d973ef fix(Hover): refactor & update hover tracking w/context menus
Fixes #2737
2026-07-04 00:08:58 -04:00
bbedward 2861cc89c6 idle: fix screensaver inhibit interfering with manually triggered
inhibitor
2026-07-03 23:27:19 -04:00
bbedward 1c5965f1e4 matugen: fix vscode text legibility on auto-complete menu
fixes #2198
2026-07-03 23:18:32 -04:00
bbedward 66d552b3d5 niri: re-work layer-rule to exclude bar for xray instead of using 2
rules
2026-07-03 23:13:25 -04:00
bbedward ebad8e2dda mango: add specific sorting for widgets using data from mango IPC
fixes #649
2026-07-03 23:11:34 -04:00
purian23 8dadf73d52 refactor: (modals/popouts): sync functions & improve blur calculations across modals 2026-07-03 22:20:10 -04:00
bbedward 9f77b3127c control center: fix brightness icon not showing up if no pinned devices
fixes #2488
2026-07-03 21:59:01 -04:00
bbedward 46f8457777 niri: make xdg pictures dir for screenshot, in case it doesnt exist
fixes #1577
2026-07-03 21:56:10 -04:00
Huỳnh Thiện Lộc 8465ed4311 feat(plugins): add update all CLI flag and settings updates dialog (#2682)
* feat(plugins): add update all CLI flag and settings update dialog

* feat(plugins): add comparison diff URL support and update button styling

* feat(plugins): skip system plugins in bulk CLI update

* fix(plugins): remove check shorthand to resolve conflict with config flag

* feat(plugins): inline update dialog, show version tags, restructure buttons
2026-07-03 21:51:39 -04:00
bbedward 6e3e178721 running apps: fix alignment of fallback icons in non-compact mode
fixes #1963
fixes #2024
2026-07-03 17:04:45 -04:00
bbedward 27230227ef tmux: fix new session hint
fixes #2556
2026-07-03 16:56:32 -04:00
purian23 7178563c2c fix(sway): correctly handle Sway named workspaces
- Fixes #2531
2026-07-03 16:33:44 -04:00
bbedward dfe309a543 dock: fix launcher button color override
fixes #2530
2026-07-03 16:29:06 -04:00
bbedward 04962ffed1 desktop widgets: fix rounding of system monitor graphs
fixes #2356
2026-07-03 15:50:06 -04:00
bbedward 5008406be8 display config: fix monitor names overflowing, add identity overlay to
ease configuration
fixes #1398
2026-07-03 15:45:58 -04:00
bbedward 930a36bcfc animations: add DankColorAnimation helper to properly animate between
colors with different alpha values
fixes #2720
2026-07-03 15:36:45 -04:00
bbedward 3253adb6dd workspace: fix display of named workspace + icon set on vertical bars
fixes #2067
2026-07-03 15:21:13 -04:00
purian23 b8fd52e00f fix(Notifications): update hasNotificationImage logic to support file paths
- Fixes #2533
2026-07-03 13:38:21 -04:00
bbedward 8e7126472e workspaces: fix hardcoded font weight to use configured value
fixes #2076
2026-07-03 12:10:45 -04:00
bbedward 61108b7668 i18n: numerous RTL layout fixes across DankDash and Settings 2026-07-03 11:57:09 -04:00
bbedward 09ca37c9fc workspace: fix occupied color in niri
fixes #2538
2026-07-03 11:21:50 -04:00
purian23 a2513e939a fix(NiriService): enhance column movement functions to focus on specified monitor
- Fixes #2498
2026-07-03 00:14:14 -04:00
purian23 a73380b310 fix(PowerMenu): defer keyboard hold actions to key release
- Fixes #2489
2026-07-03 00:02:35 -04:00
purian23 69d7c1fb13 fix(WorkspaceSwitcher): color handling w/animation transition
- Fixes #2720
2026-07-02 23:50:47 -04:00
bbedward d1eb655e46 theme: fix accent colors in light mode
fixes #2711
2026-07-02 23:38:14 -04:00
purian23 a3ecef7627 fix(PluginBrowser): simplify model binding for filtered plugins
- Fixes #2721
2026-07-02 23:01:23 -04:00
bbedward 186621ab81 lock: set LockedHint when locking with loginctl lock integration
- was previously read-only
fixes #2732
2026-07-02 22:57:09 -04:00
bbedward 31efe9b385 hyprland: fix named workspaces hiding from widget
fixes #2047
2026-07-02 22:49:09 -04:00
purian23 6cdb891551 feat(matugen): add color preview pallette & outline border option theme settings
- Closes #1738
2026-07-02 22:45:55 -04:00
bbedward 3bb3da228c dock: fix context menu overflow
fixes #2287
2026-07-02 22:12:02 -04:00
purian23 0c99e4b8d1 refactor(compositor): reduce unnecessary UI updates
- Defer updates when window state hasn't actually changed
- Cache frame-blocked state per screen instead of recalculating constantly
- keep popouts cache so reopening them is instant & clean up otherwise
2026-07-02 21:42:20 -04:00
bbedward 8b79d1dad3 notifications: fix blur on popout
fixes #2288
2026-07-02 21:32:10 -04:00
bbedward 85fa3326c1 blur: fix blur for goth corners in dankbar
fixes #2666
2026-07-02 20:27:42 -04:00
purian23 2fade1a617 fix(notepad): use surfaceVariantText color for line numbers 2026-07-02 17:14:32 -04:00
purian23 597ba597e5 feat(settings): implement greeter & lockscreen customization options
- New option to customize your font & wallpaper on the LockScreen
- New floating sync button if missed to explicitly sync on greeter changes
2026-07-02 17:13:18 -04:00
purian23 957020c9ea fix(nix): support declarative greeter auto-login (#2734) 2026-07-02 17:33:10 -03:00
bbedward ae520620ac idle: fix idle monitors firing when bar isnt visible + keep awake is
active
fixes #2698
2026-07-02 15:51:22 -04:00
bbedward 4c43bed2f8 lock: fix custom lock commands with lock before suspend and hibernate
fixes #2326
2026-07-02 15:41:05 -04:00
bbedward 2663bcd96c lock: add blinking cursor for text input
fixes #2316
2026-07-02 15:20:36 -04:00
purian23 e423e17807 fix(greeter): update NixOS declarative configuration
- Report NixOS greeter state from `var/lib/dms-greeter`
- Allows `dms greeter status` use
- Prevent imperative greeter changes on module-managed NixOS systems
- Preserve immutable-distro sync policy
- Closes #2728
2026-07-02 15:18:53 -04:00
bbedward 70fa0ec903 dankbar: fix widget registration during visibility changes
fixes #1956
2026-07-02 15:07:43 -04:00
bbedward db3615805c process list: use onClosed signal to call hide()
fixes #2560
2026-07-02 14:55:09 -04:00
bbedward 8162e60475 clock/calendar: toggle clock enabled/disabled on resume from suspend
fixes #1298
fixes #2705
2026-07-02 14:53:23 -04:00
purian23 5594ec27ff fix(xray): fix dbar autohide w/xray blur
Fixes #2733
2026-07-02 14:08:52 -04:00
bbedward ca050ace9c battery: clamp percentage to 100
fixes #1834
2026-07-02 13:35:26 -04:00
bbedward 093acdbff7 theme: sweep everything that isnt using a spacing token from singleton,
add Theme.XXS
2026-07-02 12:00:34 -04:00
Legend-017 a98417d66b fix(spotlight): select last query text on open; last-query persistence in connected frame mode (#2726)
* fix(spotlight): Fixes 'remember last query' being ignored in connected frame mode."

* fix(spotlight): select remembered query text instead of placing cursor at end
2026-07-02 11:39:35 -04:00
Youseffo13 83b9cdcb27 fix(ui): resolve ui inconsistencies and missing i18n strings (#2719)
* Update WorkspacesTab.qml

* Update SettingsButtonGroupRow.qml

* Update WallpaperTab.qml

* Update WidgetsTabSection.qml

* add missing tooltips

* Update WallpaperTab.qml

* Update WorkspacesTab.qml
2026-07-02 11:27:15 -04:00
purian23 4ae8ef927c refactor(launcher): invert framemode emerge from top position 2026-07-02 11:18:33 -04:00
bbedward 2128e6f14e compositor/xray: re-add manual bar xray toggle 2026-07-02 10:59:34 -04:00
bbedward 9ff085c46a niri/xray: make bar xray implicit, add conflict detection for
configuration
2026-07-02 10:05:23 -04:00
purian23 57d08f6b3b refactor(Xray): Update Xray & standalone to frame transitions
- Fix dbar autohide with Xray options that could have blocked bar area content
- Fixes #2729
2026-07-01 21:39:47 -04:00
purian23 7964b66827 feat(Xray): Automatic Xray blur effects for Niri & Hyprland layouts
- Utilizes include layout within niri configs & hyprland's layout layer rules for lua configs
2026-07-01 16:15:02 -04:00
bbedward 38a03a4485 hyprland: general focus fixes related to #2561 2026-06-30 23:27:25 -04:00
purian23 a1727d18fd refactor(transparency): stop overwriting dms settings buttons/sliders by default
- Fixes #2692
2026-06-30 17:01:49 -04:00
purian23 aabcef0c9c refactor(theme): decouple foreground layers dep on blur on/off 2026-06-30 16:57:49 -04:00
purian23 19406e99b9 refactor(include): Add missing layout include & normalize config banners
- The Display config process ID now includes the output name in NiriService
2026-06-30 14:48:09 -04:00
bbedward 5432d264f1 ci: add guards to workflows 2026-06-30 13:15:01 -04:00
bbedward d704a0ba3d wallpaper: migrate auto-cycling scheduling to backend 2026-06-30 13:11:29 -04:00
bbedward 161118122e launcher: fix hyprland focus on context menu
fixes #2714
2026-06-30 10:04:44 -04:00
bbedward 9d550d7c11 desktop widgets: ensure creation order based on configured order,
improve drag and drop behavior in settings
fixes #2715
2026-06-30 09:51:00 -04:00
bbedward e40faaa442 calendar: use surfaceVariantText for adjacent month day numbers 2026-06-29 16:14:41 -04:00
bbedward 894efa58a3 theme: large consolidation of colors into Theme.qml, add
surfaceContainerLow/Lowest
2026-06-29 15:50:42 -04:00
bbedward 50570023ff ui: cleanup some anti patterns 2026-06-29 12:39:56 -04:00
bbedward f293def3a8 welcome page: fix to not shell our to external dms IPC 2026-06-29 12:12:17 -04:00
Huỳnh Thiện Lộc de3e8cffa0 feat(mpris): allow excluding specific media players by identity (#2712)
* feat(mpris): allow excluding specific media players by identity

* chore(translation): update settings search index and clean trailing whitespace
2026-06-29 12:01:19 -04:00
bbedward a874f76d72 popout: fix texture sizing during animation 2026-06-29 08:21:21 -04:00
bbedward 424d4ed701 run pre-commit fixes 2026-06-29 08:01:38 -04:00
purian23 8f2a50c00e workflow(void): update the cron schedules 2026-06-28 21:23:07 -04:00
purian23 46cddb965f distro(Void): Require dbus by default 2026-06-28 21:17:11 -04:00
purian23 1ca64abad9 void: remove dbl dms-git transform 2026-06-28 20:44:17 -04:00
purian23 32d68a034f void: automate XBPS key registration 2026-06-28 20:36:15 -04:00
purian23 1a176920d4 distros(Void): refactor to read danklinux repos for local builds 2026-06-28 20:10:49 -04:00
purian23 afa6fd9e2e distros(Void): Prep DMS to host Void packages 2026-06-28 19:54:24 -04:00
purian23 6bee1b2c86 feat(HoverMode): implement hover popout & launcher functionality in all modes
- New Hover toggle found in DankBar Settings
- New Hover to Reveal Launcher in FrameTab Settings
2026-06-27 22:47:38 -04:00
Maddison Cohodas 48f6a0c632 feat(bar): separate workspace appearance for unfocused displays (#2687)
* feat(bar): separate workspace appearance for unfocused displays

Add a toggle in the Workspace Appearance card to style workspace
indicators independently on displays that aren't currently focused.

The card is split into Focused Display and Unfocused Display(s) tabs.
When the new master toggle is off (default), unfocused displays inherit
the focused settings, preserving existing behavior. When on, unfocused
displays use their own colors (focused/occupied/unfocused/urgent) and
focused border (enable, color, thickness).

WorkspaceSwitcher resolves the focused monitor per compositor and routes
color/border resolution through isFocusedMonitor/useUnfocusedAppearance.

* refactor(bar): address review feedback for unfocused workspace appearance

- Consolidate focused-monitor lookup into BarWidgetService.getFocusedScreenName(),
  adding Mango support, and drop the duplicate compositor switches in
  WorkspaceSwitcher (effectiveScreenName + isFocusedMonitor now use the helper).
- Extract the shared color and border options into reusable
  WorkspaceAppearanceColorOptions and WorkspaceAppearanceBorderFields components
  so the focused and unfocused tabs no longer duplicate the layout.
- Gate the unfocused-display options on BarWidgetService.focusedScreenDetectionSupported
  and show an explanatory note on compositors without focus detection (e.g. labwc),
  instead of silently no-opping.
2026-06-26 23:34:24 -04:00
bbedward 03d86f78f4 core: improve how DMS handles multiple-sessions under the same user 2026-06-26 10:09:00 -04:00
purian23 0bc89429bc fix(qml): use explicit mouse params in modal MouseArea handlers 2026-06-25 21:32:47 -04:00
purian23 dd3eb71485 fix(wallpaper): Hyprland import for scrolling workspace sync 2026-06-25 21:32:27 -04:00
bbedward 016527fda9 plugins: fix plugin browser parentWindow and add similar support 2026-06-25 14:27:28 -04:00
bbedward d1f833fbbb greeter: fix single user artifact 2026-06-25 13:45:31 -04:00
purian23 fd65a8cea9 fix(BatteryService): charging state binding loop 2026-06-25 13:05:20 -04:00
purian23 bcd6006e25 refactor(WidgetsTab): port updated drag-and-drop functionality & cross section target ordering 2026-06-25 12:57:34 -04:00
purian23 cde0d83620 fix(WindowBlur): ensure blurRegion is cleared if BackgroundEffect exists 2026-06-25 12:56:21 -04:00
Bruno Rocha 92994061da skill(dms-plugin-dev): update for composite plugins, startupCheck, IPC discovery (#2699)
- Add composite plugin type with multi-surface components field
- Add startupCheck manifest field and dependency gating docs
- Add IPC runtime plugin discovery commands (plugin-scan target)
- Add getPluginPath() to data persistence reference
- Document bar reveal visibility optimization for widget plugins
- Sync plugin-schema.json with source, add dependencies field
- Bump skill version to 1.1
2026-06-25 11:53:07 -04:00
purian23 1e7ee25e13 dms(settings): few misc tweaks 2026-06-25 00:22:19 -04:00
purian23 838d21aae8 feat(DankDash): configurable drag-n-drop tab arrangement & hide options in dms settings 2026-06-24 23:33:35 -04:00
bbedward 2fc3b8ee4a compositor/workspace: fix DMS binding to ext-workspace-v1 even when we
don't use it
2026-06-24 14:22:05 -04:00
bbedward 52834c6a35 packaging: add com.danklinux.dms.desktop entry, remove custom icon
resolution
2026-06-24 10:01:45 -04:00
hecate cantus 1a39b7f66c shader-based scrolling wallpaper mode (#1802)
* feat: parallax-scroll wallpaper

Add a `Scrolling` wallpaper fill mode that translates the wallpaper crop
with the active workspace — like Android home-screen parallax, but along
niri's vertical workspace axis.

The image is scaled to cover the screen on its non-scroll axis, and the
active workspace index drives a fractional offset into the cropped
overflow along the scroll axis. Scroll position is spring-animated
CPU-side and handed to a minimal single-texture shader as a UV offset.
Per-monitor scroll position is published into SessionData so the lock
screen renders the same crop as the active workspace, keeping visual
continuity across lock/unlock.

Two implementation details worth calling out for review:

- QSG_USE_SIMPLE_ANIMATION_DRIVER=1 is exported to the spawned quickshell
  process. The default animation driver advances in fixed ~16ms steps,
  capping the scroll at 60Hz and desyncing it from compositor motion on
  high-refresh displays; the simple driver advances by real elapsed time,
  restoring native-refresh pacing. Removing it visibly regresses to 60Hz.

- The wallpaper survives wl_output rebind cycles (e.g. OLED image-cleaning
  on DPMS soft-off), which otherwise leave a stuck or void background.
  Recovery re-anchors the scroll target on output-lifecycle signals,
  rebuilds the ShaderEffect against the current render context, and
  re-attaches the wallpaper-layer surface on unlock for parallax-active
  monitors — guarded against lock state so the shader gets reliable frame
  hints.

* simplify bindings and gate lock screen shader in a loader

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-06-24 00:14:42 -04:00
purian23 aea5189abb fix: gate mango matugen and Cosmic config sync on active session 2026-06-23 23:22:42 -04:00
Kangheng Liu 774796ada2 launcher: add ctrl+H/L navigation in grid mode (#2688) 2026-06-23 23:06:38 -04:00
bbedward 14f21b6c6d qs: replace all which commands with command -v 2026-06-23 22:49:34 -04:00
bbedward 21ddc3cc3f popout: fix focus regression on close 2026-06-23 22:33:27 -04:00
bbedward 8b94b149b2 plugins: add startupCheck function 2026-06-23 22:12:13 -04:00
purian23 99cc3b8449 feat(wallpaper): preload thumbnails & switch/sort options
- add sorting and clickable page jump
- wrap-around page navigation
2026-06-23 21:39:06 -04:00
purian23 b2e728315b feat(pluginBrowser): Add inline image previews while browsing 2026-06-23 20:00:55 -04:00
bbedward bed11feaa4 plugins: enhance browser and CLI with new votes and labels 2026-06-23 14:48:43 -04:00
bbedward 28f40afccf fix(network): fix excessive network prompting on failures, add VPN
connection statuses
2026-06-23 13:58:58 -04:00
bbedward 0e7901ebbe keybinds: fix mouse wheel keysims on Hyprland and Mango
fixes #2683
2026-06-23 11:21:38 -04:00
bbedward 73f3a72d00 sysupdate: fix check scheduling
- deadline-based, fixes the issue before that would reset the interval
  without accounting for prior time waited
2026-06-23 09:55:39 -04:00
bbedward 63feabf517 i18n: add esperonoto and sync 2026-06-23 09:45:19 -04:00
Youseffo13 84f87dd7f3 fix: UI polishes, uptime formatting, and missing i18n strings (#2686)
* Update CompositorLayoutTab.qml

* add missing i18n strings

* fix context menu overflow and strip uptime prefix

* fix settings index

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-06-23 09:33:29 -04:00
Ralph Zhou 39c247d9cb fix(lock): add complete bundled PAM services (#2685) 2026-06-23 09:26:51 -04:00
NihilDigit a98c4f6c54 fix(network): show WiFi connection progress (#2684) 2026-06-23 09:19:37 -04:00
bbedward bed6622530 settings: fix index/pre-commit 2026-06-22 23:29:47 -04:00
bbedward c5e08a6f2c core: migrate to mockery v3 2026-06-22 23:27:29 -04:00
purian23 ad32af6eeb settings(dbar): add bar inset padding UI opts in framemode/stanalone
Fixes #2597
2026-06-22 21:10:36 -04:00
bbedward 02ced4b87b notifications: expireTimeout already comes in miliseconds 2026-06-22 15:03:49 -04:00
bbedward 5b28a63f75 portal: add bidirectional syncing with freedesktop color-scheme, read in
addition to the existing write
2026-06-22 14:52:10 -04:00
bbedward b5e2e68a22 greeter: redesign the switch-user/auto-login flow
- Fix visual glitches
- Make auto-login and switch user texts not always visible
- Make from-zero memory state not confusing
2026-06-22 13:50:50 -04:00
bbedward 556c0819c4 core/network: avoid endless probing when no network backend is present 2026-06-22 13:09:04 -04:00
purian23 b0edccb80e fix(greeter): restore legacy remembered sessions by desktop id
- Derive a desktop id from legacy remembered session paths
- Match remembered sessions by desktop id before falling back to path
2026-06-22 12:56:45 -04:00
bbedward 4e1c921ffb notifications: respect expireTimeout set by notifiers 2026-06-22 12:33:51 -04:00
purian23 fdbbcafb6c fix(greeter): avoid pinning auto-login session commands
- Store a desktop session identity instead of relying on raw Exec commands
- Resolve the current session desktop file when auto-login launches
- Preserve legacy memory compatibility while ignoring stale lastSessionExec
- Add regression coverage for stale /nix/store session paths
- Autologin users should rerun the process
2026-06-22 11:34:27 -04:00
Huỳnh Thiện Lộc a043d477fb feat(battery): add options to toggle percentage and remaining time (#2672)
* feat(battery): add options to toggle percentage and remaining time

* fix(battery): add SettingsSpec entries and combine verticalDisplayText

* fix(battery): format vertical battery remaining time as HH\nMM and center alignment

* fix(battery): remove percent sign from vertical layout when time is enabled
2026-06-21 22:12:48 -04:00
Huỳnh Thiện Lộc 4cbe766cbd feat(wallpaper): support configurable background color (#2671)
* feat(wallpaper): support configurable background color

* chore(translations): update settings search index

* revert(wallpaper): keep Pad naming instead of Center
2026-06-21 03:57:08 -04:00
Huỳnh Thiện Lộc 8610b915ec feat: add option to hide package source badges in launcher (#2676) 2026-06-21 03:51:44 -04:00
jbwfu 59fd6db83e Enhance/clipboard history interactions (#2668)
* feat(clipboard): add entry context menu

* feat(clipboard): add click to paste option

* feat(clipboard): add optional copy and paste action buttons

* fix(clipboard): default selection and esc behavior

* fix(clipboard): prevent clear and filter overlap

* Update to remove dead kb nav & add escape action on context menu

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-06-21 03:50:51 -04:00
purian23 465cf7355b distros(void linux): initial packaging support 2026-06-21 01:21:39 -04:00
bbedward de91b78943 theme/icons: add separate light/dark path, hot-reload, detect external
DMS maangedand reset
fixes #608
closes #2674
2026-06-20 15:23:19 -04:00
bbedward 4203148cab ci: add settings index check to pre-commit 2026-06-18 18:42:33 -04:00
bbedward 5adc0c464a toast: fix blur 2026-06-18 15:49:53 -04:00
Rocho 097290f7da fix(lock): show the faillock lockout reason instead of "incorrect password" (#2669)
When pam_faillock locks the account (DMS authenticates through the system PAM
stack: /etc/pam.d/login -> system-auth), the lock screen kept showing the
generic "Incorrect password - try again" even though the real cause is a
lockout, so a correct password looks rejected and only a reboot (which clears
the tmpfs /run/faillock tally) appears to help. See #2647.

The previous onMessageChanged only matched the *English* faillock strings
("The account is locked ...") and then wiped that text again on the trailing
pam_unix "Password:" prompt. On a non-English system (e.g. German) the strings
never matched, so the lockout was never surfaced at all.

Detect the notice by position rather than by text: pam emits its informational
messages within an attempt before the password prompt. Collect every non-prompt
info message and, once the prompt arrives, surface the collected lines (minus
the prompt itself) as lockMessage. If the stack short-circuits without ever
prompting (e.g. pam_faillock preauth configured as requisite), the notice is
surfaced on completion instead. This is locale-independent. A per-attempt flag
keeps the message stable across repeated locked attempts and retires it when an
attempt completes without a lockout (faillock reset / unlock_time elapsed).

Fixes #2647
2026-06-18 09:54:02 -04:00
Ira Limitanei 475ef5d1ca feat(launcher): make dms color picker entry toggleable (#2667) 2026-06-18 08:46:04 -04:00
Ralph Zhou 2f37019782 fix(popouts): keep Hyprland focus during close (#2655) 2026-06-18 01:41:25 -04:00
Rocho 9f4123cc3c fix(bar): clear blur region when the bar is hidden (auto-hide) (#2658)
When a bar with background transparency + blur uses auto-hide, the
ext-background-effect-v1 blur region was only slid off-surface via the
reveal Translate, but the region object stayed non-empty.

Hyprland gates layer-surface blur on `!m_blurRegion.empty()`, so the
non-empty region keeps blur enabled; the renderer then intersects the
off-surface region with the surface box, the clip degenerates to empty,
and an empty clip is treated as "unclipped" — so the whole bar surface
box gets blurred, leaving a blurred strip where the hidden bar would be.
(niri clips correctly, so it never showed there.)

Gate the published blur region on `barRevealed`: tear it down (null)
whenever the bar is not currently shown, so the region is genuinely
empty and the compositor disables the effect. Fixes #2656.
2026-06-18 00:58:19 -04:00
Yechiel 482a87a80d feat(bar): add visual indication for connected bluetooth (#2662) 2026-06-18 00:57:27 -04:00
jbwfu b925010cb3 fix(clipboard): paste selected history entry (#2660) 2026-06-18 00:57:07 -04:00
Huỳnh Thiện Lộc 085ce01da6 feat: port critical battery alert from DankBatteryAlerts plugin (#2664)
* feat: port critical battery alert from DankBatteryAlerts plugin

* ui: shorten notification type labels

* refactor: remove duplicate battery charge limit from Power & Sleep
2026-06-17 22:14:57 -04:00
Jonas Bloch 7af530de8f fix(ui): use primaryText instead of primary for text and icons displayed on primaryContainer background in the launcher (#2586)
* fix(ui): use primaryText instead of primary for text and icons displayed on primaryContainer background in the launcher

* fix(ui): use onPrimaryContainer instead of primaryText on primaryContainer background

* launcher pills: switch to buttonBg buttonText

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-06-17 21:58:11 -04:00
bbedward 9a4cff4e49 settings: fix index conflict on battery tab 2026-06-17 21:30:52 -04:00
Huỳnh Thiện Lộc 480ffa4ac2 feat: add battery settings tab and update battery widget interactions (#2634)
* feat: add battery settings tab and update battery widget interactions

* fix: import Quickshell.Io in BatteryTab.qml to fix Process type compilation error

* feat: move battery tab under media player and add notify when charge limit reached option

* chore: change default notification settings to false

* feat: move battery tab under Power & Security section in settings

* feat: add notification type button selection for battery alerts
2026-06-17 20:29:23 -04:00
dionjoshualobo d5ac0c9aa0 feat(clipboard): add type filters to clipboard history (#2640)
* feat(clipboard): add active filter state

* feat(clipboard): add clipboard filtering logic

* feat(clipboard): wire clipboard filter state to UI

* feat(clipboard): add filter dropdown

* feat(clipboard): move filter dropdown beside search

* refactor(clipboard): update filter defaults

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-06-17 17:27:45 -04:00
bbedward 29f19b07a9 widgets: fix tooltip position mapping 2026-06-17 12:46:24 -04:00
purian23 39301c534c add: DankColorSwatch component & settings sync 2026-06-17 11:38:25 -04:00
Rocho 58b9e4bda7 fix(dropdown): respect Animation Duration setting for DankDropdown popups (#2661)
DankDropdown popups opened and closed at a fixed speed regardless of the
configured Animation Duration. The Popup inherited Qt Material's default
enter/exit transitions, whose durations are hardcoded and never reference
Theme.shortDuration.

Override enter/exit with theme-driven transitions that keep the Material
grow/fade look (scale + opacity) but read their duration from
Theme.shortDuration, so every DankDropdown instance follows the
animation-speed setting.

Fixes #2659
2026-06-17 09:46:09 -04:00
purian23 820a9ce983 refactor(settings): colors & cleanup 2026-06-17 00:32:58 -04:00
purian23 68410e882d feat(dbar): add workspace & widget color customization options 2026-06-17 00:02:51 -04:00
bbedward f26c0af39a keybinds: add toggle to switch to FloatingWindow and back 2026-06-16 23:03:19 -04:00
Rocho 0ca451483f fix(shell): don't treat DPMS off/on as a screen-reconnect recovery storm (#2654)
A DPMS off/on cycle removes an output from Quickshell.screens and re-adds
it, which DMSShell's onScreensChanged cannot distinguish from a hotplug. It
fired triggerSurfaceRecovery() on every such event; on hardware where
recreating layer-shell surfaces re-wakes the just-powered-down output, this
drives an endless recovery storm that visibly power-cycles the monitor.

Route the screen-reconnect path through a 450 ms debounce (collapsing the
output-remove + re-add pair into a single pass) followed by a 4 s cooldown,
so repeated flaps trigger at most one recovery per window. Recovery still
runs once per resume, so the partial-DPMS-resume recovery added for #2579 is
preserved. The session-resume path runs its own recovery directly and now
clears any queued screen-reconnect recovery to avoid a redundant follow-up.

Fixes #2642
2026-06-16 22:58:21 -04:00
purian23 2849dd0ba2 feat(distros): Add Ubuntu 26.10 Stingray Support 2026-06-16 22:50:58 -04:00
bbedward df41ae4acb running apps: fix blurred tooltips 2026-06-16 18:26:29 -04:00
lingdianshiren 2692777707 fix(weather): robust location resolution with parallel fetch and multi-tier fallback (#2638)
Decouple weather data fetching from reverse geocoding so that
weather loads as soon as coordinates are available, even when
Nominatim is unreachable (e.g. mainland China).

- Fetch Open-Meteo weather immediately once lat/lon are known.
- Resolve city name in parallel via Nominatim -> Photon -> BigDataCloud.
- If all reverse geocoding fails, keep displaying weather with a
  placeholder city name.
- Skip reverse geocoding entirely when the user has configured a
  city name.
- Fall back to ip-api.com when GeoClue2 is unavailable or returns
  zero coordinates.
- Add request-generation tracking to discard stale geocoding
  responses after location changes.
- Hold explicit WeatherService refs in bar widget and dashboard tab.

Co-authored-by: lingdiansr <2077258365@qq.com>
2026-06-16 14:42:01 -04:00
Rocho ca1a45ccf8 fix(lock): dismiss fade-to-lock overlay when using a custom lock command (#2653)
When a custom lock command is configured, Lock.lock() runs the command and
returns early without engaging WlSessionLock, so IdleService.isShellLocked
never transitions true->false. That transition is the only trigger that
dismisses a completed FadeToLockWindow, so the fully-faded black overlay stays
on screen and the desktop is unusable after re-login (regression from b8f4c35,
which added the _completed guard and tied dismissal solely to isShellLocked).

Add a dedicated dismissFadeToLock signal that the custom-lock branch emits
after launching the external locker, mirroring the existing fade signal wiring,
so the overlay is handed off and torn down. The built-in WlSessionLock path is
unchanged and still dismisses on unlock.

Fixes #2595
2026-06-16 13:45:28 -04:00
jbwfu 2f39f248fc fix(network): keep Wi-Fi when password prompt is canceled (#2651) 2026-06-16 12:52:25 -04:00
Rocho 90f8ce5035 feat(sounds): make muting sounds during media playback configurable (#2652)
Commit e3dbaed started skipping all system sounds (notifications, volume,
power, login) whenever an MPRIS player is playing, with no way to opt out.
Users who rely on notification sounds while listening to music lost them
entirely.

Add a 'Mute During Playback' toggle (SettingsData.muteSoundsWhenMediaPlaying,
default true) so the current behaviour is preserved, but users can disable
it to restore audible sounds while media is playing. The media check is
wrapped in shouldMuteForMedia(), which gates on the new setting before
querying the active player.

Closes #2616
2026-06-16 12:49:51 -04:00
Adwait Adhikari cb29125580 add lexical-binding (#2649) 2026-06-16 09:19:38 -04:00
Rocho 988b54515e feat(tailscale): add connect/disconnect, exit-node and LAN-access controls (#2644)
* feat(tailscale): add connect/disconnect/exit-node/LAN-access backend

The Tailscale backend previously exposed only read-only status
(tailscale.getStatus, tailscale.refresh). This adds write actions through the
existing tailscale.com/client/local integration:

- tailscale.connect / tailscale.disconnect (EditPrefs WantRunning)
- tailscale.setExitNode (EditPrefs ExitNodeID; empty id clears it and any
  legacy ExitNodeIP, mirroring `tailscale set --exit-node`)
- tailscale.setAllowLanAccess (EditPrefs ExitNodeAllowLANAccess)

The manager's client interface gains GetPrefs/EditPrefs; fetchState merges
ExitNodeAllowLANAccess from prefs, and Peer exposes ExitNodeOption so the UI
can list exit-node-capable peers.

* feat(tailscale): expose the new actions in TailscaleService

Adds connectTailscale/disconnectTailscale, setExitNode/clearExitNode and
setAllowLanAccess wrappers, plus derived exitNodeOptions/currentExitNode and the
exitNodeAllowLanAccess state. Write-action errors surface via ToastService.

* feat(tailscale): add connection, exit-node and LAN-access controls to the widget

The control-center widget toggle was a no-op. It now connects/disconnects, and
the detail panel gains a connection status row with a connect/disconnect button,
an exit-node picker and a LAN-access toggle.
2026-06-16 09:08:22 -04:00
Rocho 2fd9de5062 fix(keybinds): record numpad keys as KP_* keysyms (#2645)
* fix(keybinds): record numpad keys as KP_* keysyms

The shortcut recorder passed only the Qt key code to xkbKeyFromQtKey and
dropped Qt.KeypadModifier. Since Qt reuses the same Qt::Key_* values for the
numpad and the main row / nav cluster, numpad presses collapsed onto their
twins: numpad-7 became "7" (NumLock on) or "Home" (NumLock off) instead of
"KP_7"/"KP_Home", numpad-+ became "Equal", numpad-* became "8", numpad Enter
became "Return". numpad-5 with NumLock off (Qt.Key_Clear) was missing from the
map entirely, so the capture was silently dropped.

niri and the other providers bind against the xkb KP_* keysym names, so these
tokens never matched the physical key.

Pass the keypad flag through to xkbKeyFromQtKey and map keypad presses to the
KP_* keysyms: KP_0..KP_9 for the NumLock-on digit codes, the navigation names
(KP_Home, KP_End, KP_Up, ...) for the NumLock-off codes, plus the operators
and KP_Enter. Main-row keys are unaffected because they never carry the keypad
modifier.

* fix(keybinds): ignore lock keys while capturing a shortcut

NumLock/CapsLock/ScrollLock are toggles, not useful bind targets. Pressing
NumLock to switch the numpad between its digit and navigation keysyms
(KP_7 vs KP_Home) was captured as the bind itself (e.g. "Super+Num_Lock").
Skip them in the recorder like the other pure modifier keys already are.
2026-06-16 09:07:48 -04:00
Rocho fd5aabcb17 fix(keybinds): parse niri configs with leading-underscore identifiers (#2646)
DMS reads the niri config with kdl-go, which rejects '_' as the first
character of a bare identifier ("unexpected character _") even though niri's
own parser and the KDL spec accept it. The common trigger is the
`_JAVA_AWT_WM_NONREPARENTING "1"` environment node (the standard Java /
tiling-WM fix). When the parse aborts, `dms keybinds show` returns nothing and
the Keyboard Shortcuts UI shows no binds at all.

Extend the existing preprocessor approach (the brace fix from #2230) with
quoteLeadingUnderscoreIdents, which double-quotes bare identifiers that begin
with '_' before the text reaches kdl-go. The scan is string/comment aware and
only touches a leading '_' at a token boundary, so mid-identifier underscores
(XDG_CURRENT_DESKTOP) and underscores inside strings/comments are left alone.
Token boundaries include the ends of block comments and KDL slashdash (/-), so
a node abutting a comment with no whitespace is handled too. This is safe
because the niri parser only dispatches on fixed node/section names that never
start with '_', so re-quoting such a name cannot change what DMS reads.

Refs #2230
2026-06-16 09:06:55 -04:00
jbwfu 85b63219b9 feat(network): add saved WiFi state to settings (#2648) 2026-06-16 09:06:29 -04:00
bbedward ddf943846f i18n: add Vietnamese 2026-06-15 23:47:12 -04:00
purian23 e7221ec623 fix(powermodal): use overlay layer in standalone mode 2026-06-15 21:41:00 -04:00
bbedward 78daaf0cb4 calendar: remove launch button from settings 2026-06-15 19:02:22 -04:00
jbwfu a6ab3bab4c fix(settings): dedupe search index tab entries (#2643) 2026-06-15 16:50:41 -04:00
bbedward 53cea7023f calendar: rename dcal binary 2026-06-15 15:26:06 -04:00
jbwfu a098088f03 refactor(settings): split network settings into tabs (#2633) 2026-06-15 15:21:02 -04:00
bbedward 59998e9fd2 calendar(dank): Add support for DankCalendar backend
- Add keyboard navigation to overview
- Add edit events to overview
- Add create events to overview
- Add setting for auto/khal/dankcalendar backend selection
2026-06-15 14:02:35 -04:00
purian23 1df7e478df fix(FileBrowser): Improve save-to-file handling w/safety override diags
Fixes #2641
2026-06-15 01:11:32 -04:00
Artrix 1fc4890857 tray: add automatic overflow popup (#2629) 2026-06-15 00:10:02 -04:00
purian23 f5d52f1506 update(ipc): docs & dms ipc list 2026-06-14 23:35:22 -04:00
purian23 2026ba5bd2 fix(Notepad): clean up edge cases & updated popout handling 2026-06-14 22:15:34 -04:00
purian23 db56c8d74d feat(Notepad): Complete refactor - New popout mode & settings
- Add a full popout Notepad experience w/new layout settings
- New ability to choose the left or right side of the screen
- Add more safegaurds to preserve user data throughout
- New banner to show file reload/conflict handling
- New extensionless file support
- Polish settings with gap controls, compact buttons, and shortcuts help
2026-06-14 20:20:36 -04:00
Huỳnh Thiện Lộc 9d1a81c93c feat(media-control): support scroll and right-click on audio output devices in media popout (#2615)
* feat(media-control): support scroll and right-click on audio output devices in media popout

* feat(media-control): make device list volume scrolling optional
2026-06-13 17:51:03 -04:00
bbedward 3701b3d7a3 wallpaper: re-introduce updatesEnabled 2026-06-12 20:00:53 -04:00
purian23 bae98daa5c fix(wallpaper): simplify wallpaper rendering logic & reliability
- Keep wallpaper surfaces persistent and remove `updatesEnabled` throttling that could leave wallpapers grey or frozen after DPMS, suspend, fullscreen, or output changes

Fixes #2612
Fixes #2299
Fixes #2272
Fixes #2028
2026-06-12 17:30:54 -04:00
jbwfu b34a04f723 fix(clipboard): hide pin action while keeping saved indicator (#2626) 2026-06-12 15:39:23 -04:00
purian23 1c0245f2db fix(translations): add newline at end of JSON file and output file 2026-06-12 15:06:36 -04:00
purian23 7777e87dc8 refactor(settings): reorg to break out sections and verbiage 2026-06-12 14:57:25 -04:00
jbwfu 820fa07846 feat(settings): add clipboard entry action visibility controls (#2621)
* feat(settings): add clipboard entry action visibility controls

* fix(clipboard): show pinned indicator for saved entries when pin action is hidden
2026-06-12 14:08:05 -04:00
purian23 66794582c9 fix(fullscreen): retain user dbar standalone configs while in framemode fullscreen 2026-06-12 12:39:38 -04:00
jbwfu 73eb471ae3 fix(clipboard): keep first item selected when navigating upward (#2622) 2026-06-12 11:35:07 -04:00
jbwfu 0f2f4b96c4 Fix/clipboard confirmation keyboard safety (#2623)
* fix(clipboard): improve confirmation dialog keyboard focus

* fix(clipboard): require confirmation for clear-all shortcut
2026-06-12 11:34:16 -04:00
purian23 d53809cf2b refactor(framemode): unify connected surface chrome via SDF pipeline
- Shadow system rewrite with SDF quads
- Replace ConnectedShape/layer FBOs w/frame & chrome SDF shaders
- Improve frame blur performance
- Plugin performance gate
2026-06-12 11:03:39 -04:00
796 changed files with 123165 additions and 29782 deletions
+135 -23
View File
@@ -2,15 +2,16 @@
name: dms-plugin-dev
description: >
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
manifest creation, QML component development, settings UI, data persistence, theme
integration, PopoutService usage, and external command execution. Use when the user wants
to create, modify, or debug a DMS plugin, or asks about the DMS plugin API.
Quickshell. Supports five plugin types: widget (bar + Control Center), daemon (background
service), launcher (search + actions), desktop (draggable desktop widgets), and composite
(multi-surface). Covers manifest creation, QML component development, startup checks,
settings UI, data persistence, theme integration, PopoutService usage, IPC runtime
discovery, and external command execution. Use when the user wants to create, modify,
or debug a DMS plugin, or asks about the DMS plugin API.
compatibility: Designed for Claude Code (or similar products)
metadata:
author: DankMaterialShell
version: "1.0"
version: "1.1"
domain: qml-desktop-development
framework: DankMaterialShell
languages: qml, javascript
@@ -37,14 +38,15 @@ integrations, and desktop widgets. Plugins are QML components discovered from
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
**Four plugin types:**
**Five plugin types:**
| Type | Purpose | Base Component | Bar pills | CC integration |
|------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
| Type | Purpose | Base Component | Bar pills | CC integration |
|-------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
## Step 1: Determine Plugin Type
@@ -58,6 +60,9 @@ Choose the type based on what the plugin does:
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
desktop layer.
- **Needs multiple surfaces?** - Use `composite`. A single plugin that registers any combination
of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own
QML component file.
## Step 2: Create the Manifest
@@ -78,7 +83,7 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
}
```
**With settings and permissions:**
**With settings, startup check, and permissions:**
```json
{
@@ -92,7 +97,31 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
"component": "./YourWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"startupCheck": "./StartupCheck.qml",
"requires_dms": ">=0.1.0",
"dependencies": ["mytool"],
"permissions": ["settings_read", "settings_write"]
}
```
**Composite plugin (multi-surface):**
```json
{
"id": "myComposite",
"name": "My Composite Plugin",
"description": "Daemon + widget + desktop from one plugin",
"version": "1.0.0",
"author": "Your Name",
"type": "composite",
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
"icon": "extension",
"components": {
"daemon": "./MyDaemon.qml",
"widget": "./MyBarWidget.qml",
"desktop": "./MyDesktopWidget.qml"
},
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
@@ -100,9 +129,11 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
**Key rules:**
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
- `version` must be semver (e.g., `1.0.0`)
- `component` must start with `./` and end with `.qml`
- `type: "launcher"` requires a `trigger` field
- Provide either `component` (single-surface) or `components` (multi-surface), not both
- `component` / component paths must start with `./` and end with `.qml`
- `type: "launcher"` (or a `components` object with a `launcher` key) requires a `trigger` field
- `settings_write` permission is **required** if the plugin has a settings component
- `dependencies` replaces the deprecated `requires` field
## Step 3: Create the Main Component
@@ -249,7 +280,69 @@ PluginComponent {
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
## Step 4: Add Settings (Optional)
### Composite
For composite plugins, create a separate QML file per surface. Each surface uses the same
base component as the corresponding single-surface type (PluginComponent for widget/daemon,
Item for launcher, etc.). All surfaces share the same `pluginId` and `pluginService`.
```
MyCompositePlugin/
plugin.json
MyBarWidget.qml # PluginComponent (widget surface)
MyDaemon.qml # PluginComponent (daemon surface)
MyDesktopWidget.qml # Item with desktop widget properties
Settings.qml # Shared settings for all surfaces
```
Use `pluginService.pluginHasSurface(pluginId, "widget")` to check whether a specific surface
is registered for a plugin at runtime.
## Step 4: Add Startup Check (Optional)
Gate plugin activation on dependency checks by providing a `startupCheck` component. This
runs before the plugin loads and blocks activation if a required tool or condition is missing.
Create a `StartupCheck.qml` (non-visual QtObject):
```qml
import QtQuick
import qs.Common
QtObject {
function check(done) {
Proc.runCommand("myPlugin.depCheck", ["sh", "-c", "command -v mytool"], (stdout, exitCode) => {
if (exitCode === 0) {
done(null);
return;
}
done({
"title": I18n.tr("mytool is required"),
"details": I18n.tr("Install 'mytool' and re-enable this plugin.")
});
});
}
}
```
The `done` callback accepts:
- `null` - allow activation
- A string - block with a short error message
- `{ title, details }` - block with a title and expandable details body
A synchronous variant (no `done` parameter, return the result directly) is also supported.
Failed checks show a toast error and store the error in `pluginService.pluginLoadErrors`.
Add to your manifest:
```json
{
"startupCheck": "./StartupCheck.qml",
"dependencies": ["mytool"]
}
```
## Step 5: Add Settings (Optional)
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
@@ -293,7 +386,7 @@ See [settings-components-reference.md](references/settings-components-reference.
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
## Step 5: Use Data Persistence
## Step 6: Use Data Persistence
Three tiers of persistence:
@@ -302,6 +395,7 @@ Three tiers of persistence:
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
| `pluginService.getPluginPath(id)` | N/A | Get the plugin's installation directory path |
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
@@ -309,7 +403,7 @@ Three tiers of persistence:
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
## Step 6: Theme Integration
## Step 7: Theme Integration
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
@@ -324,7 +418,7 @@ Always use `Theme.*` properties from `qs.Common` - never hardcode colors or size
See [theme-reference.md](references/theme-reference.md) for the complete property list.
## Step 7: Add Popout Content (Widgets Only)
## Step 8: Add Popout Content (Widgets Only)
Add a popout that opens when the bar pill is clicked:
@@ -360,7 +454,7 @@ PluginComponent {
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
## Step 8: Control Center Integration (Widgets Only)
## Step 9: Control Center Integration (Widgets Only)
Add your widget to the Control Center grid:
@@ -389,7 +483,7 @@ PluginComponent {
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
## Step 9: External Commands and Clipboard
## Step 10: External Commands and Clipboard
**Run commands and capture output:**
@@ -420,18 +514,33 @@ Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
## Step 10: Validate and Test
## Step 11: Validate and Test
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
3. Open Settings > Plugins > Scan for Plugins
4. Enable your plugin and add it to the DankBar layout
**Runtime plugin discovery via IPC:**
Plugins can be scanned, rescanned, and reloaded at runtime without restarting the shell:
```bash
dms ipc plugin-scan scan # Trigger a full rescan of all plugin directories
dms ipc plugin-scan rescan <id> # Force rescan of a specific plugin
dms ipc plugin-scan reload <id> # Force reload of a loaded plugin
dms ipc plugin-scan list # List all known plugins (TSV: id, loaded, type, name)
dms ipc plugin-scan status <id> # Get status of a specific plugin (TSV: loaded, type, error)
```
Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
**Common issues:**
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
- Widget not showing: ensure it's enabled AND added to a DankBar section
- Settings error: verify `settings_write` permission is declared
- Data not persisting: check pluginService injection and permissions
- Startup check failing: check `pluginService.pluginLoadErrors` or run `dms ipc plugin-scan status <id>`
## Common Mistakes
@@ -445,6 +554,9 @@ Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
8. **Forgetting `categories` in launcher items** - Items won't display without it
9. **Not handling null pluginService** - Always use optional chaining or null checks
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
11. **Using `requires` instead of `dependencies`** - `requires` is deprecated; use `dependencies`
12. **Providing both `component` and `components`** - Use one or the other, not both
13. **Missing `trigger` on composite with launcher surface** - Still required when `components` has a `launcher` key
## Quick Reference: Imports
@@ -11,8 +11,7 @@
"version",
"author",
"type",
"capabilities",
"component"
"capabilities"
],
"properties": {
"id": {
@@ -42,8 +41,8 @@
},
"type": {
"type": "string",
"description": "Plugin type",
"enum": ["widget", "daemon", "launcher", "desktop"]
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
"enum": ["widget", "daemon", "launcher", "desktop", "composite"]
},
"capabilities": {
"type": "array",
@@ -55,9 +54,37 @@
},
"component": {
"type": "string",
"description": "Relative path to main QML component file",
"description": "Relative path to main QML component file. Required unless 'components' is provided.",
"pattern": "^\\./.*\\.qml$"
},
"components": {
"type": "object",
"description": "Map of surface name to relative QML component path, for multi-surface (composite) plugins. Provide any subset of surfaces; each is loaded independently.",
"properties": {
"widget": {
"type": "string",
"description": "Bar/Control Center widget component (PluginComponent)",
"pattern": "^\\./.*\\.qml$"
},
"desktop": {
"type": "string",
"description": "Desktop widget component",
"pattern": "^\\./.*\\.qml$"
},
"daemon": {
"type": "string",
"description": "Background daemon component (instantiated once)",
"pattern": "^\\./.*\\.qml$"
},
"launcher": {
"type": "string",
"description": "Launcher provider component (requires 'trigger')",
"pattern": "^\\./.*\\.qml$"
}
},
"additionalProperties": false,
"minProperties": 1
},
"trigger": {
"type": "string",
"description": "Trigger string for launcher activation (required for launcher type)"
@@ -71,14 +98,26 @@
"description": "Path to settings component QML file",
"pattern": "^\\./.*\\.qml$"
},
"startupCheck": {
"type": "string",
"description": "Path to a non-visual (QtObject) component exposing a check(done) function that gates activation. done(null) allows; done(error) blocks, where error is a string or { title, details }.",
"pattern": "^\\./.*\\.qml$"
},
"requires_dms": {
"type": "string",
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
},
"dependencies": {
"type": "array",
"description": "Array of required system tools/dependencies (registry metadata)",
"items": {
"type": "string"
}
},
"requires": {
"type": "array",
"description": "Array of required system tools/dependencies",
"description": "Deprecated alias for 'dependencies'.",
"items": {
"type": "string"
}
@@ -109,6 +148,29 @@
"then": {
"required": ["trigger"]
}
},
{
"if": {
"required": ["components"],
"properties": {
"components": {
"required": ["launcher"]
}
}
},
"then": {
"required": ["trigger"]
}
},
{
"anyOf": [
{
"required": ["component"]
},
{
"required": ["components"]
}
]
}
],
"additionalProperties": true
@@ -166,6 +166,16 @@ function increment() {
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
## Plugin Path
Retrieve a plugin's installation directory at runtime:
```qml
var dir = pluginService.getPluginPath(pluginId)
```
Returns the absolute path to the plugin's directory (e.g., `~/.config/DankMaterialShell/plugins/MyPlugin`), or an empty string if the plugin is not found. Useful for loading bundled assets (images, data files) relative to the plugin's location.
## Important Notes
1. **pluginData is reactive** - bindings update automatically when data changes
@@ -9,15 +9,22 @@
| `description` | string | Short description (shown in UI) | Non-empty |
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
| `author` | string | Creator name or email | Non-empty |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop` |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
| `capabilities` | array | Plugin capabilities | At least 1 string item |
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
One of `component` or `components` is required (not both):
| Field | Type | Description | Validation |
|-------|------|-------------|------------|
| `component` | string | Path to main QML file (single-surface plugins) | Must start with `./`, end with `.qml` |
| `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher` |
## Conditional Requirements
| Condition | Required Field | Description |
|-----------|---------------|-------------|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
| `components` has `launcher` key | `trigger` | Same requirement applies to composite plugins with a launcher surface |
## Optional Fields
@@ -25,8 +32,10 @@
|-------|------|-------------|
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
| `startupCheck` | string | Path to a QtObject component that gates plugin activation via a `check(done)` function (must start with `./`, end with `.qml`). See Startup Check section below. |
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
| `dependencies` | array | System tool dependencies (e.g., `["curl", "jq"]`). Registry metadata. |
| `requires` | array | Deprecated alias for `dependencies` |
| `permissions` | array | Required permissions |
| `trigger` | string | Launcher trigger string (required for launcher type) |
@@ -53,6 +62,65 @@ Capabilities are free-form strings that describe what the plugin does. Common va
- `ai` - AI/LLM integration
- `slideout` - uses slideout panel
## Startup Check
The `startupCheck` field points to a non-visual `QtObject` component that gates plugin activation on dependency checks. The component must expose a `check(done)` function:
```qml
import QtQuick
import qs.Common
QtObject {
function check(done) {
Proc.runCommand("myPlugin.depCheck", ["sh", "-c", "command -v mytool"], (stdout, exitCode) => {
if (exitCode === 0) {
done(null);
return;
}
done({
"title": I18n.tr("mytool is required"),
"details": I18n.tr("Install 'mytool' and re-enable this plugin.")
});
});
}
}
```
The `done` callback accepts:
- `null` - allow activation
- A string - block with a short error message
- `{ title, details }` - block with a title and expandable details
A synchronous variant (no `done` parameter, return the result directly) is also supported.
Failed startup checks show a toast error and store the error in `pluginService.pluginLoadErrors`.
## Components (Composite Plugins)
The `components` field maps surface names to QML paths, allowing a single plugin to register multiple surfaces:
```json
{
"id": "myComposite",
"name": "My Composite Plugin",
"description": "Daemon + widget + desktop from one plugin",
"version": "1.0.0",
"author": "Developer Name",
"type": "composite",
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
"icon": "extension",
"components": {
"daemon": "./MyDaemon.qml",
"widget": "./MyBarWidget.qml",
"desktop": "./MyDesktopWidget.qml"
},
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
## Complete Example
```json
@@ -67,8 +135,9 @@ Capabilities are free-form strings that describe what the plugin does. Common va
"component": "./MyWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"startupCheck": "./StartupCheck.qml",
"requires_dms": ">=0.1.18",
"requires": ["curl", "jq"],
"dependencies": ["curl", "jq"],
"permissions": ["settings_read", "settings_write", "process", "network"]
}
```
@@ -235,10 +235,12 @@ Conditionally show/hide the bar pill:
```qml
PluginComponent {
visibilityCommand: "pgrep -x myapp"
visibilityInterval: 5000 // check every 5 seconds
visibilityInterval: 5 // seconds between checks
}
```
**Bar reveal optimization:** The visibility timer automatically pauses while the bar is hidden (auto-hide mode) and resumes checks when the bar is revealed. This is handled via the internal `_barRevealed` property - no plugin code needed. Plugins using `visibilityCommand` with `visibilityInterval` benefit from this automatically.
## Popout Namespace
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
+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"
+1
View File
@@ -24,6 +24,7 @@ jobs:
check-updates:
name: Check for updates
runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
+1
View File
@@ -24,6 +24,7 @@ jobs:
check-updates:
name: Check package/series updates
runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
+340
View File
@@ -0,0 +1,340 @@
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 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
required: false
default: false
build_git:
description: 'Build dms-git package'
type: boolean
required: false
default: true
build_dms:
description: 'Build stable dms package'
type: boolean
required: false
default: true
build_greeter:
description: 'Build stable dms-greeter package'
type: boolean
required: false
default: true
permissions:
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
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: Record checked-out source commit
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Download current R2 repository
run: |
aws --version
bash .github/scripts/publish-void-r2.sh download
- name: Install XBPS Static Utilities
run: |
mkdir -p ${{ github.workspace }}/xbps
curl -s -L https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz | tar -xJ -C ${{ github.workspace }}/xbps
echo "${{ github.workspace }}/xbps/usr/bin" >> $GITHUB_PATH
- name: Clone void-packages
run: |
git clone --depth=1 https://github.com/void-linux/void-packages.git
- name: Inject templates
run: |
cp -R distro/void/srcpkgs/dms void-packages/srcpkgs/
cp -R distro/void/srcpkgs/dms-greeter void-packages/srcpkgs/
cp -R distro/void/srcpkgs/dms-git void-packages/srcpkgs/
- name: Enable unprivileged user namespaces (Ubuntu 24.04)
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- name: Bootstrap xbps-src
run: |
cd void-packages
./xbps-src binary-bootstrap
- name: Configure signing keys and trust
run: |
# Write private key; extract and register the public key
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
chmod 600 /tmp/xbps_privkey.pem
# Extract public key in PEM format
openssl rsa -in /tmp/xbps_privkey.pem -pubout -out /tmp/dms-key.pub
rm -f /tmp/xbps_privkey.pem
# Compute MD5 fingerprint in colon-separated hex format
FINGERPRINT=$(openssl rsa -pubin -in /tmp/dms-key.pub -outform DER 2>/dev/null | openssl dgst -md5 -c | tr '[:upper:]' '[:lower:]' | awk '{print $NF}')
# Format key in XML property list (plist) format as expected by xbps
mkdir -p /tmp/keys
cat <<EOF > "/tmp/keys/${FINGERPRINT}.plist"
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>public-key</key>
<data>$(base64 -w0 /tmp/dms-key.pub)</data>
<key>public-key-size</key>
<integer>4096</integer>
<key>signature-by</key>
<string>AvengeMedia</string>
</dict>
</plist>
EOF
# Copy keys to all host and chroot trust stores
sudo mkdir -p /var/db/xbps/keys
sudo cp "/tmp/keys/${FINGERPRINT}.plist" "/var/db/xbps/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/masterdir/var/db/xbps/keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/masterdir/var/db/xbps/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/etc/xbps.d/keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/etc/xbps.d/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/common/repo-keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/common/repo-keys/${FINGERPRINT}.plist"
rm -rf /tmp/keys /tmp/dms-key.pub
- name: Configure repositories
run: |
# Append the repository to repos-remote templates so xbps-src translates it automatically
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 "r2-repo/current" ]; then
mkdir -p void-packages/hostdir/binpkgs
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 r2-repo/current
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
BUILD_GIT="${{ github.event.inputs.build_git || 'true' }}"
FORCE_REBUILD="${{ github.event.inputs.force_rebuild || 'false' }}"
if [ "${{ github.event_name }}" = "schedule" ]; then
BUILD_DMS="false"
BUILD_GREETER="false"
BUILD_GIT="true"
fi
if [ "${{ github.event_name }}" = "release" ]; then
BUILD_DMS="true"
BUILD_GREETER="true"
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"
echo "Git build enabled: $BUILD_GIT"
echo "Force rebuild: $FORCE_REBUILD"
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)
GIT_VER=$(git -C .. describe --tags --always | sed 's/^v//; s/-/./g')
echo "🔨 Preparing dms-git version $GIT_VER"
# Stage source tarball in the xbps-src source cache.
# --transform adds a top-level directory so xbps-src can extract
# into $wrksrc (create_wrksrc=yes handles the rest).
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
mkdir -p "$SRC_CACHE"
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
--exclude=void-packages \
--exclude=r2-repo \
--exclude=.git \
--exclude=danklinux \
-C .. .
CHECKSUM=$(sha256sum "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" | cut -d' ' -f1)
# Dynamically patch template version, checksum, and distfiles
sed -i "s/^version=.*/version=${GIT_VER}/" srcpkgs/dms-git/template
sed -i "s/^checksum=.*/checksum=${CHECKSUM}/" srcpkgs/dms-git/template
sed -i "s|^distfiles=.*|distfiles=\"dms-git-${GIT_VER}.tar.gz\"|" srcpkgs/dms-git/template
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
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 "../r2-repo/current/${EXPECTED_GIT_FILE}"
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/
fi
fi
# 2. Build stable dms package
if [ "$BUILD_DMS" = "true" ]; then
STABLE_VER=$(grep -E '^version=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
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 "../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 "../r2-repo/current/${EXPECTED_DMS_FILE}"
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/
fi
fi
# 3. Build stable dms-greeter package
if [ "$BUILD_GREETER" = "true" ]; then
GREETER_VER=$(grep -E '^version=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
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 "../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 "../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 r2-repo/current
rm -f *.sig
# Guard: nothing to index if no .xbps files exist
if ! ls *.xbps 1>/dev/null 2>&1; then
echo "⚠️ No .xbps files found to index, skipping."
exit 0
fi
# 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
chmod 600 /tmp/xbps_privkey.pem
xbps-rindex --sign --signedby "AvengeMedia <AvengeMedia.US@gmail.com>" --privkey /tmp/xbps_privkey.pem $(pwd)
xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps
rm -f /tmp/xbps_privkey.pem
- 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 }}
+12
View File
@@ -110,6 +110,9 @@ bin/
# Core dumps
core.*
# prek-installed local git hooks (generated from .pre-commit-config.yaml)
.githooks/
# direnv
.envrc
.direnv/
@@ -117,3 +120,12 @@ quickshell/dms-plugins
__pycache__
.vscode/
# Void (xbps) build artifacts
*.xbps
distro/void/temp/
distro/void/hostdir/
distro/void/masterdir*/
# Often gets built
core/dms
+16
View File
@@ -20,6 +20,22 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
pass_filenames: false
- repo: local
hooks:
- id: settings-search-index
name: settings search index is up to date
entry: bash -c 'python3 quickshell/translations/extract_settings_index.py >/dev/null || exit 1; if ! git diff --exit-code -- quickshell/translations/settings_search_index.json; then echo "settings_search_index.json is out of date; run quickshell/translations/extract_settings_index.py and stage the result" >&2; exit 1; fi'
language: system
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false
- repo: local
hooks:
- id: i18n-term-freeze
name: i18n term freeze (no new I18n.tr/qsTr terms)
entry: python3 quickshell/translations/check_term_freeze.py
language: system
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
pass_filenames: false
- repo: local
hooks:
- id: no-console-in-qml
+11 -7
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"
@@ -74,10 +74,12 @@ install-icon:
@echo "Icon installed"
install-desktop:
@echo "Installing desktop entry..."
@echo "Installing desktop entries..."
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entry installed"
@echo "Desktop entries installed"
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
@echo ""
@@ -116,10 +118,12 @@ uninstall-icon:
@echo "Icon removed"
uninstall-desktop:
@echo "Removing desktop entry..."
@echo "Removing desktop entries..."
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entry removed"
@echo "Desktop entries removed"
uninstall: uninstall-systemd uninstall-desktop uninstall-icon uninstall-completions uninstall-shell uninstall-bin
@echo ""
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=DankMaterialShell
GenericName=Desktop Shell
Comment=Material 3 inspired shell for Wayland compositors
Exec=dms run
Icon=danklogo
Terminal=false
NoDisplay=true
StartupNotify=false
Categories=Utility;
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=DMS Notepad
GenericName=Text Editor
Comment=Quick notes and text files in the dank desktop shell
Exec=dms ipc call notepad openFile %f
Icon=danklogo
Terminal=false
NoDisplay=true
Categories=Utility;TextEditor;
MimeType=text/plain;text/markdown;
StartupNotify=false
+65 -62
View File
@@ -1,82 +1,85 @@
with-expecter: true
dir: "internal/mocks/{{.InterfaceDirRelative}}"
mockname: "Mock{{.InterfaceName}}"
outpkg: "{{.PackageName}}"
dir: internal/mocks/{{.InterfaceDirRelative}}
structname: Mock{{.InterfaceName}}
pkgname: '{{.SrcPackageName}}'
filename: 'mock_{{.InterfaceName}}.go'
template: testify
template-data:
unroll-variadic: true
packages:
github.com/Wifx/gonetworkmanager/v2:
interfaces:
NetworkManager:
Device:
DeviceWireless:
AccessPoint:
Connection:
Settings:
ActiveConnection:
IP4Config:
net:
interfaces:
Conn:
github.com/AvengeMedia/danklinux/internal/plugins:
interfaces:
GitClient:
github.com/godbus/dbus/v5:
interfaces:
BusObject:
github.com/AvengeMedia/danklinux/internal/server/brightness:
config:
dir: "internal/mocks/brightness"
outpkg: mocks_brightness
interfaces:
DBusConn:
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config:
dir: "internal/mocks/geolocation"
outpkg: mocks_geolocation
dir: internal/mocks/geolocation
pkgname: mocks_geolocation
interfaces:
Client:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config:
dir: "internal/mocks/network"
outpkg: mocks_network
interfaces:
Backend:
Client: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
config:
dir: "internal/mocks/cups"
outpkg: mocks_cups
dir: internal/mocks/cups
pkgname: mocks_cups
interfaces:
CUPSClientInterface:
CUPSClientInterface: {}
PkHelper:
config:
dir: "internal/mocks/cups_pkhelper"
outpkg: mocks_cups_pkhelper
dir: internal/mocks/cups_pkhelper
pkgname: mocks_cups_pkhelper
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
config:
dir: "internal/mocks/evdev"
outpkg: mocks_evdev
dir: internal/mocks/evdev
pkgname: mocks_evdev
interfaces:
EvdevDevice:
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
EvdevDevice: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config:
dir: "internal/mocks/version"
outpkg: mocks_version
dir: internal/mocks/network
pkgname: mocks_network
interfaces:
VersionFetcher:
Backend: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
config:
dir: "internal/mocks/wlcontext"
outpkg: mocks_wlcontext
dir: internal/mocks/wlcontext
pkgname: mocks_wlcontext
interfaces:
WaylandContext:
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
config:
dir: "internal/mocks/wlclient"
outpkg: mocks_wlclient
interfaces:
WaylandDisplay:
WaylandContext: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config:
dir: "internal/mocks/utils"
outpkg: mocks_utils
dir: internal/mocks/utils
pkgname: mocks_utils
interfaces:
AppChecker:
AppChecker: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
config:
dir: internal/mocks/version
pkgname: mocks_version
interfaces:
VersionFetcher: {}
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
config:
dir: internal/mocks/wlclient
pkgname: mocks_wlclient
interfaces:
WaylandDisplay: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
interfaces:
GitClient: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
config:
dir: internal/mocks/brightness
pkgname: mocks_brightness
interfaces:
DBusConn: {}
github.com/Wifx/gonetworkmanager/v2:
interfaces:
AccessPoint: {}
ActiveConnection: {}
Connection: {}
Device: {}
DeviceWireless: {}
IP4Config: {}
NetworkManager: {}
Settings: {}
github.com/godbus/dbus/v5:
interfaces:
BusObject: {}
net:
interfaces:
Conn: {}
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v2.10.1
rev: v2.12.2
hooks:
- id: golangci-lint-fmt
require_serial: true
+6 -3
View File
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
Command-line interface and daemon for shell management and system control.
**dankinstall**
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
## System Integration
@@ -193,7 +193,7 @@ Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory
## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, Void (and derivatives)
**Arch Linux**
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
@@ -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.
@@ -214,4 +214,7 @@ Most packages available in standard repos. Minimal building required.
**Gentoo**
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
**Void Linux**
Uses XBPS with the DMS and DankLinux self-hosted repositories.
See installer output for distribution-specific details during installation.
+8
View File
@@ -23,6 +23,8 @@ var (
replaceConfigs []string
replaceConfigsAll bool
yes bool
danksearch bool
dankcalendar bool
)
var rootCmd = &cobra.Command{
@@ -49,6 +51,8 @@ func init() {
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service")
rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar")
}
func main() {
@@ -74,6 +78,8 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
"replace-configs",
"replace-configs-all",
"yes",
"danksearch",
"dankcalendar",
}
var set []string
for _, name := range headlessOnly {
@@ -109,6 +115,8 @@ func runHeadless() error {
ReplaceConfigs: replaceConfigs,
ReplaceConfigsAll: replaceConfigsAll,
Yes: yes,
DankSearch: danksearch,
DankCalendar: dankcalendar,
}
runner := headless.NewRunner(cfg)
+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 {
+18 -1
View File
@@ -63,6 +63,15 @@ var clipPasteCmd = &cobra.Command{
Run: runClipPaste,
}
var clipSendPasteCmd = &cobra.Command{
Use: "send-paste",
Short: "Send a paste keystroke to the focused window",
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
Run: runClipSendPaste,
}
var clipSendPasteShift bool
var clipWatchCmd = &cobra.Command{
Use: "watch [command]",
Short: "Watch clipboard for changes",
@@ -216,8 +225,10 @@ func init() {
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
}
func runClipCopy(cmd *cobra.Command, args []string) {
@@ -314,6 +325,12 @@ func runClipPaste(cmd *cobra.Command, args []string) {
os.Stdout.Write(data)
}
func runClipSendPaste(cmd *cobra.Command, args []string) {
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
log.Fatalf("send-paste: %v", err)
}
}
func runClipWatch(cmd *cobra.Command, args []string) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
+9 -1
View File
@@ -19,7 +19,12 @@ var (
var colorCmd = &cobra.Command{
Use: "color",
Short: "Color utilities",
Long: "Color utilities including picking colors from the screen",
Long: `Color utilities including picking colors from the screen.
This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle
See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
}
var colorPickCmd = &cobra.Command{
@@ -29,6 +34,9 @@ var colorPickCmd = &cobra.Command{
Click on any pixel to capture its color, or press Escape to cancel.
This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle
Output format flags (mutually exclusive, default: --hex):
--hex - Hexadecimal (#RRGGBB)
--rgb - RGB values (R G B)
+241 -8
View File
@@ -77,10 +77,15 @@ var killCmd = &cobra.Command{
}
var ipcCmd = &cobra.Command{
Use: "ipc [target] [function] [args...]",
Use: "ipc",
Short: "Send IPC commands to running DMS shell",
Long: `Send IPC commands to the running DMS shell.
dms ipc call <target> <function> [args...] invoke a command
dms ipc list list all targets and functions
Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = findConfig(cmd, args)
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
@@ -88,11 +93,21 @@ var ipcCmd = &cobra.Command{
},
}
var ipcListCmd = &cobra.Command{
Use: "list",
Short: "List all IPC targets and functions",
Run: func(cmd *cobra.Command, args []string) {
printIPCHelp()
},
}
func init() {
ipcCmd.AddCommand(ipcListCmd)
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
_ = findConfig(cmd, args)
printIPCHelp()
})
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
}
var debugSrvCmd = &cobra.Command{
@@ -171,10 +186,22 @@ var pluginsUninstallCmd = &cobra.Command{
}
var pluginsUpdateCmd = &cobra.Command{
Use: "update <plugin-id>",
Short: "Update a plugin by ID",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.",
Args: cobra.ExactArgs(1),
Use: "update [plugin-id]",
Short: "Update a plugin by ID, or all plugins",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
Args: func(cmd *cobra.Command, args []string) error {
updateAll, _ := cmd.Flags().GetBool("all")
if updateAll {
if len(args) > 0 {
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
}
return nil
}
if len(args) != 1 {
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
}
return nil
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
@@ -182,6 +209,26 @@ var pluginsUpdateCmd = &cobra.Command{
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
checkOnly, _ := cmd.Flags().GetBool("check")
updateAll, _ := cmd.Flags().GetBool("all")
if checkOnly {
if updateAll {
if err := checkAllPluginsCLI(); err != nil {
log.Fatalf("Error checking updates: %v", err)
}
return
}
if err := checkPluginCLI(args[0]); err != nil {
log.Fatalf("Error checking update: %v", err)
}
return
}
if updateAll {
if err := updateAllPluginsCLI(); err != nil {
log.Fatalf("Error updating plugins: %v", err)
}
return
}
if err := updatePluginCLI(args[0]); err != nil {
log.Fatalf("Error updating plugin: %v", err)
}
@@ -267,6 +314,13 @@ func browsePlugins() error {
return nil
}
feedback := plugins.FetchFeedback()
nameByID := make(map[string]string, len(pluginList))
for _, plugin := range pluginList {
nameByID[plugin.ID] = plugin.Name
}
fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList))
for _, plugin := range pluginList {
installed, _ := manager.IsInstalled(plugin)
@@ -290,6 +344,26 @@ func browsePlugins() error {
if len(plugin.Dependencies) > 0 {
fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", "))
}
if fb, ok := feedback[plugin.ID]; ok {
fmt.Printf(" Upvotes: %d\n", fb.Upvotes)
if len(fb.Status) > 0 {
fmt.Printf(" Status: %s\n", strings.Join(fb.Status, ", "))
}
if fb.IssueURL != "" {
fmt.Printf(" Discuss: %s\n", fb.IssueURL)
}
if len(fb.Similar) > 0 {
names := make([]string, len(fb.Similar))
for i, id := range fb.Similar {
if name, ok := nameByID[id]; ok {
names[i] = name
} else {
names[i] = id
}
}
fmt.Printf(" Related: %s\n", strings.Join(names, ", "))
}
}
fmt.Println()
}
@@ -330,7 +404,11 @@ func listInstalledPlugins() error {
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok {
fmt.Printf(" %s\n", plugin.Name)
hasUpdateStr := ""
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
hasUpdateStr = " (update available)"
}
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author)
@@ -510,6 +588,160 @@ func updatePluginCLI(idOrName string) error {
return nil
}
func updateAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var errs []error
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
if plugin != nil {
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Update(*plugin); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
}
} else {
fmt.Printf("Updating plugin: %s\n", pluginID)
if err := manager.UpdateByIDOrName(pluginID); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", pluginID)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
}
}
}
if len(errs) > 0 {
for _, err := range errs {
fmt.Fprintf(os.Stderr, "%v\n", err)
}
return fmt.Errorf("failed to update some plugins")
}
return nil
}
func checkPluginCLI(idOrName string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(idOrName, pluginList)
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
} else {
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
}
return nil
}
dummyPlugin := plugins.Plugin{ID: idOrName}
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s\n", idOrName)
} else {
fmt.Printf("Plugin is up to date: %s\n", idOrName)
}
return nil
}
func checkAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var count int
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
var hasUpdates bool
var name string
if plugin != nil {
name = plugin.Name
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
} else {
name = pluginID
dummyPlugin := plugins.Plugin{ID: pluginID}
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
count++
}
}
if count > 0 {
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
} else {
fmt.Println("All plugins are up to date.")
}
return nil
}
func getCommonCommands() []*cobra.Command {
return []*cobra.Command{
versionCmd,
@@ -527,6 +759,7 @@ func getCommonCommands() []*cobra.Command {
greeterCmd,
setupCmd,
colorCmd,
qrCmd,
screenshotCmd,
notifyActionCmd,
notifyCmd,
+2
View File
@@ -37,9 +37,11 @@ var resolveIncludeCmd = &cobra.Command{
"cursor.lua",
"windowrules.lua",
"cursor.kdl",
"layout.kdl",
"outputs.kdl",
"binds.kdl",
"cursor.conf",
"layout.conf",
"outputs.conf",
"binds.conf",
}, cobra.ShellCompDirectiveNoFileComp
+27
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")
@@ -557,6 +581,8 @@ func detectRunningWM() string {
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
return "niri"
case os.Getenv("MANGO_INSTANCE_SIGNATURE") != "":
return "MangoWC"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
@@ -884,6 +910,7 @@ func checkOptionalDependencies() []checkResult {
{"cava", "cava", "Audio visualizer", true},
{"khal", "khal", "Calendar events", false},
{"danksearch", "dsearch", "File search", false},
{"dankcalendar", "dcal", "Calendar app", false},
{"fprintd", "fprintd-list", "Fingerprint auth", false},
}
+234 -18
View File
@@ -32,13 +32,14 @@ var greeterCmd = &cobra.Command{
var (
greeterConfigSyncFn = greeter.SyncDMSConfigs
sharedAuthSyncFn = sharedpam.SyncAuthConfig
greeterIsNixOSFn = greeter.IsNixOS
)
var greeterInstallCmd = &cobra.Command{
Use: "install",
Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface",
PreRunE: preRunPrivileged,
PreRunE: preRunGreeterMutation,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -64,6 +65,9 @@ var greeterSyncCmd = &cobra.Command{
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := rejectNixOSGreeterMutation(cmd); err != nil {
return err
}
profile, _ := cmd.Flags().GetBool("profile")
if profile {
return nil
@@ -95,6 +99,35 @@ var greeterSyncCmd = &cobra.Command{
},
}
var greeterLaunchSessionCmd = &cobra.Command{
Use: "launch-session",
Short: "Launch a remembered greeter session",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
sessionID, _ := cmd.Flags().GetString("session-id")
fromMemory, _ := cmd.Flags().GetBool("from-memory")
cacheDir, _ := cmd.Flags().GetString("cache-dir")
if fromMemory {
homeDir, err := os.UserHomeDir()
if err != nil {
log.Fatalf("failed to get user home directory: %v", err)
}
if err := greeter.LaunchSessionFromMemory(cacheDir, homeDir); err != nil {
log.Fatalf("failed to launch remembered greeter session: %v", err)
}
return
}
if sessionID == "" {
log.Fatal("missing --session-id or --from-memory")
}
if err := greeter.LaunchSessionByID(sessionID); err != nil {
log.Fatalf("failed to launch greeter session %q: %v", sessionID, err)
}
},
}
func init() {
greeterSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts, use defaults (for UI)")
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
@@ -102,13 +135,16 @@ func init() {
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
greeterSyncCmd.Flags().Bool("autologin", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
greeterLaunchSessionCmd.Flags().String("session-id", "", "Desktop session id to launch")
greeterLaunchSessionCmd.Flags().Bool("from-memory", false, "Resolve the session id from greeter memory")
greeterLaunchSessionCmd.Flags().String("cache-dir", greeter.GreeterCacheDir, "Greeter cache directory")
}
var greeterEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter",
PreRunE: preRunPrivileged,
PreRunE: preRunGreeterMutation,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -144,7 +180,7 @@ var greeterUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Remove DMS greeter configuration and restore previous display manager",
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
PreRunE: preRunPrivileged,
PreRunE: preRunGreeterMutation,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -174,6 +210,21 @@ func init() {
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
}
func rejectNixOSGreeterMutation(cmd *cobra.Command) error {
if !greeterIsNixOSFn() {
return nil
}
return fmt.Errorf("dms %s is disabled on NixOS because the greeter is managed declaratively\nConfigure the DMS greeter in your NixOS module, then apply the change with your normal nixos-rebuild workflow", normalizeCommandSpec(cmd.CommandPath()))
}
func preRunGreeterMutation(cmd *cobra.Command, args []string) error {
if err := rejectNixOSGreeterMutation(cmd); err != nil {
return err
}
return preRunPrivileged(cmd, args)
}
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
return err
@@ -299,7 +350,7 @@ func installGreeter(nonInteractive bool) error {
fmt.Println("\n=== Installation Complete ===")
fmt.Println("\nTo start the greeter now, run:")
fmt.Println(" sudo systemctl start greetd")
fmt.Println(startGreeterHint())
fmt.Println("\nOr reboot to see the greeter at next boot.")
return nil
@@ -326,7 +377,13 @@ func uninstallGreeter(nonInteractive bool) error {
}
fmt.Println("\nDisabling greetd...")
if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
if isRunit() {
if err := disableRunitService("greetd"); err != nil {
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
} else {
fmt.Println(" ✓ greetd disabled")
}
} else if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
} else {
fmt.Println(" ✓ greetd disabled")
@@ -449,6 +506,14 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
enableDM := func(dm string) {
fmt.Printf(" Enabling %s...\n", dm)
if isRunit() {
if err := enableRunitService(dm); err != nil {
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
} else {
fmt.Printf(" ✓ %s enabled (linked into %s).\n", dm, runitServiceDir)
}
return
}
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
} else {
@@ -495,6 +560,9 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
}
func isSystemdUnitInstalled(unit string) bool {
if isRunit() {
return runitServiceInstalled(unit)
}
cmd := exec.Command("systemctl", "list-unit-files", unit+".service", "--no-legend", "--no-pager")
out, err := cmd.Output()
return err == nil && strings.Contains(string(out), unit)
@@ -943,6 +1011,18 @@ func resolveLocalDMSPath() (string, error) {
}
func disableDisplayManager(dmName string) (bool, error) {
if isRunit() {
if !runitServiceEnabled(dmName) {
return false, nil
}
fmt.Printf("\nDisabling %s (runit)...\n", dmName)
if err := disableRunitService(dmName); err != nil {
return false, fmt.Errorf("failed to disable %s: %w", dmName, err)
}
fmt.Printf(" ✓ %s disabled (removed from %s)\n", dmName, runitServiceDir)
return true, nil
}
state, err := getSystemdServiceState(dmName)
if err != nil {
return false, fmt.Errorf("failed to check %s state: %w", dmName, err)
@@ -996,6 +1076,24 @@ func disableDisplayManager(dmName string) (bool, error) {
}
func ensureGreetdEnabled() error {
if isRunit() {
fmt.Println("\nEnabling greetd service (runit)...")
if !runitServiceInstalled("greetd") {
return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir)
}
if greeter.IsVoidLinux() {
ensureVoidLogindGreeter("_greeter")
} else {
ensureRunitSeat("_greeter")
}
ensureGreetdPamRundir()
if err := enableRunitService("greetd"); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err)
}
fmt.Printf(" ✓ greetd enabled (%s)\n", runitServiceDir)
return nil
}
fmt.Println("\nChecking greetd service status...")
state, err := getSystemdServiceState("greetd")
@@ -1043,6 +1141,12 @@ func ensureGreetdEnabled() error {
}
func ensureGraphicalTarget() error {
if isRunit() {
// runit has no targets; a supervised greetd service is the graphical
// login, so there is nothing to set here.
return nil
}
getDefaultCmd := exec.Command("systemctl", "get-default")
currentTarget, err := getDefaultCmd.Output()
if err != nil {
@@ -1160,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
@@ -1176,7 +1283,7 @@ func enableGreeter(nonInteractive bool) error {
fmt.Println("\n=== Enable Complete ===")
fmt.Println("\nGreeter configuration verified and system state corrected.")
fmt.Println("To start the greeter now, run:")
fmt.Println(" sudo systemctl start greetd")
fmt.Println(startGreeterHint())
fmt.Println("\nOr reboot to see the greeter at boot time.")
return nil
@@ -1257,7 +1364,7 @@ func enableGreeter(nonInteractive bool) error {
fmt.Println("\n=== Enable Complete ===")
fmt.Println("\nTo start the greeter now, run:")
fmt.Println(" sudo systemctl start greetd")
fmt.Println(startGreeterHint())
fmt.Println("\nOr reboot to see the greeter at boot time.")
return nil
@@ -1333,24 +1440,36 @@ func readDefaultSessionCommand(configPath string) string {
return ""
}
func extractGreeterCacheDirFromCommand(command string) string {
if command == "" {
return greeter.GreeterCacheDir
}
func explicitGreeterCacheDirFromCommand(command string) (string, bool) {
tokens := strings.Fields(command)
for i := 0; i < len(tokens); i++ {
token := strings.Trim(tokens[i], "\"")
if token == "--cache-dir" && i+1 < len(tokens) {
return strings.Trim(tokens[i+1], "\"")
value := strings.Trim(tokens[i+1], "\"")
if value != "" {
return value, true
}
}
if strings.HasPrefix(token, "--cache-dir=") {
value := strings.TrimPrefix(token, "--cache-dir=")
value = strings.Trim(value, "\"")
if value != "" {
return value
return value, true
}
}
}
return "", false
}
const nixOSGreeterStateDir = "/var/lib/dms-greeter"
func greeterStatusStateDir(command string, isNixOS bool) string {
if cacheDir, ok := explicitGreeterCacheDirFromCommand(command); ok {
return cacheDir
}
if isNixOS {
return nixOSGreeterStateDir
}
return greeter.GreeterCacheDir
}
@@ -1362,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)
}
@@ -1421,6 +1551,8 @@ func packageInstallHint() string {
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
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://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf)"
default:
return "Run 'dms greeter install' to install greeter"
}
@@ -1459,7 +1591,8 @@ func isPackageOnlyGreeterDistro() bool {
config.Family == distros.FamilySUSE ||
config.Family == distros.FamilyUbuntu ||
config.Family == distros.FamilyFedora ||
config.Family == distros.FamilyArch
config.Family == distros.FamilyArch ||
config.Family == distros.FamilyVoid
}
func promptCompositorChoice(compositors []string) (string, error) {
@@ -1487,6 +1620,10 @@ func checkGreeterStatus() error {
fmt.Println("=== DMS Greeter Status ===")
fmt.Println()
if greeterIsNixOSFn() {
return checkNixOSGreeterStatus()
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
@@ -1550,7 +1687,7 @@ func checkGreeterStatus() error {
fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions")
}
cacheDir := extractGreeterCacheDirFromCommand(configuredCommand)
cacheDir := greeterStatusStateDir(configuredCommand, false)
fmt.Println("\nGreeter Cache Directory:")
fmt.Printf(" Effective cache dir: %s\n", cacheDir)
if cacheDir != greeter.GreeterCacheDir {
@@ -1813,6 +1950,85 @@ func checkGreeterStatus() error {
return nil
}
func checkNixOSGreeterStatus() error {
const configPath = "/etc/greetd/config.toml"
configuredCommand := readDefaultSessionCommand(configPath)
allGood := true
fmt.Println("Greeter Configuration:")
switch {
case strings.Contains(configuredCommand, "dms-greeter"):
fmt.Println(" ✓ DMS greeter command found")
if wrapper := extractGreeterWrapperFromCommand(configuredCommand); wrapper != "" {
fmt.Printf(" Wrapper: %s\n", wrapper)
}
case configuredCommand != "":
fmt.Println(" ⚠ greetd default session does not reference dms-greeter")
allGood = false
default:
fmt.Printf(" No readable DMS command found in %s\n", configPath)
}
fmt.Println(" NixOS manages greeter configuration declaratively; apply changes through your NixOS module.")
stateDir := greeterStatusStateDir(configuredCommand, true)
fmt.Println("\nGreeter State Directory:")
fmt.Printf(" Effective state dir: %s\n", stateDir)
if stateDir == nixOSGreeterStateDir {
fmt.Println(" ✓ Using the NixOS module state path")
}
if stat, err := os.Stat(stateDir); err == nil && stat.IsDir() {
fmt.Printf(" ✓ %s exists\n", stateDir)
} else if os.IsNotExist(err) {
fmt.Printf(" ✗ %s not found\n", stateDir)
fmt.Println(" Rebuild your NixOS configuration after enabling the DMS greeter module.")
allGood = false
} else if err != nil {
fmt.Printf(" ✗ Could not inspect %s: %v\n", stateDir, err)
allGood = false
} else {
fmt.Printf(" ✗ %s is not a directory\n", stateDir)
allGood = false
}
fmt.Println("\nDeclarative Configuration Files:")
configFiles := []struct {
name string
path string
}{
{name: "Settings", path: filepath.Join(stateDir, "settings.json")},
{name: "Session state", path: filepath.Join(stateDir, "session.json")},
{name: "Color theme", path: filepath.Join(stateDir, "colors.json")},
}
for _, configFile := range configFiles {
if stat, err := os.Stat(configFile.path); err == nil && !stat.IsDir() {
fmt.Printf(" ✓ %s: %s\n", configFile.name, configFile.path)
} else if os.IsNotExist(err) {
fmt.Printf(" %s not present (optional; configure configHome/configFiles in the NixOS module)\n", configFile.name)
} else if err != nil {
fmt.Printf(" ⚠ %s could not be inspected: %v\n", configFile.name, err)
} else {
fmt.Printf(" ⚠ %s path is not a regular file: %s\n", configFile.name, configFile.path)
}
}
fmt.Println("\nGroup Membership:")
fmt.Println(" User group membership is managed by NixOS and is not required for declarative theme copies.")
fmt.Println("\nGreeter PAM Authentication:")
fmt.Println(" PAM is managed by NixOS modules.")
fmt.Println(" Configure fingerprint/U2F through security.pam.services.greetd.")
fmt.Println()
if allGood {
fmt.Println("✓ NixOS greeter state looks healthy and is managed declaratively.")
} else {
fmt.Println("⚠ Some issues detected. Update the DMS greeter module and rebuild NixOS; do not run 'dms greeter sync'.")
}
return nil
}
func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) {
if sampleLimit <= 0 {
sampleLimit = 3
+63
View File
@@ -3,9 +3,11 @@ package main
import (
"errors"
"reflect"
"strings"
"testing"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra"
)
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
@@ -85,3 +87,64 @@ func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
t.Fatal("expected auth sync not to run after config sync failure")
}
}
func TestGreeterStatusStateDirUsesNixOSDefault(t *testing.T) {
if got := greeterStatusStateDir("", true); got != nixOSGreeterStateDir {
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, nixOSGreeterStateDir)
}
}
func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
command := "dms-greeter --cache-dir /srv/dms-greeter --command niri"
if got := greeterStatusStateDir(command, true); got != "/srv/dms-greeter" {
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, "/srv/dms-greeter")
}
}
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 }
t.Cleanup(func() {
greeterIsNixOSFn = origGreeterIsNixOSFn
})
for _, commandName := range []string{"install", "enable", "sync", "uninstall"} {
t.Run(commandName, func(t *testing.T) {
root := &cobra.Command{Use: "dms"}
greeterCommand := &cobra.Command{Use: "greeter"}
mutationCommand := &cobra.Command{Use: commandName}
root.AddCommand(greeterCommand)
greeterCommand.AddCommand(mutationCommand)
err := rejectNixOSGreeterMutation(mutationCommand)
if err == nil {
t.Fatalf("expected NixOS greeter %s to be rejected", commandName)
}
if !strings.Contains(err.Error(), "dms greeter "+commandName+" is disabled on NixOS") {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(err.Error(), "/var/cache/dms-greeter") {
t.Fatalf("NixOS remediation should not recommend the non-NixOS cache path: %v", err)
}
})
}
}
func TestRejectNixOSGreeterMutationAllowsOtherDistros(t *testing.T) {
origGreeterIsNixOSFn := greeterIsNixOSFn
greeterIsNixOSFn = func() bool { return false }
t.Cleanup(func() {
greeterIsNixOSFn = origGreeterIsNixOSFn
})
if err := rejectNixOSGreeterMutation(&cobra.Command{Use: "sync"}); err != nil {
t.Fatalf("expected non-NixOS greeter command to be allowed, got %v", err)
}
}
+23
View File
@@ -38,10 +38,17 @@ var matugenCheckCmd = &cobra.Command{
Run: runMatugenCheck,
}
var matugenPreviewCmd = &cobra.Command{
Use: "preview",
Short: "Preview Matugen scheme colors without applying them",
Run: runMatugenPreview,
}
func init() {
matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd)
matugenCmd.AddCommand(matugenPreviewCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -62,6 +69,8 @@ func init() {
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
}
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -200,3 +209,17 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
}
fmt.Println(string(data))
}
func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color")
contrast, _ := cmd.Flags().GetFloat64("contrast")
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err)
}
data, err := json.Marshal(previews)
if err != nil {
log.Fatalf("Failed to marshal Matugen previews: %v", err)
}
fmt.Println(string(data))
}
+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)
+13
View File
@@ -10,6 +10,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
@@ -298,6 +299,9 @@ func runSetup() error {
if wmSelected {
if wm == deps.WindowManagerMango {
useSystemd = false
} else if isVoidSetup() {
useSystemd = false
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
} else {
useSystemd = promptSystemd()
}
@@ -372,6 +376,15 @@ func runSetup() error {
return nil
}
func isVoidSetup() bool {
osInfo, err := distros.GetOSInfo()
if err != nil {
return false
}
config, exists := distros.Registry[osInfo.Distribution.ID]
return exists && config.Family == distros.FamilyVoid
}
// Add user to the input group for the evdev manager for inut state tracking.
// Caps Lock OSD and the Caps Lock bar indicator.
func ensureInputGroup() {
+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)
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"encoding/json"
"testing"
)
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
var policyFile cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
t.Fatalf("failed to parse embedded CLI policy: %v", err)
}
if policyFile.BlockedCommands == nil {
t.Fatal("embedded CLI policy has no blocked_commands")
}
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
if !commandBlockedByPolicy("greeter enable", blocked) {
t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems")
}
if commandBlockedByPolicy("greeter sync", blocked) {
t.Fatal("expected greeter sync to remain available on immutable/image-based systems")
}
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
// runit (Void Linux) service helpers. Services live in /etc/sv and are "enabled"
// by symlinking them into the /var/service supervision dir, so the greeter
// commands branch on isRunit() instead of shelling systemctl.
const (
runitSvDir = "/etc/sv"
runitServiceDir = "/var/service"
)
// isRunit reports whether this system is supervised by runit (Void Linux).
func isRunit() bool {
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
return true
}
if _, err := os.Stat("/run/systemd/system"); err == nil {
return false
}
if fi, err := os.Stat(runitServiceDir); err == nil && fi.IsDir() {
return true
}
return false
}
func runitServiceInstalled(name string) bool {
fi, err := os.Stat(runitSvDir + "/" + name)
return err == nil && fi.IsDir()
}
func runitServiceEnabled(name string) bool {
_, err := os.Lstat(runitServiceDir + "/" + name)
return err == nil
}
// enableRunitService links a service into /var/service (idempotent).
func enableRunitService(name string) error {
if !runitServiceInstalled(name) {
return fmt.Errorf("runit service %q not found in %s", name, runitSvDir)
}
if runitServiceEnabled(name) {
return nil
}
return privesc.Run(context.Background(), "", "ln", "-sf",
runitSvDir+"/"+name, runitServiceDir+"/"+name)
}
// disableRunitService removes a service's supervision symlink.
func disableRunitService(name string) error {
if !runitServiceEnabled(name) {
return nil
}
return privesc.Run(context.Background(), "", "rm", "-f",
runitServiceDir+"/"+name)
}
// ensureRunitSeat sets up the seat access a Wayland greeter needs on runit (the
// equivalent of logind on systemd): enables seatd and adds the greeter user to
// the seat/video/input groups. Failures are reported but non-fatal.
func ensureRunitSeat(greeterUser string) {
if runitServiceInstalled("seatd") {
if err := enableRunitService("seatd"); err != nil {
fmt.Printf(" ⚠ could not enable seatd: %v\n", err)
} else {
fmt.Println(" ✓ seatd enabled")
}
} else {
fmt.Println(" ⚠ seatd not installed — the greeter compositor needs it for GPU/seat access")
}
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "_seatd,video,input", greeterUser); err != nil {
fmt.Printf(" ⚠ could not add %s to seat groups: %v\n", greeterUser, err)
} else {
fmt.Printf(" ✓ %s added to seat groups (_seatd, video, input)\n", greeterUser)
}
}
// 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)
if err != nil {
fmt.Printf(" ⚠ could not read %s: %v\n", pamPath, err)
return
}
if strings.Contains(string(data), "pam_rundir") {
return
}
line := "session optional pam_rundir.so"
if err := privesc.Run(context.Background(), "", "sh", "-c",
fmt.Sprintf("printf '%%s\\n' %q >> %s", line, pamPath)); err != nil {
fmt.Printf(" ⚠ could not add pam_rundir to %s: %v\n", pamPath, err)
return
}
fmt.Println(" ✓ pam_rundir added to greetd PAM (provides XDG_RUNTIME_DIR for the session)")
}
// startGreeterHint returns the init-appropriate "start greetd now" command.
func startGreeterHint() string {
if isRunit() {
return " sudo sv up greetd"
}
return " sudo systemctl start greetd"
}
+2 -2
View File
@@ -19,8 +19,8 @@ func init() {
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
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)
+2 -2
View File
@@ -19,8 +19,8 @@ func init() {
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
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()...)
+7
View File
@@ -101,6 +101,13 @@ func getServerSocketPath() string {
runtimeDir = os.TempDir()
}
if parentPID, ok := sessionParentPID(os.Getenv("WAYLAND_DISPLAY")); ok {
sessionSock := filepath.Join(runtimeDir, fmt.Sprintf("danklinux-%d.sock", parentPID))
if _, err := os.Stat(sessionSock); err == nil {
return sessionSock
}
}
entries, err := os.ReadDir(runtimeDir)
if err != nil {
return filepath.Join(runtimeDir, "danklinux.sock")
+143 -36
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
@@ -101,14 +109,23 @@ func getPIDFilePath() string {
return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", os.Getpid()))
}
func getSessionFilePath() string {
return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.session", os.Getpid()))
}
func writePIDFile(childPID int) error {
pidFile := getPIDFilePath()
if display := os.Getenv("WAYLAND_DISPLAY"); display != "" {
if err := os.WriteFile(getSessionFilePath(), []byte(display), 0o644); err != nil {
log.Warnf("Failed to write session file: %v", err)
}
}
return os.WriteFile(pidFile, []byte(strconv.Itoa(childPID)), 0o644)
}
func removePIDFile() {
pidFile := getPIDFilePath()
os.Remove(pidFile)
os.Remove(getPIDFilePath())
os.Remove(getSessionFilePath())
}
func getAllDMSPIDs() []int {
@@ -198,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)
@@ -225,6 +242,9 @@ func runShellInteractive(session bool) {
if os.Getenv("QT_QPA_PLATFORM") == "" {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" {
cmd.Env = append(cmd.Env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
}
cmd.Env = appendLogEnv(cmd.Env)
@@ -387,9 +407,11 @@ func killShell() {
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".pid") {
pidFile := filepath.Join(dir, entry.Name())
os.Remove(pidFile)
if !strings.HasPrefix(entry.Name(), "danklinux-") {
continue
}
if strings.HasSuffix(entry.Name(), ".pid") || strings.HasSuffix(entry.Name(), ".session") {
os.Remove(filepath.Join(dir, entry.Name()))
}
}
}
@@ -447,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)
@@ -477,6 +499,9 @@ func runShellDaemon(session bool) {
if os.Getenv("QT_QPA_PLATFORM") == "" {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" {
cmd.Env = append(cmd.Env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
}
cmd.Env = appendLogEnv(cmd.Env)
@@ -565,7 +590,11 @@ func runShellDaemon(session bool) {
}
var qsHasAnyDisplay = sync.OnceValue(func() bool {
out, err := exec.Command("qs", "ipc", "--help").Output()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "qs", "ipc", "--help")
cmd.WaitDelay = 500 * time.Millisecond
out, err := cmd.Output()
if err != nil {
return false
}
@@ -601,13 +630,34 @@ func parseTargetsFromIPCShowOutput(output string) ipcTargets {
return targets
}
func getShellIPCCompletions(args []string, _ string) []string {
func buildQsIPCBaseArgs() ([]string, error) {
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
switch pid, ok := getSessionDMSPID(); {
case ok:
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
default:
if err := findConfig(nil, nil); err != nil {
return nil, err
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
}
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmd := exec.Command("qs", cmdArgs...)
return cmdArgs, nil
}
func getShellIPCCompletions(args []string, _ string) []string {
baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
log.Debugf("Error building IPC args for completions: %v", err)
return nil
}
cmdArgs := append(baseArgs, "show")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "qs", cmdArgs...)
cmd.WaitDelay = 500 * time.Millisecond
var targets ipcTargets
if output, err := cmd.Output(); err == nil {
@@ -623,7 +673,7 @@ func getShellIPCCompletions(args []string, _ string) []string {
if len(args) == 0 {
targetNames := make([]string, 0)
targetNames = append(targetNames, "call")
targetNames = append(targetNames, "call", "list")
for k := range targets {
targetNames = append(targetNames, k)
}
@@ -686,6 +736,64 @@ func getFirstDMSPID() (int, bool) {
return 0, false
}
func sessionParentPID(display string) (int, bool) {
if display == "" {
return 0, false
}
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return 0, false
}
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, "danklinux-") || !strings.HasSuffix(name, ".session") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, name))
if err != nil || strings.TrimSpace(string(data)) != display {
continue
}
parentStr := strings.TrimSuffix(strings.TrimPrefix(name, "danklinux-"), ".session")
parentPID, err := strconv.Atoi(parentStr)
if err != nil {
continue
}
return parentPID, true
}
return 0, false
}
func getSessionDMSPID() (int, bool) {
parentPID, ok := sessionParentPID(os.Getenv("WAYLAND_DISPLAY"))
if !ok {
return getFirstDMSPID()
}
data, err := os.ReadFile(filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", parentPID)))
if err != nil {
return getFirstDMSPID()
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return getFirstDMSPID()
}
proc, err := os.FindProcess(pid)
if err != nil || proc.Signal(syscall.Signal(0)) != nil {
return getFirstDMSPID()
}
return pid, true
}
func runShellIPCCommand(args []string) {
if len(args) == 0 {
printIPCHelp()
@@ -696,23 +804,11 @@ func runShellIPCCommand(args []string) {
args = append([]string{"call"}, args...)
}
cmdArgs := []string{"ipc"}
switch pid, ok := getFirstDMSPID(); {
case ok:
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
default:
if err := findConfig(nil, nil); err != nil {
log.Fatalf("Error finding config: %v", err)
}
// ! TODO - remove check when QS 0.3 is released
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
log.Fatalf("Error finding config: %v", err)
}
cmdArgs = append(cmdArgs, args...)
cmdArgs := append(baseArgs, args...)
cmd := exec.Command("qs", cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
@@ -724,19 +820,20 @@ func runShellIPCCommand(args []string) {
}
func printIPCHelp() {
fmt.Println("Usage: dms ipc <target> <function> [args...]")
fmt.Println("Usage: dms ipc call <target> <function> [args...]")
fmt.Println()
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
printIPCHelpFailure(err)
return
}
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmdArgs := append(baseArgs, "show")
cmd := exec.Command("qs", cmdArgs...)
output, err := cmd.Output()
if err != nil {
fmt.Println("Could not retrieve available IPC targets (is DMS running?)")
printIPCHelpFailure(err)
return
}
@@ -765,6 +862,16 @@ func printIPCHelp() {
}
}
func printIPCHelpFailure(err error) {
fmt.Println("Could not retrieve IPC targets.")
if err != nil {
fmt.Printf(" %v\n", err)
}
fmt.Println()
fmt.Println(" Full docs: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc")
fmt.Println(" Try: dms ipc call <target> <function>")
}
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
func ensureFontCache() {
if _, err := exec.LookPath("fc-list"); err != nil {
+1 -1
View File
@@ -22,7 +22,6 @@ require (
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
tailscale.com v1.96.5
)
@@ -64,6 +63,7 @@ require (
github.com/yeqown/reedsolomon v1.0.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
+147 -319
View File
@@ -9,7 +9,6 @@ import (
"syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
const envServe = "_DMS_CLIPBOARD_SERVE"
@@ -17,6 +16,37 @@ const envMime = "_DMS_CLIPBOARD_MIME"
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
type Offer struct {
MimeType string
Data []byte
}
// textMimeAliases are offered alongside plain-text content so legacy X11
// clients bridged through XWayland find a target they can convert.
var textMimeAliases = []string{
"text/plain",
"text/plain;charset=utf-8",
"UTF8_STRING",
"STRING",
"TEXT",
}
// ExpandOffers turns raw clipboard data into the full offer list to serve,
// adding the standard alias set for text content.
func ExpandOffers(data []byte, mimeType string) []Offer {
offers := []Offer{{MimeType: mimeType, Data: data}}
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
return offers
}
for _, alias := range textMimeAliases {
if alias == mimeType {
continue
}
offers = append(offers, Offer{MimeType: alias, Data: data})
}
return offers
}
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
// child. Reads source data into memory, deletes any cache file, then serves.
func MaybeServeAndExit() {
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
os.Exit(1)
}
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
os.Exit(1)
}
@@ -55,22 +85,33 @@ func Copy(data []byte, mimeType string) error {
return copyForkCached(data, mimeType, false)
}
func CopyText(text string) error {
return Copy([]byte(text), "text/plain;charset=utf-8")
}
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground {
return serveClipboard(data, mimeType, pasteOnce)
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
}
return copyForkCached(data, mimeType, pasteOnce)
}
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if foreground {
buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
if !foreground {
return copyFork(data, mimeType, pasteOnce)
}
return copyFork(data, mimeType, pasteOnce)
buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if foreground {
return serveOffers(offers, pasteOnce)
}
return copyMultiFork(offers, pasteOnce)
}
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
@@ -132,39 +173,70 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
cmd := newForkCmd(mimeType, pasteOnce)
switch src := data.(type) {
case *os.File:
if src, ok := data.(*os.File); ok {
cmd.Stdin = src
return waitReady(cmd)
default:
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
}
}
stdin.Close()
return nil
}
func signalReady() {
@@ -194,57 +266,25 @@ func createClipboardCacheFile() (*os.File, error) {
return os.CreateTemp("", "dms-clipboard-*")
}
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
display, err := wlclient.Connect("")
// serveOffers owns the Wayland selection until cancelled (or first paste when
// pasteOnce is set), answering every offered mime type with its data.
func serveOffers(offers []Offer, pasteOnce bool) error {
if len(offers) == 0 {
return fmt.Errorf("no offers to serve")
}
s, err := connectSession()
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
return err
}
defer display.Destroy()
defer s.Close()
ctx := display.Context()
registry, err := display.GetRegistry()
dataControlMgr, err := s.requireDataControl()
if err != nil {
return fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
return err
}
device, err := dataControlMgr.GetDataDevice(seat)
device, err := dataControlMgr.GetDataDevice(s.seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
@@ -255,25 +295,12 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return fmt.Errorf("create data source: %w", err)
}
if err := source.Offer(mimeType); err != nil {
return fmt.Errorf("offer mime type: %w", err)
}
if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" {
if err := source.Offer("text/plain"); err != nil {
return fmt.Errorf("offer text/plain: %w", err)
}
if err := source.Offer("text/plain;charset=utf-8"); err != nil {
return fmt.Errorf("offer text/plain;charset=utf-8: %w", err)
}
if err := source.Offer("UTF8_STRING"); err != nil {
return fmt.Errorf("offer UTF8_STRING: %w", err)
}
if err := source.Offer("STRING"); err != nil {
return fmt.Errorf("offer STRING: %w", err)
}
if err := source.Offer("TEXT"); err != nil {
return fmt.Errorf("offer TEXT: %w", err)
offerData := make(map[string][]byte, len(offers))
for _, offer := range offers {
if err := source.Offer(offer.MimeType); err != nil {
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
}
offerData[offer.MimeType] = offer.Data
}
cancelled := make(chan struct{})
@@ -283,7 +310,11 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
_, _ = file.Write(data)
if data, ok := offerData[e.MimeType]; ok {
_, _ = file.Write(data)
}
select {
case pasted <- struct{}{}:
default:
@@ -298,7 +329,7 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return fmt.Errorf("set selection: %w", err)
}
display.Roundtrip()
s.display.Roundtrip()
signalReady()
for {
@@ -310,70 +341,26 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return nil
}
default:
if err := ctx.Dispatch(); err != nil {
if err := s.ctx.Dispatch(); err != nil {
return nil
}
}
}
}
func CopyText(text string) error {
return Copy([]byte(text), "text/plain;charset=utf-8")
}
func Paste() ([]byte, string, error) {
display, err := wlclient.Connect("")
s, err := connectSession()
if err != nil {
return nil, "", fmt.Errorf("wayland connect: %w", err)
return nil, "", err
}
defer display.Destroy()
defer s.Close()
ctx := display.Context()
registry, err := display.GetRegistry()
dataControlMgr, err := s.requireDataControl()
if err != nil {
return nil, "", fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return nil, "", fmt.Errorf("registry bind: %w", bindErr)
return nil, "", err
}
if dataControlMgr == nil {
return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return nil, "", fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
device, err := dataControlMgr.GetDataDevice(s.seat)
if err != nil {
return nil, "", fmt.Errorf("get data device: %w", err)
}
@@ -399,15 +386,14 @@ func Paste() ([]byte, string, error) {
gotSelection = true
})
display.Roundtrip()
display.Roundtrip()
s.display.Roundtrip()
s.display.Roundtrip()
if !gotSelection || selectionOffer == nil {
return nil, "", fmt.Errorf("no clipboard data")
}
mimeTypes := offerMimeTypes[selectionOffer]
selectedMime := selectPreferredMimeType(mimeTypes)
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
if selectedMime == "" {
return nil, "", fmt.Errorf("no supported mime type")
}
@@ -424,7 +410,7 @@ func Paste() ([]byte, string, error) {
}
w.Close()
display.Roundtrip()
s.display.Roundtrip()
data, err := io.ReadAll(r)
if err != nil {
@@ -470,161 +456,3 @@ func selectPreferredMimeType(mimes []string) string {
func IsImageMimeType(mime string) bool {
return len(mime) > 6 && mime[:6] == "image/"
}
type Offer struct {
MimeType string
Data []byte
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if !foreground {
return copyMultiFork(offers, pasteOnce)
}
return copyMultiServe(offers, pasteOnce)
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
}
}
stdin.Close()
return nil
}
func copyMultiServe(offers []Offer, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
}
defer display.Destroy()
ctx := display.Context()
registry, err := display.GetRegistry()
if err != nil {
return fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
defer device.Destroy()
source, err := dataControlMgr.CreateDataSource()
if err != nil {
return fmt.Errorf("create data source: %w", err)
}
offerMap := make(map[string][]byte)
for _, offer := range offers {
if err := source.Offer(offer.MimeType); err != nil {
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
}
offerMap[offer.MimeType] = offer.Data
}
cancelled := make(chan struct{})
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerMap[e.MimeType]; ok {
_, _ = file.Write(data)
}
select {
case pasted <- struct{}{}:
default:
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
close(cancelled)
})
if err := device.SetSelection(source); err != nil {
return fmt.Errorf("set selection: %w", err)
}
display.Roundtrip()
for {
select {
case <-cancelled:
return nil
case <-pasted:
if pasteOnce {
return nil
}
default:
if err := ctx.Dispatch(); err != nil {
return nil
}
}
}
}
+206
View File
@@ -0,0 +1,206 @@
package clipboard
import (
"fmt"
"regexp"
"strconv"
"strings"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
const (
xkbKeymapFormatV1 = 1
keyStateReleased = 0
keyStatePressed = 1
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
shiftModMask = 1 << 0
ctrlModMask = 1 << 2
// evdev fallbacks for a standard pc105 map
fallbackCtrlKey = 29 // KEY_LEFTCTRL
fallbackShiftKey = 42 // KEY_LEFTSHIFT
fallbackVKey = 47 // KEY_V
)
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
// using the seat's own keymap, so keycodes stay valid for XWayland clients
// (a synthetic wtype-style keymap breaks X11 apps like Steam). withShift
// selects ctrl+shift+v for terminal targets.
func SendPasteKeystroke(withShift bool) error {
s, err := connectSession()
if err != nil {
return err
}
defer s.Close()
if s.virtualKeyboardMgr == nil {
return fmt.Errorf("compositor does not support zwp_virtual_keyboard_manager_v1")
}
if s.seat == nil {
return fmt.Errorf("no seat available")
}
keyboard, err := s.seat.GetKeyboard()
if err != nil {
return fmt.Errorf("get keyboard: %w", err)
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
return fmt.Errorf("no xkb keymap from seat")
}
defer unix.Close(keymap.Fd)
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
return fmt.Errorf("read keymap: %w", err)
}
keys := resolveKeycodes(keymapText)
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
if err != nil {
return fmt.Errorf("create virtual keyboard: %w", err)
}
defer vk.Destroy()
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
return fmt.Errorf("set keymap: %w", err)
}
mods := uint32(ctrlModMask)
held := []uint32{keys.ctrl}
if withShift {
mods |= shiftModMask
held = append(held, keys.shift)
}
t := uint32(0)
press := func(key, state uint32) error {
t++
return vk.Key(t, key, state)
}
for _, key := range held {
if err := press(key, keyStatePressed); err != nil {
return fmt.Errorf("key press: %w", err)
}
}
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
return fmt.Errorf("set modifiers: %w", err)
}
if err := press(keys.v, keyStatePressed); err != nil {
return fmt.Errorf("key press: %w", err)
}
if err := press(keys.v, keyStateReleased); err != nil {
return fmt.Errorf("key release: %w", err)
}
for i := len(held) - 1; i >= 0; i-- {
if err := press(held[i], keyStateReleased); err != nil {
return fmt.Errorf("key release: %w", err)
}
}
if err := vk.Modifiers(0, 0, 0, 0); err != nil {
return fmt.Errorf("clear modifiers: %w", err)
}
s.display.Roundtrip()
return nil
}
func readKeymap(fd int, size uint32) (string, error) {
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
if err != nil {
return "", err
}
text := strings.TrimRight(string(data), "\x00")
return text, unix.Munmap(data)
}
type pasteKeycodes struct {
ctrl uint32
shift uint32
v uint32
}
var (
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
)
// xkbcommon may serialize keysyms as hex escapes instead of names
// (e.g. "0x76" for v, "0xffe3" for Control_L).
var keysymNames = map[uint32]string{
0x76: "v",
0xffe3: "Control_L",
0xffe1: "Shift_L",
}
func canonicalKeysym(sym string) string {
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
return sym
}
value, err := strconv.ParseUint(sym[2:], 16, 32)
if err != nil {
return sym
}
if name, ok := keysymNames[uint32(value)]; ok {
return name
}
return sym
}
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
// the seat keymap's first group, falling back to pc105 positions.
func resolveKeycodes(keymap string) pasteKeycodes {
codes := map[string]uint32{}
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
if code, err := strconv.Atoi(m[2]); err == nil {
codes[m[1]] = uint32(code)
}
}
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
want := map[string]*uint32{
"Control_L": &keys.ctrl,
"Shift_L": &keys.shift,
"v": &keys.v,
}
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
if group == nil {
continue
}
xkbCode, ok := codes[m[1]]
if !ok || xkbCode < 8 {
continue
}
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
target, wanted := want[level1]
if !wanted {
continue
}
*target = xkbCode - 8
delete(want, level1)
if len(want) == 0 {
break
}
}
return keys
}
@@ -0,0 +1,65 @@
package clipboard
import (
"os"
"testing"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
func TestLiveSeatKeymapResolution(t *testing.T) {
if os.Getenv("DMS_LIVE_TEST") == "" {
t.Skip("set DMS_LIVE_TEST=1 to run against the live compositor")
}
s, err := connectSession()
if err != nil {
t.Fatalf("connect: %v", err)
}
defer s.Close()
if s.virtualKeyboardMgr == nil {
t.Fatal("compositor does not advertise zwp_virtual_keyboard_manager_v1")
}
if s.seat == nil {
t.Fatal("no seat")
}
keyboard, err := s.seat.GetKeyboard()
if err != nil {
t.Fatalf("get keyboard: %v", err)
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if keymap == nil {
t.Fatal("no keymap event")
}
defer unix.Close(keymap.Fd)
text, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
t.Fatalf("read keymap: %v", err)
}
if dump := os.Getenv("DMS_LIVE_DUMP"); dump != "" {
if err := os.WriteFile(dump, []byte(text), 0o644); err != nil {
t.Fatalf("dump keymap: %v", err)
}
}
keys := resolveKeycodes(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
}
}
+105
View File
@@ -0,0 +1,105 @@
package clipboard
import "testing"
const qwertyKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
minimum = 8;
maximum = 708;
<ESC> = 9;
<AB04> = 55;
<LCTL> = 37;
<LFSH> = 50;
alias <AL01> = <AC01>;
indicator 1 = "Caps Lock";
};
xkb_types "(unnamed)" {
type "ALPHABETIC" {
modifiers = Shift+Lock;
map[Shift] = Level2;
level_name[Level1] = "Base";
};
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Escape ] };
key <AB04> { type= "ALPHABETIC", [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const hexKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB04> = 56;
<LCTL> = 38;
<LFSH> = 51;
};
xkb_symbols "(unnamed)" {
key <LCTL> { [ 0xffe3 ] };
key <LFSH> {
type= "PC_ALT_LEVEL2",
symbols[1]= [ 0xffe1, 0xfe08 ]
};
key <AB04> { [ 0x76, 0x56 ] };
};
};`
const dvorakKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB09> = 60;
<LCTL> = 37;
<LFSH> = 50;
};
xkb_symbols "(unnamed)" {
key <AB09> { [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
func TestResolveKeycodes(t *testing.T) {
tests := []struct {
name string
keymap string
want pasteKeycodes
}{
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveKeycodes(tt.keymap)
if got != tt.want {
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestExpandOffers(t *testing.T) {
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
if len(text) != 5 {
t.Fatalf("expected 5 text offers, got %d", len(text))
}
seen := map[string]bool{}
for _, o := range text {
if string(o.Data) != "hi" {
t.Errorf("offer %s has wrong data", o.MimeType)
}
if seen[o.MimeType] {
t.Errorf("duplicate offer %s", o.MimeType)
}
seen[o.MimeType] = true
}
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
t.Errorf("missing X11 alias offers: %v", seen)
}
img := ExpandOffers([]byte{1}, "image/png")
if len(img) != 1 || img[0].MimeType != "image/png" {
t.Errorf("non-text mime should not expand, got %+v", img)
}
}
+93
View File
@@ -0,0 +1,93 @@
package clipboard
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type session struct {
display *wlclient.Display
ctx *wlclient.Context
registry *wlclient.Registry
seat *wlclient.Seat
dataControlMgr *ext_data_control.ExtDataControlManagerV1
virtualKeyboardMgr *virtual_keyboard.ZwpVirtualKeyboardManagerV1
}
// connectSession opens a short-lived Wayland connection and binds the seat
// plus whichever clipboard-related globals the compositor advertises.
func connectSession() (*session, error) {
display, err := wlclient.Connect("")
if err != nil {
return nil, fmt.Errorf("wayland connect: %w", err)
}
s := &session{display: display, ctx: display.Context()}
registry, err := display.GetRegistry()
if err != nil {
display.Destroy()
return nil, fmt.Errorf("get registry: %w", err)
}
s.registry = registry
var bindErr error
bind := func(name uint32, iface string, version uint32, proxy wlclient.Proxy) {
if err := registry.Bind(name, iface, version, proxy); err != nil {
bindErr = fmt.Errorf("bind %s: %w", iface, err)
}
}
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case ext_data_control.ExtDataControlManagerV1InterfaceName:
mgr := ext_data_control.NewExtDataControlManagerV1(s.ctx)
bind(e.Name, e.Interface, e.Version, mgr)
s.dataControlMgr = mgr
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
mgr := virtual_keyboard.NewZwpVirtualKeyboardManagerV1(s.ctx)
bind(e.Name, e.Interface, e.Version, mgr)
s.virtualKeyboardMgr = mgr
case "wl_seat":
if s.seat != nil {
return
}
seat := wlclient.NewSeat(s.ctx)
bind(e.Name, e.Interface, e.Version, seat)
s.seat = seat
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
s.Close()
return nil, bindErr
}
return s, nil
}
func (s *session) requireDataControl() (*ext_data_control.ExtDataControlManagerV1, error) {
switch {
case s.dataControlMgr == nil:
return nil, fmt.Errorf("compositor does not support ext_data_control_manager_v1")
case s.seat == nil:
return nil, fmt.Errorf("no seat available")
default:
return s.dataControlMgr, nil
}
}
func (s *session) Close() {
if s.dataControlMgr != nil {
s.dataControlMgr.Destroy()
}
if s.registry != nil {
s.registry.Destroy()
}
s.display.Destroy()
}
+4
View File
@@ -61,6 +61,10 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true)
}
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstallsAndSystemd(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, useSystemd)
}
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
var results []DeploymentResult
+2 -2
View File
@@ -20,8 +20,8 @@ mouse-hide-while-typing = true
copy-on-select = false
confirm-close-surface = false
# Disable annoying copied to clipboard
app-notifications = no-clipboard-copy,no-config-reload
# Disable in-app Ghostty toast notifications
app-notifications = false
# Key bindings for common actions
#keybind = ctrl+c=copy_to_clipboard
+1 -1
View File
@@ -9,7 +9,7 @@ hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dash toggle wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
+3 -1
View File
@@ -11,8 +11,10 @@ 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 = {
tap_to_click = true,
natural_scroll = true,
@@ -22,7 +22,7 @@ bind=SUPER,n,spawn,dms ipc call notifications toggle
# Notepad
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
# Browse Wallpapers
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
bind=SUPER,y,spawn,dms ipc call dash toggle wallpaper
# Power Menu
bind=SUPER,x,spawn,dms ipc call powermenu toggle
# Cycle Display Profile
+19 -19
View File
@@ -24,7 +24,7 @@ binds {
spawn "dms" "ipc" "call" "settings" "focusOrToggle";
}
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
spawn "dms" "ipc" "call" "dash" "toggle" "wallpaper";
}
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
@@ -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 {
@@ -20,3 +20,10 @@ window-rule {
tiled-state true
draw-border-with-background false
}
layer-rule {
exclude namespace="^dms:bar$"
background-effect {
xray false
}
}
+6 -6
View File
@@ -265,9 +265,9 @@ recent-windows {
}
// Include dms files
include "dms/colors.kdl"
include "dms/layout.kdl"
include "dms/alttab.kdl"
include "dms/binds.kdl"
include "dms/outputs.kdl"
include "dms/cursor.kdl"
include optional=true "dms/colors.kdl"
include optional=true "dms/layout.kdl"
include optional=true "dms/alttab.kdl"
include optional=true "dms/binds.kdl"
include optional=true "dms/outputs.kdl"
include optional=true "dms/cursor.kdl"
+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
}
+51 -2
View File
@@ -119,10 +119,30 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop())
dependencies = append(dependencies, a.detectDanksearch())
dependencies = append(dependencies, a.detectDankCalendar())
return dependencies, nil
}
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
dep := a.BaseDistribution.detectDanksearch()
dep.CanToggle = true
if a.packageInstalled("dsearch-git") {
dep.Variant = deps.VariantGit
}
return dep
}
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
dep := a.BaseDistribution.detectDankCalendar()
dep.CanToggle = true
if a.packageInstalled("dankcalendar-git") {
dep.Variant = deps.VariantGit
}
return dep
}
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk"))
}
@@ -132,7 +152,13 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
}
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin")
dep := a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", installed)
dep.CanToggle = true
if a.packageInstalled("greetd-dms-greeter-git") {
dep.Variant = deps.VariantGit
}
return dep
}
func (a *ArchDistribution) packageInstalled(pkg string) bool {
@@ -191,7 +217,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
"git": {Name: "git", Repository: RepoTypeSystem},
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
"dms-greeter": a.getDMSGreeterMapping(variants["dms-greeter"]),
"matugen": a.getMatugenMapping(variants["matugen"]),
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
@@ -199,6 +225,8 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"danksearch": a.getDanksearchMapping(variants["danksearch"]),
"dankcalendar": a.getDankCalendarMapping(variants["dankcalendar"]),
}
switch wm {
@@ -253,6 +281,27 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
if forceDMSGit || variant == deps.VariantGit {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
+8
View File
@@ -107,6 +107,14 @@ func (b *BaseDistribution) detectDgop() deps.Dependency {
return b.detectCommand("dgop", "Desktop portal management tool")
}
func (b *BaseDistribution) detectDanksearch() deps.Dependency {
return b.detectOptionalPackage("danksearch", "File indexing and search service", b.commandExists("dsearch") || b.commandExists("danksearch"))
}
func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
}
func (b *BaseDistribution) detectDMS() deps.Dependency {
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
+62 -1
View File
@@ -71,6 +71,8 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, d.detectMatugen())
dependencies = append(dependencies, d.detectDgop())
dependencies = append(dependencies, d.detectDanksearch())
dependencies = append(dependencies, d.detectDankCalendar())
return dependencies, nil
}
@@ -135,6 +137,8 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
}
if wm == deps.WindowManagerNiri {
@@ -157,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 {
@@ -262,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{
+34
View File
@@ -0,0 +1,34 @@
package distros
import (
"context"
"fmt"
"os/exec"
)
// SetupDsearchService enables the dsearch.service user unit. Enablement failures
// are returned for the caller to surface as a non-fatal warning.
func SetupDsearchService(ctx context.Context, logf func(string)) error {
if logf == nil {
logf = func(string) {}
}
if err := runSystemctlUser(ctx, "daemon-reload"); err != nil {
return err
}
if err := runSystemctlUser(ctx, "enable", "--now", "dsearch.service"); err != nil {
return err
}
logf("Enabled dsearch.service")
return nil
}
func runSystemctlUser(ctx context.Context, args ...string) error {
cmd := exec.CommandContext(ctx, "systemctl", append([]string{"--user"}, args...)...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl --user %v failed: %w: %s", args, err, string(output))
}
return nil
}
+4
View File
@@ -104,6 +104,8 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
dependencies = append(dependencies, f.detectDanksearch())
dependencies = append(dependencies, f.detectDankCalendar())
return dependencies, nil
}
@@ -138,6 +140,8 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
}
switch wm {
+2
View File
@@ -113,6 +113,7 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
dependencies = append(dependencies, g.detectDanksearch())
return dependencies, nil
}
@@ -171,6 +172,7 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"dms (DankMaterialShell)": g.getDmsMapping(),
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"danksearch": {Name: "gui-apps/danksearch", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
}
switch wm {
+3
View File
@@ -17,6 +17,7 @@ const (
FamilyDebian DistroFamily = "debian"
FamilyNix DistroFamily = "nix"
FamilyGentoo DistroFamily = "gentoo"
FamilyVoid DistroFamily = "void"
)
// PackageManagerType defines the package manager a distro uses
@@ -29,6 +30,7 @@ const (
PackageManagerZypper PackageManagerType = "zypper"
PackageManagerNix PackageManagerType = "nix"
PackageManagerPortage PackageManagerType = "portage"
PackageManagerXBPS PackageManagerType = "xbps"
)
// RepositoryType defines the type of repository for a package
@@ -42,6 +44,7 @@ const (
RepoTypeOBS RepositoryType = "obs" // OpenBuild Service (Debian/OpenSUSE)
RepoTypeFlake RepositoryType = "flake" // Nix flake
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
RepoTypeXBPS RepositoryType = "xbps" // Custom XBPS repository
RepoTypeManual RepositoryType = "manual" // Manual build from source
)
+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
+4
View File
@@ -91,6 +91,8 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
dependencies = append(dependencies, o.detectMatugen())
dependencies = append(dependencies, o.detectDgop())
dependencies = append(dependencies, o.detectDanksearch())
dependencies = append(dependencies, o.detectDankCalendar())
return dependencies, nil
}
@@ -129,6 +131,8 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
}
switch wm {
+4
View File
@@ -80,6 +80,8 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, u.detectMatugen())
dependencies = append(dependencies, u.detectDgop())
dependencies = append(dependencies, u.detectDanksearch())
dependencies = append(dependencies, u.detectDankCalendar())
return dependencies, nil
}
@@ -124,6 +126,8 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
}
switch wm {
+541
View File
@@ -0,0 +1,541 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
const (
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"
voidRunitServiceDir = "/var/service"
)
func init() {
Register("void", "#478061", FamilyVoid, func(config DistroConfig, logChan chan<- string) Distribution {
return NewVoidDistribution(config, logChan)
})
}
type VoidDistribution struct {
*BaseDistribution
config DistroConfig
}
func NewVoidDistribution(config DistroConfig, logChan chan<- string) *VoidDistribution {
return &VoidDistribution{
BaseDistribution: NewBaseDistribution(logChan),
config: config,
}
}
func (v *VoidDistribution) GetID() string {
return v.config.ID
}
func (v *VoidDistribution) GetColorHex() string {
return v.config.ColorHex
}
func (v *VoidDistribution) GetFamily() DistroFamily {
return v.config.Family
}
func (v *VoidDistribution) GetPackageManager() PackageManagerType {
return PackageManagerXBPS
}
func (v *VoidDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return v.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, v.detectDMS())
dependencies = append(dependencies, v.detectSpecificTerminal(terminal))
dependencies = append(dependencies, v.detectGit())
dependencies = append(dependencies, v.detectWindowManager(wm))
dependencies = append(dependencies, v.detectQuickshell())
dependencies = append(dependencies, v.detectDMSGreeter())
dependencies = append(dependencies, v.detectXDGPortal())
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()...)
}
if wm == deps.WindowManagerNiri || wm == deps.WindowManagerMango {
dependencies = append(dependencies, v.detectXwaylandSatellite())
}
dependencies = append(dependencies, v.detectMatugen())
dependencies = append(dependencies, v.detectDgop())
dependencies = append(dependencies, v.detectDanksearch())
dependencies = append(dependencies, v.detectDankCalendar())
return dependencies, nil
}
func (v *VoidDistribution) detectDMS() deps.Dependency {
status := deps.StatusMissing
version := ""
variant := deps.VariantStable
if v.packageInstalled("dms-git") {
status = deps.StatusInstalled
version = v.packageVersion("dms-git")
variant = deps.VariantGit
} else if v.packageInstalled("dms") {
status = deps.StatusInstalled
version = v.packageVersion("dms")
} else if v.commandExists("dms") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: status,
Version: version,
Description: "Desktop Management System package",
Required: true,
Variant: variant,
CanToggle: true,
}
}
func (v *VoidDistribution) detectQuickshell() deps.Dependency {
dep := v.BaseDistribution.detectQuickshell()
dep.CanToggle = false
return dep
}
func (v *VoidDistribution) detectXDGPortal() deps.Dependency {
return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk"))
}
func (v *VoidDistribution) detectDMSGreeter() deps.Dependency {
return v.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", v.packageInstalled("dms-greeter"))
}
func (v *VoidDistribution) detectAccountsService() deps.Dependency {
return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice"))
}
func (v *VoidDistribution) detectDBus() deps.Dependency {
return v.detectPackage("dbus", "D-Bus system and session message bus", v.packageInstalled("dbus"))
}
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"))
}
func (v *VoidDistribution) packageInstalled(pkg string) bool {
return exec.Command("xbps-query", pkg).Run() == nil
}
func (v *VoidDistribution) packageVersion(pkg string) string {
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
func (v *VoidDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return v.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"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},
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
packages["mango"] = PackageMapping{Name: "mangowc", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (v *VoidDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantStable {
return PackageMapping{Name: "dms", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
}
return PackageMapping{Name: "dms-git", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
}
func (v *VoidDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking XBPS...",
IsComplete: false,
LogOutput: "Checking for xbps-install",
}
if _, err := exec.LookPath("xbps-install"); err != nil {
return fmt.Errorf("xbps-install not found; Void Linux package tools are required: %w", err)
}
return nil
}
func (v *VoidDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if wm == deps.WindowManagerHyprland {
arch, err := v.xbpsArch(ctx)
if err != nil {
return fmt.Errorf("failed to detect XBPS architecture for Hyprland repository selection: %w", err)
}
if arch != "x86_64" {
return fmt.Errorf("hyprland on Void Linux is installed from %s, which currently provides x86_64 packages only (detected %s)", VoidHyprlandRepo, arch)
}
}
if err := v.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, xbpsPkgs := v.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if len(xbpsPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.15,
Step: "Enabling DMS XBPS repositories...",
IsComplete: false,
NeedsSudo: true,
LogOutput: "Setting up custom XBPS repositories for DMS packages",
}
if err := v.enableXBPSRepos(ctx, xbpsPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable XBPS repositories: %w", err)
}
}
allPkgs := v.uniquePackageNames(systemPkgs, v.extractPackageNames(xbpsPkgs))
if len(allPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d XBPS packages...", len(allPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing XBPS packages: %s", strings.Join(allPkgs, ", ")),
}
if err := v.installXBPSPackages(ctx, allPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install XBPS packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
v.log("Void Linux detected; DMS environment and autostart will be configured in the compositor config instead of systemd")
if err := v.ensureSessionServices(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable Void session services: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if !v.isRunitSystem() {
v.log("Void runit service directory not detected; skipping dbus/elogind service enablement")
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))
continue
}
if v.runitServiceEnabled(service) {
v.log(fmt.Sprintf("Void runit service %s already enabled", service))
continue
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.92,
Step: fmt.Sprintf("Enabling %s runit service...", service),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)),
LogOutput: fmt.Sprintf("Enabling Void runit service: %s", service),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)))
if err := v.runWithProgress(cmd, progressChan, PhaseConfiguration, 0.92, 0.95); err != nil {
return fmt.Errorf("failed to enable %s runit service: %w", service, err)
}
v.log(fmt.Sprintf("✓ Enabled %s runit service", service))
}
return nil
}
func (v *VoidDistribution) isRunitSystem() bool {
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
return true
}
if _, err := os.Stat("/run/systemd/system"); err == nil {
return false
}
if fi, err := os.Stat(voidRunitServiceDir); err == nil && fi.IsDir() {
return true
}
return false
}
func (v *VoidDistribution) runitServiceInstalled(name string) bool {
fi, err := os.Stat(filepath.Join(voidRunitSvDir, name))
return err == nil && fi.IsDir()
}
func (v *VoidDistribution) runitServiceEnabled(name string) bool {
_, err := os.Lstat(filepath.Join(voidRunitServiceDir, name))
return err == nil
}
func (v *VoidDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping) {
systemPkgs := []string{}
xbpsPkgs := []PackageMapping{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := v.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
v.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeXBPS:
xbpsPkgs = append(xbpsPkgs, pkgInfo)
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
}
}
return systemPkgs, xbpsPkgs
}
func (v *VoidDistribution) enableXBPSRepos(ctx context.Context, xbpsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
enabledRepoURLs := []string{}
for _, pkg := range xbpsPkgs {
if pkg.RepoURL == "" || enabledRepos[pkg.RepoURL] {
continue
}
repoName := v.xbpsRepoName(pkg.RepoURL)
confPath := filepath.Join("/etc/xbps.d", repoName+".conf")
repoLine := fmt.Sprintf("repository=%s", pkg.RepoURL)
repoFileContent := repoLine + "\n"
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
v.log(fmt.Sprintf("XBPS repo %s already configured, skipping", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
continue
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.18,
Step: fmt.Sprintf("Adding XBPS repo %s...", repoName),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo 'repository=%s' | sudo tee %s", pkg.RepoURL, confPath),
LogOutput: fmt.Sprintf("Adding XBPS repository: %s", pkg.RepoURL),
}
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/xbps.d")
if err := v.runWithProgress(mkdirCmd, progressChan, PhaseSystemPackages, 0.18, 0.19); err != nil {
return fmt.Errorf("failed to create /etc/xbps.d: %w", err)
}
writeCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'printf \"%%s\\n\" %q > %s'", repoLine, confPath))
if err := v.runWithProgress(writeCmd, progressChan, PhaseSystemPackages, 0.19, 0.22); err != nil {
return fmt.Errorf("failed to add XBPS repo %s: %w", pkg.RepoURL, err)
}
enabledRepos[pkg.RepoURL] = true
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
}
if len(enabledRepos) > 0 {
syncArgs := []string{"xbps-install", "-Sy", "-i"}
for _, repoURL := range enabledRepoURLs {
syncArgs = append(syncArgs, "--repository", repoURL)
}
syncCommand := strings.Join(syncArgs, " ")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Synchronizing XBPS repositories...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo sh -c 'yes y | " + syncCommand + "'",
LogOutput: "Synchronizing XBPS repository indexes",
}
syncCmd := privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | "+syncCommand+"'")
if err := v.runWithProgress(syncCmd, progressChan, PhaseSystemPackages, 0.25, 0.30); err != nil {
return fmt.Errorf("failed to synchronize XBPS repositories: %w", err)
}
}
return nil
}
func (v *VoidDistribution) xbpsRepoName(repoURL string) string {
switch repoURL {
case VoidDMSRepo:
return "dms"
case VoidDankLinuxRepo:
return "danklinux"
case VoidHyprlandRepo:
return "hyprland"
default:
name := strings.TrimPrefix(repoURL, "https://")
name = strings.TrimPrefix(name, "http://")
name = strings.NewReplacer("/", "-", ".", "-").Replace(name)
return name
}
}
func (v *VoidDistribution) xbpsArch(ctx context.Context) (string, error) {
output, err := exec.CommandContext(ctx, "xbps-uhelper", "arch").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
func (v *VoidDistribution) installXBPSPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
args := append([]string{"xbps-install", "-Sy"}, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing XBPS packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return v.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.85)
}
func (v *VoidDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (v *VoidDistribution) uniquePackageNames(groups ...[]string) []string {
seen := make(map[string]bool)
var unique []string
for _, group := range groups {
for _, pkg := range group {
if pkg == "" || seen[pkg] {
continue
}
seen[pkg] = true
unique = append(unique, pkg)
}
}
return unique
}
+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
}
+329 -31
View File
@@ -30,6 +30,11 @@ const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter"
const GreeterCacheDir = "/var/cache/dms-greeter"
const (
runitSvDir = "/etc/sv"
runitServiceDir = "/var/service"
)
func DetectDMSPath() (string, error) {
return config.LocateDMSConfig()
}
@@ -41,6 +46,142 @@ func IsNixOS() bool {
return err == nil
}
func IsVoidLinux() bool {
osInfo, err := distros.GetOSInfo()
if err != nil {
return false
}
config, exists := distros.Registry[osInfo.Distribution.ID]
return exists && config.Family == distros.FamilyVoid
}
func isRunit() bool {
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
return true
}
if _, err := os.Stat("/run/systemd/system"); err == nil {
return false
}
if fi, err := os.Stat(runitServiceDir); err == nil && fi.IsDir() {
return true
}
return false
}
func runitServiceInstalled(name string) bool {
fi, err := os.Stat(filepath.Join(runitSvDir, name))
return err == nil && fi.IsDir()
}
func runitServiceEnabled(name string) bool {
_, err := os.Lstat(filepath.Join(runitServiceDir, name))
return err == nil
}
func enableRunitService(name, sudoPassword string) error {
if !runitServiceInstalled(name) {
return fmt.Errorf("runit service %q not found in %s", name, runitSvDir)
}
if runitServiceEnabled(name) {
return nil
}
return privesc.Run(context.Background(), sudoPassword, "ln", "-sf",
filepath.Join(runitSvDir, name), filepath.Join(runitServiceDir, name))
}
func disableRunitService(name, sudoPassword string) error {
if !runitServiceEnabled(name) {
return nil
}
return privesc.Run(context.Background(), sudoPassword, "rm", "-f",
filepath.Join(runitServiceDir, name))
}
func ensureRunitSeat(greeterUser, sudoPassword string, logFunc func(string)) {
if runitServiceInstalled("seatd") {
if err := enableRunitService("seatd", sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ could not enable seatd: %v", err))
} else {
logFunc("✓ seatd enabled")
}
} else {
logFunc("⚠ seatd not installed — the greeter compositor needs it for GPU/seat access")
}
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", "_seatd,video,input", greeterUser); err != nil {
logFunc(fmt.Sprintf("⚠ could not add %s to seat groups: %v", greeterUser, err))
} else {
logFunc(fmt.Sprintf("✓ %s added to seat groups (_seatd, video, input)", greeterUser))
}
}
// 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)
if err != nil {
logFunc(fmt.Sprintf("⚠ could not read %s: %v", pamPath, err))
return
}
if strings.Contains(string(data), "pam_rundir") {
logFunc("✓ pam_rundir already present in greetd PAM")
return
}
line := "session optional pam_rundir.so"
if err := privesc.Run(context.Background(), sudoPassword, "sh", "-c",
fmt.Sprintf("printf '%%s\\n' %q >> %s", line, pamPath)); err != nil {
logFunc(fmt.Sprintf("⚠ could not add pam_rundir to %s: %v", pamPath, err))
return
}
logFunc("✓ pam_rundir added to greetd PAM (provides XDG_RUNTIME_DIR for the session)")
}
func DetectGreeterGroup() string {
data, err := os.ReadFile("/etc/group")
if err != nil {
@@ -233,24 +374,39 @@ func stripDesktopExecCodes(execLine string) string {
return strings.Join(cleaned, " ")
}
func formatInitialSessionCommand(sessionExec string) string {
execLine := strings.TrimSpace(stripDesktopExecCodes(sessionExec))
if execLine == "" {
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func stableDMSCommand() string {
for _, candidate := range []string{"/usr/bin/dms", "/usr/local/bin/dms"} {
info, err := os.Stat(candidate)
if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
return candidate
}
}
return "dms"
}
func formatInitialSessionCommand(cacheDir string) string {
cacheDir = strings.TrimSpace(cacheDir)
if cacheDir == "" {
return `command = ""`
}
escaped := strings.ReplaceAll(execLine, `'`, `'\''`)
launcher := fmt.Sprintf("%s greeter launch-session --from-memory --cache-dir %s", stableDMSCommand(), shellQuote(cacheDir))
escaped := strings.ReplaceAll(launcher, `'`, `'\''`)
inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped)
tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`)
tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`)
return fmt.Sprintf(`command = "%s"`, tomlEscaped)
}
func upsertInitialSession(configContent, loginUser, sessionExec string, enabled bool) string {
func upsertInitialSession(configContent, loginUser, cacheDir string, enabled bool) string {
if !enabled {
return removeTomlSection(configContent, "initial_session")
}
commandLine := formatInitialSessionCommand(sessionExec)
commandLine := formatInitialSessionCommand(cacheDir)
lines := strings.Split(configContent, "\n")
var out []string
@@ -328,10 +484,11 @@ type greeterAutoLoginConfig struct {
}
type greeterAutoLoginMemory struct {
LastSuccessfulUser string `json:"lastSuccessfulUser"`
LastSessionID string `json:"lastSessionId"`
LastSessionExec string `json:"lastSessionExec"`
AutoLoginEnabled bool `json:"autoLoginEnabled"`
LastSuccessfulUser string `json:"lastSuccessfulUser"`
LastSessionID string `json:"lastSessionId"`
LastSessionDesktopID string `json:"lastSessionDesktopId"`
LastSessionExec string `json:"lastSessionExec"`
AutoLoginEnabled bool `json:"autoLoginEnabled"`
}
func readGreeterAutoLoginConfig(settingsPath string) (greeterAutoLoginConfig, error) {
@@ -372,16 +529,28 @@ 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)
}
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionExec string, err error) {
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionID string, err error) {
settingsPath := filepath.Join(cacheDir, "settings.json")
if _, statErr := os.Stat(settingsPath); statErr != nil {
settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
@@ -416,15 +585,9 @@ func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, login
loginUser = current.Username
}
sessionExec = mem.LastSessionExec
if sessionExec == "" && mem.LastSessionID != "" {
sessionExec, err = execFromDesktopFile(mem.LastSessionID)
if err != nil {
sessionExec = ""
}
}
sessionID = sessionDesktopIDFromMemory(mem)
return true, loginUser, sessionExec, nil
return true, loginUser, sessionID, nil
}
func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error {
@@ -540,7 +703,7 @@ func readGreeterMemoryFile(memoryPath, sudoPassword string) ([]byte, error) {
}
func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error {
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
enabled, loginUser, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
return err
}
@@ -568,7 +731,7 @@ func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPas
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login")
}
if loginUser == "" || sessionExec == "" {
if loginUser == "" || sessionID == "" {
if logFunc != nil {
logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.")
}
@@ -579,7 +742,7 @@ func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPas
return nil
}
newConfig := upsertInitialSession(configContent, loginUser, sessionExec, true)
newConfig := upsertInitialSession(configContent, loginUser, cacheDir, true)
if newConfig == configContent {
if logFunc != nil {
logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser))
@@ -756,6 +919,8 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
case distros.FamilyGentoo:
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
case distros.FamilyVoid:
installCmd = privesc.ExecCommand(ctx, sudoPassword, "xbps-install -Sy greetd")
case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
default:
@@ -882,6 +1047,14 @@ 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://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))
}
privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | xbps-install -Sy -i --repository "+distros.VoidDMSRepo+"'").Run()
installCmd = privesc.ExecCommand(ctx, sudoPassword, "xbps-install -Sy dms-greeter")
default:
return false
}
@@ -899,6 +1072,20 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
return true
}
func ensureVoidXBPSRepo(ctx context.Context, sudoPassword, name, repoURL string) error {
confPath := filepath.Join("/etc/xbps.d", name+".conf")
repoLine := fmt.Sprintf("repository=%s", repoURL)
repoFileContent := repoLine + "\n"
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
return nil
}
if err := privesc.Run(ctx, sudoPassword, "mkdir", "-p", "/etc/xbps.d"); err != nil {
return err
}
return privesc.Run(ctx, sudoPassword, "sh", "-c",
fmt.Sprintf("printf '%%s\\n' %q > %s", repoLine, confPath))
}
// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory
func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
if IsGreeterPackaged() {
@@ -1611,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)
@@ -2142,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)
@@ -2160,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)
@@ -2265,6 +2528,19 @@ func checkSystemdEnabled(service string) (string, error) {
func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)) error {
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
for _, dm := range conflictingDMs {
if isRunit() {
if !runitServiceEnabled(dm) {
continue
}
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
if err := disableRunitService(dm, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
} else {
logFunc(fmt.Sprintf("✓ Disabled %s", dm))
}
continue
}
state, err := checkSystemdEnabled(dm)
if err != nil || state == "" || state == "not-found" {
continue
@@ -2284,6 +2560,23 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
// EnableGreetd unmasks and enables greetd, forcing it over any other DM.
func EnableGreetd(sudoPassword string, logFunc func(string)) error {
if isRunit() {
if !runitServiceInstalled("greetd") {
return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir)
}
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)
}
logFunc(fmt.Sprintf("✓ greetd enabled (%s)", runitServiceDir))
return nil
}
state, err := checkSystemdEnabled("greetd")
if err != nil {
return fmt.Errorf("failed to check greetd state: %w", err)
@@ -2307,6 +2600,11 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
}
func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
if isRunit() {
logFunc("✓ runit detected; no graphical target is needed")
return nil
}
cmd := exec.Command("systemctl", "get-default")
output, err := cmd.Output()
if err != nil {
+120 -13
View File
@@ -111,15 +111,18 @@ command = "/usr/bin/dms-greeter --command niri"
t.Run("inserts initial session", func(t *testing.T) {
t.Parallel()
got := upsertInitialSession(baseConfig, "alice", "niri", true)
got := upsertInitialSession(baseConfig, "alice", "/var/cache/dms-greeter", true)
if !strings.Contains(got, "[initial_session]") {
t.Fatalf("expected [initial_session] section, got:\n%s", got)
}
if !strings.Contains(got, `user = "alice"`) {
t.Fatalf("expected alice user in initial session, got:\n%s", got)
}
if !strings.Contains(got, `env XDG_SESSION_TYPE=wayland sh -c 'exec niri'`) {
t.Fatalf("expected wrapped session command, got:\n%s", got)
if !strings.Contains(got, `dms greeter launch-session --from-memory --cache-dir`) {
t.Fatalf("expected stable launch-session command, got:\n%s", got)
}
if strings.Contains(got, `exec niri`) {
t.Fatalf("initial session must not bake the desktop Exec command, got:\n%s", got)
}
})
@@ -130,12 +133,12 @@ command = "/usr/bin/dms-greeter --command niri"
user = "bob"
command = "old-command"
`
got := upsertInitialSession(existing, "alice", "Hyprland", true)
got := upsertInitialSession(existing, "alice", "/var/cache/dms-greeter", true)
if strings.Contains(got, `user = "bob"`) {
t.Fatalf("expected bob to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `exec Hyprland`) {
t.Fatalf("expected Hyprland command, got:\n%s", got)
if !strings.Contains(got, `dms greeter launch-session --from-memory`) {
t.Fatalf("expected launch-session command, got:\n%s", got)
}
})
@@ -166,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()
@@ -179,15 +232,46 @@ func TestResolveGreeterAutoLoginState(t *testing.T) {
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
"lastSessionDesktopId": "niri.desktop"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
enabled, loginUser, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if !enabled || loginUser != "alice" || sessionExec != "niri" {
t.Fatalf("got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
if !enabled || loginUser != "alice" || sessionID != "niri.desktop" {
t.Fatalf("got enabled=%v user=%q session=%q", enabled, loginUser, sessionID)
}
}
func TestResolveGreeterAutoLoginStateIgnoresStaleSessionExec(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": true,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"lastSuccessfulUser": "alice",
"lastSessionId": "/nix/store/old-session/share/wayland-sessions/example.desktop",
"lastSessionExec": "/nix/store/old-session/bin/start-example-session"
}`)
enabled, loginUser, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if !enabled || loginUser != "alice" || sessionID != "example.desktop" {
t.Fatalf("got enabled=%v user=%q session=%q", enabled, loginUser, sessionID)
}
got := upsertInitialSession("", loginUser, cacheDir, true)
if strings.Contains(got, "/nix/store/old-session") {
t.Fatalf("initial session must not include stale store path, got:\n%s", got)
}
}
@@ -208,12 +292,35 @@ func TestResolveGreeterAutoLoginStateIgnoresMemoryFlag(t *testing.T) {
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
enabled, loginUser, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if enabled || loginUser != "" || sessionExec != "" {
t.Fatalf("expected disabled with empty user/exec, got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
if enabled || loginUser != "" || sessionID != "" {
t.Fatalf("expected disabled with empty user/session, got enabled=%v user=%q session=%q", enabled, loginUser, sessionID)
}
}
func TestResolveSessionExecInDirs(t *testing.T) {
t.Parallel()
oldDir := filepath.Join(t.TempDir(), "wayland-sessions")
newDir := filepath.Join(t.TempDir(), "wayland-sessions")
writeTestFile(t, filepath.Join(oldDir, "example.desktop"), `[Desktop Entry]
Name=Example Session
Exec=/nix/store/old-session/bin/start-example-session
`)
writeTestFile(t, filepath.Join(newDir, "example.desktop"), `[Desktop Entry]
Name=Example Session
Exec=/run/current-system/sw/bin/start-example-session
`)
got, err := resolveSessionExecInDirs("example.desktop", []string{newDir, oldDir})
if err != nil {
t.Fatalf("resolveSessionExecInDirs returned error: %v", err)
}
if got != "/run/current-system/sw/bin/start-example-session" {
t.Fatalf("resolveSessionExecInDirs = %q", got)
}
}
+200
View File
@@ -0,0 +1,200 @@
package greeter
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
)
func sessionDesktopIDFromPath(path string) string {
id := strings.TrimSpace(path)
if id == "" {
return ""
}
if strings.ContainsAny(id, "/\\") {
id = filepath.Base(id)
}
if id == "" {
return ""
}
if !strings.HasSuffix(id, ".desktop") {
id += ".desktop"
}
return id
}
func sessionDesktopIDFromMemory(mem greeterAutoLoginMemory) string {
if id := sessionDesktopIDFromPath(mem.LastSessionDesktopID); id != "" {
return id
}
return sessionDesktopIDFromPath(mem.LastSessionID)
}
func sessionDesktopDirs() []string {
seen := make(map[string]bool)
dirs := make([]string, 0, 8)
addBase := func(base string) {
base = strings.TrimSpace(base)
if base == "" {
return
}
for _, sub := range []string{"wayland-sessions", "xsessions"} {
dir := filepath.Join(base, sub)
if seen[dir] {
continue
}
seen[dir] = true
dirs = append(dirs, dir)
}
}
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
addBase(dataHome)
} else if home, err := os.UserHomeDir(); err == nil && home != "" {
addBase(filepath.Join(home, ".local", "share"))
}
if dataDirs := os.Getenv("XDG_DATA_DIRS"); dataDirs != "" {
for _, dir := range strings.Split(dataDirs, ":") {
addBase(dir)
}
} else {
addBase("/usr/local/share")
addBase("/usr/share")
}
return dirs
}
func ResolveSessionExec(sessionID string) (string, error) {
return resolveSessionExecInDirs(sessionID, sessionDesktopDirs())
}
func resolveSessionExecInDirs(sessionID string, dirs []string) (string, error) {
id := sessionDesktopIDFromPath(sessionID)
if id == "" {
return "", fmt.Errorf("session id is empty")
}
for _, dir := range dirs {
path := filepath.Join(dir, id)
execLine, err := execFromDesktopFile(path)
if err == nil {
return execLine, nil
}
if !os.IsNotExist(err) {
return "", err
}
}
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
}
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(resolved, argv, env)
}
func LaunchSessionFromMemory(cacheDir, homeDir string) error {
enabled, _, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
return err
}
if !enabled {
return fmt.Errorf("greeter auto-login is disabled")
}
if sessionID == "" {
return fmt.Errorf("greeter auto-login has no remembered session")
}
return LaunchSessionByID(sessionID)
}
@@ -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")
}
}
+40 -7
View File
@@ -41,6 +41,8 @@ type Config struct {
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
ReplaceConfigsAll bool // deploy/replace all configurations
Yes bool
DankSearch bool // install danksearch and enable its user service
DankCalendar bool // install dankcalendar
}
// Runner orchestrates unattended (headless) installation.
@@ -214,6 +216,11 @@ func (r *Runner) Run() error {
return fmt.Errorf("package installation failed: %w", err)
}
useSystemd := true
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
useSystemd = false
}
// 9. Greeter setup (if dms-greeter was included)
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
compositorName := "niri"
@@ -231,18 +238,32 @@ func (r *Runner) Run() error {
}
}
// 9b. danksearch service setup (if danksearch was included)
if useSystemd && !disabledItems["danksearch"] && r.depExists(dependencies, "danksearch") {
fmt.Fprintln(os.Stdout, "Enabling danksearch service...")
logFunc := func(line string) {
r.log(line)
fmt.Fprintf(os.Stdout, " danksearch: %s\n", line)
}
if err := distros.SetupDsearchService(context.Background(), logFunc); err != nil {
// Non-fatal, matching greeter behavior
fmt.Fprintf(os.Stderr, "Warning: danksearch service setup issue (non-fatal): %v\n", err)
}
}
// 10. Deploy configurations
fmt.Fprintln(os.Stdout, "Deploying configurations...")
r.log("Starting configuration deployment")
deployer := config.NewConfigDeployer(r.logChan)
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd(
context.Background(),
wm,
terminal,
dependencies,
replaceConfigs,
reinstallItems,
useSystemd,
)
if err != nil {
return fmt.Errorf("configuration deployment failed: %w", err)
@@ -267,19 +288,31 @@ func (r *Runner) Run() error {
}
// buildDisabledItems computes the set of dependencies that should be skipped
// during installation, applying the --include-deps and --exclude-deps filters.
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
// during installation. Optional components are opt-in (disabled by default),
// then re-enabled by the dedicated flags and --include-deps.
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
disabledItems := make(map[string]bool)
// dms-greeter is opt-in (disabled by default), matching TUI behavior
for i := range dependencies {
if dependencies[i].Name == "dms-greeter" {
disabledItems["dms-greeter"] = true
break
if !dependencies[i].Required {
disabledItems[dependencies[i].Name] = true
}
}
// Dedicated flags resolve before include/exclude
if r.cfg.DankSearch {
if !r.depExists(dependencies, "danksearch") {
return nil, fmt.Errorf("--danksearch: not available on this distribution")
}
delete(disabledItems, "danksearch")
}
if r.cfg.DankCalendar {
if !r.depExists(dependencies, "dankcalendar") {
return nil, fmt.Errorf("--dankcalendar: not available on this distribution")
}
delete(disabledItems, "dankcalendar")
}
// Process --include-deps (enable items that are disabled by default)
for _, name := range r.cfg.IncludeDeps {
name = strings.TrimSpace(name)
+51 -17
View File
@@ -342,17 +342,21 @@ func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
func TestBuildDisabledItems(t *testing.T) {
dependencies := []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
{Name: "ghostty", Status: deps.StatusMissing},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
{Name: "niri", Status: deps.StatusInstalled, Required: true},
{Name: "ghostty", Status: deps.StatusMissing, Required: true},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled, Required: true},
{Name: "dms-greeter", Status: deps.StatusMissing},
{Name: "waybar", Status: deps.StatusMissing},
{Name: "danksearch", Status: deps.StatusMissing},
{Name: "dankcalendar", Status: deps.StatusMissing},
{Name: "waybar", Status: deps.StatusMissing, Required: true},
}
tests := []struct {
name string
includeDeps []string
excludeDeps []string
dankSearch bool
dankCalendar bool
deps []deps.Dependency // nil means use the shared fixture
wantErr bool
errContains string // substring expected in error message
@@ -360,19 +364,20 @@ func TestBuildDisabledItems(t *testing.T) {
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
}{
{
name: "no flags set, dms-greeter disabled by default",
wantDisabled: []string{"dms-greeter"},
name: "no flags set, optional deps disabled by default",
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar"},
wantEnabled: []string{"niri", "ghostty", "waybar"},
},
{
name: "include dms-greeter enables it",
includeDeps: []string{"dms-greeter"},
wantEnabled: []string{"dms-greeter"},
name: "include dms-greeter enables it",
includeDeps: []string{"dms-greeter"},
wantEnabled: []string{"dms-greeter"},
wantDisabled: []string{"danksearch", "dankcalendar"},
},
{
name: "exclude a regular dep",
excludeDeps: []string{"waybar"},
wantDisabled: []string{"dms-greeter", "waybar"},
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar", "waybar"},
},
{
name: "include unknown dep returns error",
@@ -399,24 +404,53 @@ func TestBuildDisabledItems(t *testing.T) {
wantDisabled: []string{"dms-greeter"},
},
{
name: "whitespace entries are skipped",
includeDeps: []string{" ", "dms-greeter"},
wantEnabled: []string{"dms-greeter"},
name: "whitespace entries are skipped",
includeDeps: []string{" ", "dms-greeter"},
wantEnabled: []string{"dms-greeter"},
wantDisabled: []string{"danksearch", "dankcalendar"},
},
{
name: "no dms-greeter in deps, nothing disabled by default",
name: "no optional deps present, nothing disabled by default",
deps: []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
{Name: "niri", Status: deps.StatusInstalled, Required: true},
},
wantEnabled: []string{"niri"},
},
{
name: "danksearch flag enables it",
dankSearch: true,
wantEnabled: []string{"danksearch"},
wantDisabled: []string{"dms-greeter", "dankcalendar"},
},
{
name: "dankcalendar flag enables it",
dankCalendar: true,
wantEnabled: []string{"dankcalendar"},
wantDisabled: []string{"dms-greeter", "danksearch"},
},
{
name: "danksearch flag when unavailable errors",
dankSearch: true,
deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}},
wantErr: true,
errContains: "--danksearch",
},
{
name: "dankcalendar flag when unavailable errors",
dankCalendar: true,
deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}},
wantErr: true,
errContains: "--dankcalendar",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{
IncludeDeps: tt.includeDeps,
ExcludeDeps: tt.excludeDeps,
IncludeDeps: tt.includeDeps,
ExcludeDeps: tt.excludeDeps,
DankSearch: tt.dankSearch,
DankCalendar: tt.dankCalendar,
})
d := tt.deps
if d == nil {
+47 -18
View File
@@ -190,9 +190,13 @@ func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string {
}
func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
key := kb.Key
if canonical, ok := hyprlandScrollToCanonical(key); ok {
key = canonical
}
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
parts = append(parts, key)
return strings.Join(parts, "+")
}
@@ -295,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 {
@@ -411,6 +418,9 @@ func normalizeLuaBindKeyPart(part string) string {
case "alt", "mod1":
return "ALT"
}
if native, ok := hyprlandScrollToNative(part); ok {
return native
}
if len(part) == 1 {
return strings.ToUpper(part)
}
@@ -1039,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 {
@@ -1068,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')
}
@@ -1097,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
}
@@ -1130,6 +1154,11 @@ func parseLuaUnbindLine(line string) (string, bool) {
func luaKeyComboToInternalKey(combo string) string {
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " "))
for i, part := range parts {
if canonical, ok := hyprlandScrollToCanonical(part); ok {
parts[i] = canonical
}
}
return strings.Join(parts, "+")
}
@@ -1142,7 +1171,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
parser := NewHyprlandParser("")
pendingUnbinds := make(map[string]string)
for _, line := range lines {
@@ -0,0 +1,414 @@
package providers
import (
"maps"
"regexp"
"strconv"
"strings"
)
// Lua configs can express binds dynamically: variables (mainMod .. " + C"),
// tostring() calls, and numeric for loops (workspace binds). This resolves such
// expressions to literal key combos so the static bind parser can read them.
const luaMaxLoopIterations = 1000
var (
luaAssignRE = regexp.MustCompile(`^(?:local\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$`)
luaForRE = regexp.MustCompile(`^for\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(-?\d+)\s*,\s*(-?\d+)\s*(?:,\s*(-?\d+)\s*)?do\b(.*)$`)
luaTostringRE = regexp.MustCompile(`to(?:string|number)\s*\(\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|-?\d+(?:\.\d+)?)\s*\)`)
luaBlockOpenRE = regexp.MustCompile(`\b(?:function|for|while|if)\b`)
luaBlockCloseRE = regexp.MustCompile(`\bend\b`)
luaNumberRE = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
)
type luaVarEnv map[string]string
type luaForHeader struct {
varName string
start int
stop int
step int
inline string
}
func expandLuaConfigLines(lines []string) []string {
return expandLuaBlock(lines, luaVarEnv{})
}
func expandLuaBlock(lines []string, env luaVarEnv) []string {
out := make([]string, 0, len(lines))
for i := 0; i < len(lines); i++ {
code := strings.TrimSpace(luaStripLineComment(lines[i]))
if name, value, ok := parseLuaStringAssignment(code, env); ok {
env[name] = value
out = append(out, lines[i])
continue
}
header, ok := parseLuaForHeader(code)
if !ok {
out = append(out, resolveLuaDynamicLine(lines[i], env))
continue
}
body, consumed, complete := collectLuaForBody(header, lines, i)
if !complete {
out = append(out, resolveLuaDynamicLine(lines[i], env))
continue
}
out = append(out, expandLuaForLoop(header, body, env)...)
i = consumed
}
return out
}
func parseLuaStringAssignment(code string, env luaVarEnv) (name, value string, ok bool) {
m := luaAssignRE.FindStringSubmatch(code)
if m == nil {
return "", "", false
}
value, ok = evalLuaConcat(m[2], env)
if !ok {
return "", "", false
}
return m[1], value, true
}
func parseLuaForHeader(code string) (luaForHeader, bool) {
m := luaForRE.FindStringSubmatch(code)
if m == nil {
return luaForHeader{}, false
}
start, _ := strconv.Atoi(m[2])
stop, _ := strconv.Atoi(m[3])
step := 1
if m[4] != "" {
step, _ = strconv.Atoi(m[4])
}
if step == 0 {
return luaForHeader{}, false
}
return luaForHeader{varName: m[1], start: start, stop: stop, step: step, inline: strings.TrimSpace(m[5])}, true
}
func collectLuaForBody(header luaForHeader, lines []string, headerIdx int) (body []string, consumed int, complete bool) {
depth := 1
if header.inline != "" {
delta := luaBlockDelta(header.inline)
if depth+delta <= 0 {
if stmt := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(header.inline), "end")); stmt != "" {
body = append(body, stmt)
}
return body, headerIdx, true
}
depth += delta
body = append(body, header.inline)
}
for j := headerIdx + 1; j < len(lines); j++ {
delta := luaBlockDelta(lines[j])
if depth+delta <= 0 {
return body, j, true
}
depth += delta
body = append(body, lines[j])
}
return nil, headerIdx, false
}
func expandLuaForLoop(header luaForHeader, body []string, env luaVarEnv) []string {
var out []string
inRange := func(v int) bool {
if header.step > 0 {
return v <= header.stop
}
return v >= header.stop
}
count := 0
for v := header.start; inRange(v); v += header.step {
if count++; count > luaMaxLoopIterations {
break
}
value := strconv.Itoa(v)
iterLines := make([]string, len(body))
for k, bl := range body {
iterLines[k] = substituteLuaIdent(bl, header.varName, value)
}
out = append(out, expandLuaBlock(iterLines, cloneLuaEnv(env))...)
}
return out
}
func luaBlockDelta(line string) int {
masked := luaMaskStrings(line)
return len(luaBlockOpenRE.FindAllString(masked, -1)) - len(luaBlockCloseRE.FindAllString(masked, -1))
}
func resolveLuaDynamicLine(line string, env luaVarEnv) string {
if !strings.Contains(line, "hl.bind") && !strings.Contains(line, "hl.unbind") {
return line
}
line = normalizeLuaToString(line)
return rewriteLuaBindKeyArg(line, env)
}
func normalizeLuaToString(line string) string {
return luaTostringRE.ReplaceAllStringFunc(line, func(m string) string {
inner := luaTostringRE.FindStringSubmatch(m)[1]
if inner[0] == '"' || inner[0] == '\'' {
return inner
}
return strconv.Quote(inner)
})
}
func rewriteLuaBindKeyArg(line string, env luaVarEnv) string {
for _, fn := range []string{"hl.bind", "hl.unbind"} {
idx := strings.Index(line, fn)
if idx < 0 {
continue
}
open := skipLuaWS(line, idx+len(fn))
if open >= len(line) || line[open] != '(' {
continue
}
argStart := skipLuaWS(line, open+1)
expr, end, ok := parseLuaFirstArgExpr(line, argStart)
if !ok || isLuaPlainStringArg(expr) {
continue
}
value, ok := evalLuaConcat(expr, env)
if !ok {
continue
}
return line[:argStart] + strconv.Quote(value) + line[end:]
}
return line
}
func evalLuaConcat(expr string, env luaVarEnv) (string, bool) {
parts := splitLuaConcat(expr)
var sb strings.Builder
for _, part := range parts {
value, ok := evalLuaOperand(part, env)
if !ok {
return "", false
}
sb.WriteString(value)
}
return sb.String(), true
}
func evalLuaOperand(op string, env luaVarEnv) (string, bool) {
op = strings.TrimSpace(op)
if op == "" {
return "", false
}
switch op[0] {
case '"', '\'':
s, next, ok := parseLuaStringLiteral(op, 0)
return s, next == len(op) && ok
}
if luaNumberRE.MatchString(op) {
return op, true
}
if inner, ok := luaUnwrapCall(op, "tostring"); ok {
return evalLuaConcat(inner, env)
}
if inner, ok := luaUnwrapCall(op, "tonumber"); ok {
return evalLuaConcat(inner, env)
}
if value, ok := env[op]; ok {
return value, true
}
return "", false
}
func splitLuaConcat(expr string) []string {
var parts []string
parenDepth, braceDepth, bracketDepth := 0, 0, 0
inStr := byte(0)
esc := false
start := 0
for i := 0; i < len(expr); i++ {
c := expr[i]
if inStr != 0 {
switch {
case esc:
esc = false
case c == '\\' && inStr == '"':
esc = true
case c == inStr:
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '(':
parenDepth++
case ')':
if parenDepth > 0 {
parenDepth--
}
case '{':
braceDepth++
case '}':
if braceDepth > 0 {
braceDepth--
}
case '[':
bracketDepth++
case ']':
if bracketDepth > 0 {
bracketDepth--
}
case '.':
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && i+1 < len(expr) && expr[i+1] == '.' {
parts = append(parts, expr[start:i])
i++
start = i + 1
}
}
}
return append(parts, expr[start:])
}
func substituteLuaIdent(line, name, value string) string {
if !strings.Contains(line, name) {
return line
}
var sb strings.Builder
inStr := byte(0)
esc := false
for i := 0; i < len(line); {
c := line[i]
if inStr != 0 {
sb.WriteByte(c)
switch {
case esc:
esc = false
case c == '\\' && inStr == '"':
esc = true
case c == inStr:
inStr = 0
}
i++
continue
}
if c == '"' || c == '\'' {
inStr = c
sb.WriteByte(c)
i++
continue
}
if isLuaIdentStart(c) {
j := i + 1
for j < len(line) && isLuaIdentByte(line[j]) {
j++
}
word := line[i:j]
if word == name && (i == 0 || line[i-1] != '.') {
sb.WriteString(value)
} else {
sb.WriteString(word)
}
i = j
continue
}
sb.WriteByte(c)
i++
}
return sb.String()
}
func luaMaskStrings(line string) string {
b := []byte(line)
inStr := byte(0)
esc := false
for i := 0; i < len(b); i++ {
c := b[i]
if inStr != 0 {
wasEnd := !esc && c == inStr
esc = !esc && c == '\\' && inStr == '"'
b[i] = ' '
if wasEnd {
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
b[i] = ' '
case '-':
if i+1 < len(b) && b[i+1] == '-' {
for ; i < len(b); i++ {
b[i] = ' '
}
}
}
}
return string(b)
}
func luaStripLineComment(line string) string {
inStr := byte(0)
esc := false
for i := 0; i+1 < len(line); i++ {
c := line[i]
if inStr != 0 {
switch {
case esc:
esc = false
case c == '\\' && inStr == '"':
esc = true
case c == inStr:
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '-':
if line[i+1] == '-' {
return line[:i]
}
}
}
return line
}
func luaUnwrapCall(op, fn string) (string, bool) {
op = strings.TrimSpace(op)
if !strings.HasPrefix(op, fn) {
return "", false
}
rest := strings.TrimSpace(op[len(fn):])
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
return "", false
}
return rest[1 : len(rest)-1], true
}
func isLuaPlainStringArg(expr string) bool {
expr = strings.TrimSpace(expr)
if expr == "" || (expr[0] != '"' && expr[0] != '\'') {
return false
}
_, next, ok := parseLuaStringLiteral(expr, 0)
return ok && next == len(expr)
}
func isLuaIdentStart(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
func cloneLuaEnv(env luaVarEnv) luaVarEnv {
clone := make(luaVarEnv, len(env))
maps.Copy(clone, env)
return clone
}
@@ -0,0 +1,134 @@
package providers
import (
"strings"
"testing"
)
func TestExpandLuaConfigLinesVariableConcat(t *testing.T) {
lines := []string{
`local mainMod = "SUPER"`,
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
`hl.bind(mainMod .. " + H", hl.dsp.focus({direction = "l"}))`,
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
}
got := expandLuaConfigLines(lines)
want := []string{
`hl.bind("SUPER + C",`,
`hl.bind("SUPER + H",`,
`hl.bind("ALT + TAB",`,
}
joined := strings.Join(got, "\n")
for _, w := range want {
if !strings.Contains(joined, w) {
t.Errorf("expanded output missing %q\n---\n%s", w, joined)
}
}
}
func TestExpandLuaConfigLinesForLoop(t *testing.T) {
lines := []string{
`local mainMod = "SUPER"`,
`for i = 1, 3 do`,
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
` hl.bind(mainMod .. " SHIFT + " .. i, hl.dsp.window.move({workspace = tostring(i)}))`,
`end`,
}
got := strings.Join(expandLuaConfigLines(lines), "\n")
for _, w := range []string{
`hl.bind("SUPER + 1",`,
`hl.bind("SUPER + 2",`,
`hl.bind("SUPER + 3",`,
`hl.bind("SUPER SHIFT + 3",`,
`{workspace = "1"}`,
} {
if !strings.Contains(got, w) {
t.Errorf("expanded loop missing %q\n---\n%s", w, got)
}
}
}
func TestParseLuaLinesDynamicBinds(t *testing.T) {
content := strings.Join([]string{
`local mainMod = "SUPER"`,
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
`for i = 1, 2 do`,
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
`end`,
}, "\n")
parser := NewHyprlandParser("")
section, err := parser.parseLuaLines(content, "", "test.lua", "")
if err != nil {
t.Fatalf("parseLuaLines: %v", err)
}
keys := map[string]*HyprlandKeyBinding{}
for i := range section.Keybinds {
kb := &section.Keybinds[i]
keys[parser.formatBindKey(kb)] = kb
}
for _, want := range []string{"SUPER+C", "ALT+TAB", "SUPER+1", "SUPER+2"} {
if _, ok := keys[want]; !ok {
t.Errorf("missing bind %q; got %v", want, keysList(keys))
}
}
if kb := keys["SUPER+C"]; kb != nil && kb.Dispatcher != "killactive" {
t.Errorf("SUPER+C dispatcher = %q, want killactive", kb.Dispatcher)
}
if kb := keys["SUPER+1"]; kb != nil {
if kb.Dispatcher != "workspace" || kb.Params != "1" {
t.Errorf("SUPER+1 = %q %q, want workspace 1", kb.Dispatcher, kb.Params)
}
}
}
func keysList(m map[string]*HyprlandKeyBinding) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestEvalLuaConcat(t *testing.T) {
env := luaVarEnv{"mainMod": "SUPER", "i": "5"}
tests := []struct {
expr string
want string
ok bool
}{
{`mainMod .. " + C"`, "SUPER + C", true},
{`mainMod .. " + " .. i`, "SUPER + 5", true},
{`mainMod .. " + " .. tostring(i)`, "SUPER + 5", true},
{`"ALT + TAB"`, "ALT + TAB", true},
{`mainMod .. someFunc()`, "", false},
{`unknownVar .. "x"`, "", false},
}
for _, tt := range tests {
got, ok := evalLuaConcat(tt.expr, env)
if ok != tt.ok || (ok && got != tt.want) {
t.Errorf("evalLuaConcat(%q) = %q,%v want %q,%v", tt.expr, got, ok, tt.want, tt.ok)
}
}
}
func TestSubstituteLuaIdent(t *testing.T) {
tests := []struct {
line, name, value, want string
}{
{`hl.bind(m .. " + " .. i, x)`, "i", "3", `hl.bind(m .. " + " .. 3, x)`},
{`hl.dsp.exec("light -i")`, "i", "3", `hl.dsp.exec("light -i")`},
{`foo.i`, "i", "3", `foo.i`},
{`tostring(i)`, "i", "3", `tostring(3)`},
}
for _, tt := range tests {
if got := substituteLuaIdent(tt.line, tt.name, tt.value); got != tt.want {
t.Errorf("substituteLuaIdent(%q,%q,%q) = %q, want %q", tt.line, tt.name, tt.value, got, tt.want)
}
}
}
@@ -347,9 +347,13 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
}
func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string {
key := kb.Key
if canonical, ok := hyprlandScrollToCanonical(key); ok {
key = canonical
}
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
parts = append(parts, key)
return strings.Join(parts, "+")
}
@@ -619,7 +623,7 @@ func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, section
prevSource := p.currentSource
p.currentSource = absPath
lines := strings.Split(content, "\n")
lines := expandLuaConfigLines(strings.Split(content, "\n"))
boundInFile := make(map[string]bool)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
@@ -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) {
@@ -486,6 +502,61 @@ hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
}
}
func TestHyprlandSetBindTranslatesScrollWheelToMouse(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
bindsUser := filepath.Join(dmsDir, "binds-user.lua")
if err := os.WriteFile(bindsUser, []byte("-- DMS user keybind overrides\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.SetBind("SUPER + WheelScrollDown", "workspace 1", "", nil); err != nil {
t.Fatal(err)
}
got := readFile(t, bindsUser)
if !strings.Contains(got, `hl.bind("SUPER + mouse_down"`) {
t.Fatalf("expected scroll key translated to mouse_down, got:\n%s", got)
}
if strings.Contains(got, "WheelScroll") {
t.Fatalf("expected no raw niri scroll keysym in hyprland output, got:\n%s", got)
}
if err := provider.SetBind("SUPER + WheelScrollDown", "workspace 2", "", nil); err != nil {
t.Fatal(err)
}
got = readFile(t, bindsUser)
if strings.Count(got, `hl.bind("SUPER + mouse_down"`) != 1 {
t.Fatalf("expected exactly one mouse_down bind after re-save, got:\n%s", got)
}
}
func TestHyprlandScrollWheelRoundTrips(t *testing.T) {
for native, canonical := range map[string]string{
"mouse_up": "WheelScrollUp",
"mouse_down": "WheelScrollDown",
"mouse_left": "WheelScrollLeft",
"mouse_right": "WheelScrollRight",
} {
if got := luaKeyComboToInternalKey("SUPER + " + native); got != "SUPER+"+canonical {
t.Errorf("luaKeyComboToInternalKey(%q) = %q, want SUPER+%s", native, got, canonical)
}
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
@@ -236,6 +236,9 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" {
prefix = optionPrefix
}
if _, leaf := m.parseKeyString(key); isScrollKey(leaf) {
prefix = mangowcAxisBindPrefix
}
existingBinds[normalizedKey] = &mangowcOverrideBind{
Key: key,
@@ -346,6 +349,12 @@ func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (
keyName := strings.TrimSpace(fields[1])
command := strings.TrimSpace(fields[2])
if prefix == mangowcAxisBindPrefix {
if canonical, ok := mangowcDirectionToScroll(keyName); ok {
keyName = canonical
}
}
var params string
if len(fields) > 3 {
params = strings.TrimSpace(fields[3])
@@ -365,6 +374,9 @@ func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (
}
func (m *MangoWCProvider) isBindPrefix(prefix string) bool {
if prefix == mangowcAxisBindPrefix {
return true
}
if !strings.HasPrefix(prefix, "bind") {
return false
}
@@ -591,6 +603,11 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
if prefix == "" {
prefix = "bind"
}
if prefix == mangowcAxisBindPrefix {
if direction, ok := mangowcScrollToDirection(key); ok {
key = direction
}
}
sb.WriteString(prefix)
sb.WriteString("=")
if mods == "" {
@@ -244,7 +244,7 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
}
continue
}
if !strings.HasPrefix(trimmed, "bind") {
if !strings.HasPrefix(trimmed, "bind") && !strings.HasPrefix(trimmed, mangowcAxisBindPrefix) {
pendingComment = ""
continue
}
@@ -427,7 +427,7 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
continue
}
if !strings.HasPrefix(trimmed, "bind") {
if !strings.HasPrefix(trimmed, "bind") && !strings.HasPrefix(trimmed, mangowcAxisBindPrefix) {
pendingComment = ""
continue
}
@@ -493,7 +493,7 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
// line directly above) is the description: mango feeds inline comments to spawn
// as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback.
func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding {
bindMatch := regexp.MustCompile(`^(bind[lsrp]*)\s*=\s*(.+)$`)
bindMatch := regexp.MustCompile(`^(bind[lsrp]*|axisbind)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
@@ -527,6 +527,12 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment st
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
if matches[1] == mangowcAxisBindPrefix {
if canonical, ok := mangowcDirectionToScroll(key); ok {
key = canonical
}
}
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
@@ -6,6 +6,29 @@ import (
"testing"
)
func TestMangoWCParseAxisBindToScrollKey(t *testing.T) {
tmpDir := t.TempDir()
cfg := filepath.Join(tmpDir, "config.conf")
content := "axisbind=SUPER,UP,spawn,dms ipc call test\n"
if err := os.WriteFile(cfg, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
binds, err := ParseMangoWCKeys(cfg)
if err != nil {
t.Fatalf("ParseMangoWCKeys failed: %v", err)
}
if len(binds) != 1 {
t.Fatalf("expected 1 bind, got %d", len(binds))
}
if binds[0].Key != "WheelScrollUp" {
t.Fatalf("expected axis direction parsed as WheelScrollUp, got %q", binds[0].Key)
}
if len(binds[0].Mods) != 1 || binds[0].Mods[0] != "SUPER" {
t.Fatalf("expected SUPER mod, got %v", binds[0].Mods)
}
}
func TestMangoWCAutogenerateComment(t *testing.T) {
tests := []struct {
command string
@@ -417,6 +417,40 @@ bind=SUPER,3,view,3
}
}
func TestMangoWCSetBindTranslatesScrollWheelToAxisBind(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
seed := "# === Custom Keybinds ===\nbind=SUPER,t,spawn,ghostty\ngesturebind=none,left,3,focusdir,left\n"
if err := os.WriteFile(bindsPath, []byte(seed), 0o644); err != nil {
t.Fatalf("failed to write seed binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.SetBind("SUPER+WheelScrollDown", "spawn dms ipc call test", "Scroll down", nil); err != nil {
t.Fatalf("SetBind failed: %v", err)
}
content := readFile(t, bindsPath)
if !strings.Contains(content, "axisbind=SUPER,DOWN,spawn,dms ipc call test") {
t.Fatalf("expected scroll bind written as axisbind direction, got:\n%s", content)
}
if strings.Contains(content, "WheelScroll") {
t.Fatalf("expected no raw niri scroll keysym in mango output, got:\n%s", content)
}
if err := provider.SetBind("SUPER+WheelScrollDown", "spawn dms ipc call test2", "Scroll down", nil); err != nil {
t.Fatalf("SetBind failed: %v", err)
}
content = readFile(t, bindsPath)
if strings.Count(content, "axisbind=SUPER,DOWN,") != 1 {
t.Fatalf("expected exactly one axisbind after re-save, got:\n%s", content)
}
}
func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
+36 -9
View File
@@ -19,6 +19,20 @@ type NiriProvider struct {
parsed bool
}
type niriActionPart struct {
value string
quoted bool
}
var niriActionPropertyOrder = []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"}
var niriActionProperties = map[string]struct{}{
"focus": {},
"show-pointer": {},
"write-to-disk": {},
"skip-confirmation": {},
"delay-ms": {},
}
func NewNiriProvider(configDir string) *NiriProvider {
if configDir == "" {
configDir = defaultNiriConfigDir()
@@ -55,6 +69,7 @@ func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
sheet := &keybinds.CheatSheet{
Title: "Niri Keybinds",
Provider: n.Name(),
ModKey: result.ModKey,
Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded,
}
@@ -352,7 +367,7 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
}
if actionNode.Properties != nil {
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
for _, propName := range niriActionPropertyOrder {
if val, ok := actionNode.Properties.Get(propName); ok {
parts = append(parts, propName+"="+val.String())
}
@@ -440,10 +455,10 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
return node
}
node.SetName(parts[0])
node.SetName(parts[0].value)
for _, arg := range parts[1:] {
if strings.Contains(arg, "=") {
kv := strings.SplitN(arg, "=", 2)
if n.isNiriActionPropertyToken(arg) {
kv := strings.SplitN(arg.value, "=", 2)
switch kv[1] {
case "true":
node.AddProperty(kv[0], true, "")
@@ -454,13 +469,25 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
}
continue
}
node.AddArgument(arg, "")
node.AddArgument(arg.value, "")
}
return node
}
func (n *NiriProvider) parseActionParts(action string) []string {
var parts []string
func (n *NiriProvider) isNiriActionPropertyToken(part niriActionPart) bool {
if part.quoted || !strings.Contains(part.value, "=") {
return false
}
key, _, ok := strings.Cut(part.value, "=")
if !ok {
return false
}
_, ok = niriActionProperties[key]
return ok
}
func (n *NiriProvider) parseActionParts(action string) []niriActionPart {
var parts []niriActionPart
var current strings.Builder
var inQuote, escaped, wasQuoted bool
@@ -476,7 +503,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
inQuote = !inQuote
case r == ' ' && !inQuote:
if current.Len() > 0 || wasQuoted {
parts = append(parts, current.String())
parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
current.Reset()
wasQuoted = false
}
@@ -485,7 +512,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
}
}
if current.Len() > 0 || wasQuoted {
parts = append(parts, current.String())
parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
}
return parts
}
+107 -1
View File
@@ -33,6 +33,7 @@ type NiriSection struct {
type NiriParser struct {
configDir string
modKey string
processedFiles map[string]bool
bindMap map[string]*NiriKeyBinding
bindOrder []string
@@ -51,7 +52,7 @@ type NiriParser struct {
}
func parseKDL(data []byte) (*document.Document, error) {
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data))))
return kdl.Parse(strings.NewReader(normalizeKDLBraces(quoteLeadingUnderscoreIdents(string(data)))))
}
func normalizeKDLBraces(input string) string {
@@ -94,6 +95,93 @@ func normalizeKDLBraces(input string) string {
return sb.String()
}
// quoteLeadingUnderscoreIdents wraps bare KDL identifiers that begin with '_'
// in double quotes. kdl-go rejects '_' as the first character of a bare
// identifier (e.g. the common `_JAVA_AWT_WM_NONREPARENTING "1"` environment
// node), even though niri's own parser and the KDL spec accept it — so without
// this the whole config fails to parse and no keybinds load. Quoting lets
// kdl-go parse it; this is safe because the niri parser only dispatches on
// fixed node/section names (binds, recent-windows, include, ...) that never
// start with '_', so re-quoting such a name cannot change what DMS reads.
// Underscores elsewhere in an identifier (XDG_CURRENT_DESKTOP) are left
// untouched, and underscores inside strings or comments are skipped. Only a
// leading '_' is handled; other start characters kdl-go over-rejects (e.g. '.'
// or '?') do not occur in niri configs.
func quoteLeadingUnderscoreIdents(input string) string {
var sb strings.Builder
sb.Grow(len(input))
var prev byte
n := len(input)
for i := 0; i < n; {
c := input[i]
switch {
case c == '"':
end := findStringEnd(input, i)
sb.WriteString(input[i:end])
prev = '"'
i = end
case c == '/' && i+1 < n && input[i+1] == '/':
end := findLineCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = '\n'
i = end
case c == '/' && i+1 < n && input[i+1] == '*':
end := findBlockCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = ' '
i = end
case c == '/' && i+1 < n && input[i+1] == '-':
// KDL slashdash: /- comments out the next node/value. Keep the
// marker but treat what follows as a fresh token start, so a
// slashdashed leading-underscore node (e.g. `/-_FOO "1"`) still
// gets quoted instead of crashing kdl-go.
sb.WriteByte('/')
sb.WriteByte('-')
prev = ' '
i += 2
case c == '_' && isIdentBoundary(prev):
end := scanBareIdent(input, i)
sb.WriteByte('"')
sb.WriteString(input[i:end])
sb.WriteByte('"')
prev = '"'
i = end
default:
sb.WriteByte(c)
prev = c
i++
}
}
return sb.String()
}
// isIdentBoundary reports whether the previously emitted byte ends a token, so
// that a following '_' starts a fresh bare identifier rather than sitting in
// the middle of one.
func isIdentBoundary(prev byte) bool {
switch prev {
case 0, ' ', '\t', '\n', '\r', '{', '}', ';', '=', '(', ')', ',':
return true
}
return false
}
// scanBareIdent returns the index just past the bare identifier starting at
// start, stopping at whitespace or any KDL delimiter.
func scanBareIdent(s string, start int) int {
n := len(s)
for i := start; i < n; i++ {
switch s[i] {
case ' ', '\t', '\n', '\r', '"', '{', '}', '(', ')', ';', '=', ',', '/', '\\', '<', '>', '[', ']':
return i
}
}
return n
}
func findStringEnd(s string, start int) int {
n := len(s)
for i := start + 1; i < n; {
@@ -150,6 +238,7 @@ func isBraceAdjacentSpace(b byte) bool {
func NewNiriParser(configDir string) *NiriParser {
return &NiriParser{
configDir: configDir,
modKey: "Super",
processedFiles: make(map[string]bool),
bindMap: make(map[string]*NiriKeyBinding),
bindOrder: []string{},
@@ -290,6 +379,8 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
switch name {
case "include":
p.handleInclude(node, section, baseDir)
case "input":
p.handleInput(node)
case "binds":
p.extractBinds(node, section, "")
case "recent-windows":
@@ -298,6 +389,19 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
}
}
func (p *NiriParser) handleInput(node *document.Node) {
for _, child := range node.Children {
if child.Name.String() != "mod-key" || len(child.Arguments) == 0 {
continue
}
modKey := strings.Trim(strings.TrimSpace(child.Arguments[0].String()), "\"")
if modKey != "" {
p.modKey = modKey
}
}
}
func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) {
if len(node.Arguments) == 0 {
return
@@ -447,6 +551,7 @@ func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) {
type NiriParseResult struct {
Section *NiriSection
ModKey string
DMSBindsIncluded bool
DMSStatus *DMSBindsStatusInfo
ConflictingConfigs map[string]*NiriKeyBinding
@@ -499,6 +604,7 @@ func ParseNiriKeys(configDir string) (*NiriParseResult, error) {
}
return &NiriParseResult{
Section: section,
ModKey: parser.modKey,
DMSBindsIncluded: parser.HasDMSBindsIncluded(),
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
@@ -7,6 +7,28 @@ import (
"testing"
)
func TestNiriParseModKey(t *testing.T) {
config := `input {
mod-key "Alt"
}
binds {
Mod+T { spawn "kitty"; }
}
`
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if result.ModKey != "Alt" {
t.Errorf("ModKey = %q, want %q", result.ModKey, "Alt")
}
}
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
config := `recent-windows {
binds {
@@ -71,6 +93,101 @@ func TestNormalizeKDLBraces(t *testing.T) {
}
}
func TestQuoteLeadingUnderscoreIdents(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{"leading underscore node", `_JAVA_AWT_WM_NONREPARENTING "1"`, `"_JAVA_AWT_WM_NONREPARENTING" "1"`},
{"mid underscore untouched", `XDG_CURRENT_DESKTOP "niri"`, `XDG_CURRENT_DESKTOP "niri"`},
{"indented node", "environment {\n _FOO \"1\"\n}", "environment {\n \"_FOO\" \"1\"\n}"},
{"underscore in string", `spawn "_not_a_node"`, `spawn "_not_a_node"`},
{"underscore in line comment", "// _comment\n_FOO \"1\"", "// _comment\n\"_FOO\" \"1\""},
{"underscore in block comment", "/* _x */ _FOO \"1\"", "/* _x */ \"_FOO\" \"1\""},
{"block comment abuts node", `/* x */_FOO "1"`, `/* x */"_FOO" "1"`},
{"slashdash before node", `/-_FOO "1"`, `/-"_FOO" "1"`},
{"node after closing paren", "node (u8)_v", `node (u8)"_v"`},
{"node before brace without space", "_FOO{ }", `"_FOO"{ }`},
{"lone underscore", `_ "x"`, `"_" "x"`},
{"property value", "node key=_val", `node key="_val"`},
{"no underscores", "node child", "node child"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := quoteLeadingUnderscoreIdents(tc.in)
if got != tc.out {
t.Errorf("quoteLeadingUnderscoreIdents(%q) = %q, want %q", tc.in, got, tc.out)
}
})
}
}
func TestNiriParseLeadingUnderscoreEnvironment(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
// A leading-underscore environment node (a common Java/tiling-WM fix) must
// not abort parsing of the rest of the config — keybinds still have to load.
content := `environment {
XDG_CURRENT_DESKTOP "niri"
_JAVA_AWT_WM_NONREPARENTING "1"
}
binds {
Mod+Q { close-window; }
Mod+KP_Home { focus-workspace 1; }
}
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed on config with leading-underscore env node: %v", err)
}
if len(result.Section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds, got %d", len(result.Section.Keybinds))
}
foundClose := false
for _, kb := range result.Section.Keybinds {
if kb.Action == "close-window" {
foundClose = true
}
}
if !foundClose {
t.Error("close-window keybind not found — leading-underscore env node broke parsing")
}
}
func TestNiriParseSlashdashLeadingUnderscore(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
// A slashdashed leading-underscore node must not abort parsing either.
content := `environment {
/-_JAVA_AWT_WM_NONREPARENTING "1"
}
binds {
Mod+Q { close-window; }
}
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed on config with slashdashed leading-underscore node: %v", err)
}
if len(result.Section.Keybinds) != 1 {
t.Errorf("Expected 1 keybind, got %d", len(result.Section.Keybinds))
}
}
func TestNiriParseKeyCombo(t *testing.T) {
tests := []struct {
combo string
+60 -1
View File
@@ -17,7 +17,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
content := `binds {
content := `input {
mod-key "Alt"
}
binds {
Mod+Q { close-window; }
Mod+F { fullscreen-window; }
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
@@ -45,6 +48,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri")
}
if cheatSheet.ModKey != "Alt" {
t.Errorf("ModKey = %q, want %q", cheatSheet.ModKey, "Alt")
}
windowBinds := cheatSheet.Binds["Window"]
if len(windowBinds) < 2 {
t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds))
@@ -227,6 +234,58 @@ func TestNiriGenerateBindsContent(t *testing.T) {
expected: `binds {
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
}
`,
},
{
name: "spawn with equals arg",
binds: map[string]*overrideBind{
"Mod+B": {
Key: "Mod+B",
Action: `spawn /opt/browser --profile-directory=Default`,
},
},
expected: `binds {
Mod+B { spawn "/opt/browser" "--profile-directory=Default"; }
}
`,
},
{
name: "spawn shell command with quoted equals args",
binds: map[string]*overrideBind{
"Mod+C": {
Key: "Mod+C",
Action: `spawn sh -c "chrome --profile-directory=Default --app=x"`,
},
},
expected: `binds {
Mod+C { spawn "sh" "-c" "chrome --profile-directory=Default --app=x"; }
}
`,
},
{
name: "spawn env assignment stays arg",
binds: map[string]*overrideBind{
"Mod+E": {
Key: "Mod+E",
Action: `spawn env FOO=bar mycmd`,
},
},
expected: `binds {
Mod+E { spawn "env" "FOO=bar" "mycmd"; }
}
`,
},
{
name: "niri action property remains property",
binds: map[string]*overrideBind{
"Print": {
Key: "Print",
Action: `screenshot show-pointer=false`,
},
},
expected: `binds {
Print { screenshot show-pointer=false; }
}
`,
},
{
@@ -0,0 +1,74 @@
package providers
import "strings"
// Scroll-wheel binds are captured by the shell as niri's keysym names
// (WheelScrollUp/Down/Left/Right) regardless of the active compositor. Niri
// consumes them natively; every other provider speaks a different dialect, so the
// raw niri token must be translated on write and back again on read. Without this
// the token is emitted verbatim and the compositor rejects the bind (issue #2683).
var canonicalScrollKeys = map[string]string{
"wheelscrollup": "WheelScrollUp",
"wheelscrolldown": "WheelScrollDown",
"wheelscrollleft": "WheelScrollLeft",
"wheelscrollright": "WheelScrollRight",
}
func isScrollKey(token string) bool {
_, ok := canonicalScrollKeys[strings.ToLower(token)]
return ok
}
// Hyprland binds the wheel inside a regular bind using mouse_up/down/left/right.
var hyprlandScrollNative = map[string]string{
"wheelscrollup": "mouse_up",
"wheelscrolldown": "mouse_down",
"wheelscrollleft": "mouse_left",
"wheelscrollright": "mouse_right",
}
var hyprlandScrollCanonical = map[string]string{
"mouse_up": "WheelScrollUp",
"mouse_down": "WheelScrollDown",
"mouse_left": "WheelScrollLeft",
"mouse_right": "WheelScrollRight",
}
func hyprlandScrollToNative(token string) (string, bool) {
v, ok := hyprlandScrollNative[strings.ToLower(token)]
return v, ok
}
func hyprlandScrollToCanonical(token string) (string, bool) {
v, ok := hyprlandScrollCanonical[strings.ToLower(token)]
return v, ok
}
// MangoWC binds the wheel through a dedicated axisbind directive whose key field
// is a direction (UP/DOWN/LEFT/RIGHT) rather than a keysym.
const mangowcAxisBindPrefix = "axisbind"
var mangowcScrollDirection = map[string]string{
"wheelscrollup": "UP",
"wheelscrolldown": "DOWN",
"wheelscrollleft": "LEFT",
"wheelscrollright": "RIGHT",
}
var mangowcScrollCanonical = map[string]string{
"up": "WheelScrollUp",
"down": "WheelScrollDown",
"left": "WheelScrollLeft",
"right": "WheelScrollRight",
}
func mangowcScrollToDirection(token string) (string, bool) {
v, ok := mangowcScrollDirection[strings.ToLower(token)]
return v, ok
}
func mangowcDirectionToScroll(direction string) (string, bool) {
v, ok := mangowcScrollCanonical[strings.ToLower(direction)]
return v, ok
}
+1
View File
@@ -32,6 +32,7 @@ type DMSBindsStatus struct {
type CheatSheet struct {
Title string `json:"title"`
Provider string `json:"provider"`
ModKey string `json:"modKey,omitempty"`
Binds map[string][]Keybind `json:"binds"`
DMSBindsIncluded bool `json:"dmsBindsIncluded"`
DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"`
+12 -5
View File
@@ -68,6 +68,8 @@ func GetQtLoggingRules() string {
level = "info"
}
// scene carries QML engine warnings (e.g. QQuickImage "Cannot open" cache
// probes); suppressed except at debug level
var rules []string
switch strings.ToLower(level) {
case "fatal":
@@ -75,13 +77,13 @@ func GetQtLoggingRules() string {
case "error":
rules = []string{"*.debug=false", "*.info=false", "*.warning=false"}
case "warn", "warning":
rules = []string{"*.debug=false", "*.info=false"}
rules = []string{"*.debug=false", "*.info=false", "scene.warning=false"}
case "info":
rules = []string{"*.debug=false"}
rules = []string{"*.debug=false", "scene.warning=false"}
case "debug":
return ""
default:
rules = []string{"*.debug=false"}
rules = []string{"*.debug=false", "scene.warning=false"}
}
return strings.Join(rules, ";")
@@ -122,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
@@ -143,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)
+178 -17
View File
@@ -44,24 +44,26 @@ type TemplateDef struct {
ID string
Commands []string
Flatpaks []string
ConfigDirs []string
ConfigFile string
Kind TemplateKind
RunUnconditionally bool
RequiredEnv string
}
var templateRegistry = []TemplateDef{
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE"},
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{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},
@@ -117,10 +119,58 @@ type ColorsOutput struct {
} `json:"colors"`
}
type SchemePreview struct {
Dark string `json:"dark"`
Light string `json:"light"`
}
var previewSchemeTypes = []string{
"scheme-tonal-spot",
"scheme-vibrant",
"scheme-content",
"scheme-expressive",
"scheme-fidelity",
"scheme-fruit-salad",
"scheme-monochrome",
"scheme-neutral",
"scheme-rainbow",
}
func PreviewSchemes(sourceColor string, contrast float64) (map[string]SchemePreview, error) {
if sourceColor == "" {
return nil, fmt.Errorf("source color is required")
}
previews := make(map[string]SchemePreview, len(previewSchemeTypes))
for _, schemeType := range previewSchemeTypes {
output, err := runMatugenDryRun(&Options{
Kind: "hex",
Value: sourceColor,
MatugenType: schemeType,
Contrast: contrast,
})
if err != nil {
return nil, fmt.Errorf("preview %s: %w", schemeType, err)
}
dark := extractMatugenColor(output, "primary", "dark")
light := extractMatugenColor(output, "primary", "light")
if dark == "" || light == "" {
return nil, fmt.Errorf("preview %s: primary colors missing from matugen output", schemeType)
}
previews[schemeType] = SchemePreview{Dark: dark, Light: light}
}
return previews, nil
}
func (o *Options) ColorsOutput() string {
return filepath.Join(o.StateDir, "dms-colors.json")
}
func (o *Options) colorsStaging() string {
return o.ColorsOutput() + ".tmp"
}
func (o *Options) ShouldSkipTemplate(name string) bool {
if o.SkipTemplates == "" {
return false
@@ -133,6 +183,38 @@ func (o *Options) ShouldSkipTemplate(name string) bool {
return false
}
func acquireMatugenLock(stateDir string) (*os.File, error) {
f, err := os.OpenFile(filepath.Join(stateDir, "matugen.lock"), os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, fmt.Errorf("failed to open matugen lock: %w", err)
}
deadline := time.Now().Add(45 * time.Second)
for {
switch err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err {
case nil:
return f, nil
case syscall.EWOULDBLOCK:
if time.Now().After(deadline) {
f.Close()
return nil, fmt.Errorf("timed out waiting for matugen lock")
}
time.Sleep(100 * time.Millisecond)
default:
f.Close()
return nil, fmt.Errorf("failed to lock matugen: %w", err)
}
}
}
func releaseMatugenLock(f *os.File) {
if f == nil {
return
}
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}
func Run(opts Options) error {
if opts.StateDir == "" {
return fmt.Errorf("state-dir is required")
@@ -166,6 +248,12 @@ func Run(opts Options) error {
return fmt.Errorf("failed to create state dir: %w", err)
}
lock, err := acquireMatugenLock(opts.StateDir)
if err != nil {
return err
}
defer releaseMatugenLock(lock)
log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode)
changed, buildErr := buildOnce(&opts)
@@ -173,20 +261,22 @@ 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
}
func buildOnce(opts *Options) (bool, error) {
defer os.Remove(opts.colorsStaging())
cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
if err != nil {
return false, fmt.Errorf("failed to create temp config: %w", err)
@@ -274,10 +364,16 @@ func buildOnce(opts *Options) (bool, error) {
}
}
newColors, _ := os.ReadFile(opts.ColorsOutput())
newColors, err := os.ReadFile(opts.colorsStaging())
if err != nil {
return false, fmt.Errorf("matugen did not produce colors output: %w", err)
}
if bytes.Equal(oldColors, newColors) && len(oldColors) > 0 {
return false, nil
}
if err := os.Rename(opts.colorsStaging(), opts.ColorsOutput()); err != nil {
return false, fmt.Errorf("failed to commit colors output: %w", err)
}
if opts.ColorsOnly {
return true, nil
@@ -345,7 +441,7 @@ func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
input_path = '%s/matugen/templates/dank.json'
output_path = '%s'
`, opts.ShellDir, opts.ColorsOutput())
`, opts.ShellDir, opts.colorsStaging())
if opts.ColorsOnly {
return nil
@@ -356,14 +452,17 @@ output_path = '%s'
if opts.ShouldSkipTemplate(tmpl.ID) {
continue
}
if !templateSessionActive(tmpl) {
continue
}
switch tmpl.Kind {
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)
@@ -376,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)
}
}
@@ -414,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)
@@ -489,6 +589,32 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
cfgFile.WriteString("\n")
}
func templateSessionActive(tmpl TemplateDef) bool {
if tmpl.RequiredEnv == "" {
return true
}
socket := os.Getenv(tmpl.RequiredEnv)
if socket == "" {
return false
}
_, err := os.Stat(socket)
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 {
@@ -589,6 +715,7 @@ func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
func detectMatugenVersionLocked() (matugenFlags, error) {
cmd := exec.Command("matugen", "--version")
cmd.Env = utils.EnvWithUserBinPath(nil)
output, err := cmd.Output()
if err != nil {
return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
@@ -645,6 +772,7 @@ func runMatugen(baseArgs []string) error {
args := buildMatugenArgs(baseArgs, flags)
cmd := exec.Command("matugen", args...)
cmd.Env = utils.EnvWithUserBinPath(nil)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
runErr := cmd.Run()
@@ -662,6 +790,7 @@ func runMatugen(baseArgs []string) error {
log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
args = buildMatugenArgs(baseArgs, newFlags)
retryCmd := exec.Command("matugen", args...)
retryCmd.Env = utils.EnvWithUserBinPath(nil)
retryCmd.Stdout = os.Stdout
retryCmd.Stderr = os.Stderr
return retryCmd.Run()
@@ -704,6 +833,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
}
cmd := exec.Command("matugen", baseArgs...)
cmd.Env = utils.EnvWithUserBinPath(nil)
var stderr strings.Builder
cmd.Stderr = &stderr
output, err := cmd.Output()
@@ -802,7 +932,26 @@ func refreshGTK(mode ColorMode) {
}
}
var colorSchemeEchoHook func(scheme string)
func SetColorSchemeEchoHook(hook func(scheme string)) {
colorSchemeEchoHook = hook
}
func expectColorSchemeEcho(scheme string) {
if colorSchemeEchoHook != nil {
colorSchemeEchoHook(scheme)
}
}
// The color-scheme round trip is the only mechanism that makes running GTK4
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
// following the portal color-scheme (Chromium) can drop the restore signal
// mid-repaint and latch the wrong mode, so this is opt-in.
func refreshGTK4() {
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
return
}
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
if err != nil {
return
@@ -816,11 +965,13 @@ func refreshGTK4() {
toggle = "prefer-dark"
}
expectColorSchemeEcho(toggle)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
return
}
time.Sleep(50 * time.Millisecond)
time.Sleep(400 * time.Millisecond)
expectColorSchemeEcho(current)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
}
@@ -871,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)
}
@@ -921,6 +1079,9 @@ func closestAdwaitaAccent(primaryHex string) string {
func syncAccentColor(primaryHex string) {
accent := closestAdwaitaAccent(primaryHex)
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "accent-color"); err == nil && strings.Trim(cur, "'") == accent {
return
}
log.Infof("Setting GNOME accent color: %s", accent)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
log.Warnf("Failed to set accent-color: %v", err)
@@ -951,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)
detected = (appExists(checker, tmpl.Commands, tmpl.Flatpaks) || configDirExists(tmpl.ConfigDirs)) && templateSessionActive(tmpl)
}
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
+142 -9
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)
@@ -437,7 +437,140 @@ func TestBuildMergedConfigColorsOnly(t *testing.T) {
content := string(output)
assert.Contains(t, content, "[templates.dank]")
assert.Contains(t, content, "output_path = '"+filepath.Join(opts.StateDir, "dms-colors.json")+"'")
assert.Contains(t, content, "output_path = '"+opts.colorsStaging()+"'")
assert.NotContains(t, content, "[templates.gtk]")
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
}
func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
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, "base.toml"), []byte("[config]\n"), 0o644); err != nil {
t.Fatalf("failed to write base config: %v", err)
}
mangowcConfig := "[templates.dmsmango]\ninput_path = 'in'\noutput_path = 'out'\n"
if err := os.WriteFile(filepath.Join(configsDir, "mangowc.toml"), []byte(mangowcConfig), 0o644); err != nil {
t.Fatalf("failed to write mangowc config: %v", err)
}
cfgFile, err := os.CreateTemp(tempDir, "merged-*.toml")
if err != nil {
t.Fatalf("failed to create temp config: %v", err)
}
defer os.Remove(cfgFile.Name())
defer cfgFile.Close()
opts := &Options{
ShellDir: shellDir,
ConfigDir: filepath.Join(tempDir, "config"),
StateDir: filepath.Join(tempDir, "state"),
SkipTemplates: "gtk,niri,hyprland,qt5ct,qt6ct,firefox,pywalfox,zenbrowser,vesktop,vencord,equibop,ghostty,kitty,foot,alacritty,wezterm,nvim,dgop,kcolorscheme,vscode,emacs,zed",
}
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
t.Fatalf("buildMergedConfig failed: %v", err)
}
if err := cfgFile.Close(); err != nil {
t.Fatalf("failed to close merged config: %v", err)
}
output, err := os.ReadFile(cfgFile.Name())
if err != nil {
t.Fatalf("failed to read merged config: %v", err)
}
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))
}
+45 -35
View File
@@ -1,12 +1,28 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks_brightness
import (
dbus "github.com/godbus/dbus/v5"
"github.com/godbus/dbus/v5"
mock "github.com/stretchr/testify/mock"
)
// NewMockDBusConn creates a new instance of MockDBusConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockDBusConn(t interface {
mock.TestingT
Cleanup(func())
}) *MockDBusConn {
mock := &MockDBusConn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockDBusConn is an autogenerated mock type for the DBusConn type
type MockDBusConn struct {
mock.Mock
@@ -20,21 +36,20 @@ func (_m *MockDBusConn) EXPECT() *MockDBusConn_Expecter {
return &MockDBusConn_Expecter{mock: &_m.Mock}
}
// Close provides a mock function with no fields
func (_m *MockDBusConn) Close() error {
ret := _m.Called()
// Close provides a mock function for the type MockDBusConn
func (_mock *MockDBusConn) Close() error {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Close")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
if returnFunc, ok := ret.Get(0).(func() error); ok {
r0 = returnFunc()
} else {
r0 = ret.Error(0)
}
return r0
}
@@ -55,8 +70,8 @@ func (_c *MockDBusConn_Close_Call) Run(run func()) *MockDBusConn_Close_Call {
return _c
}
func (_c *MockDBusConn_Close_Call) Return(_a0 error) *MockDBusConn_Close_Call {
_c.Call.Return(_a0)
func (_c *MockDBusConn_Close_Call) Return(err error) *MockDBusConn_Close_Call {
_c.Call.Return(err)
return _c
}
@@ -65,23 +80,22 @@ func (_c *MockDBusConn_Close_Call) RunAndReturn(run func() error) *MockDBusConn_
return _c
}
// Object provides a mock function with given fields: dest, path
func (_m *MockDBusConn) Object(dest string, path dbus.ObjectPath) dbus.BusObject {
ret := _m.Called(dest, path)
// Object provides a mock function for the type MockDBusConn
func (_mock *MockDBusConn) Object(dest string, path dbus.ObjectPath) dbus.BusObject {
ret := _mock.Called(dest, path)
if len(ret) == 0 {
panic("no return value specified for Object")
}
var r0 dbus.BusObject
if rf, ok := ret.Get(0).(func(string, dbus.ObjectPath) dbus.BusObject); ok {
r0 = rf(dest, path)
if returnFunc, ok := ret.Get(0).(func(string, dbus.ObjectPath) dbus.BusObject); ok {
r0 = returnFunc(dest, path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(dbus.BusObject)
}
}
return r0
}
@@ -99,32 +113,28 @@ func (_e *MockDBusConn_Expecter) Object(dest any, path any) *MockDBusConn_Object
func (_c *MockDBusConn_Object_Call) Run(run func(dest string, path dbus.ObjectPath)) *MockDBusConn_Object_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(dbus.ObjectPath))
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
var arg1 dbus.ObjectPath
if args[1] != nil {
arg1 = args[1].(dbus.ObjectPath)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockDBusConn_Object_Call) Return(_a0 dbus.BusObject) *MockDBusConn_Object_Call {
_c.Call.Return(_a0)
func (_c *MockDBusConn_Object_Call) Return(busObject dbus.BusObject) *MockDBusConn_Object_Call {
_c.Call.Return(busObject)
return _c
}
func (_c *MockDBusConn_Object_Call) RunAndReturn(run func(string, dbus.ObjectPath) dbus.BusObject) *MockDBusConn_Object_Call {
func (_c *MockDBusConn_Object_Call) RunAndReturn(run func(dest string, path dbus.ObjectPath) dbus.BusObject) *MockDBusConn_Object_Call {
_c.Call.Return(run)
return _c
}
// NewMockDBusConn creates a new instance of MockDBusConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockDBusConn(t interface {
mock.TestingT
Cleanup(func())
},
) *MockDBusConn {
mock := &MockDBusConn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
File diff suppressed because it is too large Load Diff

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