Compare commits

..
117 Commits
Author SHA1 Message Date
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 <[email protected]>
(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 <[email protected]>
(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
sweenuandClaude Fable 5 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 <[email protected]>

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 <[email protected]>
(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 <[email protected]>
(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
2581 changed files with 308557 additions and 442535 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs,
| Skill | Description |
|-------|-------------|
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for Dux Shell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for DankMaterialShell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
## Installation
@@ -28,7 +28,7 @@ ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
**Personal** (all your projects):
```bash
ln -s /path/to/dms/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
ln -s /path/to/DankMaterialShell/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
```
After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development.
+30 -141
View File
@@ -1,71 +1,65 @@
---
name: dms-plugin-dev
description: >
Develop plugins for Dux Shell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports widget (bar, dock + Control Center), daemon (background service),
launcher (search + actions), desktop (draggable desktop widgets), dash (dash tab),
dashCard (dash overview card), and composite (multi-surface) plugins. Covers manifest
creation, QML component development, startup checks,
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
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: Dux Shell
version: "1.2"
author: DankMaterialShell
version: "1.1"
domain: qml-desktop-development
framework: Dux Shell
framework: DankMaterialShell
languages: qml, javascript
allowed-tools: Bash Read Write Edit
---
# Dux Shell Plugin Development
# DankMaterialShell Plugin Development
## Overview
DMS plugins extend the desktop shell with bar and dock widgets, background services, launcher
integrations, desktop widgets, and dash tabs and cards. Plugins are QML components discovered from
`~/.config/dms/plugins/`.
DMS plugins extend the desktop shell with custom widgets, background services, launcher
integrations, and desktop widgets. Plugins are QML components discovered from
`~/.config/DankMaterialShell/plugins/`.
**Minimum plugin structure:**
```
~/.config/dms/plugins/YourPlugin/
~/.config/DankMaterialShell/plugins/YourPlugin/
plugin.json # Required: manifest with metadata
YourComponent.qml # Required: main QML component
YourSettings.qml # Optional: settings UI
*.js # Optional: JavaScript utilities
```
**Plugin registry:** Community plugins are available at https://plugins.duxlabs.dev/
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
**Plugin types:**
**Five plugin types:**
| Type | Purpose | Base Component | Bar/dock pills | CC integration |
|-------------|--------------------------------|----------------------------|----------------|----------------|
| `widget` | Bar or dock 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 |
| `dash` | Tab in the dash popout | `DashTabComponent` | No | No |
| `dashCard` | Card in the dash overview grid | `DashCardComponent` | No | No |
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
| 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
Choose the type based on what the plugin does:
- **Shows in the bar or a dock?** - Use `widget`. Displays a pill in DankBar or a dock (same
component), optionally opens a popout or an attached dock panel, optionally integrates with
Control Center.
- **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
optionally integrates with Control Center.
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
changes, notifications, battery level, etc.).
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
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.
- **Full page in the dash?** - Use `dash`. A tab in the dash popout, id `plugin_<pluginId>`.
- **Small tile on the dash overview?** - Use `dashCard`. A resizable card in the overview grid.
- **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.
@@ -132,11 +126,6 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
}
```
**Standalone dash tab or card:** same single file form, `"type": "dash"` or `"type": "dashCard"`
plus `"component"`. A tab and a card together (or with a bar widget) use `components` with the
keys `dash` and `dashCard`. Both accept an optional `dash` block for labels, card size and
options. See [dash-plugin-guide.md](references/dash-plugin-guide.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`)
@@ -291,62 +280,6 @@ PluginComponent {
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
### Dash Tab
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
property date now: new Date()
implicitHeight: DashMetrics.tabMinHeight
Timer {
interval: 1000
repeat: true
running: root.live
onTriggered: root.now = new Date()
}
StyledText {
anchors.centerIn: parent
text: root.now.toLocaleTimeString(I18n.locale())
color: Theme.surfaceText
}
}
```
### Dash Card
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
DashCardComponent {
id: root
tone: options.tone ?? ""
StyledText {
anchors.centerIn: parent
text: I18n.trFor("myPlugin", "Hello")
color: root.contentColor
}
}
```
Gate timers and animations on `live`; the dash content stays alive after the popout closes.
Cards must lay out for every size in their manifest range. Use the card colors (`accentColor`,
`contentColor`, `mutedColor`) so tones work. See [dash-plugin-guide.md](references/dash-plugin-guide.md).
### Composite
For composite plugins, create a separate QML file per surface. Each surface uses the same
@@ -362,7 +295,8 @@ MyCompositePlugin/
Settings.qml # Shared settings for all surfaces
```
`pluginService.availablePlugins[pluginId].surfaces` lists the surfaces a plugin registered.
Use `pluginService.pluginHasSurface(pluginId, "widget")` to check whether a specific surface
is registered for a plugin at runtime.
## Step 4: Add Startup Check (Optional)
@@ -480,7 +414,7 @@ Always use `Theme.*` properties from `qs.Common` - never hardcode colors or size
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
**Common widgets from `qs.DCommon.Widgets`:** `StyledText`, `StyledRect`, `DIcon`, `DButton`, `DToggle`, `DTextField`, `DSlider`, `DGridView`, `CachingImage`. The old `Dank*` names in `qs.Widgets` are deprecated.
**Common widgets from `qs.Widgets`:** `StyledText`, `StyledRect`, `DankIcon`, `DankButton`, `DankToggle`, `DankTextField`, `DankSlider`, `DankGridView`, `CachingImage`.
See [theme-reference.md](references/theme-reference.md) for the complete property list.
@@ -497,6 +431,7 @@ PluginComponent {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
@@ -515,7 +450,7 @@ PluginComponent {
}
```
**PopoutComponent properties:** `headerText`, `detailsText`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
@@ -579,43 +514,12 @@ Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
## Translations (Optional)
Ship per-locale files in a `translations/` directory; DMS loads the one matching the active locale and re-reads it on locale changes:
```
MyPlugin/
└── translations/
├── es.json
└── zh_CN.json
```
File names follow DMS locale naming (`es.json`, `pt.json`, `zh_CN.json`); resolution tries the exact tag, then the hyphenated form, then the bare language. No `en.json` - the QML terms are the English strings.
Each file maps the English term to its translation, using the term as its own context bucket:
```json
{
"Cycle Speed": {
"Cycle Speed": "Velocidad de ciclo"
}
}
```
In QML, call `I18n.trFor` with the plugin id as a literal string matching `plugin.json`:
```qml
text: I18n.trFor("myPlugin", "Cycle Speed")
```
Lookup order: plugin file, then the global DMS catalog, then the English term. `ExampleEmojiPlugin` in `quickshell/PLUGINS/` demonstrates the full setup. Registry plugins can apply for central POEditor translation via the registry's CONTRIBUTING guide.
## 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, a dock, or the dash (tabs show up on their own; add cards from the dash edit mode)
4. Enable your plugin and add it to the DankBar layout
**Runtime plugin discovery via IPC:**
@@ -653,10 +557,6 @@ Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
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
14. **Using `"type": "widget"` for a dash tab** - Dash surfaces need `"type": "dash"` / `"dashCard"` or a `components` key
15. **Timers in dash tabs or cards that ignore `live`** - The dash content outlives the popout; gate on `live`
16. **Reading `dash.options` from a bar widget** - `options` only reach dash surfaces; share values through `settings` and `pluginData`
17. **Raw theme colors in a dash card** - Use `accentColor` / `contentColor` / `mutedColor` so the card follows its tone
## Quick Reference: Imports
@@ -680,16 +580,6 @@ import QtQuick
import qs.Common
```
**Dash tab / card:**
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash // DashMetrics, DashWidgetGrid
import qs.Modules.DankDash.Overview // Card, for tiles inside a tab
```
**For clipboard/exec:** `import Quickshell`
**For processes:** `import Quickshell.Io`
**For networking:** `import Quickshell.Networking`
@@ -708,8 +598,7 @@ import qs.Modules.DankDash.Overview // Card, for tiles inside a tab
Load these on demand for detailed API documentation:
- [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar and dock pills, attached dock panels, popouts, click actions, CC integration
- [dash-plugin-guide.md](references/dash-plugin-guide.md) - DashTabComponent, DashCardComponent, DashWidgetGrid, dash options, card sizing
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
@@ -1,8 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://duxlabs.dev/schemas/plugin.json",
"title": "Dux Shell Plugin Manifest",
"description": "Schema for Dux Shell plugin.json manifest files",
"$id": "https://danklinux.com/schemas/plugin.json",
"title": "DankMaterialShell Plugin Manifest",
"description": "Schema for DankMaterialShell plugin.json manifest files",
"type": "object",
"required": [
"id",
@@ -42,7 +42,7 @@
"type": {
"type": "string",
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
"enum": ["widget", "daemon", "launcher", "desktop", "dash", "dashCard", "composite"]
"enum": ["widget", "daemon", "launcher", "desktop", "composite"]
},
"capabilities": {
"type": "array",
@@ -80,83 +80,11 @@
"type": "string",
"description": "Launcher provider component (requires 'trigger')",
"pattern": "^\\./.*\\.qml$"
},
"dash": {
"type": "string",
"description": "Dashboard tab component (DashTabComponent)",
"pattern": "^\\./.*\\.qml$"
},
"dashCard": {
"type": "string",
"description": "Dashboard overview card component (DashCardComponent)",
"pattern": "^\\./.*\\.qml$"
}
},
"additionalProperties": false,
"minProperties": 1
},
"dash": {
"type": "object",
"description": "Presentation for the dashboard surfaces. Falls back to 'icon' and 'name'.",
"properties": {
"icon": {
"type": "string",
"description": "Material icon for the dashboard tab"
},
"title": {
"type": "string",
"description": "Tab label"
},
"card": {
"type": "object",
"description": "Overview card presentation and default size in grid units. Widths are clamped to the user's column count (3 to 8).",
"properties": {
"icon": { "type": "string" },
"title": { "type": "string" },
"w": { "type": "integer", "minimum": 1, "maximum": 8 },
"h": { "type": "integer", "minimum": 1 },
"minW": { "type": "integer", "minimum": 1, "maximum": 8 },
"minH": { "type": "integer", "minimum": 1 },
"maxW": { "type": "integer", "minimum": 1, "maximum": 8 },
"maxH": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"options": {
"type": "array",
"description": "User options shown in the dashboard options sheet and Settings > Dashboard; values reach the tab and card as `options.<key>`",
"items": {
"type": "object",
"required": ["key", "type"],
"properties": {
"key": { "type": "string", "not": { "const": "widgets" }, "description": "Option key, read as options.<key>. 'widgets' is reserved for DashWidgetGrid layouts" },
"text": { "type": "string", "description": "Row label, translated through the plugin catalog" },
"description": { "type": "string", "description": "Secondary text under a toggle row, translated through the plugin catalog" },
"type": { "type": "string", "enum": ["toggle", "choice", "number"] },
"def": { "description": "Default value: boolean for toggle, choice value for choice, number for number" },
"choices": {
"type": "array",
"items": {
"type": "object",
"required": ["value"],
"properties": {
"value": { "type": "string" },
"text": { "type": "string" }
},
"additionalProperties": false
}
},
"min": { "type": "number" },
"max": { "type": "number" },
"step": { "type": "number", "exclusiveMinimum": 0 },
"unit": { "type": "string" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"trigger": {
"type": "string",
"description": "Trigger string for launcher activation (required for launcher type)"
@@ -1,33 +0,0 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashCardComponent {
id: root
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
tone: options.tone ?? ""
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "extension"
size: Theme.iconSizeLarge
color: root.accentColor
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
visible: root.wide
text: I18n.trFor("myDashCard", "Hello")
font.pixelSize: Theme.fontSizeLarge
color: root.contentColor
}
}
}
@@ -1,27 +0,0 @@
{
"id": "myDashCard",
"name": "My Dash Card",
"description": "A custom dash overview card",
"version": "1.0.0",
"author": "Your Name",
"type": "dashCard",
"capabilities": ["dash-card"],
"component": "./Card.qml",
"icon": "extension",
"dash": {
"card": { "w": 2, "h": 1, "minW": 1, "minH": 1, "maxW": 3, "maxH": 2 },
"options": [
{
"key": "tone",
"text": "Tone",
"type": "choice",
"def": "",
"choices": [
{ "value": "", "text": "Surface" },
{ "value": "primary", "text": "Primary" }
]
}
]
},
"requires_dms": ">=1.7.0"
}
@@ -1,37 +0,0 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
focusTarget: button
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
function restoreFocus() {
button.forceActiveFocus(Qt.OtherFocusReason);
}
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingM
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
visible: !root.options.compact
text: I18n.trFor("myDashTab", "Hello from a dash tab")
font.pixelSize: Theme.fontSizeXLarge
color: Theme.surfaceText
}
DankButton {
id: button
anchors.horizontalCenter: parent.horizontalCenter
text: I18n.trFor("myDashTab", "Open overview")
onClicked: root.tabRequested("overview")
}
}
}
@@ -1,18 +0,0 @@
{
"id": "myDashTab",
"name": "My Dash Tab",
"description": "A custom dash tab",
"version": "1.0.0",
"author": "Your Name",
"type": "dash",
"capabilities": ["dash-tab"],
"component": "./Tab.qml",
"icon": "extension",
"dash": {
"title": "My Tab",
"options": [
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false }
]
},
"requires_dms": ">=1.7.0"
}
@@ -59,6 +59,7 @@ PluginComponent {
// popoutContent: Component {
// PopoutComponent {
// headerText: "My Widget"
// showCloseButton: true
//
// Column {
// width: parent.width
@@ -43,7 +43,7 @@ PluginSettings {
pluginId: "exampleVariants"
// Variant creation UI
DButton {
DankButton {
text: "Add New Instance"
onClicked: {
var id = "variant_" + Date.now()
@@ -1,179 +0,0 @@
# Dash Plugin Guide
The dash popout (`dms ipc call dash open`) holds tabs and an overview grid of cards. A plugin can add a tab (`dash` surface), a card (`dashCard` surface), or both. Built-in tabs and cards use the same registry (`quickshell/Modules/DankDash/DashRegistry.qml`).
Requires DMS 1.7 or later: set `"requires_dms": ">=1.7.0"`.
## Picking the manifest form
| Plugin provides | Manifest |
|-----------------|----------|
| Tab only | `"type": "dash"`, `"component": "./Tab.qml"` |
| Card only | `"type": "dashCard"`, `"component": "./Card.qml"` |
| Tab and card | `"type": "composite"`, `"components": { "dash": ..., "dashCard": ... }` |
| Dash plus bar/dock widget, daemon, ... | `"type": "composite"` with every surface in `components` |
Add the optional `dash` block for labels, card size and options (see plugin-manifest-reference.md, Dash Block).
## Rules that trip people up
- Only enabled plugins appear in the dash. There is no "disabled" placeholder.
- A plugin tab shows in the tab bar as soon as the plugin loads. Cards are never placed automatically; users add them in edit mode (three-dot menu, Edit, Add widget).
- Tab id is `plugin_<pluginId>`. Use it with `dms ipc call dash open plugin_<pluginId>` and `popoutService.toggleDankDash("plugin_" + pluginId, x, y, width, section, screen)`.
- A tab is created when it becomes current and destroyed when the user leaves it. The dash content stays alive after the popout closes, so gate every Timer, animation and service ref on `live`.
- Widths are grid columns (user picks 3 to 8, default 6). Rows are 96 px (`DashMetrics.gridRowUnit`). Cards must lay out for every size in `minW..maxW` x `minH..maxH`.
- `dash.options` values reach only the dash surfaces as `options.<key>`. A bar widget or daemon in the same plugin cannot read them; use a `settings` component and `pluginData` for values every surface needs.
- `widgets` is a reserved option key.
- Use `I18n.trFor("<plugin id literal>", "...")` for every string.
## Tab: DashTabComponent
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.DCommon.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
focusTarget: button
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
menuActions: [
{
label: I18n.trFor("myPlugin", "Refresh"),
iconName: "refresh",
action: () => root.refresh()
}
]
function refresh() {
}
function restoreFocus() {
button.forceActiveFocus(Qt.OtherFocusReason);
}
function handleKeyEvent(event) {
if (event.key !== Qt.Key_R)
return false;
refresh();
return true;
}
Timer {
interval: 1000
repeat: true
running: root.live
onTriggered: root.refresh()
}
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingM
StyledText {
text: root.options.compact ? "" : I18n.trFor("myPlugin", "Hello")
color: Theme.surfaceText
}
DButton {
id: button
text: I18n.trFor("myPlugin", "Refresh")
onClicked: root.refresh()
}
}
}
```
Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`, `dashHost` (`dashVisible`, `requestTab(id)`, `editMode`), `editMode`, `live`. Also `targetScreen`, `active` and `rowBudget` when the tab declares them.
Base provides: `options`, `pluginData`, `getData(key, def)`, `setData(key, value)`.
Optional: `implicitHeight`, `focusTarget`, `restoreFocus()`, `handleKeyEvent(event)` (return true to consume; Ctrl+Tab is reserved), `blocksTabNavigation`, `menuActions`, `signal tabRequested(string id)`, `signal navFocusRequested`.
For tiles inside a tab, use `Card` from `qs.Modules.DankDash.Overview` so colors, radius and tones match the overview.
### Editable widgets in a tab
Assign a `DashWidgetGrid` (from `qs.Modules.DankDash`) to `widgetGrid` to get Edit, Add widget, Reset and Clear All:
```qml
DashTabComponent {
id: tab
widgetGrid: grid
implicitHeight: grid.implicitHeight
focusTarget: grid.focusTarget
DashWidgetGrid {
id: grid
width: parent.width
entryId: tab.entryId
live: tab.live
editMode: tab.editMode
definitions: [
{ id: "main", text: I18n.trFor("myPlugin", "Main"), icon: "star", component: mainWidget, w: 2, h: 2, minW: 1, minH: 1, maxW: 4, maxH: 3 }
]
}
Component {
id: mainWidget
Card {}
}
}
```
The grid is 4 columns (2 when narrower than `Theme.smallBreakpoint`). `enabled: false` on a definition leaves it in the Add menu. The layout is saved in `dashOptions[entryId].widgets`.
## Card: DashCardComponent
```qml
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashCardComponent {
id: root
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
readonly property bool tall: height >= DashMetrics.gridRowUnit * 2
tone: options.tone ?? ""
SystemClock {
id: clock
enabled: root.live
precision: SystemClock.Minutes
}
StyledText {
anchors.centerIn: parent
text: clock.date.toLocaleTimeString(I18n.locale(), "hh:mm")
font.pixelSize: root.tall ? Theme.fontSizeDisplay : Theme.fontSizeXLarge
color: root.accentColor
}
}
```
Base (`Card`) provides: `pad`, `title`, `tone` (`""`, `"primary"`, `"secondary"`, `"tertiary"`), `containerColor`, `contentColor`, `accentColor`, `onAccentColor`, `mutedColor`, `chipColor`, `tinted`, `clickable`, `signal clicked`, `options`, `pluginData`, `getData`, `setData`.
Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`; bound: `live` (overview visible), `interactive` (false in edit mode).
Clicks: when the plugin also has a tab, a click on a `clickable` card opens it. Set `opensTab: false` to handle the click yourself. Non-clickable cards are skipped by keyboard navigation unless `focusTarget` points at a child control.
Use the card colors, never raw `Theme.primary` or `Theme.surfaceText`, so tones work.
## Testing
```bash
dms ipc call plugins reload myPlugin
dms ipc call dash open plugin_myPlugin
```
Working examples in `quickshell/PLUGINS/`: `DashTabExample` (standalone tab), `DashCardExample` (standalone card), `DashCounterExample` (tab + card + bar/dock widget).
@@ -174,7 +174,7 @@ Retrieve a plugin's installation directory at runtime:
var dir = pluginService.getPluginPath(pluginId)
```
Returns the absolute path to the plugin's directory (e.g., `~/.config/dms/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.
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
@@ -9,7 +9,7 @@
| `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`, `dash`, `dashCard`, `composite` |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
| `capabilities` | array | Plugin capabilities | At least 1 string item |
One of `component` or `components` is required (not both):
@@ -17,7 +17,7 @@ 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`, `dash`, `dashCard` |
| `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher` |
## Conditional Requirements
@@ -38,7 +38,6 @@ One of `component` or `components` is required (not both):
| `requires` | array | Deprecated alias for `dependencies` |
| `permissions` | array | Required permissions |
| `trigger` | string | Launcher trigger string (required for launcher type) |
| `dash` | object | Dash tab label, overview card size range and dash options. See Dash Block below. |
## Permissions
@@ -60,8 +59,6 @@ Capabilities are free-form strings that describe what the plugin does. Common va
- `monitoring` - system/service monitoring
- `launcher` - launcher search provider
- `desktop-widget` - desktop background widget
- `dash-tab` - dash tab
- `dash-card` - dash overview card
- `ai` - AI/LLM integration
- `slideout` - uses slideout panel
@@ -122,68 +119,7 @@ The `components` field maps surface names to QML paths, allowing a single plugin
}
```
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`, `dash`, `dashCard`. Provide any subset. Each surface is loaded independently in the appropriate registry.
## Dash Block
Plugins with a `dash` or `dashCard` surface can describe them in a `dash` object. Everything is optional.
```json
"dash": {
"icon": "timer",
"title": "Stopwatch",
"card": { "title": "Progress", "icon": "hourglass_bottom", "w": 2, "h": 1, "minW": 1, "minH": 1, "maxW": 4, "maxH": 2 },
"options": [
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false, "description": "Hide the secondary line" },
{ "key": "style", "text": "Style", "type": "choice", "def": "bars", "choices": [{ "value": "bars", "text": "Bars" }, { "value": "dots", "text": "Dots" }] },
{ "key": "limit", "text": "Items", "type": "number", "def": 5, "min": 1, "max": 20, "step": 1 }
]
}
```
| Field | Description |
|-------|-------------|
| `icon`, `title` | Tab icon and label. Fall back to the top-level `icon` and `name` |
| `card.title`, `card.icon` | Card name and icon in the Add widget menu. Fall back to the tab label and icon |
| `card.w`, `card.h` | Default size in grid cells. Columns are 3 to 8 (default 6), rows are 96 px |
| `card.minW` .. `card.maxH` | Resize range. Widths clamp to the user's column count; heights have no fixed limit |
| `options` | Rows in the dash options sheet and Settings > Dashboard. Read as `options.<key>` in the tab and card. Types: `toggle`, `choice`, `number`. `widgets` is a reserved key |
## Dash Examples
Standalone tab and standalone card use the single file form:
```json
{
"id": "dashTabExample",
"name": "Stopwatch Example",
"description": "A standalone dash tab",
"version": "1.0.0",
"author": "Developer Name",
"type": "dash",
"capabilities": ["dash-tab"],
"component": "./StopwatchTab.qml",
"dash": { "title": "Stopwatch" },
"requires_dms": ">=1.7.0"
}
```
```json
{
"id": "dashCardExample",
"name": "Progress Example",
"description": "A standalone overview card",
"version": "1.0.0",
"author": "Developer Name",
"type": "dashCard",
"capabilities": ["dash-card"],
"component": "./ProgressCard.qml",
"dash": { "card": { "w": 2, "h": 1, "maxW": 4, "maxH": 2 } },
"requires_dms": ">=1.7.0"
}
```
A tab plus a card, or dash surfaces plus a bar widget, use `components` with `"type": "composite"`.
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
## Complete Example
@@ -83,12 +83,11 @@ Theme.popupBackground() // Popup background with proper opacity
```qml
import qs.Widgets
import qs.DCommon.Widgets
Row {
spacing: Theme.spacingS
DIcon {
DankIcon {
name: "icon_name"
color: Theme.onSurface
font.pixelSize: Theme.iconSize
@@ -152,7 +151,7 @@ StyledRect {
anchors.centerIn: parent
spacing: Theme.spacingS
DIcon {
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
@@ -192,17 +191,17 @@ font.pixelSize: 14 // Use Theme.fontSizeMedium
|--------|-------------|
| `StyledText` | Themed text with proper color defaults |
| `StyledRect` | Themed rectangle |
| `DIcon` | Material Symbols icon renderer |
| `DankIcon` | Material Symbols icon renderer |
| `DankNFIcon` | Nerd Font icon renderer |
| `DButton` | Themed button |
| `DToggle` | Toggle switch |
| `DTextField` | Text input field |
| `DSlider` | Slider control |
| `DDropdown` | Dropdown menu |
| `DGridView` | Grid layout view |
| `DListView` | List layout view |
| `DFlickable` | Scrollable container |
| `DTabBar` | Tab bar navigation |
| `DankButton` | Themed button |
| `DankToggle` | Toggle switch |
| `DankTextField` | Text input field |
| `DankSlider` | Slider control |
| `DankDropdown` | Dropdown menu |
| `DankGridView` | Grid layout view |
| `DankListView` | List layout view |
| `DankFlickable` | Scrollable container |
| `DankTabBar` | Tab bar navigation |
| `DankCollapsibleSection` | Collapsible content section |
| `DankTooltip` | Hover tooltip |
| `DankNumberStepper` | Number +/- control |
@@ -1,6 +1,6 @@
# Widget Plugin Guide
Widgets display pills in DankBar and in docks (same component, no extra code), optionally open popouts, and can integrate with the Control Center.
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
## Base Component
@@ -37,8 +37,6 @@ These are automatically set by the plugin host:
| `pluginId` | string | This plugin's ID |
| `pluginService` | object | PluginService reference |
| `pluginData` | object | Reactive plugin settings data |
| `surfaceContext` | object | The host surface: `kind` (`"bar"` or `"dock"`), `id`, `screen`, `isVertical`, `thickness`, `live`, `editMode`, `inlineExpansion` |
| `widgetInstanceId` | string | This occurrence; the same widget can appear on several bars and docks |
## Bar Pills
@@ -59,7 +57,7 @@ horizontalBarPill: Component {
anchors.centerIn: parent
spacing: Theme.spacingS
DIcon {
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
@@ -92,7 +90,7 @@ verticalBarPill: Component {
anchors.centerIn: parent
spacing: Theme.spacingS
DIcon {
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSizeSmall
@@ -117,6 +115,7 @@ PluginComponent {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
@@ -139,6 +138,7 @@ PluginComponent {
|----------|------|---------|-------------|
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
| `closePopout` | function | (injected) | Call to close the popout programmatically. |
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
@@ -241,49 +241,16 @@ PluginComponent {
**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.
## Docks
Users add any loaded widget plugin to a dock from the dock's edit mode or Settings > Dock > Apps & widgets. Nothing in the manifest is needed.
- Define both `horizontalBarPill` and `verticalBarPill`; docks sit on any edge.
- Size icons with `root.iconSize` and text with `Theme.barTextSize(barThickness, barConfig?.fontScale, barConfig?.maximizeWidgetText)`. The dock hands its own thickness and scale in those properties.
- Gate timers and animations on `surfaceLive` (host visible and `visibilityCommand` passing). Docks auto-hide.
- Don't assume one instance. Use `widgetInstanceId` if an instance needs its own state.
Optional attached panel: set `attachedContent: Component { ... }`. When the dock's "Open widgets" setting is Inline (`surfaceContext.inlineExpansion`), a click grows the dock and shows the panel next to the apps. With Popout (the default) the normal `popoutContent` opens instead; a widget with no popout still expands inline. Bars always use `popoutContent`. A `pillClickAction` always wins over both.
```qml
PluginComponent {
id: root
attachedContent: Component {
Column {
padding: Theme.spacingM
spacing: Theme.spacingM
DButton {
text: I18n.trFor("myPlugin", "Close")
onClicked: root.surfaceContext.dismissExpansion()
}
}
}
}
```
The panel spans the dock's length and grows up to 320 px away from the edge (at most half the screen). The plugin cannot size it. `attachedActive` is true while this widget owns the panel. Escape closes it. See `quickshell/PLUGINS/AttachedPanelExample` and `quickshell/PLUGINS/SURFACE_WIDGETS.md`.
## Popout Namespace
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
```qml
PluginComponent {
layerNamespacePlugin: "my-plugin"
layerNamespacePlugin: true
}
```
The shell prefixes it, so the popout's namespace becomes `dms:plugins:my-plugin`.
## Reading Plugin Data
Access saved settings reactively via the injected `pluginData`:
@@ -311,7 +278,6 @@ import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.DCommon.Widgets
import qs.Services
import qs.Modules.Plugins
@@ -367,8 +333,9 @@ PluginComponent {
popoutContent: Component {
PopoutComponent {
headerText: "Emoji Picker"
showCloseButton: true
DGridView {
DankGridView {
width: parent.width
height: 300
cellWidth: 50
+1 -1
View File
@@ -3,7 +3,7 @@
github: [avengemedia]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: duxlabs
ko_fi: danklinux
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
## Dux Shell Bug Report
## DankMaterialShell Bug Report
Limit your report to one issue per submission unless similarly related
- type: dropdown
id: compositor
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
## Dux Shell Feature Request
## DankMaterialShell Feature Request
- type: textarea
id: feature_description
attributes:
+2 -2
View File
@@ -1,12 +1,12 @@
name: Support Request
description: Help with installation, usage, or general questions about Dux Shell
description: Help with installation, usage, or general questions about DankMaterialShell
labels:
- support
body:
- type: markdown
attributes:
value: |
## Dux Shell Support Request
## DankMaterialShell Support Request
- type: dropdown
id: compositor
attributes:
+1 -1
View File
@@ -28,4 +28,4 @@
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
- [ ] QML changes: ran `make lint-qml` with no new warnings
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/duxlabs-dev/dux-docs
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/AvengeMedia/DankLinux-Docs
-150
View File
@@ -1,150 +0,0 @@
#!/usr/bin/env bash
# Prunes retired XBPS packages from the R2 bucket so it stays inside the
# Cloudflare free tier. Only objects under ARCHIVE_PREFIX are eligible; live
# repositories (<repo>/current/) are never touched.
#
# publish-void-r2.sh writes retired packages to archive/<repo>/<published_at>/.
# Snapshots are dropped oldest first:
# 1. snapshots older than KEEP_DAYS, keeping KEEP_MIN_SNAPSHOTS per repo
# 2. then more until the whole bucket is <= MAX_BUCKET_GB
set -euo pipefail
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
ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-archive/}"
MAX_BUCKET_GB="${MAX_BUCKET_GB:-8}"
KEEP_DAYS="${KEEP_DAYS:-30}"
KEEP_MIN_SNAPSHOTS="${KEEP_MIN_SNAPSHOTS:-2}"
DRY_RUN="${DRY_RUN:-false}"
case "$ARCHIVE_PREFIX" in
*/) ;;
*) echo "error: ARCHIVE_PREFIX must end with /" >&2; exit 2 ;;
esac
aws_r2() {
aws --endpoint-url "$R2_ENDPOINT" "$@"
}
human() {
numfmt --to=iec-i --suffix=B "$1"
}
objects="$(aws_r2 s3api list-objects-v2 \
--bucket "$R2_BUCKET" \
--output json \
--query 'Contents[].{key:Key,size:Size}')"
[[ -n "$objects" && "$objects" != null ]] || objects='[]'
total="$(jq 'map(.size) | add // 0' <<<"$objects")"
budget="$(awk -v g="$MAX_BUCKET_GB" 'BEGIN { printf "%d", g * 1073741824 }')"
now="$(date -u +%s)"
snapshots="$(jq -c --arg p "$ARCHIVE_PREFIX" '
map(select(.key | startswith($p)))
| map(.rel = (.key[($p | length):] | split("/")))
| map(select((.rel | length) >= 3))
| group_by(.rel[0:2])
| map({
repo: .[0].rel[0],
stamp: .[0].rel[1],
prefix: ($p + (.[0].rel[0:2] | join("/")) + "/"),
size: (map(.size) | add),
count: length
})
| sort_by(.stamp)
' <<<"$objects")"
to_delete="$(jq -c \
--argjson now "$now" \
--argjson keepDays "$KEEP_DAYS" \
--argjson keepMin "$KEEP_MIN_SNAPSHOTS" \
--argjson total "$total" \
--argjson budget "$budget" '
def epoch:
.stamp
| sub("T(?<h>[0-9]{2})-(?<m>[0-9]{2})-(?<s>[0-9]{2})Z$"; "T\(.h):\(.m):\(.s)Z")
| try fromdateiso8601 catch null;
(group_by(.repo) | map({key: .[0].repo, value: length}) | from_entries) as $perRepo
| reduce .[] as $s ({kept: $perRepo, total: $total, delete: []};
($s | epoch) as $e
| (.kept[$s.repo] > $keepMin and $e != null and ($now - $e) > $keepDays * 86400) as $expired
| (.total > $budget) as $over
| if $expired or $over then
.delete += [$s + {reason: (if $over then "budget" else "age" end)}]
| .total -= $s.size
| .kept[$s.repo] -= 1
else . end)
| .delete
' <<<"$snapshots")"
echo "bucket: $R2_BUCKET ($(human "$total") in $(jq length <<<"$objects") objects)"
echo "budget: $(human "$budget") (MAX_BUCKET_GB=$MAX_BUCKET_GB)"
echo "archive: $(jq length <<<"$snapshots") snapshots, $(human "$(jq 'map(.size) | add // 0' <<<"$snapshots")")"
echo "policy: KEEP_DAYS=$KEEP_DAYS KEEP_MIN_SNAPSHOTS=$KEEP_MIN_SNAPSHOTS DRY_RUN=$DRY_RUN"
deleted=0
freed=0
while IFS=$'\t' read -r prefix size count reason; do
[[ -n "$prefix" ]] || continue
# Belt and braces: never delete outside the archive or anything live.
case "$prefix" in
"$ARCHIVE_PREFIX"*) ;;
*) echo "error: refusing to delete $prefix (outside $ARCHIVE_PREFIX)" >&2; exit 1 ;;
esac
case "$prefix" in
*/current/*) echo "error: refusing to delete live prefix $prefix" >&2; exit 1 ;;
esac
if [[ "$DRY_RUN" == "true" ]]; then
echo "would delete $prefix ($count objects, $(human "$size"), $reason)"
else
echo "deleting $prefix ($count objects, $(human "$size"), $reason)"
aws_r2 s3 rm "s3://${R2_BUCKET}/${prefix}" --recursive --only-show-errors
fi
deleted=$((deleted + 1))
freed=$((freed + size))
done < <(jq -r '.[] | [.prefix, .size, .count, .reason] | @tsv' <<<"$to_delete")
remaining=$((total - freed))
echo "pruned: $deleted snapshots, $(human "$freed") freed, $(human "$remaining") remaining"
if (( remaining > budget )); then
echo "::warning::bucket is still $(human "$remaining") after pruning (budget $(human "$budget")); live repositories exceed the budget"
fi
note=""
if [[ "$DRY_RUN" == "true" ]]; then
note=" (dry run)"
fi
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
{
echo "### R2 prune: \`$R2_BUCKET\`"
echo
echo "| | |"
echo "|---|---|"
echo "| Before | $(human "$total") |"
echo "| Budget | $(human "$budget") |"
echo "| Snapshots pruned | $deleted$note |"
echo "| Freed | $(human "$freed") |"
echo "| After | $(human "$remaining") |"
if (( deleted > 0 )); then
echo
echo "| Snapshot | Objects | Bytes | Reason |"
echo "|---|---|---|---|"
jq -r '.[] | "| `\(.prefix)` | \(.count) | \(.size) | \(.reason) |"' <<<"$to_delete"
fi
} >> "$GITHUB_STEP_SUMMARY"
fi
-140
View File
@@ -1,140 +0,0 @@
name: Claude Review
# `/claude review` on a PR by a member of an allowed team. Review only, no edits or pushes.
# Secrets: CLAUDE_CODE_OAUTH_TOKEN (claude setup-token), CLAUDE_REVIEW_ORG_READ_TOKEN (PAT, org Members: read)
on:
issue_comment:
types: [created]
permissions: {}
jobs:
gate:
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/claude review')
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
outputs:
allowed: ${{ steps.team.outputs.allowed }}
steps:
- name: Check team membership
id: team
env:
GH_TOKEN: ${{ secrets.CLAUDE_REVIEW_ORG_READ_TOKEN }}
ORG: ${{ github.repository_owner }}
USER: ${{ github.event.comment.user.login }}
TEAMS: dms-collaborators owners
run: |
allowed=false
for team in $TEAMS; do
state=$(gh api "orgs/$ORG/teams/$team/memberships/$USER" --jq .state 2>/dev/null || true)
[ "$state" = active ] && allowed=true && break
done
echo "allowed=$allowed" >> "$GITHUB_OUTPUT"
- name: React
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOWED: ${{ steps.team.outputs.allowed }}
COMMENT_ID: ${{ github.event.comment.id }}
run: |
[ "$ALLOWED" = true ] && content=eyes || content=-1
gh api -X POST "repos/${{ github.repository }}/issues/comments/$COMMENT_ID/reactions" -f "content=$content"
review:
needs: gate
if: needs.gate.outputs.allowed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: claude-review-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: refs/pull/${{ github.event.issue.number }}/head
fetch-depth: 0
submodules: recursive
- name: Resolve base branch
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: echo "base=$(gh pr view ${{ github.event.issue.number }} --json baseRefName --jq .baseRefName)" >> "$GITHUB_OUTPUT"
- uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507
id: claude
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Review pull request #${{ github.event.issue.number }} in ${{ github.repository }}, requested by @${{ github.event.comment.user.login }}:
---
${{ github.event.comment.body }}
---
Text after `/claude review` is an optional focus. Honor it but still report anything serious outside it.
The PR head is checked out and the base branch is fetched as `origin/${{ steps.pr.outputs.base }}`. Use `gh pr view ${{ github.event.issue.number }}` for title and description.
Get the diff from git, never `gh pr diff` (GitHub refuses diffs over 20000 lines): `git diff --stat origin/${{ steps.pr.outputs.base }}...HEAD`, then `git diff origin/${{ steps.pr.outputs.base }}...HEAD -- <path>` per file or directory. Read surrounding code as needed. Split a large PR across Task agents by area.
Steps:
1. Read CONTRIBUTING.md.
2. Read the PR title, description and diff.
3. Check existing comments so you don't repeat them: `gh api --method GET repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments` and `gh pr view ${{ github.event.issue.number }} --comments`. Skip findings already covered by a comment that still applies.
4. Verify each candidate finding against the code. Drop anything you can't confirm.
5. Post one inline comment per confirmed finding with `mcp__github_inline_comment__create_inline_comment` (`confirmed: true`). Say what's wrong, why, and the fix. Add a committable suggestion only if it alone fully fixes the issue.
6. Post one new summary comment with `gh pr comment ${{ github.event.issue.number }} --body ...`. Never edit earlier comments. Format:
## Claude review
<one line verdict>
- <finding, file:line> per inline comment posted this run
Checked: <one line>. Model: claude-opus-5-5.
No findings: "No issues found." plus what was checked.
Flag only issues introduced by this PR:
- Bugs: wrong logic, crashes, nil/undefined access, races, leaks (timers, connections, processes, handles), bad Go error handling, edge cases that will clearly be hit.
- QML: undefined refs, missing imports, binding loops, bindings that never update, handlers on the wrong object, raw Flickable/ListView/ScrollView instead of the D* wrappers (DFlickable, DListView, DGridView).
- Deprecated shims: added code that uses a deprecated type or imports a deprecated module. Every shim calls `Deprecation.type(` or `Deprecation.module(`; list them with `git grep -h -e "Deprecation.type(" -e "Deprecation.module(" origin/${{ steps.pr.outputs.base }} -- quickshell`, which gives the old and new name. Grep the base, not HEAD, since a PR branched before the rename has no shims. Flag it even when the rest of the file still uses old names on the PR's base; suggest the new name and a rebase if the base predates it.
- Security: command injection, path traversal, unsafe file permissions, secrets in code.
- CONTRIBUTING.md rules: user-facing strings in `I18n.tr()` with translator context, reuse existing terms from quickshell/translations/en.json; en.json and template.json must not be touched in a PR; mocks under core/internal/mocks/ are regenerated not hand-edited; new dux-qml-common contract properties need the matching library change.
Don't flag style or naming (deprecated shims are not naming), pre-existing issues, anything a linter or compiler catches, speculative input-dependent problems, missing tests or docs, or refactor ideas. Not sure it's real: don't post it.
Review only. Never edit files, commit, push, approve or request changes. Do not build, vet, format or run tests; CI already does that and the tools are not available here. Post each finding once. Keep comments short.
claude_args: |
--model claude-opus-5-5
--max-turns 120
--allowedTools "Read,Grep,Glob,LS,Task,mcp__github_inline_comment__create_inline_comment,Bash(gh pr view:*),Bash(gh pr comment:*),Bash(gh api --method GET repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git merge-base:*),Bash(git grep:*)"
--disallowedTools "Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch"
- name: Upload transcript
if: always() && steps.claude.outputs.execution_file != ''
uses: actions/upload-artifact@v4
with:
name: claude-review-${{ github.event.issue.number }}-${{ github.run_id }}
path: ${{ steps.claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: ignore
- name: Require a summary comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SINCE: ${{ github.event.comment.created_at }}
run: |
gh api "repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments?since=$SINCE&per_page=100" \
--jq '[.[] | select(.body | startswith("## Claude review"))] | length' | grep -qv '^0$'
- if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ github.event.issue.number }} --body "Claude review failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
-57
View File
@@ -1,57 +0,0 @@
name: Sync flake.lock to dux-qml-common
on:
workflow_dispatch:
push:
paths:
- "dux-qml-common"
branches:
- master
- "stable-*"
permissions:
contents: write
jobs:
sync:
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: Install Nix
uses: cachix/install-nix-action@v31
- name: Point flake input at the submodule commit
run: |
set -euo pipefail
submodule_rev=$(git ls-tree HEAD dux-qml-common --object-only)
flake_rev=$(python3 -c "import json; print(json.load(open('flake.lock'))['nodes']['dank-qml-common']['locked']['rev'])")
[ "$submodule_rev" = "$flake_rev" ] && { echo "flake.lock already matches $submodule_rev"; exit 0; }
nix flake lock --override-input dank-qml-common "github:duxlabs-dev/dux-qml-common/$submodule_rev"
- name: Commit and push flake.lock update
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
if git diff --quiet flake.lock; then
echo "No changes to flake.lock"
exit 0
fi
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "nix: sync flake.lock to dux-qml-common submodule"
git pull --rebase origin ${{ github.ref_name }}
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
+7 -48
View File
@@ -27,70 +27,29 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install flatpak
run: sudo apt-get update && sudo apt-get install -y flatpak
run: sudo apt update && sudo apt install -y flatpak
- name: Cache flatpak runtime
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-org.freedesktop.Platform-24.08
- name: Add flathub
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Install a flatpak for tests
run: |
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak info org.freedesktop.Platform//24.08 >/dev/null 2>&1 || \
flatpak install --user -y org.freedesktop.Platform//24.08
- name: Add a flatpak that mutagen could support
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: ./core/go.mod
cache-dependency-path: ./core/go.sum
- name: Test
run: go test -race -v ./...
run: go test -v ./...
- name: Build dms
run: go build -v ./cmd/dms
- name: Build dms (embedded shell)
run: make build
- name: Build dms (distropkg)
run: go build -v -tags 'distro_binary withshell' ./cmd/dms
run: go build -v -tags distro_binary ./cmd/dms
- name: Build dankinstall
run: go build -v ./cmd/dankinstall
build-freebsd:
runs-on: ubuntu-latest
defaults:
run:
working-directory: core
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: ./core/go.mod
cache-dependency-path: ./core/go.sum
- name: Build all packages
env:
GOOS: freebsd
CGO_ENABLED: 0
run: go build -v ./...
- name: Build dms (distropkg, embedded shell)
run: |
make sync-shell
GOOS=freebsd CGO_ENABLED=0 go build -v -tags 'distro_binary withshell' ./cmd/dms
+2
View File
@@ -33,6 +33,8 @@ jobs:
- name: Run NixOS service start test
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
- name: Run greeter niri test
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
- name: Run home-manager module test
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
+2 -2
View File
@@ -81,8 +81,8 @@ jobs:
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 "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
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
+1 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
inputs:
target:
description: "Release branch to audit (default: newest stable-*)"
description: "Release branch to audit (default: newest release/*)"
required: false
type: string
+4 -4
View File
@@ -48,8 +48,8 @@ jobs:
COMMITS: ${{ toJSON(github.event.commits) }}
run: |
set -euo pipefail
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
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
@@ -86,8 +86,8 @@ jobs:
PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
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)
-4
View File
@@ -10,8 +10,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
@@ -29,5 +27,3 @@ jobs:
- name: run pre-commit hooks
uses: j178/prek-action@v2
env:
SKIP: qml-tests
-66
View File
@@ -1,66 +0,0 @@
name: Prune Void R2 archive
# Keeps the danklinux-void bucket inside the Cloudflare R2 free tier by
# deleting the oldest archive/<repo>/<timestamp>/ snapshots written by
# publish-void-r2.sh. Live repositories (<repo>/current/) are never touched.
on:
schedule:
- cron: "30 6 * * *"
workflow_dispatch:
inputs:
dry_run:
description: 'Report what would be deleted without deleting'
type: boolean
required: false
default: false
max_bucket_gb:
description: 'Total bucket size to prune down to (GB)'
type: string
required: false
default: '8'
keep_days:
description: 'Delete archive snapshots older than this many days'
type: string
required: false
default: '30'
keep_min_snapshots:
description: 'Always keep at least this many snapshots per repo (age rule only)'
type: string
required: false
default: '2'
permissions:
contents: read
# Serialize with the XBPS publisher so a snapshot is never pruned mid-publish.
concurrency:
group: void-xbps-${{ github.ref }}
cancel-in-progress: false
jobs:
prune:
name: Prune archive snapshots
runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell'
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
ARCHIVE_PREFIX: archive/
MAX_BUCKET_GB: ${{ github.event.inputs.max_bucket_gb || '8' }}
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
KEEP_MIN_SNAPSHOTS: ${{ github.event.inputs.keep_min_snapshots || '2' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Prune archive
run: bash .github/scripts/prune-void-r2.sh
-62
View File
@@ -1,62 +0,0 @@
name: QML tests
on:
push:
paths:
- "quickshell/**"
- "dux-qml-common"
- "Makefile"
- "core/Makefile"
- "scripts/shell-test-excludes.txt"
- "flake.nix"
- ".pre-commit-config.yaml"
- ".github/workflows/qml-tests.yml"
- ".github/workflows/prek.yml"
pull_request:
branches: [master, main, "stable-*"]
paths:
- "quickshell/**"
- "dux-qml-common"
- "Makefile"
- "core/Makefile"
- "scripts/shell-test-excludes.txt"
- "flake.nix"
- ".pre-commit-config.yaml"
- ".github/workflows/qml-tests.yml"
- ".github/workflows/prek.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
test-qml:
runs-on: ubuntu-latest
container: archlinux:base
timeout-minutes: 10
steps:
- name: Install test dependencies
run: |
pacman-key --init
pacman -Syu --noconfirm --needed git make nodejs python lua quickshell qt6-declarative ttf-dejavu
- name: Record runtime versions
run: |
pacman -Q quickshell qt6-declarative
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- name: Run QML pre-commit hook
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16
with:
prek-version: "0.5.5"
extra-args: qml-tests --all-files
cache: ${{ github.event_name != 'pull_request' }}
env:
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: safe.directory
GIT_CONFIG_VALUE_0: ${{ github.workspace }}
+52 -160
View File
@@ -23,34 +23,11 @@ concurrency:
cancel-in-progress: true
jobs:
verify-version:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag }}
- name: Verify quickshell/VERSION matches the tag
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
version=$(cat quickshell/VERSION)
if [ "${version#v}" != "${TAG#v}" ]; then
echo "::error::quickshell/VERSION is '${version}' but the tag is '${TAG}'"; exit 1
fi
build-core:
needs: [verify-version]
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { goos: linux, arch: amd64 }
- { goos: linux, arch: arm64 }
- { goos: freebsd, arch: amd64 }
- { goos: freebsd, arch: arm64 }
arch: [amd64, arm64]
defaults:
run:
@@ -58,8 +35,6 @@ jobs:
env:
TAG: ${{ inputs.tag }}
# linux assets keep their historical arch-only names
ASSET: ${{ matrix.goos == 'linux' && matrix.arch || format('{0}-{1}', matrix.goos, matrix.arch) }}
steps:
- name: Checkout
@@ -67,7 +42,6 @@ jobs:
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v6
@@ -75,7 +49,6 @@ jobs:
go-version-file: ./core/go.mod
- name: Format check
if: matrix.goos == 'linux'
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "The following files are not formatted:"
@@ -84,43 +57,38 @@ jobs:
fi
- name: Run tests
if: matrix.goos == 'linux'
run: go test -v ./...
- name: Build dankinstall (${{ env.ASSET }})
if: matrix.goos == 'linux'
- name: Build dankinstall (${{ matrix.arch }})
env:
GOOS: ${{ matrix.goos }}
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dankinstall
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dankinstall-${{ env.ASSET }}
-o ../../dankinstall-${{ matrix.arch }}
cd ../..
gzip -9 -k dankinstall-${{ env.ASSET }}
sha256sum dankinstall-${{ env.ASSET }}.gz > dankinstall-${{ env.ASSET }}.gz.sha256
gzip -9 -k dankinstall-${{ matrix.arch }}
sha256sum dankinstall-${{ matrix.arch }}.gz > dankinstall-${{ matrix.arch }}.gz.sha256
- name: Sync embedded shell
run: make sync-shell
- name: Build dms (${{ env.ASSET }})
- name: Build dms (${{ matrix.arch }})
env:
GOOS: ${{ matrix.goos }}
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dms
go build -trimpath -tags withshell -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-${{ env.ASSET }}
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-${{ matrix.arch }}
cd ../..
gzip -9 -k dms-${{ env.ASSET }}
sha256sum dms-${{ env.ASSET }}.gz > dms-${{ env.ASSET }}.gz.sha256
gzip -9 -k dms-${{ matrix.arch }}
sha256sum dms-${{ matrix.arch }}.gz > dms-${{ matrix.arch }}.gz.sha256
- name: Generate shell completions
if: matrix.goos == 'linux' && matrix.arch == 'amd64'
if: matrix.arch == 'amd64'
run: |
set -eux
chmod +x dms-amd64
@@ -128,58 +96,46 @@ jobs:
./dms-amd64 completion fish > completion.fish
./dms-amd64 completion zsh > completion.zsh
- name: Build dms-distropkg (${{ env.ASSET }})
- name: Build dms-distropkg (${{ matrix.arch }})
env:
GOOS: ${{ matrix.goos }}
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dms
go build -trimpath -tags 'distro_binary withshell' -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-distropkg-${{ env.ASSET }}
go build -trimpath -tags distro_binary -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-distropkg-${{ matrix.arch }}
cd ../..
gzip -9 -k dms-distropkg-${{ env.ASSET }}
sha256sum dms-distropkg-${{ env.ASSET }}.gz > dms-distropkg-${{ env.ASSET }}.gz.sha256
gzip -9 -k dms-distropkg-${{ matrix.arch }}
sha256sum dms-distropkg-${{ matrix.arch }}.gz > dms-distropkg-${{ matrix.arch }}.gz.sha256
- name: Upload artifacts (${{ env.ASSET }})
if: matrix.goos == 'linux' && matrix.arch == 'arm64'
- name: Upload artifacts (${{ matrix.arch }})
if: matrix.arch == 'arm64'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ env.ASSET }}
name: core-assets-${{ matrix.arch }}
path: |
core/dankinstall-${{ env.ASSET }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256
core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256
if-no-files-found: error
- name: Upload artifacts (${{ env.ASSET }}, no installer)
if: matrix.goos != 'linux'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ env.ASSET }}
path: |
core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256
core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ matrix.arch }}.gz
core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ matrix.arch }}.gz.sha256
if-no-files-found: error
- name: Upload artifacts with completions
if: matrix.goos == 'linux' && matrix.arch == 'amd64'
if: matrix.arch == 'amd64'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ env.ASSET }}
name: core-assets-${{ matrix.arch }}
path: |
core/dankinstall-${{ env.ASSET }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256
core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256
core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ matrix.arch }}.gz
core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ matrix.arch }}.gz.sha256
core/completion.bash
core/completion.fish
core/completion.zsh
@@ -207,8 +163,8 @@ jobs:
# GH_TOKEN: ${{ steps.app_token.outputs.token }}
# run: |
# set -euo pipefail
# git config user.name "dux-ci[bot]"
# git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
# git config user.name "dms-ci[bot]"
# git config user.email "dms-ci[bot]@users.noreply.github.com"
# version="${GITHUB_REF#refs/tags/}"
# echo "Updating to version: $version"
@@ -235,13 +191,11 @@ jobs:
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
submodules: recursive
- name: Fetch updated tag after version bump
run: |
git fetch origin --force tag ${TAG}
git checkout ${TAG}
git submodule update --init --recursive
- name: Set up Go
uses: actions/setup-go@v6
@@ -280,7 +234,7 @@ jobs:
## Installation
```bash
curl -fsSL https://install.duxlabs.dev | sh
curl -fsSL https://install.danklinux.com | sh
```
## Assets
@@ -288,23 +242,16 @@ jobs:
### Complete Packages
- **`dms-full-amd64.tar.gz`** - Complete package for x86_64 systems (CLI binaries + QML source + shell completions + installation guide)
- **`dms-full-arm64.tar.gz`** - Complete package for ARM64 systems (CLI binaries + QML source + shell completions + installation guide)
- **`dms-full-freebsd-amd64.tar.gz`** - Complete package for FreeBSD x86_64 systems
- **`dms-full-freebsd-arm64.tar.gz`** - Complete package for FreeBSD ARM64 systems
### Individual Components
- **`dms-cli-amd64.gz`** - DMS CLI binary for x86_64 systems
- **`dms-cli-arm64.gz`** - DMS CLI binary for ARM64 systems
- **`dms-cli-freebsd-amd64.gz`** - DMS CLI binary for FreeBSD x86_64 systems
- **`dms-cli-freebsd-arm64.gz`** - DMS CLI binary for FreeBSD ARM64 systems
- **`dms-distropkg-amd64.gz`** - DMS CLI binary built with distro_package tag for AMD64 systems
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
- **`dms-distropkg-freebsd-amd64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD x86_64 systems
- **`dms-distropkg-freebsd-arm64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD ARM64 systems
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
- **`dms-qml.tar.gz`** - QML source code only
- **`dms-source.tar.gz`** - Full repository source with bundled DCommon (for distro packaging)
### Checksums
- **`*.sha256`** - SHA256 checksums for verifying download integrity
@@ -352,22 +299,6 @@ jobs:
# Copy completions
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
# Replace the DCommon symlink with real submodule content for packaging
rm quickshell/DCommon
cp -r dux-qml-common/DCommon quickshell/DCommon
# Create full source tarball (GitHub tag archives never contain submodule content)
VERSION_NUM=${TAG#v}
tar --exclude='.git' \
--exclude='.github' \
--exclude='dux-qml-common' \
--exclude='_release_assets' \
--exclude='_core_assets' \
--exclude='RELEASE_BODY.md' \
--transform "s,^\.,DankMaterialShell-${VERSION_NUM},S" \
-czf _release_assets/dms-source.tar.gz .
(cd _release_assets && sha256sum dms-source.tar.gz > dms-source.tar.gz.sha256)
# Create QML source package (exclude build artifacts and git files)
# Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging
cp LICENSE CONTRIBUTING.md quickshell/
@@ -378,16 +309,14 @@ jobs:
# Tar the CONTENTS of quickshell/, not the directory itself
(cd quickshell && tar --exclude='.git' \
--exclude='.github' \
--exclude='./tests' \
--exclude='*.tar.gz' \
--exclude-from=../scripts/shell-test-excludes.txt \
-czf ../_release_assets/dms-qml.tar.gz .)
# Generate checksum for QML package
(cd _release_assets && sha256sum dms-qml.tar.gz > dms-qml.tar.gz.sha256)
# Create full packages for each os/architecture
for arch in amd64 arm64 freebsd-amd64 freebsd-arm64; do
# Create full packages for each architecture
for arch in amd64 arm64; do
mkdir -p _temp_full/dms
mkdir -p _temp_full/bin
mkdir -p _temp_full/completions
@@ -420,7 +349,7 @@ jobs:
# Create installation guide
cat > _temp_full/INSTALL.md << 'EOFINSTALL'
# Dux Shell Installation
# DankMaterialShell Installation
## Requirements
@@ -430,16 +359,18 @@ jobs:
## Installation Steps
The Quickshell UI is embedded in the `dms` binary. The bundled `dms/`
tree is optional — pass it with `-c` or `DMS_SHELL_DIR` to run a
modified copy instead of the embedded UI.
1. **Install quickshell assets:**
```bash
mkdir -p ~/.config/quickshell
cp -r dms ~/.config/quickshell/
```
1. **Install the DMS CLI binaries:**
2. **Install the DMS CLI binaries:**
```bash
sudo install -m 755 bin/dms /usr/local/bin/dms
```
2. **Install shell completions (optional):**
3. **Install shell completions (optional):**
```bash
# Bash
sudo install -m 644 completions/completion.bash /usr/share/bash-completion/completions/dms
@@ -451,15 +382,15 @@ jobs:
sudo install -m 644 completions/completion.zsh /usr/share/zsh/site-functions/_dms
```
3. **Start the shell:**
4. **Start the shell:**
```bash
dms run
```
## Configuration
- Settings are stored in `~/.config/dms/settings.json`
- Plugins go in `~/.config/dms/plugins/`
- Settings are stored in `~/.config/DankMaterialShell/settings.json`
- Plugins go in `~/.config/DankMaterialShell/plugins/`
- See the documentation in the `dms/` directory for more details
## Troubleshooting
@@ -518,42 +449,3 @@ jobs:
exit 0
fi
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
# Pin the satellite repos to this release's version. Each dispatch target
# decides for itself whether to tag: X.Y.0 always tags master and cuts
# stable-X.Y, X.Y.Z tags stable-X.Y and skips when it has nothing new.
fanout:
name: Pin ${{ matrix.repo }}
runs-on: ubuntu-latest
needs: [release]
if: ${{ !contains(inputs.tag, '-') }}
strategy:
fail-fast: false
matrix:
repo:
- dux-gop
- dux-go
- dux-qml-common
- dux-calendar
- dux-search
- dux-greeter
- dux-files
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 }}
owner: ${{ github.repository_owner }}
repositories: ${{ matrix.repo }}
- name: Dispatch pin-release
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
+11 -3
View File
@@ -4,12 +4,14 @@ on:
workflow_dispatch:
inputs:
package:
description: 'Package to build'
description: 'Package to build (dms, dms-greeter, or both)'
required: false
default: 'dms'
type: choice
options:
- dms
- dms-greeter
- both
version:
description: 'Versioning (e.g., 1.0.3, leave empty for latest release)'
required: false
@@ -29,7 +31,11 @@ jobs:
id: set-packages
run: |
PACKAGE_INPUT="${{ github.event.inputs.package || 'dms' }}"
echo "packages=[\"$PACKAGE_INPUT\"]" >> $GITHUB_OUTPUT
if [ "$PACKAGE_INPUT" = "both" ]; then
echo 'packages=["dms","dms-greeter"]' >> $GITHUB_OUTPUT
else
echo "packages=[\"$PACKAGE_INPUT\"]" >> $GITHUB_OUTPUT
fi
build-and-upload:
needs: determine-packages
@@ -78,7 +84,7 @@ jobs:
echo "📦 Downloading DMS QML source for v${VERSION}..."
# Download DMS QML source
wget "https://github.com/duxlabs-dev/dux-shell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
wget "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}"
exit 1
}
@@ -157,6 +163,8 @@ jobs:
PACKAGE="${{ matrix.package }}"
if [ "$PACKAGE" = "dms" ]; then
COPR_PROJECT="avengemedia/dms"
elif [ "$PACKAGE" = "dms-greeter" ]; then
COPR_PROJECT="avengemedia/danklinux"
else
echo "❌ Unknown package: $PACKAGE"
exit 1
+60 -14
View File
@@ -9,6 +9,7 @@ on:
type: choice
options:
- dms
- dms-greeter
- dms-git
- all
default: "dms"
@@ -23,7 +24,7 @@ jobs:
check-updates:
name: Check for updates
runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell'
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
@@ -35,7 +36,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Check for updates
id: check
@@ -61,7 +61,7 @@ jobs:
# Helper function to check dms stable tag
# Sets LATEST_TAG variable in parent scope if update needed
check_dms_stable() {
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
@@ -74,12 +74,27 @@ jobs:
fi
}
# Helper function to check dms-greeter stable tag
check_dms_greeter_stable() {
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:danklinux/dms-greeter/dms-greeter.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs | sed 's/^v//')
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "v$OBS_VERSION" ]]; then
echo "📋 dms-greeter: Tag $LATEST_TAG already exists, skipping"
return 1 # No update needed
else
echo "📋 dms-greeter: New tag ${LATEST_TAG:-unknown} (OBS has ${OBS_VERSION:-none})"
return 0 # Update needed
fi
}
# Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}"
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then
# Run from tag with no package specified - update stable package
echo "packages=dms" >> $GITHUB_OUTPUT
# Run from tag with no package specified - update both stable packages
echo "packages=dms dms-greeter" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
@@ -105,7 +120,7 @@ jobs:
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
elif [[ "$PKG" == "all" ]]; then
# Check the stable package and build list of those needing updates
# Check each stable package and build list of those needing updates
PACKAGES_TO_UPDATE=()
if check_dms_stable; then
PACKAGES_TO_UPDATE+=("dms")
@@ -113,6 +128,10 @@ jobs:
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
fi
if check_dms_greeter_stable; then
PACKAGES_TO_UPDATE+=("dms-greeter")
[[ -n "$LATEST_TAG" ]] && echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
@@ -121,7 +140,7 @@ jobs:
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
echo "✓ All packages up to date"
echo "✓ Both packages up to date"
fi
elif [[ "$PKG" == "dms-git" ]]; then
@@ -145,6 +164,18 @@ jobs:
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms-greeter" ]]; then
if check_dms_greeter_stable; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
else
# Unknown package - proceed anyway
echo "packages=$PKG" >> $GITHUB_OUTPUT
@@ -168,7 +199,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Wait before OBS upload
run: sleep 3
@@ -189,10 +219,10 @@ jobs:
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
# Manual workflow dispatch
# Determine version for dms stable using the API
# Determine version for dms stable and dms-greeter using the API
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "dms-greeter" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Using latest release from API: $LATEST_TAG"
@@ -257,7 +287,7 @@ jobs:
echo " -- Avenge Media <[email protected]> $CHANGELOG_DATE"
} > "distro/debian/dms-git/debian/changelog"
- name: Update stable version (dms)
- name: Update stable version (dms + dms-greeter)
if: steps.packages.outputs.version != ''
run: |
VERSION="${{ steps.packages.outputs.version }}"
@@ -293,12 +323,25 @@ jobs:
fi
fi
# Update dms-greeter changelog when dms-greeter is in the upload list
if [[ "$PACKAGES" == *"dms-greeter"* ]] && [[ -f "distro/debian/dms-greeter/debian/changelog" ]]; then
CHANGELOG_DATE=$(date -R)
{
echo "dms-greeter (${VERSION_NO_V}db1) unstable; urgency=medium"
echo ""
echo " * Update to $VERSION stable release"
echo ""
echo " -- Avenge Media <[email protected]> $CHANGELOG_DATE"
} > "distro/debian/dms-greeter/debian/changelog"
echo "✓ Updated dms-greeter changelog to ${VERSION_NO_V}db1"
fi
# Update Debian _service files for packages in upload list (download_url paths)
for service in distro/debian/*/_service; do
if [[ -f "$service" ]]; then
# Update tar_scm revision parameter (for dms-git)
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
# Update download_url paths (for dms stable)
# Update download_url paths (for dms, dms-greeter stable)
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
fi
@@ -359,7 +402,7 @@ jobs:
UPLOADED_PACKAGES=()
SKIPPED_PACKAGES=()
# PACKAGES can be a space-separated list (from the "all" check)
# PACKAGES can be space-separated list (e.g., "dms dms-greeter" from "all" check)
# Loop through each package and upload
for PKG in $PACKAGES; do
echo ""
@@ -462,6 +505,9 @@ jobs:
dms-git)
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
;;
dms-greeter)
echo "- $STATUS_ICON **dms-greeter** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:danklinux/dms-greeter)" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
+4 -3
View File
@@ -9,6 +9,7 @@ on:
type: choice
options:
- dms
- dms-greeter
- dms-git
- all
default: "dms"
@@ -23,7 +24,7 @@ jobs:
check-updates:
name: Check package/series updates
runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell'
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
@@ -35,7 +36,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Install dependencies
run: |
@@ -94,7 +94,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v6
@@ -134,6 +133,7 @@ jobs:
case "$PACKAGE" in
dms) PPA_NAME="dms" ;;
dms-git) PPA_NAME="dms-git" ;;
dms-greeter) PPA_NAME="danklinux" ;;
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
esac
@@ -148,3 +148,4 @@ jobs:
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
+35 -8
View File
@@ -27,6 +27,11 @@ on:
type: boolean
required: false
default: true
build_greeter:
description: 'Build stable dms-greeter package'
type: boolean
required: false
default: true
permissions:
contents: read
@@ -61,7 +66,7 @@ jobs:
name: Build & Deploy XBPS packages
runs-on: ubuntu-latest
needs: preflight
if: github.repository == 'duxlabs-dev/dux-shell' && needs.preflight.outputs.should_build == 'true'
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 }}
@@ -78,7 +83,6 @@ jobs:
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
submodules: recursive
- name: Record checked-out source commit
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
@@ -101,6 +105,7 @@ jobs:
- 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)
@@ -174,16 +179,19 @@ jobs:
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
@@ -208,20 +216,23 @@ jobs:
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" ]; then
echo "🔧 Updating stable template for $ARCHIVE_TAG"
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 }}/releases/download/${ARCHIVE_TAG}/dms-source.tar.gz"
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"
sed -i "s/^version=.*/version=${RELEASE_VER}/" srcpkgs/dms/template
sed -i "s/^checksum=.*/checksum=${RELEASE_CHECKSUM}/" srcpkgs/dms/template
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)
@@ -235,7 +246,7 @@ jobs:
# into $wrksrc (create_wrksrc=yes handles the rest).
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
mkdir -p "$SRC_CACHE"
tar -czhf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
--exclude=void-packages \
--exclude=r2-repo \
--exclude=.git \
@@ -277,6 +288,22 @@ jobs:
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
+17 -4
View File
@@ -8,7 +8,6 @@ on:
- "core/go.sum"
branches:
- master
- "stable-*"
permissions:
contents: write
@@ -34,7 +33,21 @@ jobs:
uses: cachix/install-nix-action@v31
- name: Update vendorHash in flake.nix
run: scripts/update-vendor-hash.sh
run: |
set -euo pipefail
echo "Attempting nix build to get new vendorHash..."
if output=$(nix build .#dms-shell 2>&1); then
echo "Build succeeded, no hash update needed"
exit 0
fi
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1 || true)
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
sed -i "s|vendorHash = \"$current_hash\"|vendorHash = \"$new_hash\"|" flake.nix
echo "Verifying build with new vendorHash..."
nix build .#dms-shell
echo "vendorHash updated successfully!"
- name: Commit and push vendorHash update
env:
@@ -42,8 +55,8 @@ jobs:
run: |
set -euo pipefail
if ! git diff --quiet flake.nix; then
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.nix
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
git pull --rebase origin ${{ github.ref_name }}
+1 -5
View File
@@ -64,7 +64,7 @@ AGENTS-temp.md
niri-colors.generated.kdl
ghostty-colors.generated.conf
# Notepad files (should be in ~/.local/state/dms/)
# Notepad files (should be in ~/.local/state/DankMaterialShell/)
untitled-*.txt
file:*
notepad-files/
@@ -117,7 +117,6 @@ core.*
.envrc
.direnv/
quickshell/dms-plugins
quickshell/dms-plugins-external
__pycache__
.vscode/
@@ -130,6 +129,3 @@ distro/void/masterdir*/
# Often gets built
core/dms
core/dankinstall
core/internal/shellembed/dist
-4
View File
@@ -1,4 +0,0 @@
[submodule "dank-qml-common"]
path = dux-qml-common
url = https://github.com/duxlabs-dev/dux-qml-common.git
branch = master
+2 -26
View File
@@ -5,16 +5,6 @@ repos:
- id: trailing-whitespace
- id: check-yaml
- id: end-of-file-fixer
- repo: local
hooks:
- id: qml-tests
name: shell logic and QML behavior tests
entry: python3 quickshell/tests/run-qml.py
language: system
types: []
files: ^(quickshell/|dux-qml-common$|Makefile$|core/Makefile$|scripts/shell-test-excludes\.txt$|flake\.nix$|\.pre-commit-config\.yaml$|\.github/workflows/(qml-tests|prek)\.yml$)
pass_filenames: false
require_serial: true
- repo: local
hooks:
- id: shellcheck
@@ -30,19 +20,13 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
pass_filenames: false
- id: nix-vendor-hash
name: update Nix vendor hash
entry: bash -c 'command -v nix >/dev/null 2>&1 || exit 0; scripts/update-vendor-hash.sh --stage'
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|Common/SettingsTabs\.qml|Services/SettingsSearchService\.qml|translations/(extract_settings_index\.py|en\.json))$
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false
- repo: local
hooks:
@@ -52,14 +36,6 @@ repos:
language: system
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
pass_filenames: false
- repo: local
hooks:
- id: i18n-term-variants
name: i18n term variants (no case/punctuation duplicates)
entry: python3 quickshell/translations/check_term_variants.py
language: system
files: ^quickshell/(.*\.qml|translations/(check_term_variants\.py|extract_translations\.py))$
pass_filenames: false
- repo: local
hooks:
- id: no-console-in-qml
@@ -67,4 +43,4 @@ repos:
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
language: system
files: ^quickshell/.*\.qml$
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/|tests/)
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
-46
View File
@@ -1,46 +0,0 @@
## What is this?
DMS is an open-source desktop shell for Wayland compositors on linux, built to work with many compositors.
## Repo Structure
- core/ - Go. `cmd/dms` runs quickshell, CLI utilities, and the unix socket server the UI depends on. `cmd/dankinstall` is a separate installer TUI.
- quickshell/ - the entire UI: Common/ (singletons, Theme, settings), Services/ (system and core access), Modules/ (bar, dock, lock, ...), Modals/, Widgets/ (shell-specific widgets and deprecated Dank* shims), PLUGINS/, translations/
- dux-qml-common/ - submodule of shared QML widgets
- docs/, distro/ - IPC and theme docs, packaging
## Rules
- Keep it simple.
- Do not edit code in response to a question.
- Never commit, push, or open a PR unless asked.
- Point out glaring issues we missed.
- The shell runs 24/7. Audit every change for idle CPU, extra processes, timers, and retained memory.
- `core` and `quickshell` share the socket protocol. Changing one means checking the other.
- QML uses Theme tokens, never hardcoded colors, spacing, or constants.
- Use the D* widgets from qs.DCommon.Widgets (DListView, DFlickable, and so on) instead of raw ListView/Flickable/ScrollView. The Dank* names in Widgets/ are deprecated shims.
- User-facing text goes through I18n.tr(). Reuse catalog terms. Never edit translation catalogs.
- Guard clauses, early returns.
- Comment only what the code cannot say: a non-obvious why, a trap, a workaround. Never narrate what the code does or restate a name. Keep it to a line or two, and delete useless or paragraph comments you come across.
- Do not edit generated code. Mocks come from mockery.
- No library for a simple problem. Use a modern one for a complex, common problem.
## Tests
- A test must fail on the broken code. Otherwise do not write it.
- No useless tests: "main.go contains func main()", defaults, "it instantiates", behavior the change didn't touch.
- QML tests must be fast. Call logic directly, load the smallest component, no fixed delays or animation waits, few meaningful assertions over a matrix.
- Tests cover `.js` modules, functions of `Services/` and `Common/` singletons, and IPC handlers. Never load, slice or mock anything under `Modules/`, `Modals/` or `Widgets/`, and never spawn a compositor, display server or shell. Those tests only ever broke on UI tweaks.
## Connections vs bound properties
- In per-item or per-screen components (delegates, bar widgets, popout and modal hosts, OSDs), no `Connections` for `on<Prop>Changed` handlers.
- Source in the same file: put the handler on it.
- Source outside (singleton, loader item, delegate parent): bind to a typed readonly property, never `var`, and handle that.
- Keep `Connections` for plain signals, hand-emitted change signals (`SettingsData.barConfigsChanged()`), constant-pointer targets, reassignable targets, and per-frame `var` sources.
- A bound handler fires once at creation if the initial value differs from the type default, and never on a same-value write. Convert only idempotent bodies. `enabled:` conditions become a guard inside the handler.
- Singletons under Services/ and Common/ keep their `Connections`.
## Commit Messages
`area: short lowercase explanation`, for example `network: report wifi state from the associated adapter`. Description only if required, in simple language.
-9
View File
@@ -1,14 +1,5 @@
This file is more of a quick reference so I know what to account for before next releases.
# Next
- notifications: timed per-app popup mutes (1h / 4h) from the notification context menu
- Go core migrated to dankgo (shared log/paths/errdefs/ipc/shellapp)
- Quickshell UI embedded in the dms binary, -c / DMS_SHELL_DIR override - breaking for setups relying on ~/.config/quickshell/dms auto-discovery (XDG search removed)
- release binaries + all -git packages build with withshell; sockets/pidfiles/env names unchanged, IPC APIVersion 28 wire-compatible
- -git packages (AUR dms-shell-git, fedora, opensuse, void, debian, ubuntu) no longer install /usr/share/quickshell/dms; dms greeter install falls back to the embedded UI when the greeter package is present
- ping over the socket returns {"pong":true} instead of "pong"
- startup: embedded UI resolution is keyed by build-time .dankrev (no per-start content hashing)
# 1.5.0
- Overhauled shadows
- App ID changed to com.danklinux.dms - breaking for window rules
+1 -84
View File
@@ -6,20 +6,6 @@ To contribute fork this repository, make your changes, and open a pull request.
## Setup
Clone with submodules — the shared widget library ([dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common)) is vendored at `dux-qml-common/` and symlinked into `quickshell/DCommon`:
```bash
git clone --recurse-submodules https://github.com/duxlabs-dev/dux-shell.git
# or, in an existing clone:
git submodule update --init
```
To have `git pull` keep the submodule in sync automatically (moving it to the commit this repo points at, no separate `git submodule update` step), set:
```bash
git config submodule.recurse true
```
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
```bash
@@ -42,42 +28,6 @@ This will provide:
The dev shell automatically creates the `.qmlls.ini` file in the `quickshell/` directory.
## Building and running
The Quickshell UI is embedded into the `dms` binary at build time. `make build` copies `quickshell/` into `core/internal/shellembed/dist/` (generated, never committed) and compiles with the `withshell` tag. `make dev` builds without the tag — that binary carries no UI and requires an explicit config dir.
```bash
make build # embedded binary at core/bin/dms
make dev # untagged development build
make run # dev build, then launch against the live quickshell/ tree
```
The UI config dir resolves in order: `-c <dir>`, `DMS_SHELL_DIR`, the dir a running instance is using, then the embedded UI. Each candidate must contain `shell.qml`. `make run` uses `-c $(pwd)/quickshell`, so QML edits hot-reload from the working tree.
The Go core depends on [dankgo](https://github.com/duxlabs-dev/dux-go) for logging, XDG paths, the IPC transport, and the quickshell process lifecycle. To develop against a local dankgo checkout, create a gitignored `go.work` at the repo root:
```
go work init ./core ../dankgo
```
## Shared widgets (dux-qml-common)
Everything under `quickshell/DCommon/` (core widgets, the file browser, scroll physics, bundled fonts) is shared across the DMS suite and lives in the `dux-qml-common` submodule. It is a normal git worktree:
1. Edit files under `dux-qml-common/` (or through the `quickshell/DCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dux-qml-common`.
2. Commit and PR those changes in the `dux-qml-common` repo: `cd dux-qml-common && git switch -c my-change`, push, open the PR there.
3. Once merged, bump the pointer here: `make update-common` (updates the submodule and the nix flake input together), then commit alongside any DMS-side changes. If you only bump the submodule, CI syncs `flake.lock` to it automatically on master.
The submodule URL in `.gitmodules` is HTTPS so CI and anonymous clones keep working. To push over SSH instead of being prompted for credentials, add a push rewrite to your git config — fetches stay HTTPS, pushes use SSH:
```bash
git config --global url."[email protected]:duxlabs-dev/".pushInsteadOf "https://github.com/duxlabs-dev/"
```
Shared widgets read app-provided singletons (`Theme`, `SettingsData`, ...) through a documented contract — see the dux-qml-common README. If your change needs a new contract property, add it to the library's stub singletons in the same PR, then to `quickshell/Common/` here when you bump.
Files in `quickshell/Widgets/`, `quickshell/Common/`, and `quickshell/Modals/FileBrowser/` that moved to the library remain in place as thin wrappers, so existing `qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` imports keep working for the shell and for plugins. The `Dank*` widget names are deprecated: each one logs a warning naming its replacement. New code imports `qs.DCommon.Widgets` and uses the `D*` names (`DButton`, `DIcon`, `DListView`, and so on).
## VSCode Setup
This is a monorepo, the easiest thing to do is to open an editor in either `quickshell`, `core`, or both depending on which part of the project you are working on.
@@ -138,9 +88,7 @@ touch .qmlls.ini
5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`.
6. Run `make test-qml` for QML unit tests (`nix develop --command make test-qml` with Nix). These use Qt 6 `qmltestrunner` offscreen and do not require a running shell. Set `QMLTESTRUNNER=/path/to/qmltestrunner` if needed.
7. Make your changes, test, and open a pull request.
6. Make your changes, test, and open a pull request.
### I18n/Localization
@@ -156,10 +104,6 @@ Text {
Preferably, try to keep new terms to a minimum and re-use existing terms where possible. See `quickshell/translations/en.json` for the list of existing terms. (This isn't always possible obviously, but instead of using `Auto-connect` you would use `Autoconnect` since it's already translated)
Don't re-extract the translations. `en.json` and `template.json` are synced with POEditor by a maintainer script, so running `extract_translations.py` in your PR just makes a diff that fights the next sync. Add your `I18n.tr()` calls and leave the catalogs alone. (`settings_search_index.json` is the exception, the pre-commit hook regenerates that one when you touch settings QML.)
Strings inside `quickshell/DCommon/` are owned by the dux-qml-common repo but stay in the DMS POEditor project — extraction here deliberately skips them, and `scripts/i18nsync.py sync` uploads the union of app terms and the submodule's terms instead (common terms carry the `dux-qml-common` tag). On download the sync splits the exports: app translations go to `quickshell/translations/poexports/`, common translations go to `dux-qml-common/DCommon/translations/poexports/` for you to commit in that repo and bump. At runtime `I18n` merges both catalogs (app terms win). Other apps (dankcalendar) keep their own POEditor projects and merge the `dux-qml-common`-tagged terms from the DMS project.
### GO (`core` directory)
1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go)
@@ -168,33 +112,6 @@ Strings inside `quickshell/DCommon/` are owned by the dux-qml-common repo but st
4. Run `go mod tidy`
5. Open pull request
golangci-lint runs as a pre-commit hook and covers `go vet`, so there's nothing separate to run. If you run `go vet ./...` yourself you'll see complaints about the generated mocks, ignore them, `core/.golangci.yml` excludes that directory.
#### Mocks
Test mocks under `core/internal/mocks/` are generated with [mockery](https://vektra.github.io/mockery/) v3. Don't edit them by hand, regenerate after changing a mocked interface:
```bash
cd core
go run github.com/vektra/mockery/v3@latest
# or with it installed (go install github.com/vektra/mockery/v3@latest):
mockery
```
`core/.mockery.yml` lists every mocked interface and where its mock goes (e.g. `network.Backend` -> `internal/mocks/network/mock_Backend.go`). To mock a new interface, add it there under its package and regenerate.
## Generative AI
Using an LLM to help write code is fine. Submitting its output unread is not.
- You are responsible for every line you submit. You have read it, tested it, and can explain it in review.
- Say in the PR when a meaningful part of the code was AI generated.
- Write PR descriptions, issues, and comments yourself. No AI-written or AI-rewritten text. A few plain sentences in your own words beat a page of generated summary. PRs and issues with AI-written text get closed without review.
- You can use AI to troubleshoot, gather data, and information for new issues. The description should be written by a human.
- If you are not an english-native speaker you can use AI to translate your request or PR description
- Do not file issues or leave comments you have not verified yourself. Reports that do not reproduce get closed.
- PRs that read like unreviewed output, with narrating comments, invented APIs, or style that ignores the file they are in, get closed without review.
## Pull request
Include screenshots/video if applicable in your pull request if applicable, to visualize what your change is affecting.
+18 -37
View File
@@ -1,4 +1,4 @@
# Root Makefile for Dux Shell (DMS)
# Root Makefile for DankMaterialShell (DMS)
# Orchestrates building, installation, and systemd management
# Build configuration
@@ -14,12 +14,11 @@ USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6)
SYSTEMD_USER_DIR=$(USER_HOME)/.config/systemd/user
SHELL_DIR=quickshell
QMLTESTRUNNER ?=
SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications
.PHONY: all build dev run clean lint-qml install install-bin install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
.PHONY: all build clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
all: build
@@ -28,12 +27,6 @@ build:
@$(MAKE) -C $(CORE_DIR) build
@echo "Build complete"
dev:
@$(MAKE) -C $(CORE_DIR) dev
run: dev
@$(BUILD_DIR)/$(BINARY_NAME) run -c $(CURDIR)/$(SHELL_DIR)
clean:
@echo "Cleaning build artifacts..."
@$(MAKE) -C $(CORE_DIR) clean
@@ -42,22 +35,19 @@ clean:
lint-qml:
@./quickshell/scripts/qmllint-entrypoints.sh
.PHONY: test-qml
test-qml:
QMLTESTRUNNER="$(QMLTESTRUNNER)" python3 quickshell/tests/run-qml.py
# Pull the latest dux-qml-common and pin it everywhere it is consumed
# (submodule pointer + nix flake input). Commit both in one change.
update-common:
git submodule update --remote --merge dux-qml-common
nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common
# Installation targets
install-bin:
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Binary installed"
install-shell:
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
@mkdir -p $(SHELL_INSTALL_DIR)
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@echo "Shell files installed"
install-completions:
@echo "Installing shell completions..."
@mkdir -p $(DATA_DIR)/bash-completion/completions
@@ -69,9 +59,6 @@ install-completions:
@echo "Shell completions installed"
install-systemd:
ifneq ($(shell uname),Linux)
@echo "Skipping systemd user service (non-Linux); start the shell from your compositor config with 'dms run'"
else
@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
@@ -79,25 +66,22 @@ else
@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"
endif
install-icon:
@echo "Installing icon..."
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.svg $(ICON_DIR)/dev.duxlabs.shell.svg
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
@install -D -m 644 $(ASSETS_DIR)/danklogo.svg $(ICON_DIR)/danklogo.svg
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
@echo "Icon installed"
install-desktop:
@echo "Installing desktop entries..."
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.notepad.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.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 entries installed"
install: install-bin install-completions install-systemd install-icon install-desktop
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
@echo ""
@echo "Installation complete!"
@echo ""
@@ -110,7 +94,7 @@ uninstall-bin:
@echo "Binary removed"
uninstall-shell:
@echo "Removing pre-1.6 shell files from $(SHELL_INSTALL_DIR)..."
@echo "Removing shell files from $(SHELL_INSTALL_DIR)..."
@rm -rf $(SHELL_INSTALL_DIR)
@echo "Shell files removed"
@@ -129,8 +113,6 @@ uninstall-systemd:
uninstall-icon:
@echo "Removing icon..."
@rm -f $(ICON_DIR)/dev.duxlabs.shell.svg
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
@rm -f $(ICON_DIR)/danklogo.svg
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
@echo "Icon removed"
@@ -138,9 +120,8 @@ uninstall-icon:
uninstall-desktop:
@echo "Removing desktop entries..."
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.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 entries removed"
@@ -157,11 +138,11 @@ help:
@echo " build - Same as 'all'"
@echo " clean - Clean build artifacts"
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
@echo " test-qml - Run shell logic and Qt unit tests"
@echo ""
@echo "Install:"
@echo " install - Build and install everything (requires sudo)"
@echo " install-bin - Install only the binary"
@echo " install-shell - Install only shell files"
@echo " install-completions - Install only shell completions"
@echo " install-systemd - Install only systemd service"
@echo " install-icon - Install only icon"
@@ -170,7 +151,7 @@ help:
@echo "Uninstall:"
@echo " uninstall - Remove everything (requires sudo)"
@echo " uninstall-bin - Remove only the binary"
@echo " uninstall-shell - Remove shell files left by pre-1.6 installs"
@echo " uninstall-shell - Remove only shell files"
@echo " uninstall-completions - Remove only shell completions"
@echo " uninstall-systemd - Remove only systemd service"
@echo " uninstall-icon - Remove only icon"
+27 -45
View File
@@ -1,32 +1,32 @@
# Dux Shell
# DankMaterialShell
<div align="center">
<a href="https://duxlabs.dev">
<img src="assets/danklogo.svg" alt="Dux Shell" width="200">
<a href="https://danklinux.com">
<img src="assets/danklogo.svg" alt="DankMaterialShell" width="200">
</a>
### A modern desktop shell for Wayland
Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![Documentation](https://img.shields.io/badge/docs-duxlabs.dev-9ccbfb?style=for-the-badge&labelColor=101418)](https://duxlabs.dev/docs)
[![GitHub stars](https://img.shields.io/github/stars/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/duxlabs-dev/dux-shell/stargazers)
[![GitHub License](https://img.shields.io/github/license/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/duxlabs-dev/dux-shell/releases)
[![Documentation](https://img.shields.io/badge/docs-danklinux.com-9ccbfb?style=for-the-badge&labelColor=101418)](https://danklinux.com/docs)
[![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases)
[![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/)
[![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fduxlabs)](https://ko-fi.com/duxlabs)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fdanklinux)](https://ko-fi.com/danklinux)
</div>
Dux Shell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
## Repository Structure
This is a monorepo containing both the shell interface and the core backend services:
```
dux-shell/
DankMaterialShell/
├── quickshell/ # QML-based shell interface
│ ├── Modules/ # UI components (panels, widgets, overlays)
│ ├── Services/ # System integration (audio, network, bluetooth)
@@ -47,7 +47,7 @@ dux-shell/
<div align="center">
https://github.com/user-attachments/assets/d310418b-cc44-4ace-ac0d-2fc0c851291d
https://github.com/user-attachments/assets/1200a739-7770-4601-8b85-695ca527819a
</div>
@@ -70,14 +70,12 @@ https://github.com/user-attachments/assets/d310418b-cc44-4ace-ac0d-2fc0c851291d
## Installation
```bash
curl -fsSL https://install.duxlabs.dev | sh
curl -fsSL https://install.danklinux.com | sh
```
One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, openSUSE, or Gentoo.
**FreeBSD:** native support is included in-tree; see [distro/freebsd/README.md](distro/freebsd/README.md) for supported integrations and setup notes.
**[Manual installation guide](https://duxlabs.dev/docs/dms/installation)**
**[Manual installation guide](https://danklinux.com/docs/dankmaterialshell/installation)**
## Features
@@ -85,10 +83,10 @@ One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, o
Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16.
**System Monitoring**
Real-time CPU, RAM, GPU metrics and temperatures built into the dms daemon (powered by [dgop](https://github.com/duxlabs-dev/dux-gop)). Process list with search and management.
Real-time CPU, RAM, GPU metrics and temperatures with [dgop](https://github.com/AvengeMedia/dgop). Process list with search and management.
**Powerful Launcher**
Spotlight-style search for applications, files ([dsearch](https://github.com/duxlabs-dev/dux-search)), emojis, running windows, calculator, and commands. Extensible with plugins.
Spotlight-style search for applications, files ([dsearch](https://github.com/AvengeMedia/danksearch)), emojis, running windows, calculator, and commands. Extensible with plugins.
**Control Center**
Unified interface for network, Bluetooth, audio devices, display settings, and night mode.
@@ -100,18 +98,16 @@ Notification center with grouping, rich text support, and keyboard navigation.
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
**Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/duxlabs-dev/dux-greeter).
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and greeter support.
**Plugin System**
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps
`~/.config/dms/plugins.lock.json` synchronized with managed plugin installs and
their exact Git commits, so the same plugins can be reproduced on another machine.
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
## Supported Compositors
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and [Miracle WM](https://github.com/miracle-wm-org/miracle-wm) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
[Compositor configuration guide](https://duxlabs.dev/docs/dms/compositors)
[Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
## Command Line Interface
@@ -124,30 +120,17 @@ dms ipc call audio setvolume 50
dms ipc call wallpaper set /path/to/image.jpg
dms brightness list # List available displays
dms plugins search # Browse plugin registry
dms plugins lock # Refresh the portable plugin lockfile
dms plugins restore ~/plugins.lock.json
```
[Full CLI and IPC documentation](https://duxlabs.dev/docs/dms/keybinds-ipc)
[Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
## Documentation
- **Website:** [duxlabs.dev](https://duxlabs.dev)
- **Docs:** [duxlabs.dev/docs](https://duxlabs.dev/docs/)
- **Theming:** [Application themes](https://duxlabs.dev/docs/dms/application-themes) | [Custom themes](https://duxlabs.dev/docs/dms/custom-themes)
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs)
## Dank Projects
DMS is one piece of the suite. The rest lives in its own repos:
- [dank-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it.
- [dankcalendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop.
- [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
- [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher.
- [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar.
- [dankgo](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps.
- **Website:** [danklinux.com](https://danklinux.com)
- **Docs:** [danklinux.com/docs](https://danklinux.com/docs/)
- **Theming:** [Application themes](https://danklinux.com/docs/dankmaterialshell/application-themes) | [Custom themes](https://danklinux.com/docs/dankmaterialshell/custom-themes)
- **Plugins:** [Development guide](https://danklinux.com/docs/dankmaterialshell/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/avengemediallc)
## Development
@@ -177,7 +160,7 @@ quickshell -p quickshell/
```nix
{
inputs.dms.url = "github:duxlabs-dev/dux-shell";
inputs.dms.url = "github:AvengeMedia/DankMaterialShell";
# Use in home-manager or NixOS configuration
imports = [ inputs.dms.homeModules.dank-material-shell ];
@@ -193,7 +176,7 @@ Contributions welcome. Bug fixes, widgets, features, documentation, and plugins
3. Test thoroughly
4. Open a pull request
For documentation contributions, see [DankLinux-Docs](https://github.com/duxlabs-dev/dux-docs).
For documentation contributions, see [DankLinux-Docs](https://github.com/AvengeMedia/DankLinux-Docs).
## Credits
@@ -202,11 +185,10 @@ For documentation contributions, see [DankLinux-Docs](https://github.com/duxlabs
- [Ly-sec](http://github.com/ly-sec) - Wallpaper effects from [Noctalia](https://github.com/noctalia-dev/noctalia-shell)
- [soramanew](https://github.com/soramanew) - [Caelestia](https://github.com/caelestia-dots/shell) inspiration
- [end-4](https://github.com/end-4) - [dots-hyprland](https://github.com/end-4/dots-hyprland) inspiration
- [Reverier-Xu](https://github.com/Reverier-Xu) - [Ori-fcitx5](https://github.com/Reverier-Xu/Ori-fcitx5) inspiration for Fcitx5 theme
## Star History
[![Star History Chart](https://star-history.dera.page/svg?repos=duxlabs-dev/dux-shell&type=date&legend=top-left)](https://star-history.dera.page/#duxlabs-dev/dux-shell&type=date&legend=top-left)
[![Star History Chart](https://api.star-history.com/svg?repos=AvengeMedia/DankMaterialShell&type=date&legend=top-left)](https://www.star-history.com/#AvengeMedia/DankMaterialShell&type=date&legend=top-left)
## License
@@ -1,10 +1,10 @@
[Desktop Entry]
Type=Application
Name=Dux Shell
Name=DankMaterialShell
GenericName=Desktop Shell
Comment=Material 3 inspired shell for Wayland compositors
Exec=dms run
Icon=dev.duxlabs.shell
Icon=danklogo
Terminal=false
NoDisplay=true
StartupNotify=false
@@ -2,9 +2,9 @@
Type=Application
Name=DMS Notepad
GenericName=Text Editor
Comment=Quick notes and text files in Dux Shell
Comment=Quick notes and text files in the dank desktop shell
Exec=dms ipc call notepad openFile %f
Icon=dev.duxlabs.shell
Icon=danklogo
Terminal=false
NoDisplay=true
Categories=Utility;TextEditor;
-113
View File
@@ -1,113 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="482.90668"
height="558.15088"
viewBox="0 0 482.90667 558.15088"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
shape-rendering="auto"
style="image-rendering: auto; filter: url(#smoothing);">
<defs
id="defs1">
<filter id="smoothing" x="-0.05" y="-0.05" width="1.1" height="1.1">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.5" />
</filter>
<color-profile
name="sRGB IEC61966-2.1"
xlink:href="data:application/vnd.iccprofile;base64,AAAMbExpbm8CEAAAbW50clJHQiBYWVogB84AAgAJAAYAMQAAYWNzcE1TRlQAAAAASUVDIHNSR0IAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1IUCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARY3BydAAAAVAAAAAzZGVzYwAAAYQAAACQd3RwdAAAAhQAAAAUYmtwdAAAAigAAAAUclhZWgAAAjwAAAAUZ1hZWgAAAlAAAAAUYlhZWgAAAmQAAAAUZG1uZAAAAngAAABwZG1kZAAAAugAAACIdnVlZAAAA3AAAACGdmlldwAAA/gAAAAkbHVtaQAABBwAAAAUbWVhcwAABDAAAAAkdGVjaAAABFQAAAAMclRSQwAABGAAAAgMZ1RSQwAABGAAAAgMYlRSQwAABGAAAAgMdGV4dAAAAABDb3B5cmlnaHQgKGMpIDE5OTggSGV3bGV0dC1QYWNrYXJkIENvbXBhbnkAAGRlc2MAAAAAAAAAEnNSR0IgSUVDNjE5NjYtMi4xAAAAAAAAAAASAHMAUgBHAEIAIABJAEUAQwA2ADEAOQA2ADYALQAyAC4AMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAAAAAAAAAAAAAABYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9kZXNjAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGRlc2MAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAACxSZWZlcmVuY2UgVmlld2luZyBDb25kaXRpb24gaW4gSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2aWV3AAAAAAATpP4AFF8uABDPFAAD7cwABBMLAANcngAAAAFYWVogAAAAAABMCVYAUAAAAFcf521lYXMAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAKPAAAAAnNpZyAAAAAAQ1JUIGN1cnYAAAAAAAAEAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4AYwBoAG0AcgB3AHwAgQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2wDgAOUA6wDwAPYA+wEBAQcBDQETARkBHwElASsBMgE4AT4BRQFMAVIBWQFgAWcBbgF1AXwBgwGLAZIBmgGhAakBsQG5AcEByQHRAdkB4QHpAfIB+gIDAgwCFAIdAiYCLwI4AkECSwJUAl0CZwJxAnoChAKOApgCogKsArYCwQLLAtUC4ALrAvUDAAMLAxYDIQMtAzgDQwNPA1oDZgNyA34DigOWA6IDrgO6A8cD0wPgA+wD+QQGBBMEIAQtBDsESARVBGMEcQR+BIwEmgSoBLYExATTBOEE8AT+BQ0FHAUrBToFSQVYBWcFdwWGBZYFpgW1BcUF1QXlBfYGBgYWBicGNwZIBlkGagZ7BowGnQavBsAG0QbjBvUHBwcZBysHPQdPB2EHdAeGB5kHrAe/B9IH5Qf4CAsIHwgyCEYIWghuCIIIlgiqCL4I0gjnCPsJEAklCToJTwlkCXkJjwmkCboJzwnlCfsKEQonCj0KVApqCoEKmAquCsUK3ArzCwsLIgs5C1ELaQuAC5gLsAvIC+EL+QwSDCoMQwxcDHUMjgynDMAM2QzzDQ0NJg1ADVoNdA2ODakNww3eDfgOEw4uDkkOZA5/DpsOtg7SDu4PCQ8lD0EPXg96D5YPsw/PD+wQCRAmEEMQYRB+EJsQuRDXEPURExExEU8RbRGMEaoRyRHoEgcSJhJFEmQShBKjEsMS4xMDEyMTQxNjE4MTpBPFE+UUBhQnFEkUahSLFK0UzhTwFRIVNBVWFXgVmxW9FeAWAxYmFkkWbBaPFrIW1hb6Fx0XQRdlF4kXrhfSF/cYGxhAGGUYihivGNUY+hkgGUUZaxmRGbcZ3RoEGioaURp3Gp4axRrsGxQbOxtjG4obshvaHAIcKhxSHHscoxzMHPUdHh1HHXAdmR3DHeweFh5AHmoelB6+HukfEx8+H2kflB+/H+ogFSBBIGwgmCDEIPAhHCFIIXUhoSHOIfsiJyJVIoIiryLdIwojOCNmI5QjwiPwJB8kTSR8JKsk2iUJJTglaCWXJccl9yYnJlcmhya3JugnGCdJJ3onqyfcKA0oPyhxKKIo1CkGKTgpaymdKdAqAio1KmgqmyrPKwIrNitpK50r0SwFLDksbiyiLNctDC1BLXYtqy3hLhYuTC6CLrcu7i8kL1ovkS/HL/4wNTBsMKQw2zESMUoxgjG6MfIyKjJjMpsy1DMNM0YzfzO4M/E0KzRlNJ402DUTNU01hzXCNf02NzZyNq426TckN2A3nDfXOBQ4UDiMOMg5BTlCOX85vDn5OjY6dDqyOu87LTtrO6o76DwnPGU8pDzjPSI9YT2hPeA+ID5gPqA+4D8hP2E/oj/iQCNAZECmQOdBKUFqQaxB7kIwQnJCtUL3QzpDfUPARANER0SKRM5FEkVVRZpF3kYiRmdGq0bwRzVHe0fASAVIS0iRSNdJHUljSalJ8Eo3Sn1KxEsMS1NLmkviTCpMcky6TQJNSk2TTdxOJU5uTrdPAE9JT5NP3VAnUHFQu1EGUVBRm1HmUjFSfFLHUxNTX1OqU/ZUQlSPVNtVKFV1VcJWD1ZcVqlW91dEV5JX4FgvWH1Yy1kaWWlZuFoHWlZaplr1W0VblVvlXDVchlzWXSddeF3JXhpebF69Xw9fYV+zYAVgV2CqYPxhT2GiYfViSWKcYvBjQ2OXY+tkQGSUZOllPWWSZedmPWaSZuhnPWeTZ+loP2iWaOxpQ2maafFqSGqfavdrT2una/9sV2yvbQhtYG25bhJua27Ebx5veG/RcCtwhnDgcTpxlXHwcktypnMBc11zuHQUdHB0zHUodYV14XY+dpt2+HdWd7N4EXhueMx5KnmJeed6RnqlewR7Y3vCfCF8gXzhfUF9oX4BfmJ+wn8jf4R/5YBHgKiBCoFrgc2CMIKSgvSDV4O6hB2EgITjhUeFq4YOhnKG14c7h5+IBIhpiM6JM4mZif6KZIrKizCLlov8jGOMyo0xjZiN/45mjs6PNo+ekAaQbpDWkT+RqJIRknqS45NNk7aUIJSKlPSVX5XJljSWn5cKl3WX4JhMmLiZJJmQmfyaaJrVm0Kbr5wcnImc951kndKeQJ6unx2fi5/6oGmg2KFHobaiJqKWowajdqPmpFakx6U4pammGqaLpv2nbqfgqFKoxKk3qamqHKqPqwKrdavprFys0K1ErbiuLa6hrxavi7AAsHWw6rFgsdayS7LCszizrrQltJy1E7WKtgG2ebbwt2i34LhZuNG5SrnCuju6tbsuu6e8IbybvRW9j74KvoS+/796v/XAcMDswWfB48JfwtvDWMPUxFHEzsVLxcjGRsbDx0HHv8g9yLzJOsm5yjjKt8s2y7bMNcy1zTXNtc42zrbPN8+40DnQutE80b7SP9LB00TTxtRJ1MvVTtXR1lXW2Ndc1+DYZNjo2WzZ8dp22vvbgNwF3IrdEN2W3hzeot8p36/gNuC94UThzOJT4tvjY+Pr5HPk/OWE5g3mlucf56noMui86Ubp0Opb6uXrcOv77IbtEe2c7ijutO9A78zwWPDl8XLx//KM8xnzp/Q09ML1UPXe9m32+/eK+Bn4qPk4+cf6V/rn+3f8B/yY/Sn9uv5L/tz/bf//"
id="color-profile1" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath21">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-673.87432,-704.25842)"
id="path21" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath25">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path25" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath27">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-695.28002,-473.92741)"
id="path27" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath29">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-457.93881,-632.99062)"
id="path29" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath31">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path31" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath33">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-614.51722,-638.93302)"
id="path33" />
</clipPath>
</defs>
<g
id="layer-MC0"
transform="translate(-486.31024,-515.02722)">
<path
id="path20"
d="M 0,0 C -1.568,1.568 -3.163,3.098 -4.787,4.61 -5.944,3.966 -7.185,3.35 -8.529,2.762 -9.658,2.277 -10.815,1.82 -11.981,1.4 c 1.885,-1.689 3.742,-3.425 5.552,-5.207 0.448,-0.429 0.886,-0.868 1.325,-1.306 2.221,-2.221 4.386,-4.498 6.476,-6.84 27.639,-30.784 44.453,-71.459 44.453,-116.089 0,-29.347 -7.259,-56.977 -20.09,-81.21 -2.192,-4.134 -4.544,-8.174 -7.054,-12.102 -6.83,-10.74 -14.827,-20.669 -23.785,-29.636 -5.944,-5.944 -12.317,-11.459 -19.073,-16.498 -0.56,-0.42 -1.12,-0.83 -1.689,-1.231 -28.675,-20.893 -63.975,-33.201 -102.186,-33.201 -48.018,0 -91.464,19.456 -122.948,50.93 -0.737,0.737 -1.465,1.474 -2.174,2.221 -0.55,0.569 -1.101,1.147 -1.633,1.726 -15.545,16.553 -27.881,36.14 -36.018,57.779 -3.098,8.211 -5.58,16.712 -7.409,25.464 -2.417,11.534 -3.686,23.496 -3.686,35.758 0.01,42.326 15.117,81.097 40.246,111.246 -2.072,1.278 -3.975,2.809 -5.534,4.637 -26.174,-31.399 -41.934,-71.822 -41.934,-115.883 0,-18.187 2.678,-35.748 7.67,-52.311 3.359,-11.142 7.754,-21.835 13.092,-31.95 8.528,-16.208 19.446,-30.961 32.276,-43.801 1.251,-1.25 2.529,-2.491 3.817,-3.685 0.662,-0.644 1.334,-1.26 2.006,-1.876 10.862,-9.938 22.945,-18.578 36,-25.661 25.632,-13.913 55.016,-21.816 86.229,-21.816 2.454,0 4.908,0.047 7.334,0.14 36.056,1.446 69.424,13.427 97.054,32.976 0.569,0.392 1.148,0.793 1.698,1.204 7.82,5.655 15.164,11.925 21.966,18.718 7.904,7.904 15.07,16.526 21.406,25.773 2.556,3.723 4.973,7.558 7.25,11.477 15.499,26.697 24.382,57.723 24.382,90.812 C 53.038,-78.055 32.762,-32.762 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,898.49907,660.9888)"
clip-path="url(#clipPath21)" />
<path
id="path24"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath25)" />
<path
id="path26"
d="m 0,0 c -6.336,-9.247 -13.502,-17.869 -21.406,-25.773 -6.802,-6.793 -14.146,-13.063 -21.965,-18.718 -0.551,-0.411 -1.129,-0.812 -1.699,-1.204 -27.629,-19.549 -60.998,-31.53 -97.053,-32.976 -33.247,1.129 -64.852,8.762 -93.564,21.676 -13.054,7.082 -25.138,15.723 -36,25.661 -0.672,0.616 -1.343,1.232 -2.006,1.876 -1.288,1.194 -2.566,2.435 -3.816,3.685 -12.831,12.84 -23.748,27.593 -32.277,43.801 -1.092,7.651 -1.941,15.378 -2.445,23.039 -0.102,1.502 -0.186,3.004 -0.261,4.497 0,0 -2.865,29.795 23.944,36.634 26.827,6.84 65.654,19.745 87.722,50.305 0,0 0.327,-8.38 5.506,-15.779 8.034,-11.422 46.674,-100.46 46.674,-100.46 l 6.121,51.135 -13.978,15.079 -4.553,-1.987 15.228,16.544 c 0,0 8.94,4.218 16.554,-0.653 7.605,-4.899 14.146,-16.153 14.146,-16.153 l -5.879,3.892 -4.227,-12.364 c -0.756,-2.184 -0.83,-4.535 -0.233,-6.784 l 16.796,-62.687 c 0,0 -0.243,87.415 -2.781,111.685 0,0 14.221,-10.367 21.621,-14.454 7.381,-4.078 47.215,-20.407 53.738,-22.395 6.504,-1.987 13.222,-5.841 15.882,-11.916 1.026,-2.351 8.594,-26.939 17.486,-56.229 C -1.829,6.028 -0.914,3.023 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,927.04,968.0968)"
clip-path="url(#clipPath27)" />
<path
id="path28"
d="m 0,0 c 0,0 -3.081,-67.22 -8.64,-90.616 -5.559,-23.397 30.316,0 30.316,0 l 20.9,68.629 z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,610.58507,756.01253)"
clip-path="url(#clipPath29)" />
<path
id="path30"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath31)" />
<path
id="path32"
d="m 0,0 c 0,0 12.178,7.604 15.029,18.355 0,0 36.913,-9.243 52.904,-26.719 C 66.887,-8.091 29.642,2.031 0,0 m -36.152,-5.643 c -26.791,0.404 -31.781,16.05 -34.086,29.523 -0.511,2.958 -3.148,5.073 -6.13,4.883 l -46.275,-4.764 14.922,7.14 c 3.505,1.687 7.164,3.077 10.895,4.17 3.742,1.093 7.556,1.901 11.429,2.376 4.051,0.499 8.542,1.01 13.46,1.509 0,0 6.19,2.459 7.414,-4.479 1.259,-6.926 0.499,-31.864 28.287,-31.103 27.777,0.76 32.612,19.412 32.612,19.412 0,0 3.458,14.471 9.232,14.257 5.263,-0.202 7.27,-6.142 7.853,-10.479 0.142,-1.045 -0.06,-3.54 0.582,-4.324 -0.939,1.152 -2.162,2.032 -3.564,2.554 C 7.604,26.113 3.065,26.945 2.471,21.658 1.616,13.877 -9.599,-6.059 -36.152,-5.643 m 13.306,48.354 9.528,-2.377 c 0,0 -2.388,-17.5 -11.227,-21.979 -0.25,-0.13 4.194,14.281 1.699,24.356 m 59.022,3.041 3.79,-1.687 c 0,0 2.198,-14.209 -3.79,-20.577 0,0 2.174,12.474 0,22.264 m -187.451,35.63 c 12.225,2.067 24.45,4.229 36.664,6.332 l 36.663,6.392 73.303,12.748 c 2.958,0.522 5.774,-1.462 6.285,-4.408 0.522,-2.947 -1.462,-5.762 -4.42,-6.285 -0.119,-0.024 -0.261,-0.036 -0.38,-0.047 H -3.184 L -114.243,85.029 c -12.344,-1.224 -24.676,-2.495 -37.032,-3.647 M 43.97,16.93 c 3.54,4.693 5.215,23.096 5.215,23.096 l 1.497,2.518 v 8.744 C 29.381,61.327 11.869,50.682 11.869,50.682 l -16.491,0.749 c -3.112,0.142 -7.449,2.637 -10.644,3.433 -4.42,1.093 -8.804,2.068 -13.342,2.566 -16.407,1.735 -32.933,0 -49.091,-2.934 -7.021,-1.271 -13.971,-2.851 -20.957,-4.325 -6.701,-1.425 -12.878,-3.908 -19.151,-6.605 -4.313,-1.842 -8.649,-3.659 -12.641,-6.119 -2.436,-1.496 -4.741,-3.243 -6.737,-5.31 -2.126,-2.21 -3.659,-4.859 -5.476,-7.319 -1.545,-2.091 -4.907,-0.463 -4.147,2.032 0.024,0.059 0.048,0.119 0.06,0.154 0.867,2.044 2.221,4.646 3.659,6.345 3.588,4.241 8.958,8.292 13.686,11.12 10.039,6.071 21.48,9.766 32.767,12.985 24.771,7.057 51.442,8.138 77.009,10.55 14.114,1.331 28.43,2.091 42.473,4.016 12.784,1.734 37.935,4.859 36.176,22.882 -1.71,18.129 -59.355,18.712 -59.355,18.712 0,0 -21.943,45.027 -27.372,50.468 -5.418,5.418 -16.503,18.474 -74.254,4.384 -57.739,-14.078 -55.327,-42.259 -55.327,-42.259 l -2.412,-39.622 c 0,0 -43.198,-11.179 -41.832,-23.321 1.391,-12.142 39.171,-12.819 39.171,-12.819 0,0 8.577,0.439 20.696,1.152 l -5.382,-9.683 c -6.749,-12.13 -6.38,-25.258 -8.851,-38.908 -4.277,-23.631 11.963,-52.702 33.978,-62.147 21.1,-9.053 53.949,-13.782 99.012,6.368 11.085,4.954 20.328,13.282 26.66,23.618 l 0.499,0.796 c 22.728,26.125 80.574,9.386 80.574,9.386 C 81.774,1.699 43.97,16.93 43.97,16.93"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,819.35627,748.08933)"
clip-path="url(#clipPath33)" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

+1 -1
View File
@@ -3,7 +3,7 @@ Type=Application
Name=DMS
Comment=Select an application to open links and files
Exec=dms open %u
Icon=dev.duxlabs.shell
Icon=danklogo
Terminal=false
NoDisplay=true
MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/dms;text/html;application/xhtml+xml;
+4 -9
View File
@@ -1,21 +1,16 @@
[Unit]
Description=Dux Shell (DMS)
Description=Dank Material Shell (DMS)
PartOf=graphical-session.target
After=graphical-session.target
Requisite=graphical-session.target
[Service]
Type=notify
NotifyAccess=main
Environment=QS_DISABLE_CRASH_HANDLER=1
Type=dbus
BusName=org.freedesktop.Notifications
ExecStart=/usr/bin/dms run --session
ExecReload=/bin/kill -USR1 $MAINPID
LimitNOFILE=16384:infinity
ExecReload=/usr/bin/pkill -USR1 -x dms
Restart=on-failure
RestartForceExitStatus=TEMPFAIL
SuccessExitStatus=TEMPFAIL
RestartSec=1.23
TimeoutStartSec=90s
TimeoutStopSec=10
[Install]
+6 -5
View File
@@ -72,6 +72,7 @@ linters:
- path: _test\.go
linters:
- errcheck
- govet
- unused
- ineffassign
- staticcheck
@@ -92,15 +93,15 @@ linters:
- linters:
- errcheck
text: "Error return value of `.+\\.AddMatchSignal` is not checked"
# Exclude wayland pkg from errcheck and ineffassign (generated code patterns)
- linters:
- errcheck
- ineffassign
path: pkg/go-wayland/
# Exclude proto pkg from ineffassign (generated protocol code)
- linters:
- ineffassign
path: internal/proto/
# Exclude proto pkg SA4006 (generated protocol code has trailing dead offset increments)
- linters:
- staticcheck
text: "SA4006:"
path: internal/proto/
# binary.Write/Read to bytes.Buffer can't fail
- linters:
- errcheck
+10 -10
View File
@@ -6,13 +6,13 @@ template: testify
template-data:
unroll-variadic: true
packages:
github.com/duxlabs-dev/dux-shell/core/internal/geolocation:
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config:
dir: internal/mocks/geolocation
pkgname: mocks_geolocation
interfaces:
Client: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/cups:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
config:
dir: internal/mocks/cups
pkgname: mocks_cups
@@ -22,46 +22,46 @@ packages:
config:
dir: internal/mocks/cups_pkhelper
pkgname: mocks_cups_pkhelper
github.com/duxlabs-dev/dux-shell/core/internal/server/evdev:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
config:
dir: internal/mocks/evdev
pkgname: mocks_evdev
interfaces:
EvdevDevice: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/network:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config:
dir: internal/mocks/network
pkgname: mocks_network
interfaces:
Backend: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/wlcontext:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
config:
dir: internal/mocks/wlcontext
pkgname: mocks_wlcontext
interfaces:
WaylandContext: {}
github.com/duxlabs-dev/dux-shell/core/internal/utils:
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config:
dir: internal/mocks/utils
pkgname: mocks_utils
interfaces:
AppChecker: {}
github.com/duxlabs-dev/dux-shell/core/internal/version:
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
config:
dir: internal/mocks/version
pkgname: mocks_version
interfaces:
VersionFetcher: {}
github.com/duxlabs-dev/dux-go/wayland/client:
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
config:
dir: internal/mocks/wlclient
pkgname: mocks_wlclient
interfaces:
WaylandDisplay: {}
github.com/duxlabs-dev/dux-shell/core/internal/plugins:
github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
interfaces:
GitClient: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/brightness:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
config:
dir: internal/mocks/brightness
pkgname: mocks_brightness
+4 -17
View File
@@ -1,26 +1,13 @@
repos:
- repo: local
- repo: https://github.com/golangci/golangci-lint
rev: v2.12.2
hooks:
- id: golangci-lint-fmt
name: golangci-lint-fmt
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] fmt
language: system
types: [go]
require_serial: true
pass_filenames: false
- id: golangci-lint-full
name: golangci-lint-full
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --fix
language: system
types: [go]
require_serial: true
pass_filenames: false
- id: golangci-lint-config-verify
name: golangci-lint-config-verify
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] config verify
language: system
files: '\.golangci\.(?:yml|yaml|toml|json)'
pass_filenames: false
- repo: local
hooks:
- id: go-test
name: go test
entry: go test ./...
+27 -45
View File
@@ -3,8 +3,6 @@ BINARY_NAME_INSTALL=dankinstall
SOURCE_DIR=cmd/dms
SOURCE_DIR_INSTALL=cmd/dankinstall
BUILD_DIR=bin
SHELL_SRC=../quickshell
EMBED_DIR=internal/shellembed/dist
PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin
@@ -23,39 +21,17 @@ BUILD_LDFLAGS=-ldflags='-s -w -X main.Version=$(VERSION) -X main.buildTime=$(BUI
# Architecture to build for dist target (amd64, arm64, or all)
ARCH ?= all
# Target OSes for dist builds
DIST_OSES ?= linux freebsd
ifeq ($(ARCH),all)
DIST_ARCHS = amd64 arm64
else
DIST_ARCHS = $(ARCH)
endif
.PHONY: all build sync-shell dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help
.PHONY: all build dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help
# Default target
all: build
sync-shell:
@test -e $(SHELL_SRC)/DCommon/Widgets/DIcon.qml || { echo "DCommon missing: run git submodule update --init"; exit 1; }
@rm -rf $(EMBED_DIR)
@mkdir -p $(EMBED_DIR)
@tar -C $(SHELL_SRC) --exclude=.qmlls.ini --exclude-from=../scripts/shell-test-excludes.txt -chf - . | tar -C $(EMBED_DIR) -xf -
@rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github
@rm -rf $(EMBED_DIR)/tests
@find $(EMBED_DIR) -type d \( -name .claude -o -name .vscode \) -prune -exec rm -rf {} +
@rm -f $(EMBED_DIR)/AGENTS.md $(EMBED_DIR)/qmlformat-all.sh
@rm -f $(EMBED_DIR)/scripts/i18nsync.py $(EMBED_DIR)/scripts/build-vscode-vsix.sh $(EMBED_DIR)/scripts/qmllint-entrypoints.sh
@rm -f $(EMBED_DIR)/scripts/spam-notifications.sh $(EMBED_DIR)/scripts/verify-notifications.sh
@rm -f $(EMBED_DIR)/translations/*.py $(EMBED_DIR)/translations/WORKFLOW.md
@cd $(EMBED_DIR) && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | cut -c1-16 > .dankrev
# Build the main binary (dms)
build: sync-shell
build:
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build -tags withshell $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR)
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
dankinstall:
@@ -64,18 +40,26 @@ dankinstall:
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)"
# Build distro binaries (no update/greeter support) for each DIST_OSES/DIST_ARCHS pair
dist: sync-shell
@echo "Building $(BINARY_NAME) for distribution ($(DIST_OSES) x $(DIST_ARCHS))..."
# Build distro binaries for amd64 and arm64 (Linux only, no update/greeter support)
dist:
ifeq ($(ARCH),all)
@echo "Building $(BINARY_NAME) for distribution (amd64 and arm64)..."
@mkdir -p $(BUILD_DIR)
@for os in $(DIST_OSES); do \
for arch in $(DIST_ARCHS); do \
echo "Building for $$os/$$arch..."; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build -tags 'distro_binary withshell' $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-$$os-$$arch ./$(SOURCE_DIR) || exit 1; \
echo " $(BUILD_DIR)/$(BINARY_NAME)-$$os-$$arch"; \
done; \
done
@echo "Distribution builds complete"
@echo "Building for linux/amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(SOURCE_DIR)
@echo "Building for linux/arm64..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(SOURCE_DIR)
@echo "Distribution builds complete:"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
else
@echo "Building $(BINARY_NAME) for distribution ($(ARCH))..."
@mkdir -p $(BUILD_DIR)
@echo "Building for linux/$(ARCH)..."
CGO_ENABLED=0 GOOS=linux GOARCH=$(ARCH) $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH) ./$(SOURCE_DIR)
@echo "Distribution build complete:"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH)"
endif
build-all: build dankinstall
@@ -115,7 +99,7 @@ uninstall-dankinstall:
clean:
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR) $(EMBED_DIR)
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
test:
@@ -128,8 +112,7 @@ fmt:
vet:
@echo "Running go vet..."
$(GO) vet $$($(GO) list ./... | grep -v internal/highlight/lexers)
$(GO) vet -composites=false ./internal/highlight/lexers
$(GO) vet ./...
deps:
@echo "Updating dependencies..."
@@ -158,11 +141,10 @@ print-version:
help:
@echo "Available targets:"
@echo " all - Build the main binary (dms) (default)"
@echo " build - Build the main binary (dms) with the embedded UI"
@echo " sync-shell - Copy quickshell/ into the embed dir (runs before tagged builds)"
@echo " build - Build the main binary (dms)"
@echo " dankinstall - Build dankinstall binary"
@echo " dist - Build dms for linux/freebsd amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 and/or DIST_OSES=linux to narrow"
@echo " dist - Build dms for linux amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 to build only one"
@echo " build-all - Build both binaries"
@echo " install - Install dms to $(INSTALL_DIR)"
@echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)"
+16 -26
View File
@@ -1,6 +1,6 @@
# DMS Backend & CLI
Go-based backend for Dux Shell providing system integration, IPC, and installation tools.
Go-based backend for DankMaterialShell providing system integration, IPC, and installation tools.
**See [root README](../README.md) for project overview and installation.**
@@ -74,11 +74,11 @@ Custom IPC via unix socket (JSON API) for shell communication.
- `dms run [-d]` - Start shell (optionally as daemon)
- `dms restart` / `dms kill` - Manage running processes
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles
- `dms plugins [install|browse|search]` - Plugin management
- `dms brightness [list|set]` - Control display/monitor brightness
- `dms color pick` - Native color picker (see below)
- `dms update` - Update DMS and dependencies (disabled in distro packages)
- `dms greeter` - Deprecated; forwards to the standalone [dms-greeter](https://github.com/duxlabs-dev/dux-greeter) binary
- `dms greeter install` - Install greetd greeter (disabled in distro packages)
### Color Picker
@@ -109,7 +109,7 @@ make test # Run tests
**Distribution build:**
```bash
make dist # Build without update features
make dist # Build without update/greeter features
```
Produces `bin/dms-linux-amd64` and `bin/dms-linux-arm64`
@@ -132,11 +132,9 @@ This runs gofmt, golangci-lint, tests, and builds before each commit when `core/
**Regenerating Wayland Protocol Bindings:**
Protocol XML files live in `internal/proto/xml/`. Bindings are generated with the [dankgo](https://github.com/duxlabs-dev/dux-go) `go-wayland-scanner`:
```bash
go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \
-i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
go install github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest
go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
-pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go
```
@@ -152,39 +150,31 @@ go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \
**Interactive (TUI):**
```bash
curl -fsSL https://install.duxlabs.dev | sh
curl -fsSL https://install.danklinux.com | sh
```
**Headless (unattended):**
Headless mode requires cached credentials or a passwordless rule for your privilege escalation tool (sudo, doas, or run0). With sudo, run `sudo -v` first:
Headless mode requires cached sudo credentials. Run `sudo -v` first:
```bash
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c niri -t ghostty -y
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c hyprland -t kitty --dms-greeter -y
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c niri -t ghostty --git-deps niri,quickshell --all-features -y
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y
```
| Flag | Short | Description |
|------|-------|-------------|
| `--compositor <niri\|hyprland\|mango>` | `-c` | Compositor/WM to install (required for headless) |
| `--term <ghostty\|kitty\|alacritty>` | `-t` | Terminal emulator (required for headless) |
| `--privesc <sudo\|doas\|run0>` | | Privilege escalation tool (default: autodetect) |
| `--git` | | Install the git version of every dependency that has one |
| `--git-deps <name,...>` | | Install the git version of specific dependencies (e.g. `niri,quickshell`) |
| `--all-features` | | Enable all optional dependencies (`dms-greeter`, `danksearch`, `dankcalendar`) |
| `--dms-greeter` | | Install dms-greeter |
| `--danksearch` | | Install danksearch and enable its user indexing service |
| `--dankcalendar` | | Install dankcalendar |
| `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
| `--exclude-deps <name,...>` | | Skip specific dependencies |
| `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) |
| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) |
| `--yes` | `-y` | Required for headless mode - confirms installation without interactive prompts |
| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts |
Headless mode requires `--yes` to proceed; without it, the installer exits with an error.
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
Optional packages (`dms-greeter`, `danksearch`, `dankcalendar`) are disabled by default; enable them with their dedicated flags, `--include-deps`, or `--all-features`.
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
When no flags are provided, `dankinstall` launches the interactive TUI.
@@ -193,7 +183,7 @@ When no flags are provided, `dankinstall` launches the interactive TUI.
Headless mode activates when `--compositor` or `--term` is provided.
- Both `--compositor` and `--term` are required; providing only one results in an error.
- Headless-only flags (`--privesc`, `--git`, `--git-deps`, `--all-features`, `--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`, `--danksearch`, `--dankcalendar`, `--dms-greeter`) are rejected in TUI mode.
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
- Positional arguments are not accepted.
### Log file location
@@ -225,6 +215,6 @@ Most packages available in standard repos. Minimal building required.
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
**Void Linux**
Uses XBPS with the DMS and Dux Labs self-hosted repositories.
Uses XBPS with the DMS and DankLinux self-hosted repositories.
See installer output for distribution-specific details during installation.
+23 -30
View File
@@ -5,22 +5,19 @@ import (
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
tea "github.com/charmbracelet/bubbletea"
"github.com/duxlabs-dev/dux-shell/core/internal/headless"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/tui"
"github.com/spf13/cobra"
)
var Version = "dev"
// Flag variables bound via pflag
var (
compositor string
term string
privescTool string
gitAll bool
gitDeps []string
allFeatures bool
includeDeps []string
excludeDeps []string
replaceConfigs []string
@@ -28,19 +25,18 @@ var (
yes bool
danksearch bool
dankcalendar bool
dmsGreeter bool
)
var rootCmd = &cobra.Command{
Use: "dankinstall",
Short: "Install Dux Shell and its dependencies",
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal.
Short: "Install DankMaterialShell and its dependencies",
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags.
Headless mode requires cached credentials or a passwordless rule for your
privilege escalation tool (sudo, doas, or run0).`,
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
configure passwordless sudo for your user.`,
Args: cobra.NoArgs,
RunE: runDankinstall,
SilenceErrors: true,
@@ -50,10 +46,6 @@ privilege escalation tool (sudo, doas, or run0).`,
func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
rootCmd.Flags().StringVar(&privescTool, "privesc", "", "Privilege escalation tool: sudo, doas, or run0 (default: autodetect)")
rootCmd.Flags().BoolVar(&gitAll, "git", false, "Install the git version of every dep that has one")
rootCmd.Flags().StringSliceVar(&gitDeps, "git-deps", []string{}, "Deps to install the git version of (e.g. niri,quickshell)")
rootCmd.Flags().BoolVar(&allFeatures, "all-features", false, "Enable all optional deps (dms-greeter, danksearch, dankcalendar)")
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
@@ -61,7 +53,6 @@ func init() {
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")
rootCmd.Flags().BoolVar(&dmsGreeter, "dms-greeter", false, "Install dms-greeter")
}
func main() {
@@ -80,11 +71,8 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
headlessMode := compositor != "" || term != ""
if !headlessMode {
// Reject headless-only flags when running in TUI mode.
headlessOnly := []string{
"privesc",
"git",
"git-deps",
"all-features",
"include-deps",
"exclude-deps",
"replace-configs",
@@ -92,7 +80,6 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
"yes",
"danksearch",
"dankcalendar",
"dms-greeter",
}
var set []string
for _, name := range headlessOnly {
@@ -112,6 +99,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
}
func runHeadless() error {
// Validate required flags
if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
}
@@ -122,10 +110,6 @@ func runHeadless() error {
cfg := headless.Config{
Compositor: compositor,
Terminal: term,
PrivescTool: privescTool,
GitAll: gitAll,
GitDeps: gitDeps,
AllFeatures: allFeatures,
IncludeDeps: includeDeps,
ExcludeDeps: excludeDeps,
ReplaceConfigs: replaceConfigs,
@@ -133,11 +117,11 @@ func runHeadless() error {
Yes: yes,
DankSearch: danksearch,
DankCalendar: dankcalendar,
DmsGreeter: dmsGreeter,
}
runner := headless.NewRunner(cfg)
// Set up file logging
fileLogger, err := log.NewFileLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
@@ -152,6 +136,10 @@ func runHeadless() error {
}
}()
} else {
// Drain the log channel to prevent blocking sends from deadlocking
// downstream components (distros, config deployer) that write to it.
// Use an explicit stop signal because this code does not own the
// runner log channel and cannot assume it will be closed.
defer drainLogChan(runner.GetLogChan())()
}
@@ -191,6 +179,10 @@ func runTUI() error {
if fileLogger != nil {
fileLogger.StartListening(model.GetLogChan())
} else {
// Drain the log channel to prevent blocking sends from deadlocking
// downstream components (distros, config deployer) that write to it.
// Use an explicit stop signal because this code does not own the
// model log channel and cannot assume it will be closed.
defer drainLogChan(model.GetLogChan())()
}
@@ -208,9 +200,10 @@ func runTUI() error {
return nil
}
// drainLogChan discards messages from logCh so blocking sends in downstream
// components cannot deadlock when no file logger is attached; the returned
// cleanup stops the drain without assuming the channel will ever close.
// drainLogChan starts a goroutine that discards all messages from logCh,
// preventing blocking sends from deadlocking downstream components. It returns
// a cleanup function that signals the goroutine to stop and waits for it to
// exit. Callers should defer the returned function.
func drainLogChan(logCh <-chan string) func() {
drainStop := make(chan struct{})
drainDone := make(chan struct{})
+3 -32
View File
@@ -4,19 +4,18 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
sharedpam "github.com/duxlabs-dev/dux-shell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra"
)
var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage DMS authentication sync",
Long: "Manage PAM/authentication setup for the DMS lock screen",
Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen",
}
var authSyncCmd = &cobra.Command{
@@ -212,31 +211,3 @@ func syncAuthInTerminal(nonInteractive bool) error {
shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3`
return runCommandInTerminal(shellCmd)
}
func runCommandInTerminal(shellCmd string) error {
terminals := []struct {
name string
args []string
}{
{"gnome-terminal", []string{"--", "bash", "-c", shellCmd}},
{"konsole", []string{"-e", "bash", "-c", shellCmd}},
{"xfce4-terminal", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}},
{"ghostty", []string{"-e", "bash", "-c", shellCmd}},
{"wezterm", []string{"start", "--", "bash", "-c", shellCmd}},
{"alacritty", []string{"-e", "bash", "-c", shellCmd}},
{"kitty", []string{"bash", "-c", shellCmd}},
{"xterm", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}},
}
for _, t := range terminals {
if _, err := exec.LookPath(t.name); err != nil {
continue
}
cmd := exec.Command(t.name, t.args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return nil
}
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
}
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/duxlabs-dev/dux-shell/core/internal/backup"
"github.com/spf13/cobra"
)
var backupOutputPath string
var backupCmd = &cobra.Command{
Use: "backup",
Short: "Backup and restore DMS configuration",
}
var backupCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a tar.gz backup of ~/.config/dms (settings, plugins, themes)",
Args: cobra.NoArgs,
Run: runBackupCreate,
}
var backupRestoreCmd = &cobra.Command{
Use: "restore <archive>",
Short: "Restore a DMS configuration backup; the current configuration is moved aside first",
Args: cobra.ExactArgs(1),
Run: runBackupRestore,
}
func init() {
backupCreateCmd.Flags().StringVarP(&backupOutputPath, "output", "o", "", "Output archive path (default dms-backup-<timestamp>.tar.gz)")
backupCmd.AddCommand(backupCreateCmd, backupRestoreCmd)
rootCmd.AddCommand(backupCmd)
}
func runBackupCreate(cmd *cobra.Command, args []string) {
output := backupOutputPath
if output == "" {
output = backup.DefaultArchiveName()
}
if err := backup.Create(output); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(output)
}
func runBackupRestore(cmd *cobra.Command, args []string) {
previous, err := backup.Restore(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if previous != "" {
fmt.Printf("Previous configuration moved to %s\n", previous)
}
fmt.Println("Restore complete. Restart the shell to apply.")
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"os"
"github.com/duxlabs-dev/dux-shell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/spf13/cobra"
)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/spf13/cobra"
)
+10 -8
View File
@@ -10,11 +10,11 @@ import (
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight/lexers"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight/styles"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
ghtml "github.com/yuin/goldmark/renderer/html"
@@ -116,7 +116,7 @@ func getCachedStyle(name string) *chroma.Style {
style := styles.Get(name)
if style == nil {
fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name)
style = styles.Fallback()
style = styles.Fallback
}
cacheMutex.Lock()
@@ -209,10 +209,12 @@ func runChroma(cmd *cobra.Command, args []string) {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
highlight.Markdown{
Style: styles.Get(chromaStyle),
Formatter: html.New(html.WithClasses(!chromaInline)),
},
highlighting.NewHighlighting(
highlighting.WithStyle(chromaStyle),
highlighting.WithFormatOptions(
html.WithClasses(!chromaInline),
),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
+28 -35
View File
@@ -28,10 +28,9 @@ import (
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-go/wlclipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra"
)
@@ -284,8 +283,8 @@ func runClipCopy(cmd *cobra.Command, args []string) {
}
}
func parseMultiOffers(data []byte) ([]wlclipboard.Offer, error) {
var offers []wlclipboard.Offer
func parseMultiOffers(data []byte) ([]clipboard.Offer, error) {
var offers []clipboard.Offer
pos := 0
for pos < len(data) {
@@ -312,14 +311,14 @@ func parseMultiOffers(data []byte) ([]wlclipboard.Offer, error) {
offerData := data[pos : pos+dataLen]
pos += dataLen
offers = append(offers, wlclipboard.Offer{MimeType: mimeType, Data: offerData})
offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData})
}
return offers, nil
}
func runClipPaste(cmd *cobra.Command, args []string) {
data, _, err := wlclipboard.Paste()
data, _, err := clipboard.Paste()
if err != nil {
log.Fatalf("paste: %v", err)
}
@@ -345,13 +344,13 @@ func runClipWatch(cmd *cobra.Command, args []string) {
switch {
case len(args) > 0:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
runCommand(args, data)
}); err != nil && err != context.Canceled {
log.Fatalf("Watch error: %v", err)
}
case clipWatchStore:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Store(data, mimeType); err != nil {
log.Errorf("store: %v", err)
}
@@ -383,7 +382,7 @@ func runClipWatch(cmd *cobra.Command, args []string) {
log.Fatalf("Watch error: %v", err)
}
case clipJSONOutput:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
out := map[string]any{
"data": string(data),
"mimeType": mimeType,
@@ -396,7 +395,7 @@ func runClipWatch(cmd *cobra.Command, args []string) {
log.Fatalf("Watch error: %v", err)
}
default:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
os.Stdout.Write(data)
os.Stdout.WriteString("\n")
}); err != nil && err != context.Canceled {
@@ -430,7 +429,7 @@ func runCommand(args []string, stdin []byte) {
}
func runClipHistory(cmd *cobra.Command, args []string) {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.getHistory",
}
@@ -501,7 +500,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
}
if clipGetCopy {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.copyEntry",
Params: map[string]any{"id": id},
@@ -519,7 +518,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
return
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.getEntry",
Params: map[string]any{
@@ -565,7 +564,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
log.Fatalf("Invalid ID: %v", err)
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.deleteEntry",
Params: map[string]any{
@@ -586,7 +585,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
}
func runClipClear(cmd *cobra.Command, args []string) {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.clearHistory",
}
@@ -621,7 +620,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
params["isImage"] = false
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.search",
Params: params,
@@ -688,7 +687,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
}
func runClipConfigGet(cmd *cobra.Command, args []string) {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.getConfig",
}
@@ -742,7 +741,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
return
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.setConfig",
Params: params,
@@ -761,7 +760,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
}
func runClipExport(cmd *cobra.Command, args []string) {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.getHistory",
}
@@ -932,7 +931,7 @@ func downloadToTempFile(rawURL string) (string, error) {
var contentType string
var lastErr error
for attempt := range 3 {
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
@@ -1004,25 +1003,19 @@ func downloadToTempFile(rawURL string) (string, error) {
return filePath, nil
}
// copyFileToClipboard copies through the server so the entry lands in
// history, and serves the same offers itself when the server is down or
// refuses (e.g. file over the history size limit).
func copyFileToClipboard(filePath string) error {
if abs, err := filepath.Abs(filePath); err == nil {
filePath = abs
}
resp, ok := tryServerRequest(ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.copyFile",
Params: map[string]any{"filePath": filePath},
})
if ok && resp.Error == "" {
return nil
}
data, err := os.ReadFile(filePath)
resp, err := sendServerRequest(req)
if err != nil {
return fmt.Errorf("read file: %w", err)
return fmt.Errorf("server request: %w", err)
}
return clipboard.CopyMulti(clipboard.FileOffers(filePath, filePath, data), false, false)
if resp.Error != "" {
return fmt.Errorf("server error: %s", resp.Error)
}
return nil
}
+13 -23
View File
@@ -3,20 +3,17 @@ package main
import (
"fmt"
"os"
"runtime/debug"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/colorpicker"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker"
"github.com/spf13/cobra"
)
var (
colorOutputFmt string
colorAutocopy bool
colorNotify bool
colorLowercase bool
colorAllowMulti bool
colorOutputFmt string
colorAutocopy bool
colorNotify bool
colorLowercase bool
)
var colorCmd = &cobra.Command{
@@ -27,7 +24,7 @@ var colorCmd = &cobra.Command{
This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle
See: ` + site.Docs + "/dms/keybinds-ipc",
See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
}
var colorPickCmd = &cobra.Command{
@@ -56,8 +53,7 @@ Examples:
dms color pick --rgb # Output as RGB
dms color pick --json # Output all formats as JSON
dms color pick --hex -l # Output hex in lowercase
dms color pick -a # Auto-copy result to clipboard
dms color pick --allow-multiple # Skip the one-picker-at-a-time guard`,
dms color pick -a # Auto-copy result to clipboard`,
Run: runColorPick,
}
@@ -72,7 +68,6 @@ func init() {
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
colorPickCmd.Flags().BoolVar(&colorAllowMulti, "allow-multiple", false, "Open a picker even when another selection overlay is already open")
colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
@@ -94,18 +89,13 @@ func runColorPick(cmd *cobra.Command, args []string) {
}
config := colorpicker.Config{
Format: format,
CustomFormat: colorOutputFmt,
Lowercase: colorLowercase,
Autocopy: colorAutocopy,
Notify: colorNotify,
AllowMultiple: colorAllowMulti,
Format: format,
CustomFormat: colorOutputFmt,
Lowercase: colorLowercase,
Autocopy: colorAutocopy,
Notify: colorNotify,
}
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
debug.SetGCPercent(-1)
debug.SetMemoryLimit(1 << 30)
picker := colorpicker.New(config)
color, err := picker.Run()
if err != nil {
+94 -115
View File
@@ -6,12 +6,10 @@ import (
"regexp"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/plugins"
"github.com/duxlabs-dev/dux-shell/core/internal/server"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/spf13/cobra"
)
@@ -21,6 +19,63 @@ var versionCmd = &cobra.Command{
Run: runVersion,
}
var runCmd = &cobra.Command{
Use: "run",
Short: "Launch quickshell with DMS configuration",
Long: "Launch quickshell with DMS configuration (qs -c dms)",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon")
session, _ := cmd.Flags().GetBool("session")
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
}
}
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
}
}
log.ApplyEnvOverrides()
config.CleanupStrayHyprlandConfFile(log.Infof)
if daemon {
runShellDaemon(session)
} else {
runShellInteractive(session)
}
},
}
var restartCmd = &cobra.Command{
Use: "restart",
Short: "Restart quickshell with DMS configuration",
Long: "Kill existing DMS shell processes and restart quickshell with DMS configuration",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
restartShell()
},
}
var restartDetachedCmd = &cobra.Command{
Use: "restart-detached <pid>",
Hidden: true,
Args: cobra.ExactArgs(1),
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runDetachedRestart(args[0])
},
}
var killCmd = &cobra.Command{
Use: "kill",
Short: "Kill running DMS shell processes",
Long: "Kill all running quickshell processes with DMS configuration",
Run: func(cmd *cobra.Command, args []string) {
killShell()
},
}
var ipcCmd = &cobra.Command{
Use: "ipc",
Short: "Send IPC commands to running DMS shell",
@@ -29,7 +84,7 @@ var ipcCmd = &cobra.Command{
dms ipc call <target> <function> [args...] invoke a command
dms ipc list list all targets and functions
Full reference: ` + site.Docs + "/dms/keybinds-ipc",
Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
},
@@ -53,8 +108,6 @@ func init() {
})
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
pluginsLockCmd.Flags().StringP("output", "o", "", "Also write the lockfile to this path")
pluginsRestoreCmd.Flags().Bool("prune", false, "Remove managed plugins that are not in the lockfile")
}
var debugSrvCmd = &cobra.Command{
@@ -182,36 +235,6 @@ var pluginsUpdateCmd = &cobra.Command{
},
}
var pluginsLockCmd = &cobra.Command{
Use: "lock",
Short: "Record installed plugins and their exact revisions",
Long: "Write a portable plugins.lock.json containing every managed user plugin and its current Git commit.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
if err := lockPluginsCLI(output); err != nil {
log.Fatalf("Error writing plugin lockfile: %v", err)
}
},
}
var pluginsRestoreCmd = &cobra.Command{
Use: "restore [lockfile]",
Short: "Restore plugins from exact revisions in a lockfile",
Long: "Install or reset all plugins in a portable plugins.lock.json. Existing managed plugins are retained unless --prune is specified.",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
path := ""
if len(args) == 1 {
path = args[0]
}
prune, _ := cmd.Flags().GetBool("prune")
if err := restorePluginsCLI(path, prune); err != nil {
log.Fatalf("Error restoring plugins: %v", err)
}
},
}
func runVersion(cmd *cobra.Command, args []string) {
fmt.Printf("%s\n", formatVersion(Version))
}
@@ -219,17 +242,21 @@ func runVersion(cmd *cobra.Command, args []string) {
// Git builds: dms (git) v0.6.2-XXXX
// Stable releases: dms v0.6.2
func formatVersion(version string) string {
if n := sysupdate.GitBuildCount(version); n > 0 {
// Fedora COPR (0.0.git.2267.d430cae9) carries no real base version.
base, _, ok := strings.Cut(version, "+git")
if !ok {
base = getBaseVersion()
}
return fmt.Sprintf("dms (git) v%s-%d", strings.TrimPrefix(base, "v"), n)
// Arch/Debian/Ubuntu/OpenSUSE git format: 0.6.2+git2264.c5c5ce84
re := regexp.MustCompile(`^([\d.]+)\+git(\d+)\.`)
if matches := re.FindStringSubmatch(version); matches != nil {
return fmt.Sprintf("dms (git) v%s-%s", matches[1], matches[2])
}
// Fedora COPR git format: 0.0.git.2267.d430cae9
re = regexp.MustCompile(`^[\d.]+\.git\.(\d+)\.`)
if matches := re.FindStringSubmatch(version); matches != nil {
baseVersion := getBaseVersion()
return fmt.Sprintf("dms (git) v%s-%s", baseVersion, matches[1])
}
// Stable release format: 0.6.2
re := regexp.MustCompile(`^([\d.]+)$`)
re = regexp.MustCompile(`^([\d.]+)$`)
if matches := re.FindStringSubmatch(version); matches != nil {
return fmt.Sprintf("dms v%s", matches[1])
}
@@ -237,41 +264,29 @@ func formatVersion(version string) string {
return fmt.Sprintf("dms %s", version)
}
var baseVersionRe = regexp.MustCompile(`^([\d.]+)`)
// Installed UI trees, for builds without an embedded UI.
var shellVersionPaths = []string{
"/usr/share/quickshell/dms/VERSION",
"/usr/local/share/quickshell/dms/VERSION",
"/etc/xdg/quickshell/dms/VERSION",
}
func getBaseVersion() string {
if ver := parseBaseVersion(shellembed.Version()); ver != "" {
return ver
paths := []string{
"/usr/share/quickshell/dms/VERSION",
"/usr/local/share/quickshell/dms/VERSION",
"/etc/xdg/quickshell/dms/VERSION",
}
for _, path := range shellVersionPaths {
content, err := os.ReadFile(path)
if err != nil {
continue
}
if ver := parseBaseVersion(string(content)); ver != "" {
return ver
for _, path := range paths {
if content, err := os.ReadFile(path); err == nil {
ver := strings.TrimSpace(string(content))
ver = strings.TrimPrefix(ver, "v")
if re := regexp.MustCompile(`^([\d.]+)`); re.MatchString(ver) {
if matches := re.FindStringSubmatch(ver); matches != nil {
return matches[1]
}
}
}
}
// Fallback
return "1.0.2"
}
func parseBaseVersion(raw string) string {
matches := baseVersionRe.FindStringSubmatch(strings.TrimPrefix(strings.TrimSpace(raw), "v"))
if matches == nil {
return ""
}
return matches[1]
}
func startDebugServer() error {
server.CLIVersion = Version
return server.Start(true)
@@ -463,40 +478,6 @@ func installPluginCLI(idOrName string) error {
return nil
}
func lockPluginsCLI(outputPath string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
warnings, err := manager.WriteCurrentLockfile(outputPath)
if err != nil {
return err
}
for _, warning := range warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
}
fmt.Printf("Plugin lockfile written: %s\n", manager.GetLockfilePath())
if outputPath != "" && outputPath != manager.GetLockfilePath() {
fmt.Printf("Plugin lockfile exported: %s\n", outputPath)
}
return nil
}
func restorePluginsCLI(path string, prune bool) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
if err := manager.RestoreFromLockfile(path, prune); err != nil {
return err
}
if path == "" {
path = manager.GetLockfilePath()
}
fmt.Printf("Plugins restored from lockfile: %s\n", path)
return nil
}
func getAvailablePluginIDs() []string {
registry, err := plugins.NewRegistry()
if err != nil {
@@ -762,16 +743,17 @@ func checkAllPluginsCLI() error {
}
func getCommonCommands() []*cobra.Command {
commands := shellApp.Commands()
return append(commands, []*cobra.Command{
return []*cobra.Command{
versionCmd,
runCmd,
restartCmd,
restartDetachedCmd,
killCmd,
ipcCmd,
debugSrvCmd,
pluginsCmd,
registryCmd,
dank16Cmd,
brightnessCmd,
iccCmd,
dpmsCmd,
keybindsCmd,
greeterCmd,
@@ -792,9 +774,6 @@ func getCommonCommands() []*cobra.Command {
blurCmd,
trashCmd,
systemCmd,
systemdCmd,
switchUserCmd,
switchCmd,
dmenuCmd,
}...)
}
}
+72 -73
View File
@@ -7,10 +7,9 @@ import (
"path/filepath"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/luaconfig"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -36,15 +35,12 @@ var resolveIncludeCmd = &cobra.Command{
"layout.lua",
"outputs.lua",
"cursor.lua",
"input.lua",
"windowrules.lua",
"cursor.kdl",
"layout.kdl",
"outputs.kdl",
"binds.kdl",
"input.kdl",
"cursor.conf",
"input.conf",
"layout.conf",
"outputs.conf",
"binds.conf",
@@ -55,32 +51,8 @@ var resolveIncludeCmd = &cobra.Command{
Run: runResolveInclude,
}
var mangoMigrateMain bool
var mangoMigrateCmd = &cobra.Command{
Use: "mango-migrate",
Short: "Respell Mango config keys for the installed Mango",
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dialect := mangoconf.Detect()
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
for _, path := range changed {
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
}
// Printed before any error: the shell needs the dialect even when a fragment failed.
fmt.Println(dialect)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
configCmd.AddCommand(resolveIncludeCmd)
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
configCmd.AddCommand(mangoMigrateCmd)
}
type IncludeResult struct {
@@ -131,21 +103,32 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
return result, err
}
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua"
result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool))
return result, nil
result.ReadOnly = false
processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
return result, nil
}
}
// 0.55/0.56 installs still on hyprland.conf: read-only until migrated.
mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err != nil {
return result, nil
if _, err := os.Stat(mainConf); err == nil {
if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
return result, nil
}
}
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
result.Included = hyprlandFindIncludeHyprlang(mainConf, targetAbs, make(map[string]bool))
return result, nil
}
@@ -166,8 +149,14 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
}
baseDir := filepath.Dir(absPath)
for line := range strings.SplitSeq(string(data), "\n") {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") {
continue
}
@@ -177,12 +166,22 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
continue
}
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1]))
sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) {
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
return true
}
}
@@ -205,13 +204,8 @@ func checkNiriInclude(filename string) (IncludeResult, error) {
return result, nil
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
processed := make(map[string]bool)
result.Included = niriFindInclude(mainConfig, targetAbs, processed)
result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
@@ -234,7 +228,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
baseDir := filepath.Dir(absPath)
content := string(data)
for line := range strings.SplitSeq(content, "\n") {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || trimmed == "" {
continue
@@ -253,12 +247,17 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
continue
}
fullPath, err := resolveSourcePath(baseDir, trimmed[startQuote+1:endQuote])
if err != nil {
continue
includePath := trimmed[startQuote+1 : endQuote]
if matchesTarget(includePath, target) {
return true
}
if fullPath == target || niriFindInclude(fullPath, target, processed) {
fullPath := includePath
if !filepath.IsAbs(includePath) {
fullPath = filepath.Join(baseDir, includePath)
}
if niriFindInclude(fullPath, target, processed) {
return true
}
}
@@ -267,7 +266,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
}
func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir := mangoconf.Dir()
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
@@ -284,13 +283,8 @@ func checkMangoWCInclude(filename string) (IncludeResult, error) {
return result, nil
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
processed := make(map[string]bool)
result.Included = mangowcFindInclude(mainConfig, targetAbs, processed)
result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
@@ -311,9 +305,9 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
}
baseDir := filepath.Dir(absPath)
lines := strings.SplitSeq(string(data), "\n")
lines := strings.Split(string(data), "\n")
for line := range lines {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
@@ -328,12 +322,22 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
continue
}
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1]))
sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
if fullPath == target || mangowcFindInclude(fullPath, target, processed) {
if mangowcFindInclude(expanded, target, processed) {
return true
}
}
@@ -341,13 +345,8 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
return false
}
func resolveSourcePath(baseDir, raw string) (string, error) {
expanded, err := utils.ExpandPath(raw)
if err != nil {
return "", err
}
if !filepath.IsAbs(expanded) {
expanded = filepath.Join(baseDir, expanded)
}
return filepath.Abs(expanded)
func matchesTarget(path, target string) bool {
path = strings.TrimPrefix(path, "./")
target = strings.TrimPrefix(target, "./")
return path == target || strings.HasSuffix(path, "/"+target)
}
-74
View File
@@ -1,74 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func writeConfigFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestNiriFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
dir := t.TempDir()
writeConfigFile(t, filepath.Join(dir, "config.kdl"), "include \"dms/dms.kdl\"\n")
writeConfigFile(t, filepath.Join(dir, "dms", "dms.kdl"), "include \"binds.kdl\"\ninclude \"./outputs.kdl\"\n")
for _, name := range []string{"binds.kdl", "outputs.kdl"} {
target := filepath.Join(dir, "dms", name)
if !niriFindInclude(filepath.Join(dir, "config.kdl"), target, map[string]bool{}) {
t.Errorf("%s included through dms/dms.kdl not detected", name)
}
}
if niriFindInclude(filepath.Join(dir, "config.kdl"), filepath.Join(dir, "dms", "layout.kdl"), map[string]bool{}) {
t.Error("layout.kdl reported included without an include line")
}
}
func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
dir := t.TempDir()
writeConfigFile(t, filepath.Join(dir, "config.conf"), "source=./dms/dms.conf\n")
writeConfigFile(t, filepath.Join(dir, "dms", "dms.conf"), "source = binds.conf\n")
if !mangowcFindInclude(filepath.Join(dir, "config.conf"), filepath.Join(dir, "dms", "binds.conf"), map[string]bool{}) {
t.Error("binds.conf sourced through dms/dms.conf not detected")
}
}
func TestCheckHyprlandIncludeFormat(t *testing.T) {
tests := []struct {
name string
files map[string]string
wantFormat string
wantReadOnly bool
wantIncluded bool
}{
{"hyprlang sourced", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n"}, "hyprlang", true, true},
{"hyprlang not sourced", map[string]string{"hyprland.conf": "bind = SUPER, Q, killactive\n"}, "hyprlang", true, false},
{"lua wins over leftover conf", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n", "hyprland.lua": "\n"}, "lua", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
for name, content := range tt.files {
writeConfigFile(t, filepath.Join(xdg, "hypr", name), content)
}
got, err := checkHyprlandInclude("colors.conf")
if err != nil {
t.Fatal(err)
}
if got.ConfigFormat != tt.wantFormat || got.ReadOnly != tt.wantReadOnly || got.Included != tt.wantIncluded {
t.Fatalf("got %+v, want format %q readOnly %v included %v", got, tt.wantFormat, tt.wantReadOnly, tt.wantIncluded)
}
})
}
}
+7 -14
View File
@@ -4,8 +4,8 @@ import (
"fmt"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/dank16"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
@@ -28,13 +28,9 @@ func init() {
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
dank16Cmd.Flags().String("background", "", "Custom background color")
dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag")
dank16Cmd.Flags().String("profile", "default", "Palette: default, low, medium or high")
dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON")
dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)")
dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)")
_ = dank16Cmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"default", "low", "medium", "high"}, cobra.ShellCompDirectiveNoFileComp
})
_ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -51,7 +47,6 @@ func runDank16(cmd *cobra.Command, args []string) {
isWezterm, _ := cmd.Flags().GetBool("wezterm")
background, _ := cmd.Flags().GetString("background")
contrastAlgo, _ := cmd.Flags().GetString("contrast")
profile, _ := cmd.Flags().GetString("profile")
useVariants, _ := cmd.Flags().GetBool("variants")
primaryDark, _ := cmd.Flags().GetString("primary-dark")
primaryLight, _ := cmd.Flags().GetString("primary-light")
@@ -84,12 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
primaryLight = primaryColor
}
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
BackgroundDark: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
Profile: profile,
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors))
@@ -108,7 +102,6 @@ func runDank16(cmd *cobra.Command, args []string) {
IsLight: isLight,
Background: background,
UseDPS: contrastAlgo == "dps",
Profile: profile,
}
colors := dank16.GeneratePalette(primaryColor, opts)
-643
View File
@@ -1,643 +0,0 @@
package main
import (
"bufio"
"bytes"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/dmenuipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const dmenuConnectTimeout = 5 * time.Second
var (
dmenuPrompt string
dmenuLines int
dmenuPlaceholder string
dmenuPromptOnly bool
dmenuSep string
dmenuActiveSpec string
dmenuUrgentSpec string
dmenuOnlyMatch bool
dmenuNoCustom bool
dmenuSelect string
dmenuMesg string
dmenuInput string
dmenuPassword bool
dmenuMarkupRows bool
dmenuSync bool
dmenuNoRunIfEmpty bool
dmenuFormat string
dmenuMultiSelect bool
dmenuDump bool
dmenuKeybindSpecs []string
dmenuPrintInfo bool
dmenuView string
dmenuSize string
dmenuIcon string
dmenuCaseInsens bool
dmenuDisableHist bool
)
var dmenuCmd = &cobra.Command{
Use: "dmenu",
Short: "dmenu-compatible picker backed by the DMS app launcher",
Long: `Read newline-separated items from stdin, show them in the DMS launcher,
and print the selected item to stdout
Examples:
printf '%s\n' opt1 opt2 opt3 | dms dmenu -p "Pick one:"
dms dmenu --prompt-only -p "Rename to:"
Matching is always case-insensitive; -i/--case-insensitive is accepted for
dmenu script compatibility but has no effect.
Most, but not all, rofi flags are implemented.
Long-form flags also accept rofi's single-dash form
(e.g. -only-match as well as --only-match).
Exit codes: 0 on accept, 1 if the user cancelled (or stdin was empty),
2 if the shell was unreachable.`,
Run: runDmenu,
}
func init() {
dmenuCmd.Flags().BoolVarP(&dmenuCaseInsens, "case-insensitive", "i", false, "No effect: matching is always case-insensitive. Accepted for dmenu/rofi script compatibility")
dmenuCmd.Flags().StringVarP(&dmenuPrompt, "prompt", "p", "dmenu", "Prompt label")
dmenuCmd.Flags().IntVarP(&dmenuLines, "lines", "l", 0, "Max visible result rows (0 = shell default)")
dmenuCmd.Flags().StringVar(&dmenuPlaceholder, "placeholder", "", "Input placeholder text")
dmenuCmd.Flags().BoolVar(&dmenuPromptOnly, "prompt-only", false, "No piped item list — pure free-text prompt")
dmenuCmd.Flags().StringVar(&dmenuSep, "sep", "\n", "Input item separator")
dmenuCmd.Flags().StringVarP(&dmenuActiveSpec, "active", "a", "", "Mark rows active by index (e.g. \"1,3,7:11,-3:\")")
dmenuCmd.Flags().StringVarP(&dmenuUrgentSpec, "urgent", "u", "", "Mark rows urgent by index (same spec syntax as --active)")
dmenuCmd.Flags().BoolVar(&dmenuOnlyMatch, "only-match", false, "Reject free text; only a listed item may be returned; also blocks Escape/click-outside cancel, forcing a real pick")
dmenuCmd.Flags().BoolVar(&dmenuNoCustom, "no-custom", false, "Reject free text; only a listed item may be returned (unlike --only-match, Escape/cancel still works normally)")
dmenuCmd.Flags().StringVar(&dmenuSelect, "select", "", "Pre-select the first row matching this string (or, with --dump, filter to matching rows)")
dmenuCmd.Flags().StringVar(&dmenuMesg, "mesg", "", "Extra message line shown alongside the list")
dmenuCmd.Flags().StringVar(&dmenuInput, "input", "", "Read items from this file instead of stdin")
dmenuCmd.Flags().BoolVar(&dmenuPassword, "password", false, "Mask typed input (cosmetic only, not a security boundary)")
dmenuCmd.Flags().BoolVar(&dmenuMarkupRows, "markup-rows", false, "Render each row's text as rich markup")
dmenuCmd.Flags().BoolVar(&dmenuSync, "sync", false, "Read all input to EOF before showing the picker (default: stream as items arrive)")
dmenuCmd.Flags().BoolVar(&dmenuNoRunIfEmpty, "no-run-if-empty", false, "If stdin is empty, exit 1 immediately with no UI shown")
dmenuCmd.Flags().StringVar(&dmenuFormat, "format", "s", "What to print on accept: s=text (default), i=0-based index, d=1-based index, q=shell-quoted, p=markup-stripped, f=typed filter text, F=quoted filter text")
dmenuCmd.Flags().BoolVar(&dmenuMultiSelect, "multi-select", false, "Allow selecting multiple rows; each is printed on its own line on accept")
dmenuCmd.Flags().BoolVar(&dmenuDump, "dump", false, "Apply --select against the item list and print the result immediately; no UI is ever shown")
dmenuCmd.Flags().StringArrayVar(&dmenuKeybindSpecs, "keybind", nil, "Bind a custom accept key (repeatable): N=keyspec, e.g. --keybind 1=ctrl+e (N is 1-19); exits 9+N (10-28) instead of 0 when accepted that way")
dmenuCmd.Flags().BoolVar(&dmenuPrintInfo, "print-info", false, "Also print an accepted row's info metadata (the per-row \\0info\\x1f... field, see the per-row metadata protocol) on a second line")
dmenuCmd.Flags().BoolVar(&dmenuDisableHist, "disable-history", false, "Don't add this session's search query to the launcher's persistent history (default: recorded, like other launcher modes)")
dmenuCmd.Flags().StringVar(&dmenuView, "view", "", "Override the result view mode for this invocation: list, grid, or tile (default: list)")
dmenuCmd.Flags().StringVar(&dmenuSize, "size", "", "Override the popup size preset for this invocation: 1-4, matching the Appearance settings size buttons (default: the shell's own launcher size setting)")
dmenuCmd.Flags().StringVar(&dmenuIcon, "icon", "", "Show a Material Symbols icon next to the prompt badge (default: no icon)")
}
var dmenuLongFlagToken = regexp.MustCompile(`^-([a-zA-Z][a-zA-Z0-9-]+)(=.*)?$`)
func dmenuAliasableLongFlags() map[string]bool {
set := map[string]bool{}
dmenuCmd.Flags().VisitAll(func(f *pflag.Flag) {
if len(f.Name) > 1 {
set[f.Name] = true
}
})
return set
}
func normalizeDmenuArgv(args []string) []string {
if len(args) < 2 || args[1] != "dmenu" {
return args
}
aliasable := dmenuAliasableLongFlags()
out := make([]string, len(args))
copy(out, args)
for i := 2; i < len(out); i++ {
tok := out[i]
if tok == "--" {
break
}
m := dmenuLongFlagToken.FindStringSubmatch(tok)
if m == nil {
continue
}
name, valueSuffix := m[1], m[2]
if aliasable[name] {
out[i] = "--" + name + valueSuffix
}
}
return out
}
func dmenuSplitFunc(sep string) bufio.SplitFunc {
if sep == "\n" {
return bufio.ScanLines
}
sepBytes := []byte(sep)
return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.Index(data, sepBytes); i >= 0 {
return i + len(sepBytes), data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
}
}
func parseIndexSpec(spec string) ([]dmenuipc.Range, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, nil
}
var ranges []dmenuipc.Range
for _, tok := range strings.Split(spec, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
if idx := strings.Index(tok, ":"); idx >= 0 {
startStr, endStr := strings.TrimSpace(tok[:idx]), strings.TrimSpace(tok[idx+1:])
start, end := 0, -1
if startStr != "" {
n, err := strconv.Atoi(startStr)
if err != nil {
return nil, fmt.Errorf("invalid range start %q in spec %q", startStr, spec)
}
start = n
}
if endStr != "" {
n, err := strconv.Atoi(endStr)
if err != nil {
return nil, fmt.Errorf("invalid range end %q in spec %q", endStr, spec)
}
end = n
}
ranges = append(ranges, dmenuipc.Range{Start: start, End: end})
continue
}
n, err := strconv.Atoi(tok)
if err != nil {
return nil, fmt.Errorf("invalid index %q in spec %q", tok, spec)
}
ranges = append(ranges, dmenuipc.Range{Start: n, End: n + 1})
}
return ranges, nil
}
func parseDmenuKeybinds(specs []string) ([]dmenuipc.Keybind, error) {
var out []dmenuipc.Keybind
seen := map[int]bool{}
for _, s := range specs {
eq := strings.IndexByte(s, '=')
if eq <= 0 {
return nil, fmt.Errorf("invalid --keybind %q (expected N=keyspec, e.g. 1=ctrl+e)", s)
}
n, err := strconv.Atoi(strings.TrimSpace(s[:eq]))
if err != nil || n < 1 || n > 19 {
return nil, fmt.Errorf("invalid --keybind index in %q (expected 1-19)", s)
}
key := strings.TrimSpace(s[eq+1:])
if key == "" {
return nil, fmt.Errorf("invalid --keybind %q: empty key spec", s)
}
if seen[n] {
return nil, fmt.Errorf("duplicate --keybind index %d", n)
}
seen[n] = true
out = append(out, dmenuipc.Keybind{N: n, Key: key})
}
return out, nil
}
func dmenuParseBool(val string) bool {
switch strings.ToLower(strings.TrimSpace(val)) {
case "", "0", "false", "no":
return false
default:
return true
}
}
func parseDmenuRow(line string) (text string, meta *dmenuipc.ItemMeta) {
idx := strings.IndexByte(line, 0)
if idx < 0 {
return line, nil
}
text = line[:idx]
fields := strings.Split(line[idx+1:], "\x1f")
m := dmenuipc.ItemMeta{}
any := false
for i := 0; i+1 < len(fields); i += 2 {
key, val := fields[i], fields[i+1]
switch key {
case "icon":
m.Icon = val
case "display":
m.Display = val
case "meta":
m.Meta = val
case "info":
m.Info = val
case "nonselectable":
m.NonSelectable = dmenuParseBool(val)
case "urgent":
m.Urgent = dmenuParseBool(val)
case "active":
m.Active = dmenuParseBool(val)
case "permanent":
m.Permanent = dmenuParseBool(val)
case "header":
m.Header = dmenuParseBool(val)
default:
continue
}
any = true
}
if !any {
return text, nil
}
return text, &m
}
func validateDmenuFormat(format string) error {
switch format {
case "", "s", "i", "d", "q", "p", "f", "F":
return nil
default:
return fmt.Errorf("invalid --format %q (expected one of s,i,d,q,p,f,F)", format)
}
}
func validateDmenuView(view string) error {
switch view {
case "", "list", "grid", "tile":
return nil
default:
return fmt.Errorf("invalid --view %q (expected one of list,grid,tile)", view)
}
}
func dmenuSizePreset(size string) (string, error) {
switch size {
case "":
return "", nil
case "1":
return "micro", nil
case "2":
return "compact", nil
case "3":
return "medium", nil
case "4":
return "large", nil
default:
return "", fmt.Errorf("invalid --size %q (expected one of 1,2,3,4)", size)
}
}
var dmenuMarkupTagRe = regexp.MustCompile(`<[^>]*>`)
func dmenuShellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func dmenuFormatOutput(format string, index int, text, filterText string) (string, error) {
switch format {
case "", "s":
return text, nil
case "i":
return strconv.Itoa(index), nil
case "d":
if index < 0 {
return strconv.Itoa(index), nil
}
return strconv.Itoa(index + 1), nil
case "q":
return dmenuShellQuote(text), nil
case "p":
return dmenuMarkupTagRe.ReplaceAllString(text, ""), nil
case "f":
return filterText, nil
case "F":
return dmenuShellQuote(filterText), nil
default:
return "", fmt.Errorf("invalid --format %q (expected one of s,i,d,q,p,f,F)", format)
}
}
func dmenuOpenInput() *os.File {
if dmenuPromptOnly {
return nil
}
if dmenuInput != "" {
f, err := os.Open(dmenuInput)
if err != nil {
log.Fatalf("Error opening --input file: %v", err)
}
return f
}
return os.Stdin
}
type dmenuRow struct {
Text string
Meta *dmenuipc.ItemMeta
}
func dmenuReadAll(input *os.File) []dmenuRow {
if input == nil {
return nil
}
scanner := bufio.NewScanner(input)
scanner.Buffer(make([]byte, 64*1024), 1<<20)
scanner.Split(dmenuSplitFunc(dmenuSep))
var rows []dmenuRow
for scanner.Scan() {
text, meta := parseDmenuRow(scanner.Text())
rows = append(rows, dmenuRow{Text: text, Meta: meta})
}
if err := scanner.Err(); err != nil {
log.Fatalf("Error reading items: %v", err)
}
return rows
}
func dmenuDumpLines(rows []dmenuRow, select_, format string, printInfo bool) ([]string, error) {
var lower string
filtering := select_ != ""
if filtering {
lower = strings.ToLower(select_)
}
var lines []string
for i, row := range rows {
if row.Meta != nil && (row.Meta.NonSelectable || row.Meta.Header) {
continue
}
if filtering && !strings.Contains(strings.ToLower(row.Text), lower) {
continue
}
out, err := dmenuFormatOutput(format, i, row.Text, select_)
if err != nil {
return nil, err
}
lines = append(lines, out)
if printInfo && row.Meta != nil && row.Meta.Info != "" {
lines = append(lines, row.Meta.Info)
}
}
return lines, nil
}
func runDmenuDump(rows []dmenuRow, format string) {
lines, err := dmenuDumpLines(rows, dmenuSelect, format, dmenuPrintInfo)
if err != nil {
log.Fatalf("Error formatting output: %v", err)
}
for _, l := range lines {
fmt.Println(l)
}
os.Exit(0)
}
func runDmenu(cmd *cobra.Command, args []string) {
if cmd.Flags().Changed("case-insensitive") {
fmt.Fprintln(os.Stderr, "dms dmenu: warning: -i/--case-insensitive has no effect (matching is always case-insensitive)")
}
os.Exit(dmenuSession())
}
func dmenuSession() int {
format := dmenuFormat
if format == "" {
format = "s"
}
if err := validateDmenuFormat(format); err != nil {
log.Fatalf("Error in --format: %v", err)
}
if dmenuDump {
input := dmenuOpenInput()
if input != nil && input != os.Stdin {
defer input.Close()
}
runDmenuDump(dmenuReadAll(input), format)
return 0
}
activeRanges, err := parseIndexSpec(dmenuActiveSpec)
if err != nil {
log.Fatalf("Error parsing --active: %v", err)
}
urgentRanges, err := parseIndexSpec(dmenuUrgentSpec)
if err != nil {
log.Fatalf("Error parsing --urgent: %v", err)
}
keybinds, err := parseDmenuKeybinds(dmenuKeybindSpecs)
if err != nil {
log.Fatalf("Error parsing --keybind: %v", err)
}
if err := validateDmenuView(dmenuView); err != nil {
log.Fatalf("Error in --view: %v", err)
}
dmenuSizePresetName, err := dmenuSizePreset(dmenuSize)
if err != nil {
log.Fatalf("Error in --size: %v", err)
}
input := dmenuOpenInput()
if input != nil && input != os.Stdin {
defer input.Close()
}
var scanner *bufio.Scanner
if input != nil {
scanner = bufio.NewScanner(input)
scanner.Buffer(make([]byte, 64*1024), 1<<20)
scanner.Split(dmenuSplitFunc(dmenuSep))
}
var bufferedItems []string
streaming := scanner != nil && !dmenuSync
if scanner != nil && !streaming {
for scanner.Scan() {
bufferedItems = append(bufferedItems, scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Errorf("Error reading items: %v", err)
return 1
}
}
var pendingFirstItem string
havePendingFirstItem := false
if dmenuNoRunIfEmpty {
if streaming {
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
log.Errorf("Error reading items: %v", err)
return 1
}
return 1
}
pendingFirstItem = scanner.Text()
havePendingFirstItem = true
} else if len(bufferedItems) == 0 {
return 1
}
}
release, err := dmenuipc.AcquireQueueSlot(func() {
fmt.Fprintln(os.Stderr, "dms dmenu: waiting for another dmenu session to finish...")
})
if err != nil {
log.Fatalf("Error acquiring dmenu queue slot: %v", err)
}
defer release()
sess, err := dmenuipc.Listen()
if err != nil {
log.Fatalf("Error creating dmenu socket: %v", err)
}
defer sess.Close()
pid, ok := shellApp.SessionPID()
if !ok {
log.Errorf("DMS shell is not running")
return 1
}
mode := "dmenu:" + sess.Path()
if _, _, err := qsipc.Call(qsipc.SocketPathForPID(pid), "launcher", "openWith", []string{mode}); err != nil {
log.Errorf("Error opening launcher: %v", err)
return 1
}
conn, err := sess.Accept(dmenuConnectTimeout)
if err != nil {
log.Debugf("dmenu: shell never connected: %v", err)
return 2
}
defer conn.Close()
header := dmenuipc.Header{
Prompt: dmenuPrompt,
Placeholder: dmenuPlaceholder,
Lines: dmenuLines,
PromptOnly: dmenuPromptOnly,
Select: dmenuSelect,
Mesg: dmenuMesg,
Password: dmenuPassword,
MarkupRows: dmenuMarkupRows,
OnlyMatch: dmenuOnlyMatch,
NoCustom: dmenuNoCustom,
MultiSelect: dmenuMultiSelect,
Active: activeRanges,
Urgent: urgentRanges,
Keybinds: keybinds,
View: dmenuView,
Size: dmenuSizePresetName,
Icon: dmenuIcon,
DisableHistory: dmenuDisableHist,
}
if err := conn.SendHeader(header); err != nil {
log.Debugf("dmenu: failed to send header: %v", err)
return 2
}
var sendErr error
sendItem := func(line string) {
text, meta := parseDmenuRow(line)
if err := conn.SendItem(text, meta); err != nil {
log.Debugf("dmenu: failed to send item: %v", err)
sendErr = err
}
}
if streaming {
if havePendingFirstItem && sendErr == nil {
sendItem(pendingFirstItem)
}
for sendErr == nil && scanner.Scan() {
sendItem(scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Debugf("dmenu: error reading items mid-stream: %v", err)
return 2
}
} else {
for _, item := range bufferedItems {
if sendErr != nil {
break
}
sendItem(item)
}
}
if sendErr != nil {
return 2
}
if err := conn.SendEnd(); err != nil {
log.Debugf("dmenu: failed to send end marker: %v", err)
return 2
}
sel, selected, err := conn.ReadSelection()
if err != nil {
log.Debugf("dmenu: failed to read result: %v", err)
return 2
}
if !selected {
return 1
}
switch sel.Kind {
case "multi":
for _, item := range sel.Items {
out, err := dmenuFormatOutput(format, item.Index, item.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
if dmenuPrintInfo && item.Info != "" {
fmt.Println(item.Info)
}
}
case "freetext":
out, err := dmenuFormatOutput(format, -1, sel.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
default: // "row"
out, err := dmenuFormatOutput(format, sel.Index, sel.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
if dmenuPrintInfo && sel.Info != "" {
fmt.Println(sel.Info)
}
}
return dmenuKeybindExitCode(sel.KeybindN)
}
func dmenuKeybindExitCode(keybindN int) int {
if keybindN <= 0 {
return 0
}
return 9 + keybindN
}
-350
View File
@@ -1,350 +0,0 @@
package main
import (
"reflect"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/dmenuipc"
)
func TestParseIndexSpec(t *testing.T) {
for _, tc := range []struct {
spec string
want []dmenuipc.Range
wantErr bool
}{
{"", nil, false},
{"1,3", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}}, false},
{"7:11", []dmenuipc.Range{{Start: 7, End: 11}}, false},
{"-3:", []dmenuipc.Range{{Start: -3, End: -1}}, false},
{":5", []dmenuipc.Range{{Start: 0, End: 5}}, false},
{"1, 3 , 7:11", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}, {Start: 7, End: 11}}, false},
{"1,,3", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}}, false},
{"x", nil, true},
{"1:y", nil, true},
{"x:1", nil, true},
} {
got, err := parseIndexSpec(tc.spec)
if tc.wantErr {
if err == nil {
t.Errorf("parseIndexSpec(%q): expected error, got %v", tc.spec, got)
}
continue
}
if err != nil {
t.Errorf("parseIndexSpec(%q): unexpected error: %v", tc.spec, err)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("parseIndexSpec(%q) = %+v, want %+v", tc.spec, got, tc.want)
}
}
}
func TestParseDmenuKeybinds(t *testing.T) {
for _, tc := range []struct {
name string
specs []string
want []dmenuipc.Keybind
wantErr bool
}{
{"empty", nil, nil, false},
{"multiple", []string{"1=ctrl+e", "2=f5"}, []dmenuipc.Keybind{{N: 1, Key: "ctrl+e"}, {N: 2, Key: "f5"}}, false},
{"missing equals", []string{"bad"}, nil, true},
{"zero index", []string{"0=x"}, nil, true},
{"out of range index", []string{"20=x"}, nil, true},
{"empty key spec", []string{"1="}, nil, true},
{"duplicate index", []string{"1=ctrl+e", "1=f5"}, nil, true},
{"non numeric index", []string{"a=x"}, nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := parseDmenuKeybinds(tc.specs)
if tc.wantErr {
if err == nil {
t.Fatalf("parseDmenuKeybinds(%v): expected error, got %v", tc.specs, got)
}
return
}
if err != nil {
t.Fatalf("parseDmenuKeybinds(%v): unexpected error: %v", tc.specs, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("parseDmenuKeybinds(%v) = %+v, want %+v", tc.specs, got, tc.want)
}
})
}
}
func TestDmenuParseBool(t *testing.T) {
for _, tc := range []struct {
val string
want bool
}{
{"", false},
{"0", false},
{"false", false},
{"False", false},
{"no", false},
{"anything-else", true},
} {
if got := dmenuParseBool(tc.val); got != tc.want {
t.Errorf("dmenuParseBool(%q) = %v, want %v", tc.val, got, tc.want)
}
}
}
func TestParseDmenuRow(t *testing.T) {
t.Run("plain text, no metadata", func(t *testing.T) {
text, meta := parseDmenuRow("apple")
if text != "apple" || meta != nil {
t.Fatalf("parseDmenuRow(apple) = %q, %+v, want apple, nil", text, meta)
}
})
t.Run("single field", func(t *testing.T) {
text, meta := parseDmenuRow("Firefox\x00icon\x1ffirefox")
if text != "Firefox" {
t.Fatalf("text = %q, want Firefox", text)
}
if meta == nil || meta.Icon != "firefox" {
t.Fatalf("meta = %+v, want Icon=firefox", meta)
}
})
t.Run("all recognized fields", func(t *testing.T) {
line := "Firefox\x00icon\x1ffirefox\x1fdisplay\x1fMozilla Firefox\x1fmeta\x1fbrowser\x1finfo\x1ffirefox.desktop" +
"\x1fnonselectable\x1ftrue\x1furgent\x1ftrue\x1factive\x1ftrue\x1fpermanent\x1ftrue\x1fheader\x1ftrue"
text, meta := parseDmenuRow(line)
want := dmenuipc.ItemMeta{
Icon: "firefox", Display: "Mozilla Firefox", Meta: "browser", Info: "firefox.desktop",
NonSelectable: true, Urgent: true, Active: true, Permanent: true, Header: true,
}
if text != "Firefox" {
t.Fatalf("text = %q, want Firefox", text)
}
if meta == nil || *meta != want {
t.Fatalf("meta = %+v, want %+v", meta, want)
}
})
t.Run("unknown field ignored but still produces meta", func(t *testing.T) {
text, meta := parseDmenuRow("apple\x00bogus\x1fvalue")
if text != "apple" {
t.Fatalf("text = %q, want apple", text)
}
if meta != nil {
t.Fatalf("meta = %+v, want nil (no recognized fields)", meta)
}
})
t.Run("dangling unpaired field ignored", func(t *testing.T) {
text, meta := parseDmenuRow("apple\x00icon\x1ffirefox\x1fdangling")
if text != "apple" {
t.Fatalf("text = %q, want apple", text)
}
if meta == nil || meta.Icon != "firefox" {
t.Fatalf("meta = %+v, want Icon=firefox", meta)
}
})
}
func TestValidateDmenuFormat(t *testing.T) {
for _, f := range []string{"", "s", "i", "d", "q", "p", "f", "F"} {
if err := validateDmenuFormat(f); err != nil {
t.Errorf("validateDmenuFormat(%q): unexpected error: %v", f, err)
}
}
for _, f := range []string{"x", "S", "ff"} {
if err := validateDmenuFormat(f); err == nil {
t.Errorf("validateDmenuFormat(%q): expected error, got nil", f)
}
}
}
func TestValidateDmenuView(t *testing.T) {
for _, v := range []string{"", "list", "grid", "tile"} {
if err := validateDmenuView(v); err != nil {
t.Errorf("validateDmenuView(%q): unexpected error: %v", v, err)
}
}
for _, v := range []string{"bogus", "List"} {
if err := validateDmenuView(v); err == nil {
t.Errorf("validateDmenuView(%q): expected error, got nil", v)
}
}
}
func TestDmenuShellQuote(t *testing.T) {
for _, tc := range []struct {
in string
want string
}{
{"banana", "'banana'"},
{"it's", `'it'\''s'`},
{"", "''"},
} {
if got := dmenuShellQuote(tc.in); got != tc.want {
t.Errorf("dmenuShellQuote(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestDmenuFormatOutput(t *testing.T) {
for _, tc := range []struct {
format string
index int
text string
filterText string
want string
wantErr bool
}{
{"", 1, "banana", "ban", "banana", false},
{"s", 1, "banana", "ban", "banana", false},
{"i", 1, "banana", "ban", "1", false},
{"d", 1, "banana", "ban", "2", false},
{"d", -1, "banana", "xyz", "-1", false},
{"q", 0, "it's", "", "'it'\\''s'", false},
{"p", 0, "<b>Bold</b> item", "", "Bold item", false},
{"f", 0, "banana", "ban", "ban", false},
{"F", 0, "banana", "it's", "'it'\\''s'", false},
{"bogus", 0, "banana", "", "", true},
} {
got, err := dmenuFormatOutput(tc.format, tc.index, tc.text, tc.filterText)
if tc.wantErr {
if err == nil {
t.Errorf("dmenuFormatOutput(%q, ...): expected error, got %q", tc.format, got)
}
continue
}
if err != nil {
t.Errorf("dmenuFormatOutput(%q, ...): unexpected error: %v", tc.format, err)
continue
}
if got != tc.want {
t.Errorf("dmenuFormatOutput(%q, %d, %q, %q) = %q, want %q", tc.format, tc.index, tc.text, tc.filterText, got, tc.want)
}
}
}
func TestDmenuDumpLines(t *testing.T) {
rows := []dmenuRow{
{Text: "Header", Meta: &dmenuipc.ItemMeta{Header: true}},
{Text: "Firefox", Meta: &dmenuipc.ItemMeta{Info: "firefox.desktop"}},
{Text: "apple"},
{Text: "skip", Meta: &dmenuipc.ItemMeta{NonSelectable: true}},
{Text: "banana"},
}
t.Run("no filter, default format", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "", "s", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"Firefox", "apple", "banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("with select filter", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "an", "s", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("print-info appends info lines", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "", "s", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"Firefox", "firefox.desktop", "apple", "banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("invalid format propagates error", func(t *testing.T) {
if _, err := dmenuDumpLines(rows, "", "bogus", false); err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestDmenuKeybindExitCode(t *testing.T) {
for _, tc := range []struct {
n int
want int
}{
{0, 0},
{1, 10},
{19, 28},
} {
if got := dmenuKeybindExitCode(tc.n); got != tc.want {
t.Errorf("dmenuKeybindExitCode(%d) = %d, want %d", tc.n, got, tc.want)
}
}
}
func TestNormalizeDmenuArgv(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want []string
}{
{
"not the dmenu subcommand: untouched",
[]string{"dms", "ipc", "-password"},
[]string{"dms", "ipc", "-password"},
},
{
"rofi-style single-dash long flags get a second dash",
[]string{"dms", "dmenu", "-only-match", "-password", "-sep", ","},
[]string{"dms", "dmenu", "--only-match", "--password", "--sep", ","},
},
{
"= form is preserved",
[]string{"dms", "dmenu", "-format=i"},
[]string{"dms", "dmenu", "--format=i"},
},
{
"already-double-dash flags are untouched",
[]string{"dms", "dmenu", "--password", "-p", "hi"},
[]string{"dms", "dmenu", "--password", "-p", "hi"},
},
{
"single-char shorthands are untouched",
[]string{"dms", "dmenu", "-p", "hi", "-l", "5", "-i"},
[]string{"dms", "dmenu", "-p", "hi", "-l", "5", "-i"},
},
{
"negative --active/--urgent spec values are not mistaken for flag names",
[]string{"dms", "dmenu", "--active", "-3:"},
[]string{"dms", "dmenu", "--active", "-3:"},
},
{
"DMS-native long flags also get the single-dash alias",
[]string{"dms", "dmenu", "-icon", "star"},
[]string{"dms", "dmenu", "--icon", "star"},
},
{
"rofi's -a/-u abbreviations are real shorthands, untouched by the rewrite",
[]string{"dms", "dmenu", "-a", "1,3", "-u", "2"},
[]string{"dms", "dmenu", "-a", "1,3", "-u", "2"},
},
{
"stops rewriting after a bare --",
[]string{"dms", "dmenu", "--", "-password"},
[]string{"dms", "dmenu", "--", "-password"},
},
} {
got := normalizeDmenuArgv(tc.args)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%s: normalizeDmenuArgv(%v) = %v, want %v", tc.name, tc.args, got, tc.want)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCheckBundledGoogleSans(t *testing.T) {
shellPath := t.TempDir()
fontPath := filepath.Join(shellPath, "DCommon", "assets", "fonts", "google-sans-flex", "GoogleSansFlex.ttf")
missing := checkConfiguredFont("UI font", "Google Sans Flex", shellPath, "", false, "")
if missing.status != statusWarn {
t.Fatalf("missing bundled font status = %v, want %v", missing.status, statusWarn)
}
if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
t.Fatal(err)
}
for _, family := range []string{"Google Sans Flex", " google sans flex "} {
result := checkConfiguredFont("UI font", family, shellPath, "", false, "")
if result.status != statusOK {
t.Fatalf("bundled font %q status = %v, want %v", family, result.status, statusOK)
}
}
}
-437
View File
@@ -1,437 +0,0 @@
package main
import (
"bytes"
"debug/elf"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
)
func TestQuickshellVersionFailureDetails(t *testing.T) {
tests := []struct {
name string
stderr string
want string
}{
{
name: "Qt private API symbol mismatch",
stderr: "qs: symbol lookup error: qs: undefined symbol: _ZN23QUntypedPropertyBindingC1EP23QPropertyBindingPrivate, version Qt_6_PRIVATE_API",
want: "Quickshell is incompatible with the installed Qt libraries. Rebuild or reinstall Quickshell against the current Qt version.",
},
{
name: "other failure",
stderr: "qs: unsupported option --version",
want: "Run 'qs --version' to inspect the failure.",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := quickshellVersionFailureDetails(tc.stderr); got != tc.want {
t.Fatalf("quickshellVersionFailureDetails(%q) = %q, want %q", tc.stderr, got, tc.want)
}
})
}
}
// Parsing a full property dump as a bare value would yield a bogus plugin root,
// making the qtengine check warn at a user whose install is fine.
func TestQtQueryValue(t *testing.T) {
const dumpAll = `QT_INSTALL_PREFIX:/usr
QT_INSTALL_PLUGINS:/usr/lib/qt/plugins
QT_VERSION:5.15.19`
tests := []struct {
name string
output string
key string
want string
}{
{
name: "bare value as qmake prints it",
output: "/usr/lib/qt6/plugins\n",
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt6/plugins",
},
{
name: "key:value line among a full property dump",
output: dumpAll,
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt/plugins",
},
{
name: "key absent from a multi line dump yields nothing",
output: dumpAll,
key: "QT_INSTALL_LIBEXECS",
want: "",
},
{
name: "surrounding whitespace is trimmed",
output: " QT_INSTALL_PLUGINS: /usr/lib/qt/plugins \n",
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt/plugins",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := qtQueryValue(tc.output, tc.key); got != tc.want {
t.Fatalf("qtQueryValue(%q, %q) = %q, want %q", tc.output, tc.key, got, tc.want)
}
})
}
}
// stubQtQueryTool writes a fake Qt build tool answering QT_VERSION and
// QT_INSTALL_PLUGINS from the given values, and points PATH at its directory
// alone so the real toolchain cannot interfere.
func stubQtQueryTool(t *testing.T, bin, version, plugins string) string {
t.Helper()
dir := t.TempDir()
script := "#!/bin/sh\ncase \"$2\" in\n QT_VERSION) echo " + version +
" ;;\n QT_INSTALL_PLUGINS) echo " + plugins + " ;;\nesac\n"
if err := os.WriteFile(filepath.Join(dir, bin), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
return dir
}
func writePlugin(t *testing.T, pluginsDir, file string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(pluginsDir, "platformthemes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginsDir, "platformthemes", file), nil, 0o644); err != nil {
t.Fatal(err)
}
}
// writeQtPluginELF writes a minimal ELF holding only the .note.qt.metadata
// note Qt 6 embeds in plugins, recording the Qt version it was built against.
func writeQtPluginELF(t *testing.T, pluginsDir, file string, major, minor byte) {
t.Helper()
var note bytes.Buffer
binary.Write(&note, binary.LittleEndian, []uint32{12, 4, 0x74510001})
note.WriteString("qt-project!\x00")
note.Write([]byte{1, major, minor, 1})
shstrtab := []byte("\x00.note.qt.metadata\x00.shstrtab\x00")
noteOff := uint64(64)
strOff := noteOff + uint64(note.Len())
shOff := strOff + uint64(len(shstrtab))
var out bytes.Buffer
header := elf.Header64{
Type: uint16(elf.ET_DYN), Machine: uint16(elf.EM_X86_64), Version: uint32(elf.EV_CURRENT),
Shoff: shOff, Ehsize: 64, Shentsize: 64, Shnum: 3, Shstrndx: 2,
}
copy(header.Ident[:], elf.ELFMAG)
header.Ident[elf.EI_CLASS] = byte(elf.ELFCLASS64)
header.Ident[elf.EI_DATA] = byte(elf.ELFDATA2LSB)
header.Ident[elf.EI_VERSION] = byte(elf.EV_CURRENT)
binary.Write(&out, binary.LittleEndian, header)
out.Write(note.Bytes())
out.Write(shstrtab)
binary.Write(&out, binary.LittleEndian, []elf.Section64{
{},
{Name: 1, Type: uint32(elf.SHT_NOTE), Off: noteOff, Size: uint64(note.Len()), Addralign: 4},
{Name: 20, Type: uint32(elf.SHT_STRTAB), Off: strOff, Size: uint64(len(shstrtab)), Addralign: 1},
})
if err := os.MkdirAll(filepath.Join(pluginsDir, "platformthemes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginsDir, "platformthemes", file), out.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
func TestCheckQtPlatformThemePlugin(t *testing.T) {
t.Run("qt6ct-kde is flagged as a package name, not a theme", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct-kde")
t.Setenv("PATH", t.TempDir()) // no Qt tools reachable
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 {
t.Fatalf("got %d results, want 1: %+v", len(results), results)
}
if results[0].status != statusWarn {
t.Fatalf("status = %q, want warn", results[0].status)
}
if !strings.Contains(results[0].details, "package name") || !strings.Contains(results[0].details, "qt6ct") {
t.Fatalf("details should name the package/value mixup, got %q", results[0].details)
}
})
t.Run("installed plugin reports OK", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
writePlugin(t, plugins, "libqt6ct.so")
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK result", results)
}
if !strings.Contains(results[0].details, "libqt6ct.so") {
t.Fatalf("details should point at the plugin, got %q", results[0].details)
}
})
t.Run("plugin built for the installed Qt minor reports OK", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
writeQtPluginELF(t, plugins, "libqt6ct.so", 6, 12)
stubQtQueryTool(t, "qmake6", "6.12.3", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK result", results)
}
})
t.Run("plugin built for another Qt minor warns", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
writeQtPluginELF(t, plugins, "libqt6ct.so", 6, 11)
stubQtQueryTool(t, "qmake6", "6.12.0", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusWarn {
t.Fatalf("got %+v, want one warn result", results)
}
if results[0].message != "Built for Qt 6.11, Qt is 6.12.0" {
t.Fatalf("message = %q, want both Qt versions", results[0].message)
}
if !strings.Contains(results[0].details, filepath.Join(plugins, "platformthemes", "libqt6ct.so")) {
t.Fatalf("details should name the stale plugin, got %q", results[0].details)
}
})
t.Run("missing plugin warns with the expected path", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
stubQtQueryTool(t, "qtpaths6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusWarn {
t.Fatalf("got %+v, want one warn result", results)
}
want := filepath.Join(plugins, "platformthemes", "libqt6ct.so")
if !strings.Contains(results[0].details, want) {
t.Fatalf("details should contain expected path %s, got %q", want, results[0].details)
}
})
t.Run("no Qt build tools degrades to info", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
t.Setenv("PATH", t.TempDir())
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("unset is left to checkEnvVar", func(t *testing.T) {
t.Setenv("PATH", t.TempDir())
t.Setenv("QT_QPA_PLATFORMTHEME", "")
if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
t.Fatalf("unset should yield no results, got %+v", results)
}
})
t.Run("qtengine checks the Qt5 and Qt6 plugins", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qtengine")
qt5, qt6 := t.TempDir(), t.TempDir()
writePlugin(t, qt6, "libqt6engine-plugin.so")
dir5 := stubQtQueryTool(t, "qmake-qt5", "5.15.19", qt5)
dir6 := stubQtQueryTool(t, "qmake6", "6.11.2", qt6)
t.Setenv("PATH", dir6+string(os.PathListSeparator)+dir5)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 2 {
t.Fatalf("got %d results, want 2: %+v", len(results), results)
}
if results[0].name != "qtengine plugin (Qt5)" || results[0].status != statusWarn {
t.Fatalf("Qt5 result = %+v, want a warning for the missing plugin", results[0])
}
if !strings.Contains(results[0].details, filepath.Join(qt5, "platformthemes", "libqt5engine-plugin.so")) {
t.Fatalf("Qt5 details should contain the expected path, got %q", results[0].details)
}
if results[1].name != "qtengine plugin (Qt6)" || results[1].status != statusOK {
t.Fatalf("Qt6 result = %+v, want OK", results[1])
}
})
t.Run("qtengine in the qt6 variable skips Qt5", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME_QT6", "qtengine")
qt5, qt6 := t.TempDir(), t.TempDir()
writePlugin(t, qt6, "libqt6engine-plugin.so")
dir5 := stubQtQueryTool(t, "qmake-qt5", "5.15.19", qt5)
dir6 := stubQtQueryTool(t, "qmake6", "6.11.2", qt6)
t.Setenv("PATH", dir6+string(os.PathListSeparator)+dir5)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")
if len(results) != 1 || results[0].name != "qtengine plugin (Qt6)" || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK Qt6 result", results)
}
})
t.Run("qtengine without Qt tools degrades to one info", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qtengine")
t.Setenv("PATH", t.TempDir())
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusInfo || results[0].name != "qtengine plugin (Qt6)" {
t.Fatalf("got %+v, want one Qt6 info result", results)
}
})
t.Run("qt6 variable is not satisfied by a Qt5 tool alone", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME_QT6", "qt6ct")
plugins := t.TempDir()
stubQtQueryTool(t, "qmake-qt5", "5.15.19", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("kde plugin miss degrades to info", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "kde")
plugins := t.TempDir()
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("gtk3 resolves through the libq convention", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "gtk3")
plugins := t.TempDir()
writePlugin(t, plugins, "libqgtk3.so")
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK result", results)
}
})
}
func TestCheckQtengineConfig(t *testing.T) {
configHome, dataHome := t.TempDir(), t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("XDG_DATA_HOME", dataHome)
path := filepath.Join(configHome, "qtengine", "config.json")
scheme := filepath.Join(dataHome, "color-schemes", "DankMatugen.colors")
write := func(t *testing.T, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
tests := []struct {
name string
content string // "" leaves the file absent
status status
message string
}{
{"missing", "", statusInfo, "Not found"},
{"empty", "\n", statusInfo, "Not found"},
{"unparseable", "{", statusWarn, "Unreadable"},
{"theme is not an object", `{"theme": 1}`, statusWarn, "Unreadable"},
{"dms scheme", `{"theme": {"colorScheme": "` + scheme + `"}}`, statusOK, "Uses DMS colors"},
{"other scheme", `{"theme": {"colorScheme": "/usr/share/color-schemes/BreezeDark.colors"}}`, statusInfo, "Not using DMS colors"},
{"no scheme", `{"theme": {}}`, statusInfo, "Not using DMS colors"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
os.Remove(path)
if tc.content != "" {
write(t, tc.content)
}
results := checkQtengineConfig()
if len(results) != 1 || results[0].status != tc.status || results[0].message != tc.message {
t.Fatalf("got %+v, want %s %q", results, tc.status, tc.message)
}
})
}
}
func TestCheckXDGMenuFile(t *testing.T) {
configHome, systemDir := t.TempDir(), t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("XDG_CONFIG_DIRS", systemDir)
if checkXDGMenuFile("hyprland-") {
t.Fatal("no menu file anywhere should not be found")
}
for _, dir := range []string{configHome, systemDir} {
menus := filepath.Join(dir, "menus")
if err := os.MkdirAll(menus, 0o755); err != nil {
t.Fatal(err)
}
file := filepath.Join(menus, "hyprland-applications.menu")
if err := os.WriteFile(file, nil, 0o644); err != nil {
t.Fatal(err)
}
if !checkXDGMenuFile("hyprland-") {
t.Fatalf("menu file in %s should be found", menus)
}
os.Remove(file)
}
}
func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "dms", "layout.conf"), []byte("border_px=2\n"), 0o644)
if got := checkMangoConfig(dir, mangoconf.Legacy); len(got) != 2 || got[0].message != "Not legacy keys: layout.conf" {
t.Fatalf("legacy: %+v", got)
}
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" {
t.Fatalf("snake: %+v", got)
}
}
func TestHyprlandLacksLua(t *testing.T) {
for ver, want := range map[string]bool{
"0.54.9": true,
"0.55.0": false,
"": false,
} {
if got := hyprlandLacksLua(ver); got != want {
t.Errorf("hyprlandLacksLua(%q) = %v, want %v", ver, got, want)
}
}
}
func TestCheckHyprlandConfigFormat(t *testing.T) {
for name, tc := range map[string]struct {
files []string
warn bool
}{
"lua only": {[]string{"hyprland.lua"}, false},
"lua wins": {[]string{"hyprland.lua", "hyprland.conf"}, false},
"conf only": {[]string{"hyprland.conf"}, true},
"neither": {nil, false},
} {
dir := t.TempDir()
for _, f := range tc.files {
if err := os.WriteFile(filepath.Join(dir, f), nil, 0o644); err != nil {
t.Fatal(err)
}
}
got := checkHyprlandConfigFormat(dir)
if warned := len(got) == 1 && got[0].status == statusWarn; warned != tc.warn || (!tc.warn && len(got) != 0) {
t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn)
}
}
}
+51 -38
View File
@@ -3,19 +3,19 @@ package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"path/filepath"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
"github.com/spf13/cobra"
)
var dlOutput string
var dlUserAgent string
var dlHeaders []string
var dlTimeout int
var dlConnectTimeout int
var dlIPv4Only bool
var dlCmd = &cobra.Command{
@@ -33,52 +33,65 @@ var dlCmd = &cobra.Command{
func init() {
dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)")
dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header")
dlCmd.Flags().StringArrayVarP(&dlHeaders, "header", "H", nil, "Extra request header as 'Name: value' (repeatable)")
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Total request timeout in seconds")
dlCmd.Flags().IntVar(&dlConnectTimeout, "connect-timeout", 5, "Connection timeout in seconds")
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds")
dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only")
}
func parseHeaders(raw []string) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
headers := make(map[string]string, len(raw))
for _, entry := range raw {
name, value, found := strings.Cut(entry, ":")
if !found {
return nil, fmt.Errorf("invalid header %q: expected 'Name: value'", entry)
}
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("invalid header %q: empty name", entry)
}
headers[name] = strings.TrimSpace(value)
}
return headers, nil
}
func runDownload(url string) error {
headers, err := parseHeaders(dlHeaders)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
return fmt.Errorf("invalid request: %w", err)
}
opts := netfetch.Options{
Headers: headers,
UserAgent: dlUserAgent,
Timeout: time.Duration(dlTimeout) * time.Second,
ConnectTimeout: time.Duration(dlConnectTimeout) * time.Second,
IPv4Only: dlIPv4Only,
switch {
case dlUserAgent != "":
req.Header.Set("User-Agent", dlUserAgent)
default:
req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)")
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{DialContext: dialer.DialContext}
if dlIPv4Only {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
}
}
client := &http.Client{Transport: transport}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
if dlOutput == "" {
return netfetch.ToWriter(context.Background(), url, opts, os.Stdout)
_, err = io.Copy(os.Stdout, resp.Body)
return err
}
if err := netfetch.ToFile(context.Background(), url, opts, dlOutput); err != nil {
return err
if dir := filepath.Dir(dlOutput); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir failed: %w", err)
}
}
f, err := os.Create(dlOutput)
if err != nil {
return fmt.Errorf("create failed: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
os.Remove(dlOutput)
return fmt.Errorf("write failed: %w", err)
}
fmt.Println(dlOutput)
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
+44 -69
View File
@@ -13,22 +13,20 @@ import (
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/version"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update Dux Shell to the latest version",
Long: "Update Dux Shell to the latest version using the appropriate package manager for your distribution",
PreRunE: shellApp.ResolveConfig,
Short: "Update DankMaterialShell to the latest version",
Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runUpdate()
},
@@ -36,7 +34,7 @@ var updateCmd = &cobra.Command{
var updateCheckCmd = &cobra.Command{
Use: "check",
Short: "Check if updates are available for Dux Shell",
Short: "Check if updates are available for DankMaterialShell",
Long: "Check for available updates without performing the actual update",
Run: func(cmd *cobra.Command, args []string) {
runUpdateCheck()
@@ -44,19 +42,9 @@ var updateCheckCmd = &cobra.Command{
}
func runUpdateCheck() {
fmt.Println("Checking for Dux Shell updates...")
fmt.Println("Checking for DankMaterialShell updates...")
fmt.Println()
shell := sysupdate.ProbeShell(context.Background(), Version)
switch shell.InstallMethod {
case sysupdate.InstallPacman, sysupdate.InstallRPM, sysupdate.InstallDpkg, sysupdate.InstallXbps:
fmt.Printf("Installed as package %s (%s); update it with your package manager.\n", shell.PackageName, shell.InstallMethod)
os.Exit(0)
case sysupdate.InstallNix:
fmt.Println("Managed by Nix; update through your flake or channel.")
os.Exit(0)
}
versionInfo, err := version.GetDMSVersionInfo()
if err != nil {
log.Fatalf("Error checking for updates: %v", err)
@@ -110,7 +98,7 @@ func runUpdate() {
}
log.Info("Update complete! Restarting DMS...")
shellApp.Restart()
restartShell()
}
func updateArchLinux() error {
@@ -169,7 +157,7 @@ func updateArchLinux() error {
return updateOtherDistros()
}
fmt.Printf("This will update Dux Shell using %s.\n", helper)
fmt.Printf("This will update DankMaterialShell using %s.\n", helper)
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
@@ -193,8 +181,12 @@ func updateOtherDistros() error {
}
dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
_, statErr := os.Stat(dmsPath)
hasClone := statErr == nil
if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
return fmt.Errorf("DMS configuration directory not found at %s", dmsPath)
}
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
versionInfo, err := version.GetDMSVersionInfo()
if err == nil && !versionInfo.HasUpdate {
@@ -206,25 +198,9 @@ func updateOtherDistros() error {
return errdefs.ErrNoUpdateNeeded
}
// Release binaries embed the shell; without a clone the binary is the whole install.
if !hasClone {
fmt.Println("\nThis will update the dms binary from GitHub releases.")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Println("\n=== Updating dms binary ===")
if err := updateDMSBinary(); err != nil {
return fmt.Errorf("failed to update dms binary: %w", err)
}
fmt.Println("dms binary successfully updated")
return nil
}
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
fmt.Println("\nThis will update:")
fmt.Println(" 1. The dms binary from GitHub releases")
fmt.Println(" 2. Dux Shell configuration using git")
fmt.Println(" 2. DankMaterialShell configuration using git")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
@@ -274,10 +250,6 @@ func updateOtherDistros() error {
return fmt.Errorf("failed to fetch changes: %w", err)
}
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
if currentTag != "" {
latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname")
latestTagOutput, err := latestTagCmd.Output()
@@ -319,10 +291,6 @@ func updateOtherDistros() error {
return fmt.Errorf("update cancelled")
}
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag)
return nil
}
@@ -352,21 +320,10 @@ func updateOtherDistros() error {
return fmt.Errorf("update cancelled")
}
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
fmt.Println("\nUpdate complete!")
return nil
}
func updateSubmodules() error {
submoduleCmd := exec.Command("git", "submodule", "update", "--init", "--recursive")
submoduleCmd.Stdout = os.Stdout
submoduleCmd.Stderr = os.Stderr
return submoduleCmd.Run()
}
func offerReclone(dmsPath string) bool {
fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ")
reader := bufio.NewReader(os.Stdin)
@@ -385,7 +342,7 @@ func offerReclone(dmsPath string) bool {
}
fmt.Println("Cloning fresh copy...")
cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/duxlabs-dev/dux-shell.git", dmsPath)
cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil {
@@ -436,11 +393,27 @@ func updateDMSBinary() error {
}
fmt.Println("Fetching latest release version...")
version, err := netfetch.LatestReleaseTag(context.Background())
cmd := exec.Command("curl", "-s", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err)
}
version := ""
for line := range strings.SplitSeq(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"")
if len(parts) >= 4 {
version = parts[3]
break
}
}
}
if version == "" {
return fmt.Errorf("could not determine latest version")
}
fmt.Printf("Latest version: %s\n", version)
tempDir, err := os.MkdirTemp("", "dms-update-*")
@@ -449,19 +422,21 @@ func updateDMSBinary() error {
}
defer os.RemoveAll(tempDir)
binaryURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/releases/download/%s/dms-cli-%s.gz", version, arch)
checksumURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch)
binaryURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch)
checksumURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch)
binaryPath := filepath.Join(tempDir, "dms.gz")
checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
fmt.Println("Downloading dms binary...")
if err := netfetch.ToFile(context.Background(), binaryURL, netfetch.Options{Timeout: 5 * time.Minute}, binaryPath); err != nil {
downloadCmd := exec.Command("curl", "-L", binaryURL, "-o", binaryPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download binary: %w", err)
}
fmt.Println("Downloading checksum...")
if err := netfetch.ToFile(context.Background(), checksumURL, netfetch.Options{Timeout: 30 * time.Second}, checksumPath); err != nil {
downloadCmd = exec.Command("curl", "-L", checksumURL, "-o", checksumPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download checksum: %w", err)
}
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"errors"
"reflect"
"strings"
"testing"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra"
)
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
origGreeterConfigSyncFn := greeterConfigSyncFn
origSharedAuthSyncFn := sharedAuthSyncFn
t.Cleanup(func() {
greeterConfigSyncFn = origGreeterConfigSyncFn
sharedAuthSyncFn = origSharedAuthSyncFn
})
var calls []string
greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
if dmsPath != "/tmp/dms" {
t.Fatalf("unexpected dmsPath %q", dmsPath)
}
if compositor != "niri" {
t.Fatalf("unexpected compositor %q", compositor)
}
if sudoPassword != "" {
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
}
calls = append(calls, "configs")
return nil
}
var gotOptions sharedpam.SyncAuthOptions
sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error {
if sudoPassword != "" {
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
}
gotOptions = options
calls = append(calls, "auth")
return nil
}
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{
ForceGreeterAuth: true,
}, func() {
calls = append(calls, "before-auth")
})
if err != nil {
t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err)
}
wantCalls := []string{"configs", "before-auth", "auth"}
if !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("call order = %v, want %v", calls, wantCalls)
}
if !gotOptions.ForceGreeterAuth {
t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions)
}
}
func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
origGreeterConfigSyncFn := greeterConfigSyncFn
origSharedAuthSyncFn := sharedAuthSyncFn
t.Cleanup(func() {
greeterConfigSyncFn = origGreeterConfigSyncFn
sharedAuthSyncFn = origSharedAuthSyncFn
})
greeterConfigSyncFn = func(string, string, func(string), string) error {
return errors.New("config sync failed")
}
authCalled := false
sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error {
authCalled = true
return nil
}
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil)
if err == nil || err.Error() != "config sync failed" {
t.Fatalf("expected config sync error, got %v", err)
}
if authCalled {
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)
}
}
-485
View File
@@ -1,485 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/icc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server/wayland"
"github.com/spf13/cobra"
)
var iccCmd = &cobra.Command{
Use: "icc",
Short: "Manage ICC color profiles",
Long: "Manage ICC color profile assignments for displays",
}
var iccListCmd = &cobra.Command{
Use: "list",
Short: "List available ICC profiles and current assignments",
Args: cobra.NoArgs,
Run: runICCList,
}
var iccInfoCmd = &cobra.Command{
Use: "info <file>",
Short: "Show details of an ICC profile",
Args: cobra.ExactArgs(1),
Run: runICCInfo,
}
var iccApplyCmd = &cobra.Command{
Use: "apply <output> <file>",
Short: "Apply ICC profile to a specific output",
Args: cobra.ExactArgs(2),
Run: runICCApply,
}
var iccRemoveCmd = &cobra.Command{
Use: "remove <output>",
Short: "Remove ICC profile from a specific output",
Args: cobra.ExactArgs(1),
Run: runICCRemove,
}
var iccStatusCmd = &cobra.Command{
Use: "status",
Short: "Show ICC profile status for all outputs",
Args: cobra.NoArgs,
Run: runICCStatus,
}
var iccSetTempCmd = &cobra.Command{
Use: "set-temp <output> <kelvin>",
Aliases: []string{"setTemp"},
Short: "Set the color temperature for a specific output",
Long: "Set the color temperature for a specific output, independently of the\n" +
"night light schedule: every display keeps its own value, which also makes\n" +
"the range wider than the night light's.\n\n" +
"For an output with an ICC profile the temperature is composed on top of\n" +
"it, treating the profile as measured at 6500K.\n\n" +
"Accepts 1000-10000K; 0 removes the override, so the output follows the\n" +
"night light schedule again (6500K while it is disabled).",
Args: cobra.ExactArgs(2),
Run: runICCSetTemp,
}
func init() {
iccCmd.AddCommand(iccListCmd, iccInfoCmd, iccApplyCmd, iccRemoveCmd, iccStatusCmd, iccSetTempCmd)
}
func getICCConfigDir() string {
return wayland.ICCProfilesDir()
}
func listAvailableProfiles() []string {
dir := getICCConfigDir()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var profiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext == ".icc" || ext == ".icm" {
profiles = append(profiles, filepath.Join(dir, name))
}
}
return profiles
}
func runICCList(cmd *cobra.Command, args []string) {
// Get current assignments from running server
req := ipc.Request{
Method: "wayland.icc.getStatus",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get ICC status: %v", err)
}
statusMap := make(map[string]map[string]any)
outputs := []string{}
if resp.Result != nil {
if resultBytes, err := json.Marshal(*resp.Result); err == nil {
var result map[string]any
if err := json.Unmarshal(resultBytes, &result); err == nil {
if outputsRaw, ok := result["outputs"].([]any); ok {
for _, o := range outputsRaw {
if s, ok := o.(string); ok {
outputs = append(outputs, s)
}
}
}
if profilesRaw, ok := result["profiles"].(map[string]any); ok {
for k, v := range profilesRaw {
if m, ok := v.(map[string]any); ok {
statusMap[k] = m
}
}
}
}
}
}
// Show existing assignments
fmt.Println("Current ICC Assignments:")
fmt.Println(strings.Repeat("─", 80))
if len(outputs) == 0 && len(statusMap) == 0 {
fmt.Println(" (no outputs found)")
} else {
shown := make(map[string]bool)
for _, output := range outputs {
shown[output] = true
if status, ok := statusMap[output]; ok {
desc := ""
if d, ok := status["description"].(string); ok {
desc = d
}
path := ""
if p, ok := status["path"].(string); ok {
path = p
}
fmt.Printf(" %-20s %-30s %s\n", output, desc, path)
} else {
fmt.Printf(" %-20s %-30s %s\n", output, "(none)", "")
}
}
// Show any outputs that are in statusMap but not in outputs list
for output, status := range statusMap {
if shown[output] {
continue
}
desc := ""
if d, ok := status["description"].(string); ok {
desc = d
}
path := ""
if p, ok := status["path"].(string); ok {
path = p
}
fmt.Printf(" %-20s %-30s %s\n", output, desc, path)
}
}
// Show available profiles in config dir
fmt.Println()
fmt.Println("Available Profiles in", getICCConfigDir()+":")
fmt.Println(strings.Repeat("─", 80))
profiles := listAvailableProfiles()
if len(profiles) == 0 {
fmt.Println(" (no .icc/.icm files found)")
} else {
for _, path := range profiles {
profile, err := icc.ParseFile(path)
if err != nil {
fmt.Printf(" %-40s (parse error: %v)\n", filepath.Base(path), err)
continue
}
// A profile without a vcgt table has no video card gamma ramp, so the
// daemon refuses to apply it: say so here instead of letting the user
// find out from a rejected `dms icc apply`.
note := ""
if !profile.HasVCGT {
note = " (no vcgt table: cannot be applied)"
}
fmt.Printf(" %-40s %s%s\n", filepath.Base(path), profile.Description, note)
}
}
}
func runICCInfo(cmd *cobra.Command, args []string) {
path := args[0]
profile, err := icc.ParseFile(path)
if err != nil {
log.Fatalf("Failed to parse ICC profile: %v", err)
}
fmt.Printf("ICC Profile: %s\n", filepath.Base(path))
fmt.Println(strings.Repeat("─", 60))
fmt.Printf(" Description: %s\n", profile.Description)
fmt.Printf(" Version: %s\n", profile.Version)
fmt.Printf(" Class: %s\n", profile.Class)
fmt.Printf(" Color Space: %s\n", profile.ColorSpace)
fmt.Println()
if profile.HasMatrix {
fmt.Println("Matrix:")
for i := 0; i < 3; i++ {
fmt.Printf(" %8.4f %8.4f %8.4f\n",
profile.Matrix[i][0], profile.Matrix[i][1], profile.Matrix[i][2])
}
fmt.Println()
}
if profile.HasTRC {
fmt.Println("TRC (Tone Reproduction Curves):")
trcNames := []string{" Red", " Green", " Blue"}
for i, name := range trcNames {
curve := profile.TRC[i]
switch curve.Type {
case icc.CurveIdentity:
fmt.Printf("%-8s identity\n", name)
case icc.CurveParametric:
fmt.Printf("%-8s gamma=%.2f\n", name, curve.Gamma)
case icc.CurveTable:
fmt.Printf("%-8s table (%d entries)\n", name, len(curve.Entries))
}
}
fmt.Println()
}
if profile.HasVCGT && profile.VCGT != nil {
fmt.Printf("VCGT: %d channels, %d entries per channel\n", profile.VCGT.Channels, profile.VCGT.Entries)
fmt.Println()
}
if x, y, ok := profile.WhitePointXY(); ok {
cct := profile.WhitePointCCT()
name := profile.WhitePointName()
switch {
case name != "" && cct > 0:
fmt.Printf("White Point: %s (%.4f, %.4f, %dK)\n", name, x, y, cct)
case cct > 0:
fmt.Printf("White Point: (%.4f, %.4f, %dK)\n", x, y, cct)
default:
fmt.Printf("White Point: (%.4f, %.4f)\n", x, y)
}
} else {
fmt.Printf("White Point: %.4f %.4f %.4f\n",
profile.WhitePoint[0], profile.WhitePoint[1], profile.WhitePoint[2])
}
}
func runICCApply(cmd *cobra.Command, args []string) {
outputName := args[0]
filePath, err := filepath.Abs(args[1])
if err != nil {
log.Fatalf("Failed to resolve path: %v", err)
}
profile, err := icc.ParseFile(filePath)
if err != nil {
log.Fatalf("Invalid ICC profile: %v", err)
}
req := ipc.Request{
Method: "wayland.icc.apply",
Params: map[string]any{
"output": outputName,
"path": filePath,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to apply ICC profile: %v", err)
}
if resp.Error != "" {
log.Fatalf("Server error: %s", resp.Error)
}
fmt.Printf("Applied ICC profile '%s' to output '%s'\n", profile.Description, outputName)
}
func runICCRemove(cmd *cobra.Command, args []string) {
outputName := args[0]
req := ipc.Request{
Method: "wayland.icc.remove",
Params: map[string]any{
"output": outputName,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to remove ICC profile: %v", err)
}
if resp.Error != "" {
log.Fatalf("Server error: %s", resp.Error)
}
fmt.Printf("Removed ICC profile from output '%s'\n", outputName)
}
func runICCSetTemp(cmd *cobra.Command, args []string) {
outputName := args[0]
temp, err := strconv.Atoi(args[1])
if err != nil {
log.Fatalf("Invalid temperature %q: expected a number in kelvin", args[1])
}
if temp != 0 && (temp < 1000 || temp > 10000) {
log.Fatalf("Temperature %d out of range (1000-10000, or 0 to follow the schedule)", temp)
}
req := ipc.Request{
Method: "wayland.icc.setTemp",
Params: map[string]any{
"output": outputName,
"temp": temp,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to set output temperature: %v", err)
}
if resp.Error != "" {
log.Fatalf("Server error: %s", resp.Error)
}
if temp == 0 {
fmt.Printf("Output '%s' follows the night light schedule again\n", outputName)
return
}
fmt.Printf("Set output '%s' color temperature to %dK\n", outputName, temp)
}
// fetchICCOutputTemps asks the running server for the per-output overrides.
// Outputs without an override report 0.
func fetchICCOutputTemps() map[string]int {
temps := make(map[string]int)
resp, err := sendServerRequest(ipc.Request{Method: "wayland.icc.getTemps"})
if err != nil || resp.Result == nil {
return temps
}
resultBytes, err := json.Marshal(*resp.Result)
if err != nil {
return temps
}
var raw map[string]any
if err := json.Unmarshal(resultBytes, &raw); err != nil {
return temps
}
for output, value := range raw {
if f, ok := value.(float64); ok {
temps[output] = int(f)
}
}
return temps
}
// formatOutputTemp renders a per-output override, or the schedule marker.
func formatOutputTemp(temps map[string]int, output string) string {
temp, ok := temps[output]
if !ok || temp == 0 {
return "schedule"
}
return strconv.Itoa(temp) + "K"
}
func runICCStatus(cmd *cobra.Command, args []string) {
req := ipc.Request{
Method: "wayland.icc.getStatus",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get ICC status: %v", err)
}
statusMap := make(map[string]map[string]any)
outputs := []string{}
if resp.Result != nil {
if resultBytes, err := json.Marshal(*resp.Result); err == nil {
var result map[string]any
if err := json.Unmarshal(resultBytes, &result); err == nil {
if outputsRaw, ok := result["outputs"].([]any); ok {
for _, o := range outputsRaw {
if s, ok := o.(string); ok {
outputs = append(outputs, s)
}
}
}
if profilesRaw, ok := result["profiles"].(map[string]any); ok {
for k, v := range profilesRaw {
if m, ok := v.(map[string]any); ok {
statusMap[k] = m
}
}
}
}
}
}
temps := fetchICCOutputTemps()
fmt.Println("ICC Profile Status:")
fmt.Println(strings.Repeat("─", 100))
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", "Output", "Profile", "Version", "ColorSpace", "Active", "Temp")
fmt.Println(" " + strings.Repeat("─", 96))
shown := make(map[string]bool)
for _, output := range outputs {
shown[output] = true
if status, ok := statusMap[output]; ok {
active := false
if a, ok := status["active"].(bool); ok {
active = a
}
desc := "(none)"
if d, ok := status["description"].(string); ok && d != "" {
desc = d
}
version := ""
if v, ok := status["version"].(string); ok {
version = v
}
colorSpace := ""
if c, ok := status["colorSpace"].(string); ok {
colorSpace = c
}
activeStr := "no"
if active {
activeStr = "yes"
}
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, desc, version, colorSpace, activeStr, formatOutputTemp(temps, output))
} else {
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, "(none)", "", "", "no", formatOutputTemp(temps, output))
}
}
// Show any outputs that are in statusMap but not in outputs list
for output, status := range statusMap {
if shown[output] {
continue
}
active := false
if a, ok := status["active"].(bool); ok {
active = a
}
desc := "(none)"
if d, ok := status["description"].(string); ok && d != "" {
desc = d
}
version := ""
if v, ok := status["version"].(string); ok {
version = v
}
colorSpace := ""
if c, ok := status["colorSpace"].(string); ok {
colorSpace = c
}
activeStr := "no"
if active {
activeStr = "yes"
}
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, desc, version, colorSpace, activeStr, formatOutputTemp(temps, output))
}
}
+83 -139
View File
@@ -2,18 +2,13 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
@@ -51,7 +46,7 @@ var keybindsSetCmd = &cobra.Command{
Short: "Set a keybind override",
Long: "Create or update a keybind override for the specified provider",
Args: cobra.ExactArgs(3),
Run: runKeybindsEdit,
Run: runKeybindsSet,
}
var keybindsRemoveCmd = &cobra.Command{
@@ -59,7 +54,7 @@ var keybindsRemoveCmd = &cobra.Command{
Short: "Remove a keybind",
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
Args: cobra.ExactArgs(2),
Run: runKeybindsEdit,
Run: runKeybindsRemove,
}
var keybindsResetCmd = &cobra.Command{
@@ -67,23 +62,10 @@ var keybindsResetCmd = &cobra.Command{
Short: "Reset a keybind override to its DMS default",
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
Args: cobra.ExactArgs(2),
Run: runKeybindsEdit,
}
var keybindsKeymapCmd = &cobra.Command{
Use: "keymap",
Short: "Report which keysym each physical key carries on the first level",
Long: "Print the keysyms the active keyboard layout puts on the first level of every physical key, keyed by xkb keycode.\n\n" +
"Compositors resolve bind keysyms at that level, so a keysym missing from this list cannot be bound however many modifiers are pressed. " +
"The keybind editor uses it to warn instead of storing a bind that never fires.",
Run: runKeybindsKeymap,
Run: runKeybindsReset,
}
func init() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
command.Flags().Bool("json", false, "Return structured mutation results, including errors")
}
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
@@ -92,14 +74,13 @@ func init() {
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)")
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags as letters: e repeat, l locked, r release, o long-press, n non-consuming, m mouse, t transparent, i ignore-mods, u submap-universal, a auto-consuming, x allow-input-capture")
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
keybindsCmd.AddCommand(keybindsListCmd)
keybindsCmd.AddCommand(keybindsShowCmd)
keybindsCmd.AddCommand(keybindsSetCmd)
keybindsCmd.AddCommand(keybindsRemoveCmd)
keybindsCmd.AddCommand(keybindsResetCmd)
keybindsCmd.AddCommand(keybindsKeymapCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath)
@@ -112,9 +93,6 @@ func initializeProviders() {
registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("")
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
log.Warnf("Failed to register Aqueous provider: %v", err)
}
if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err)
}
@@ -177,33 +155,6 @@ func runKeybindsList(cmd *cobra.Command, _ []string) {
}
}
// runKeybindsKeymap answers with empty fields rather than an error when there
// is no Wayland seat to ask, so the caller can simply skip the check instead
// of having to tell "unknown layout" apart from "nothing wrong".
func runKeybindsKeymap(_ *cobra.Command, _ []string) {
keysyms := map[string][]string{}
named := []string{}
km, err := keymap.FromSeat()
if err != nil {
log.Warnf("Failed to read the seat keymap: %v", err)
} else {
for code, syms := range km.Level1ByXkbKeycode() {
keysyms[strconv.FormatUint(uint64(code), 10)] = syms
}
// The vocabulary the keysyms above are drawn from, so a caller can
// tell a keysym we could not name from one the layout is missing.
named = slices.Compact(slices.Sorted(maps.Values(keymap.KeysymNames)))
}
output, err := json.Marshal(map[string]any{"keysyms": keysyms, "named": named})
if err != nil {
log.Warnf("Failed to encode keymap: %v", err)
return
}
fmt.Fprintln(os.Stdout, string(output))
}
func makeProviderWithPath(name, path string) keybinds.Provider {
switch name {
case "hyprland":
@@ -255,92 +206,85 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
printCheatSheet(provider)
}
func keybindEditFailure(err error) map[string]any {
code := "command_failed"
var helperError *providers.AqueousError
if errors.As(err, &helperError) {
code = helperError.Code
}
return map[string]any{"success": false, "code": code, "message": err.Error()}
}
func runKeybindsEdit(cmd *cobra.Command, args []string) {
result, err := editKeybind(cmd, args)
if err == nil {
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(result)
return
}
structured, _ := cmd.Flags().GetBool("json")
if !structured {
log.Fatalf("Failed to save keybind: %v", err)
return
}
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(keybindEditFailure(err))
os.Exit(1)
}
func editKeybind(cmd *cobra.Command, args []string) (map[string]any, error) {
provider, err := keybinds.GetDefaultRegistry().Get(args[0])
func getWritableProvider(name string) keybinds.WritableProvider {
provider, err := keybinds.GetDefaultRegistry().Get(name)
if err != nil {
return nil, err
}
key := args[1]
if aqueous, ok := provider.(*providers.AqueousProvider); ok {
generation, _ := cmd.Flags().GetString("expected-generation")
edit := providers.AqueousBindEdit{Generation: generation, Key: key, Remove: cmd.Name() != "set"}
if !edit.Remove {
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
if cmd.Flags().Changed(flag) {
return nil, fmt.Errorf("aqueous does not support --%s in this provider", flag)
}
}
edit.Action = args[2]
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
}
result, err := aqueous.Edit(cmd.Context(), edit)
if err != nil {
return nil, err
}
return map[string]any{"success": true, "code": "applied", "generation": result.String("generation"), "key": key}, nil
log.Fatalf("Error: %v", err)
}
writable, ok := provider.(keybinds.WritableProvider)
if !ok {
return nil, fmt.Errorf("provider %s does not support writing keybinds", args[0])
log.Fatalf("Provider %s does not support writing keybinds", name)
}
result := map[string]any{"success": true, "key": key}
switch cmd.Name() {
case "set":
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
desc, _ := cmd.Flags().GetString("desc")
err = writable.SetBind(key, args[2], desc, options)
result["action"] = args[2]
result["path"] = writable.GetOverridePath()
case "remove":
err = writable.RemoveBind(key)
result["removed"] = true
case "reset":
err = writable.ResetBind(key)
result["reset"] = true
default:
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
}
return result, err
return writable
}
func runKeybindsSet(cmd *cobra.Command, args []string) {
providerName, key, action := args[0], args[1], args[2]
writable := getWritableProvider(providerName)
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
desc, _ := cmd.Flags().GetString("desc")
if err := writable.SetBind(key, action, desc, options); err != nil {
log.Fatalf("Error setting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"action": action,
"path": writable.GetOverridePath(),
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsRemove(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.RemoveBind(key); err != nil {
log.Fatalf("Error removing keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"removed": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsReset(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.ResetBind(key); err != nil {
log.Fatalf("Error resetting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"reset": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
-25
View File
@@ -1,25 +0,0 @@
package main
import (
"errors"
"fmt"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers"
)
func TestKeybindEditFailure(t *testing.T) {
for _, tc := range []struct {
err error
code string
}{
{&providers.AqueousError{Code: "external_change", Message: "changed"}, "external_change"},
{fmt.Errorf("helper: %w", &providers.AqueousError{Code: "read_only", Message: "denied"}), "read_only"},
{errors.New("transport failed"), "command_failed"},
} {
result := keybindEditFailure(tc.err)
if result["success"] != false || result["code"] != tc.code || result["message"] != tc.err.Error() {
t.Fatalf("unexpected failure result: %#v", result)
}
}
}
+7 -49
View File
@@ -8,9 +8,9 @@ import (
"os"
"time"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra"
)
@@ -44,18 +44,11 @@ var matugenPreviewCmd = &cobra.Command{
Run: runMatugenPreview,
}
var matugenQtengineCmd = &cobra.Command{
Use: "qtengine",
Short: "Sync qtengine's config with the current DMS theme",
Run: runMatugenQtengine,
}
func init() {
matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd)
matugenCmd.AddCommand(matugenPreviewCmd)
matugenCmd.AddCommand(matugenQtengineCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -63,30 +56,21 @@ func init() {
cmd.Flags().String("config-dir", "", "User config directory")
cmd.Flags().String("kind", "image", "Source type: image or hex")
cmd.Flags().String("value", "", "Wallpaper path or hex color")
cmd.Flags().String("mode", "dark", "Color mode: dark, light, or smart (smart requires matugen 4.2+ and an image source)")
cmd.Flags().String("mode", "dark", "Color mode: dark or light")
cmd.Flags().String("icon-theme", "System Default", "Icon theme name")
cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type (supports scheme-smart on matugen 4.2+)")
cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type")
cmd.Flags().Bool("run-user-templates", true, "Run user matugen templates")
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
cmd.Flags().Int("terminal-opacity", 0, "Terminal background opacity percent, 1-99 (0 or 100 leaves the terminal's own setting)")
cmd.Flags().String("terminal-palette", "", "Terminal palette: default, low, medium or high")
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
cmd.Flags().String("source-mode", "", "Source color selection: dominant, colorful, darkness, lightness, saturation, less-saturation, value")
cmd.Flags().String("seed-color", "", "Hex color to build the palette from instead of extracting one from the wallpaper")
cmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default), 2025 (Material 3 Expressive) or dms (2025 accents on neutral surfaces)")
}
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().String("image", "", "Wallpaper image used to resolve the scheme-smart preview")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
matugenPreviewCmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default), 2025 (Material 3 Expressive) or dms (2025 accents on neutral surfaces)")
matugenQtengineCmd.Flags().String("config-dir", "", "User config directory")
matugenQtengineCmd.Flags().String("icon-theme", "", "Icon theme name")
}
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -102,13 +86,8 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
stockColors, _ := cmd.Flags().GetString("stock-colors")
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
terminalOpacity, _ := cmd.Flags().GetInt("terminal-opacity")
terminalPalette, _ := cmd.Flags().GetString("terminal-palette")
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
contrast, _ := cmd.Flags().GetFloat64("contrast")
sourceMode, _ := cmd.Flags().GetString("source-mode")
seedColor, _ := cmd.Flags().GetString("seed-color")
spec, _ := cmd.Flags().GetString("spec")
return matugen.Options{
StateDir: stateDir,
@@ -124,12 +103,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
StockColors: stockColors,
SyncModeWithPortal: syncModeWithPortal,
TerminalsAlwaysDark: terminalsAlwaysDark,
TerminalOpacity: terminalOpacity,
TerminalPalette: terminalPalette,
SkipTemplates: skipTemplates,
SourceMode: sourceMode,
SeedColor: seedColor,
Spec: spec,
}
}
@@ -149,7 +123,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
wait, _ := cmd.Flags().GetBool("wait")
timeout, _ := cmd.Flags().GetDuration("timeout")
request := ipc.Request{
request := models.Request{
ID: 1,
Method: "matugen.queue",
Params: map[string]any{
@@ -165,13 +139,8 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"stockColors": opts.StockColors,
"syncModeWithPortal": opts.SyncModeWithPortal,
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"terminalOpacity": opts.TerminalOpacity,
"terminalPalette": opts.TerminalPalette,
"skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast,
"sourceMode": opts.SourceMode,
"seedColor": opts.SeedColor,
"spec": opts.Spec,
"wait": wait,
},
}
@@ -243,10 +212,8 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color")
imagePath, _ := cmd.Flags().GetString("image")
contrast, _ := cmd.Flags().GetFloat64("contrast")
spec, _ := cmd.Flags().GetString("spec")
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath, spec)
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err)
}
@@ -256,12 +223,3 @@ func runMatugenPreview(cmd *cobra.Command, args []string) {
}
fmt.Println(string(data))
}
func runMatugenQtengine(cmd *cobra.Command, args []string) {
configDir, _ := cmd.Flags().GetString("config-dir")
iconTheme, _ := cmd.Flags().GetString("icon-theme")
if err := matugen.SyncQtengineConfigAt(configDir, iconTheme); err != nil {
log.Fatalf("Failed to sync qtengine config: %v", err)
}
}
+26 -257
View File
@@ -3,50 +3,29 @@ package main
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/notify"
"github.com/godbus/dbus/v5"
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var (
notifyAppName string
notifyAppIcon string
notifyIcon string
notifyFile string
notifyTimeout int
notifyUrgency string
notifyCategory string
notifyTransient bool
notifyHints []string
notifyActions []string
notifyPrintID bool
notifyReplaceID uint32
notifyWait bool
notifyIDFd int
notifyActionFd int
notifyAppName string
notifyIcon string
notifyFile string
notifyTimeout int
)
var notifyCmd = &cobra.Command{
Use: "notify <summary> [body]",
Short: "Send a desktop notification",
Long: `Send a desktop notification. Flags follow notify-send.
Long: `Send a desktop notification with optional actions.
If --file is provided, the notification gets "Open" and "Open Folder" actions
that open the file or its directory when clicked.
If --file is provided, the notification will have "Open" and "Open Folder" actions.
Examples:
dms notify "Hello" "World"
dms notify -u critical -t 0 "Disk almost full"
dms notify -a "Backup" -i drive-harddisk -c transfer.complete "Backup finished"
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg
dms notify -A retry=Retry -A "Dismiss" "Build failed"
dms notify -h string:x-dms-tag:build -h int:value:42 "Hinted"`,
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
Args: cobra.MinimumNArgs(1),
Run: runNotify,
}
@@ -59,241 +38,31 @@ var genericNotifyActionCmd = &cobra.Command{
},
}
var notifyFlagAliases = map[string]string{
"app": "app-name",
"timeout": "expire-time",
}
func init() {
f := notifyCmd.Flags()
f.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
if alias, ok := notifyFlagAliases[name]; ok {
return pflag.NormalizedName(alias)
}
return pflag.NormalizedName(name)
})
// Frees -h for --hint like notify-send; cobra skips its own help flag when one exists.
f.Bool("help", false, "Show help")
f.StringVarP(&notifyUrgency, "urgency", "u", "", "Urgency level: low, normal, critical")
f.IntVarP(&notifyTimeout, "expire-time", "t", -1, "Timeout in milliseconds (0 never expires, -1 server default)")
f.StringVarP(&notifyAppName, "app-name", "a", "DMS", "Application name")
f.StringVarP(&notifyIcon, "icon", "i", "", "Icon name or path")
f.StringVarP(&notifyAppIcon, "app-icon", "n", "", "Application icon name or path; --icon then becomes the image")
f.StringVar(&notifyCategory, "category", "", "Notification category, comma separated (-c is taken by the global --config)")
f.BoolVarP(&notifyTransient, "transient", "e", false, "Do not keep the notification in history")
f.StringArrayVarP(&notifyHints, "hint", "h", nil, "Extra hint as TYPE:NAME:VALUE (boolean, int, double, string, byte)")
f.BoolVarP(&notifyPrintID, "print-id", "p", false, "Print the notification id")
f.IntVar(&notifyIDFd, "id-fd", -1, "File descriptor to write the notification id to")
f.Uint32VarP(&notifyReplaceID, "replace-id", "r", 0, "Id of the notification to replace")
f.BoolVarP(&notifyWait, "wait", "w", false, "Wait until the notification closes")
f.StringArrayVarP(&notifyActions, "action", "A", nil, "Action as [NAME=]Text; implies --wait, prints the chosen NAME (repeatable)")
f.IntVar(&notifyActionFd, "selected-action-fd", -1, "File descriptor to write the chosen action to")
f.StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
notifyCmd.Flags().StringVar(&notifyAppName, "app", "DMS", "Application name")
notifyCmd.Flags().StringVar(&notifyIcon, "icon", "", "Icon name or path")
notifyCmd.Flags().StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
notifyCmd.Flags().IntVar(&notifyTimeout, "timeout", 5000, "Timeout in milliseconds")
}
func runNotify(cmd *cobra.Command, args []string) {
n, err := buildNotification(args)
if err != nil {
fail(err)
}
wait := notifyWait || len(n.Actions) > 0
var waiter *notify.Waiter
if wait {
waiter, err = notify.NewWaiter()
if err != nil {
fail(err)
}
defer waiter.Close()
}
id, err := notify.Send(n)
if err != nil {
fail(err)
}
reportID(id)
if n.FilePath != "" {
watchNotificationAction(id, n.FilePath)
}
if !wait {
return
}
if id == 0 {
return
}
res := waiter.Wait(id, waitTimeout())
if res.TimedOut {
fmt.Fprintln(os.Stderr, "Wait timeout expired")
return
}
if !res.Invoked {
return
}
fmt.Println(res.Action)
writeFd(notifyActionFd, res.Action)
}
func buildNotification(args []string) (notify.Notification, error) {
n := notify.Notification{
AppName: notifyAppName,
AppIcon: notifyAppIcon,
Icon: notifyIcon,
Summary: args[0],
FilePath: notifyFile,
Timeout: int32(notifyTimeout),
ReplaceID: notifyReplaceID,
Hints: map[string]dbus.Variant{},
}
summary := args[0]
body := ""
if len(args) > 1 {
n.Body = args[1]
body = args[1]
}
if notifyFile != "" && len(notifyActions) > 0 {
return n, fmt.Errorf("--file and --action cannot be combined")
}
if notifyUrgency != "" {
level, err := parseUrgency(notifyUrgency)
if err != nil {
return n, err
}
n.Hints["urgency"] = dbus.MakeVariant(level)
}
if notifyCategory != "" {
n.Hints["category"] = dbus.MakeVariant(notifyCategory)
}
if notifyTransient {
n.Hints["transient"] = dbus.MakeVariant(true)
}
for _, spec := range notifyHints {
name, value, err := parseHint(spec)
if err != nil {
return n, err
}
n.Hints[name] = value
}
n.Actions = parseActions(notifyActions)
return n, nil
}
func parseUrgency(s string) (byte, error) {
switch strings.ToLower(s) {
case "low", "0":
return 0, nil
case "normal", "1":
return 1, nil
case "critical", "2":
return 2, nil
n := notify.Notification{
AppName: notifyAppName,
Icon: notifyIcon,
Summary: summary,
Body: body,
FilePath: notifyFile,
Timeout: int32(notifyTimeout),
}
return 0, fmt.Errorf("invalid urgency %q: use low, normal or critical", s)
}
func parseHint(spec string) (string, dbus.Variant, error) {
parts := strings.SplitN(spec, ":", 3)
if len(parts) != 3 || parts[1] == "" {
return "", dbus.Variant{}, fmt.Errorf("invalid hint %q: expected TYPE:NAME:VALUE", spec)
if err := notify.Send(n); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
typ, name, value := strings.ToLower(parts[0]), parts[1], parts[2]
switch typ {
case "string":
return name, dbus.MakeVariant(value), nil
case "int":
v, err := strconv.ParseInt(value, 10, 32)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid int %q", name, value)
}
return name, dbus.MakeVariant(int32(v)), nil
case "double":
v, err := strconv.ParseFloat(value, 64)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid double %q", name, value)
}
return name, dbus.MakeVariant(v), nil
case "byte":
v, err := strconv.ParseUint(value, 0, 8)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid byte %q", name, value)
}
return name, dbus.MakeVariant(byte(v)), nil
case "boolean":
v, err := parseBoolean(value)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid boolean %q", name, value)
}
return name, dbus.MakeVariant(v), nil
}
return "", dbus.Variant{}, fmt.Errorf("invalid hint type %q: use boolean, int, double, string or byte", parts[0])
}
func parseBoolean(s string) (bool, error) {
switch strings.ToLower(s) {
case "true":
return true, nil
case "false":
return false, nil
}
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return false, err
}
return v != 0, nil
}
func parseActions(specs []string) []string {
if len(specs) == 0 {
return nil
}
actions := make([]string, 0, len(specs)*2)
for i, spec := range specs {
name, label, hasName := strings.Cut(spec, "=")
if !hasName {
name, label = strconv.Itoa(i), spec
}
actions = append(actions, name, label)
}
return actions
}
func waitTimeout() time.Duration {
if notifyTimeout <= 0 {
return 0
}
return time.Duration(notifyTimeout) * time.Millisecond
}
func reportID(id uint32) {
if notifyPrintID {
fmt.Println(id)
}
writeFd(notifyIDFd, strconv.FormatUint(uint64(id), 10))
}
func writeFd(fd int, line string) {
if fd < 0 {
return
}
f := os.NewFile(uintptr(fd), "")
if f == nil {
return
}
fmt.Fprintln(f, line)
_ = f.Sync()
}
func fail(err error) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
func watchNotificationAction(id uint32, path string) {
if id == 0 {
return
}
resp, ok := tryServerRequest(ipc.Request{
Method: "notify.watchAction",
Params: map[string]any{"id": id, "path": path},
})
if ok && resp.Error == "" {
return
}
notify.SpawnActionListener(id, path)
}
+3 -3
View File
@@ -8,8 +8,8 @@ import (
"path/filepath"
"strings"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra"
)
@@ -123,7 +123,7 @@ func runOpen(target string) {
params["url"] = target
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: method,
Params: params,
+4 -4
View File
@@ -6,9 +6,9 @@ import (
"os"
"strings"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/qrcode"
"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"
)
@@ -243,7 +243,7 @@ func fatalf(format string, args ...any) {
}
func fetchWifiQRContent(ssid string) (string, error) {
resp, err := sendServerRequest(ipc.Request{
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "network.qrcode-content",
Params: map[string]any{"ssid": ssid},
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/registries"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
var registryCmd = &cobra.Command{
Use: "registry",
Short: "Manage plugin and theme registries",
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active and additional registries can be added by name and git URL, unless the CLI policy sets disable_registries.",
}
var registryListCmd = &cobra.Command{
Use: "list",
Short: "List configured registries",
Run: func(cmd *cobra.Command, args []string) {
for _, s := range registries.Load(afero.NewOsFs()) {
suffix := ""
if s.Official() {
suffix = " (official)"
}
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
}
},
}
var registryAddCmd = &cobra.Command{
Use: "add <name> <url>",
Short: "Add a registry",
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
log.Fatalf("Error adding registry: %v", err)
}
fmt.Printf("Registry added: %s\n", args[0])
},
}
var registryRemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove a registry",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for _, s := range registries.Load(afero.NewOsFs()) {
if !s.Official() {
names = append(names, s.Name)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
log.Fatalf("Error removing registry: %v", err)
}
fmt.Printf("Registry removed: %s\n", args[0])
},
}
+61 -2
View File
@@ -1,15 +1,74 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var customConfigPath string
var configPath string
var rootCmd = &cobra.Command{
Use: "dms",
Short: "dms CLI",
Long: "dms is the Dux Shell management CLI and backend server.",
Long: "dms is the DankMaterialShell management CLI and backend server.",
}
func init() {
rootCmd.PersistentFlags().StringVarP(shellApp.CustomConfigVar(), "config", "c", "", "Path to a UI config dir (containing shell.qml) to use instead of the embedded UI (env: DMS_SHELL_DIR)")
rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
}
func findConfig(cmd *cobra.Command, args []string) error {
if customConfigPath != "" {
log.Debug("Custom config path provided via -c flag: %s", customConfigPath)
shellPath := filepath.Join(customConfigPath, "shell.qml")
info, statErr := os.Stat(shellPath)
if statErr == nil && !info.IsDir() {
configPath = customConfigPath
log.Debug("Using config from: %s", configPath)
return nil
}
if statErr != nil {
return fmt.Errorf("custom config path error: %w", statErr)
}
return fmt.Errorf("path is a directory, not a file: %s", shellPath)
}
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if data, readErr := os.ReadFile(configStateFile); readErr == nil {
if len(getAllDMSPIDs()) == 0 {
os.Remove(configStateFile)
} else {
statePath := strings.TrimSpace(string(data))
shellPath := filepath.Join(statePath, "shell.qml")
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
log.Debug("Using config from active session state file: %s", statePath)
configPath = statePath
log.Debug("Using config from: %s", configPath)
return nil
}
os.Remove(configStateFile)
}
}
log.Debug("No custom path or active session, searching default XDG locations...")
var err error
configPath, err = config.LocateDMSConfig()
if err != nil {
return err
}
log.Debug("Using config from: %s", configPath)
return nil
}
+98 -198
View File
@@ -2,25 +2,21 @@ package main
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/notify"
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc"
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
"github.com/spf13/cobra"
)
var (
ssOutputName string
ssSeat string
ssCursor string
ssFormat string
ssQuality int
@@ -33,16 +29,11 @@ var (
ssReset bool
ssStdout bool
ssJSON bool
ssGeometry bool
ssAllowMulti bool
ssHUD string
)
type screenshotMetadata struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
X *int `json:"x,omitempty"`
Y *int `json:"y,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Scale float64 `json:"scale,omitempty"`
@@ -60,7 +51,7 @@ 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/niri/Aqueous)
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
@@ -74,7 +65,7 @@ Examples:
dms screenshot full # Full screen of focused output
dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window
dms screenshot window # Focused window (Hyprland)
dms screenshot last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only
@@ -83,8 +74,6 @@ Examples:
dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON
dms screenshot --allow-multiple # Skip the one-selector-at-a-time guard
dms screenshot -g # Print selected region geometry (X,Y WxH) to stdout
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
}
@@ -124,7 +113,7 @@ 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, Mango, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`,
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
Run: runScreenshotWindow,
}
@@ -161,13 +150,12 @@ var notifyActionCmd = &cobra.Command{
Use: "notify-action",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
notify.RunActionListener(args)
screenshot.RunNotifyActionListener(args)
},
}
func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
@@ -180,9 +168,6 @@ func init() {
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")
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
screenshotCmd.PersistentFlags().StringVar(&ssHUD, "hud", "auto", "HUD overlay scale in region selector (auto, off/0, or integer scale 1-4)")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
@@ -202,7 +187,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config := screenshot.DefaultConfig()
config.Mode = mode
config.OutputName = ssOutputName
config.Seat = ssSeat
if strings.EqualFold(ssCursor, "on") {
config.Cursor = screenshot.CursorOn
}
@@ -212,15 +196,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.NoConfirm = ssNoConfirm
config.Reset = ssReset
config.Stdout = ssStdout
config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti
config.HUD = ssHUD
if ssGeometry {
config.Clipboard = false
config.SaveFile = false
config.Notify = false
}
if ssOutputDir != "" {
config.OutputDir = ssOutputDir
@@ -246,50 +221,29 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
}
config.Quality = ssQuality
if mode == screenshot.ModeRegion || mode == screenshot.ModeScroll {
config.SnapTargets = fetchShellSnapTargets()
}
return config
}
func fetchShellSnapTargets() []screenshot.SnapTarget {
pid, ok := shellApp.SessionPID()
if !ok {
return nil
}
res, isVoid, err := qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", "getSurfaces", nil)
if err != nil || isVoid || res == "" {
return nil
}
var targets []screenshot.SnapTarget
if err := json.Unmarshal([]byte(res), &targets); err != nil {
return nil
}
return targets
}
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select.
// Best-effort and not awaited: qs takes ~20ms to come up, longer than the selector needs to show.
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
func setPopoutScreenshotMode(begin bool) {
fn := "end"
if begin {
fn = "begin"
}
if pid, ok := shellApp.SessionPID(); ok {
_, _, _ = qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", fn, nil)
return
}
cmdArgs := []string{"ipc"}
if err := shellApp.ResolveConfig(nil, nil); err != nil {
return
if pid, ok := getFirstDMSPID(); ok {
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
} else {
if err := findConfig(nil, nil); err != nil {
return
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Start()
_ = exec.Command("qs", cmdArgs...).Run()
}
func writeScreenshotJSON(meta screenshotMetadata) {
@@ -321,24 +275,17 @@ func runScreenshot(config screenshot.Config) {
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
os.Exit(1)
}
if config.Geometry {
if config.Stdout {
fmt.Fprintln(os.Stderr, "Error: --geometry cannot be combined with --stdout")
os.Exit(1)
}
if config.Mode == screenshot.ModeScroll {
fmt.Fprintln(os.Stderr, "Error: --geometry cannot be combined with scroll mode")
os.Exit(1)
}
}
// Short-lived process over a few tens of MB: let the heap grow instead of paying GC cycles mid-capture.
debug.SetGCPercent(-1)
debug.SetMemoryLimit(1 << 30)
// Region select needs the keyboard; drop popout grabs for its duration.
config.SelectorHook = setPopoutScreenshotMode
result, err := screenshot.New(config).Run()
result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
}
return screenshot.New(config).Run()
}()
if err != nil {
exitScreenshotError("", err)
}
@@ -347,45 +294,17 @@ func runScreenshot(config screenshot.Config) {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
}
if config.Geometry {
os.Exit(1)
}
os.Exit(0)
}
if config.Geometry {
if ssJSON {
x := int(result.Region.X)
y := int(result.Region.Y)
writeScreenshotJSON(screenshotMetadata{
Status: "success",
X: &x,
Y: &y,
Width: int(result.Region.Width),
Height: int(result.Region.Height),
})
} else {
fmt.Println(result.Region.GeometryString())
}
os.Exit(0)
}
defer result.Buffer.Close()
if result.Buffer != nil {
defer result.Buffer.Close()
if result.YInverted {
result.Buffer.FlipVertical()
}
}
if result.CopyOnly {
config.SaveFile = false
config.Clipboard = true
config.Stdout = false
if result.YInverted {
result.Buffer.FlipVertical()
}
if config.Stdout {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing to stdout", err)
}
return
@@ -405,7 +324,7 @@ func runScreenshot(config screenshot.Config) {
}
filePath = filepath.Join(outputDir, filename)
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format, result.CICP); err != nil {
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing file", err)
}
if !ssJSON {
@@ -414,72 +333,56 @@ func runScreenshot(config screenshot.Config) {
}
if config.Clipboard {
var err error
if filePath != "" && config.Format != screenshot.FormatPPM {
err = copyFileToClipboard(filePath)
} else {
err = copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format, result.CICP)
}
if err != nil {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" copying to clipboard", err)
}
if !ssJSON && !config.SaveFile {
if ssStdout {
fmt.Fprintln(os.Stderr, "Copied to clipboard")
} else {
fmt.Println("Copied to clipboard")
}
fmt.Println("Copied to clipboard")
}
}
if ssJSON {
if result.CopyOnly {
writeScreenshotJSON(screenshotMetadata{
Status: "aborted",
Error: "User copied to clipboard",
})
} else {
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),
})
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 := bufferToRGBAThumbnail(result.Buffer, 640, result.Format)
id := screenshot.SendNotification(screenshot.NotifyResult{
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
screenshot.SendNotification(screenshot.NotifyResult{
FilePath: filePath,
Clipboard: config.Clipboard,
ImageData: thumbData,
Width: thumbW,
Height: thumbH,
})
watchNotificationAction(id, filePath)
}
}
func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32, cicp *screenshot.CICP) error {
func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
var mimeType string
var data bytes.Buffer
img := screenshot.BufferToImageWithFormat(buf, pixelFormat)
switch format {
case screenshot.FormatJPEG:
mimeType = "image/jpeg"
if err := screenshot.EncodeBufferJPEG(&data, buf, pixelFormat, quality); err != nil {
if err := screenshot.EncodeJPEG(&data, img, quality); err != nil {
return err
}
default:
mimeType = "image/png"
if err := screenshot.EncodeBufferPNG(&data, buf, pixelFormat, cicp); err != nil {
if err := screenshot.EncodePNG(&data, img); err != nil {
return err
}
}
@@ -487,78 +390,75 @@ func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, q
return clipboard.Copy(data.Bytes(), mimeType)
}
func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32, cicp *screenshot.CICP) error {
func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
img := screenshot.BufferToImageWithFormat(buf, pixelFormat)
switch format {
case screenshot.FormatJPEG:
return screenshot.EncodeBufferJPEG(os.Stdout, buf, pixelFormat, quality)
return screenshot.EncodeJPEG(os.Stdout, img, quality)
default:
return screenshot.EncodeBufferPNG(os.Stdout, buf, pixelFormat, cicp)
return screenshot.EncodePNG(os.Stdout, img)
}
}
func bufferToRGBAThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) {
if buf == nil || buf.Width <= 0 || buf.Height <= 0 || maxSize <= 0 {
return nil, 0, 0
func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) {
srcW, srcH := buf.Width, buf.Height
scale := 1.0
if srcW > maxSize || srcH > maxSize {
if srcW > srcH {
scale = float64(maxSize) / float64(srcW)
} else {
scale = float64(maxSize) / float64(srcH)
}
}
srcW, srcH := buf.Width, buf.Height
longest := max(srcW, srcH)
dstW, dstH := srcW, srcH
if longest > maxSize {
dstW = max(1, srcW*maxSize/longest)
dstH = max(1, srcH*maxSize/longest)
dstW := int(float64(srcW) * scale)
dstH := int(float64(srcH) * scale)
if dstW < 1 {
dstW = 1
}
if dstH < 1 {
dstH = 1
}
data := buf.Data()
rgba := make([]byte, dstW*dstH*4)
is10Bit := screenshot.PixelFormat(pixelFormat).Is10Bit()
rgb := make([]byte, dstW*dstH*3)
var swapRB bool
switch pixelFormat {
case uint32(screenshot.FormatABGR8888), uint32(screenshot.FormatXBGR8888),
uint32(screenshot.FormatABGR2101010), uint32(screenshot.FormatXBGR2101010):
case uint32(screenshot.FormatABGR8888), uint32(screenshot.FormatXBGR8888):
swapRB = false
default:
swapRB = true
}
for y := range dstH {
y0, y1 := y*srcH/dstH, (y+1)*srcH/dstH
for x := range dstW {
x0, x1 := x*srcW/dstW, (x+1)*srcW/dstW
var r, g, b, samples uint64
for srcY := y0; srcY < y1; srcY++ {
for srcX := x0; srcX < x1; srcX++ {
si := srcY*buf.Stride + srcX*4
if si+3 >= len(data) {
continue
}
c0, c1, c2 := data[si], data[si+1], data[si+2]
if is10Bit {
v := binary.LittleEndian.Uint32(data[si:])
c0, c1, c2 = uint8(v>>2), uint8(v>>12), uint8(v>>22)
}
if swapRB {
c0, c2 = c2, c0
}
r += uint64(c0)
g += uint64(c1)
b += uint64(c2)
samples++
}
for y := 0; y < dstH; y++ {
srcY := int(float64(y) / scale)
if srcY >= srcH {
srcY = srcH - 1
}
for x := 0; x < dstW; x++ {
srcX := int(float64(x) / scale)
if srcX >= srcW {
srcX = srcW - 1
}
if samples == 0 {
si := srcY*buf.Stride + srcX*4
di := (y*dstW + x) * 3
if si+3 >= len(data) {
continue
}
di := (y*dstW + x) * 4
rgba[di+0] = byte((r + samples/2) / samples)
rgba[di+1] = byte((g + samples/2) / samples)
rgba[di+2] = byte((b + samples/2) / samples)
rgba[di+3] = 255
if swapRB {
rgb[di+0] = data[si+2]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+0]
} else {
rgb[di+0] = data[si+0]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+2]
}
}
}
return rgba, dstW, dstH
return rgb, dstW, dstH
}
func runScreenshotRegion(cmd *cobra.Command, args []string) {
-147
View File
@@ -1,147 +0,0 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
)
func thumbnailTestBuffer(t testing.TB, width, height int) *screenshot.ShmBuffer {
t.Helper()
buf, err := screenshot.CreateShmBuffer(width, height, width*4+16)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { buf.Close() })
return buf
}
func TestBufferToRGBAThumbnailPixels(t *testing.T) {
formats := []struct {
name string
format screenshot.PixelFormat
swapRB bool
tenBit bool
}{
{"argb", screenshot.FormatARGB8888, true, false},
{"xrgb", screenshot.FormatXRGB8888, true, false},
{"abgr", screenshot.FormatABGR8888, false, false},
{"xbgr", screenshot.FormatXBGR8888, false, false},
{"argb10", screenshot.FormatARGB2101010, true, true},
{"xrgb10", screenshot.FormatXRGB2101010, true, true},
{"abgr10", screenshot.FormatABGR2101010, false, true},
{"xbgr10", screenshot.FormatXBGR2101010, false, true},
}
for _, tc := range formats {
t.Run(tc.name, func(t *testing.T) {
buf := thumbnailTestBuffer(t, 4, 2)
data := buf.Data()
var originalRGBA []byte
for y := range buf.Height {
for x := range buf.Width {
r, g, b := byte(20), byte(40), byte(60)
if (x+y)%2 != 0 {
r, g, b = 100, 160, 220
}
originalRGBA = append(originalRGBA, r, g, b, 255)
if tc.swapRB {
r, b = b, r
}
i := y*buf.Stride + x*4
if tc.tenBit {
binary.LittleEndian.PutUint32(data[i:], uint32(r)<<2|uint32(g)<<12|uint32(b)<<22)
continue
}
data[i], data[i+1], data[i+2] = r, g, b
}
}
original := bytes.Clone(data)
got, width, height := bufferToRGBAThumbnail(buf, 2, uint32(tc.format))
want := []byte{60, 100, 140, 255, 60, 100, 140, 255}
if width != 2 || height != 1 || !bytes.Equal(got, want) {
t.Fatalf("averaged thumbnail = %dx%d %v, want 2x1 %v", width, height, got, want)
}
got, width, height = bufferToRGBAThumbnail(buf, 640, uint32(tc.format))
if width != 4 || height != 2 || !bytes.Equal(got, originalRGBA) {
t.Fatalf("unscaled thumbnail = %dx%d %v, want 4x2 %v", width, height, got, originalRGBA)
}
if !bytes.Equal(data, original) {
t.Fatal("source pixels changed")
}
})
}
}
func TestBufferToRGBAThumbnailDimensions(t *testing.T) {
for _, tc := range []struct {
width, height int
wantWidth, wantHeight int
}{
{1920, 1080, 640, 360},
{1080, 1920, 360, 640},
{1365, 767, 640, 359},
{1, 10000, 1, 640},
{10000, 1, 640, 1},
{1, 1, 1, 1},
} {
t.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func(t *testing.T) {
buf := thumbnailTestBuffer(t, tc.width, tc.height)
got, width, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
if width != tc.wantWidth || height != tc.wantHeight || len(got) != width*height*4 {
t.Fatalf("thumbnail = %dx%d, %d bytes; want %dx%d RGBA", width, height, len(got), tc.wantWidth, tc.wantHeight)
}
})
}
}
func TestBufferToRGBAThumbnailOddWidthRows(t *testing.T) {
for _, width := range []int{319, 321, 361} {
t.Run(fmt.Sprint(width), func(t *testing.T) {
buf := thumbnailTestBuffer(t, width, 64)
data := buf.Data()
for y := range buf.Height {
for x := range width {
i := y*buf.Stride + x*4
data[i], data[i+1], data[i+2] = byte(y), byte(x>>8), byte(x)
}
}
got, gotWidth, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
if gotWidth != width || height != buf.Height || len(got) != width*height*4 {
t.Fatalf("thumbnail = %dx%d, %d bytes; want %dx%d RGBA", gotWidth, height, len(got), width, buf.Height)
}
for y := range height {
for x := range width {
i := y*width*4 + x*4
want := []byte{byte(x), byte(x >> 8), byte(y), 255}
if !bytes.Equal(got[i:i+4], want) {
t.Fatalf("pixel (%d, %d) = %v, want %v", x, y, got[i:i+4], want)
}
}
}
})
}
}
func TestBufferToRGBAThumbnailEmpty(t *testing.T) {
for _, buf := range []*screenshot.ShmBuffer{nil, {}} {
data, width, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
if data != nil || width != 0 || height != 0 {
t.Fatalf("empty thumbnail = %dx%d %v", width, height, data)
}
}
}
func BenchmarkBufferToRGBAThumbnail(b *testing.B) {
for _, size := range [][2]int{{3840, 2160}, {3840, 3840}, {1920, 10000}} {
b.Run(fmt.Sprintf("%dx%d", size[0], size[1]), func(b *testing.B) {
buf := thumbnailTestBuffer(b, size[0], size[1])
b.ReportAllocs()
for b.Loop() {
bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
}
})
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"sort"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
@@ -120,7 +120,7 @@ func showSession(id string) (sessionInfo, error) {
return sessionInfo{}, err
}
fields := map[string]string{}
for line := range strings.SplitSeq(string(out), "\n") {
for _, line := range strings.Split(string(out), "\n") {
idx := strings.IndexByte(line, '=')
if idx <= 0 {
continue
+70 -148
View File
@@ -8,13 +8,13 @@ import (
"path/filepath"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"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"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -130,7 +130,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) },
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
},
"colors": {
niriFile: "colors.kdl",
@@ -138,20 +138,12 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) },
mangoContent: func(_ string) string { return config.MangoColorsConfig },
},
"alttab": {
niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig },
},
"input": {
niriFile: "input.kdl",
hyprFile: "input.lua",
mangoFile: "input.conf",
niriContent: func(_ string) string { return config.NiriInputConfig },
hyprContent: func(_ string) string { return config.DMSInputLuaConfig },
mangoContent: func(_ string) string { return "" },
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
@@ -178,43 +170,15 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
},
}
var knownTerminals = []string{"ghostty", "foot", "kitty", "alacritty"}
func terminalFromEnv() string {
return strings.TrimSpace(os.Getenv("TERMINAL"))
}
func installedTerminals() []string {
func detectTerminal() (string, error) {
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
var found []string
for _, t := range knownTerminals {
for _, t := range terminals {
if utils.CommandExists(t) {
found = append(found, t)
}
}
return found
}
func installedHyprlandVersion() string {
_, ver := getVersionFromCommand("Hyprland", "--version", distros.ParseHyprlandVersion)
return ver
}
func defaultTerminalCommand() string {
if env := terminalFromEnv(); env != "" {
return env
}
if found := installedTerminals(); len(found) > 0 {
return found[0]
}
return "ghostty"
}
func detectTerminal() (string, error) {
if env := terminalFromEnv(); env != "" {
return env, nil
}
found := installedTerminals()
switch len(found) {
case 0:
return "ghostty", nil
@@ -241,12 +205,7 @@ func detectTerminal() (string, error) {
}
func detectCompositorForSetup() (string, error) {
var compositors []string
for _, candidate := range []string{"niri", "Hyprland", "mango"} {
if utils.CommandExists(candidate) {
compositors = append(compositors, candidate)
}
}
compositors := greeter.DetectCompositors()
switch len(compositors) {
case 0:
@@ -255,20 +214,11 @@ func detectCompositorForSetup() (string, error) {
return strings.ToLower(compositors[0]), nil
}
fmt.Println("Multiple compositors detected:")
for i, compositor := range compositors {
fmt.Printf("%d) %s\n", i+1, compositor)
selected, err := greeter.PromptCompositorChoice(compositors)
if err != nil {
return "", err
}
fmt.Printf("\nChoice (1-%d): ", len(compositors))
var response string
fmt.Scanln(&response)
choice := 0
fmt.Sscanf(strings.TrimSpace(response), "%d", &choice)
if choice < 1 || choice > len(compositors) {
return "", fmt.Errorf("invalid choice")
}
return strings.ToLower(compositors[choice-1]), nil
return strings.ToLower(selected), nil
}
func runSetupDmsConfig(name string) error {
@@ -309,7 +259,7 @@ func runSetupDmsConfig(name string) error {
case "hyprland":
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc":
dmsDir = filepath.Join(mangoconf.Dir(), "dms")
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
}
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -347,9 +297,8 @@ func runSetup() error {
terminal, terminalSelected := promptTerminal()
useSystemd := true
if wmSelected {
if wm == deps.WindowManagerMango && !mangoconf.SessionTargetInstalled() {
if wm == deps.WindowManagerMango {
useSystemd = false
fmt.Println("\nThis Mango has no mango-session.target (added in 0.17.1); DMS will start from the Mango config.")
} else if isVoidSetup() {
useSystemd = false
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
@@ -363,13 +312,6 @@ func runSetup() error {
return nil
}
replaceBinds := false
if wmSelected {
if path, ok := config.ExistingBinds(wm); ok {
replaceBinds = promptBinds(path)
}
}
if wmSelected || terminalSelected {
willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected)
if willBackup {
@@ -390,7 +332,6 @@ func runSetup() error {
fmt.Println("\nDeploying configurations...")
logChan := make(chan string, 100)
deployer := config.NewConfigDeployer(logChan)
deployer.SetReplaceBinds(replaceBinds)
go func() {
for msg := range logChan {
@@ -398,25 +339,22 @@ func runSetup() error {
}
}()
ctx := context.Background()
var results []config.DeploymentResult
var err error
if wmSelected {
terminalCommand := defaultTerminalCommand()
if terminalSelected {
terminalCommand = terminal.Command()
if wmSelected && terminalSelected {
results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, terminal, useSystemd)
} else if wmSelected {
results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, deps.TerminalGhostty, useSystemd)
if len(results) > 1 {
results = results[:1]
}
if wm == deps.WindowManagerHyprland {
deployer.SetHyprlandVersion(installedHyprlandVersion())
} else if terminalSelected {
results, err = deployer.DeployConfigurationsWithSystemd(ctx, deps.WindowManagerNiri, terminal, useSystemd)
if len(results) > 0 && results[0].ConfigType == "Niri" {
results = results[1:]
}
var result config.DeploymentResult
result, err = deployer.DeployCompositor(wm, terminalCommand, useSystemd)
results = append(results, result)
}
if err == nil && terminalSelected {
var terminalResults []config.DeploymentResult
terminalResults, err = deployer.DeployTerminal(terminal)
results = append(results, terminalResults...)
}
close(logChan)
@@ -526,17 +464,6 @@ func promptTerminal() (deps.Terminal, bool) {
}
}
func promptBinds(path string) bool {
fmt.Printf("\nFound your keybinds in %s.\n", path)
fmt.Println(" 1) Keep my binds as they are")
fmt.Println(" 2) Start from the DMS stock binds")
fmt.Println("Either way a timestamped backup is kept.")
fmt.Print("Choice (1-2) [1]: ")
var response string
fmt.Scanln(&response)
return strings.TrimSpace(response) == "2"
}
func promptSystemd() bool {
fmt.Println("\nUse systemd for session management?")
fmt.Println("1) Yes (recommended for most distros)")
@@ -551,54 +478,49 @@ func promptSystemd() bool {
}
func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.Terminal, terminalSelected bool) bool {
if wmSelected && firstExistingPath(compositorPrimaryPaths(wm)) != "" {
return true
}
return terminalSelected && firstExistingPath([]string{terminalPrimaryPath(terminal)}) != ""
}
func compositorPrimaryPaths(wm deps.WindowManager) []string {
homeDir := os.Getenv("HOME")
switch wm {
case deps.WindowManagerNiri:
return []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland:
return []string{
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
case deps.WindowManagerMango:
return []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
default:
return nil
}
}
willBackup := false
func terminalPrimaryPath(terminal deps.Terminal) string {
homeDir := os.Getenv("HOME")
switch terminal {
case deps.TerminalGhostty:
return filepath.Join(homeDir, ".config", "ghostty", "config")
case deps.TerminalKitty:
return filepath.Join(homeDir, ".config", "kitty", "kitty.conf")
case deps.TerminalAlacritty:
return filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml")
default:
return ""
}
}
if wmSelected {
var configPaths []string
switch wm {
case deps.WindowManagerNiri:
configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland:
configPaths = []string{
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
case deps.WindowManagerMango:
configPaths = []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
}
func firstExistingPath(paths []string) string {
for _, path := range paths {
if path == "" {
continue
}
if _, err := os.Stat(path); err == nil {
return path
for _, configPath := range configPaths {
if _, err := os.Stat(configPath); err == nil {
willBackup = true
break
}
}
}
return ""
if terminalSelected {
var configPath string
switch terminal {
case deps.TerminalGhostty:
configPath = filepath.Join(homeDir, ".config", "ghostty", "config")
case deps.TerminalKitty:
configPath = filepath.Join(homeDir, ".config", "kitty", "kitty.conf")
case deps.TerminalAlacritty:
configPath = filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml")
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
}
}
return willBackup
}
-158
View File
@@ -1,158 +0,0 @@
package main
import (
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/spf13/cobra"
)
var setupHeadlessCmd = &cobra.Command{
Use: "headless",
Short: "Deploy DMS configurations without prompts",
Long: "Deploy compositor and terminal configurations non-interactively, for installers and scripts",
PersistentPreRunE: requireMutableSystemCommand,
RunE: func(cmd *cobra.Command, args []string) error {
compositor, _ := cmd.Flags().GetString("compositor")
terminal, _ := cmd.Flags().GetString("terminal")
noSystemd, _ := cmd.Flags().GetBool("no-systemd")
force, _ := cmd.Flags().GetBool("force")
skipExisting, _ := cmd.Flags().GetBool("skip-existing")
stockBinds, _ := cmd.Flags().GetBool("stock-binds")
cmd.SilenceUsage = true
return runSetupHeadless(compositor, terminal, noSystemd, force, skipExisting, stockBinds)
},
}
func init() {
setupHeadlessCmd.Flags().String("compositor", "", "Compositor to configure: niri, hyprland, or mango")
setupHeadlessCmd.Flags().String("terminal", "", "Also deploy a terminal config: ghostty, kitty, or alacritty (binds otherwise use $TERMINAL or the first installed terminal)")
setupHeadlessCmd.Flags().Bool("no-systemd", false, "Deploy session config without systemd integration")
setupHeadlessCmd.Flags().Bool("force", false, "Overwrite existing configs (timestamped backups are created)")
setupHeadlessCmd.Flags().Bool("skip-existing", false, "Warn and skip instead of failing when a config already exists")
setupHeadlessCmd.Flags().Bool("stock-binds", false, "Replace existing keybinds with the DMS stock set (a backup is kept)")
_ = setupHeadlessCmd.MarkFlagRequired("compositor")
setupHeadlessCmd.MarkFlagsMutuallyExclusive("force", "skip-existing")
}
func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExisting, stockBinds bool) error {
wm, err := parseHeadlessCompositor(compositor)
if err != nil {
return err
}
term, termSelected, err := parseHeadlessTerminal(terminal)
if err != nil {
return err
}
deployCompositor := true
deployTerminal := termSelected
if !force {
if existing := firstExistingPath(compositorPrimaryPaths(wm)); existing != "" {
if !skipExisting {
return fmt.Errorf("%s config already exists: %s (use --force to overwrite with backup, or --skip-existing)", compositor, existing)
}
fmt.Printf("⚠ Skipping %s config, already exists: %s\n", compositor, existing)
deployCompositor = false
}
if deployTerminal {
if existing := firstExistingPath([]string{terminalPrimaryPath(term)}); existing != "" {
if !skipExisting {
return fmt.Errorf("%s config already exists: %s (use --force to overwrite with backup, or --skip-existing)", terminal, existing)
}
fmt.Printf("⚠ Skipping %s config, already exists: %s\n", terminal, existing)
deployTerminal = false
}
}
}
if !deployCompositor && !deployTerminal {
return nil
}
logChan := make(chan string, 100)
logDone := make(chan struct{})
go func() {
for msg := range logChan {
fmt.Println(" " + msg)
}
close(logDone)
}()
deployer := config.NewConfigDeployer(logChan)
deployer.SetReplaceBinds(stockBinds)
var results []config.DeploymentResult
var deployErr error
if deployCompositor {
terminalCommand := defaultTerminalCommand()
if termSelected {
terminalCommand = term.Command()
}
if wm == deps.WindowManagerHyprland {
deployer.SetHyprlandVersion(installedHyprlandVersion())
}
result, err := deployer.DeployCompositor(wm, terminalCommand, headlessUseSystemd(wm, noSystemd))
results = append(results, result)
deployErr = err
}
if deployErr == nil && deployTerminal {
terminalResults, err := deployer.DeployTerminal(term)
results = append(results, terminalResults...)
deployErr = err
}
close(logChan)
<-logDone
for _, result := range results {
if !result.Deployed {
continue
}
fmt.Printf("✓ %s: %s\n", result.ConfigType, result.Path)
if result.BackupPath != "" {
fmt.Printf(" Backup: %s\n", result.BackupPath)
}
}
if deployErr != nil {
return fmt.Errorf("deployment failed: %w", deployErr)
}
return nil
}
func parseHeadlessCompositor(name string) (deps.WindowManager, error) {
switch name {
case "niri":
return deps.WindowManagerNiri, nil
case "hyprland":
return deps.WindowManagerHyprland, nil
case "mango":
return deps.WindowManagerMango, nil
default:
return 0, fmt.Errorf("unknown compositor %q (expected niri, hyprland, or mango)", name)
}
}
func parseHeadlessTerminal(name string) (deps.Terminal, bool, error) {
switch name {
case "":
return deps.TerminalGhostty, false, nil
case "ghostty":
return deps.TerminalGhostty, true, nil
case "kitty":
return deps.TerminalKitty, true, nil
case "alacritty":
return deps.TerminalAlacritty, true, nil
default:
return 0, false, fmt.Errorf("unknown terminal %q (expected ghostty, kitty, or alacritty)", name)
}
}
func headlessUseSystemd(wm deps.WindowManager, noSystemd bool) bool {
if noSystemd {
return false
}
return !isVoidSetup()
}
-282
View File
@@ -1,282 +0,0 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/backup"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/spf13/cobra"
)
const switchDMSDep = "dms (Dux Shell)"
// The greeter runs the shell's QML, so it moves with dms or the login screen can break.
var switchFamily = []string{switchDMSDep, "quickshell", "dms-greeter"}
var switchAliases = map[string]string{
"dms": switchDMSDep,
"quickshell": "quickshell",
"dms-greeter": "dms-greeter",
"greeter": "dms-greeter",
"dankcalendar": "dankcalendar",
"danksearch": "danksearch",
}
var switchAssumeYes bool
var switchCmd = &cobra.Command{
Use: "switch <stable|beta> [package...]",
Short: "Switch DMS packages between stable and beta builds",
Long: `Switch installed DMS packages between stable releases and beta builds.
With no packages, dms, quickshell and dms-greeter switch together. Name packages
(dms, quickshell, greeter, dankcalendar, danksearch) to switch only those.
Settings, plugins, themes and session state are backed up before anything changes.
Settings written by beta can lose options on stable, so switching back to stable
offers to restore the backup taken the last time you left stable.`,
Args: cobra.MinimumNArgs(1),
ValidArgs: []string{"stable", "beta"},
Run: func(cmd *cobra.Command, args []string) {
if err := runSwitch(args[0], args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
switchCmd.Flags().BoolVarP(&switchAssumeYes, "yes", "y", false, "Do not ask for confirmation; keeps current settings when switching to stable")
}
func runSwitch(channel string, packages []string) error {
var target deps.PackageVariant
switch channel {
case "stable":
target = deps.VariantStable
case "beta":
target = deps.VariantGit
default:
return fmt.Errorf("unknown channel %q: use stable or beta", channel)
}
explicit := len(packages) > 0
names := switchFamily
if explicit {
names = nil
for _, pkg := range packages {
name, ok := switchAliases[strings.ToLower(pkg)]
if !ok {
return fmt.Errorf("unknown package %q", pkg)
}
names = append(names, name)
}
}
osInfo, err := distros.GetOSInfo()
if err != nil {
return fmt.Errorf("detecting distribution: %w", err)
}
distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil)
if err != nil {
return err
}
// The package names are compositor independent, so any window manager works here.
var wm deps.WindowManager
var swaps []distros.VariantSwap
for _, name := range names {
swap, status := distros.PlanVariantSwap(distro, wm, name, target)
switch status {
case distros.SwapNeeded:
swaps = append(swaps, swap)
case distros.SwapUnsupported:
return fmt.Errorf("switching packages is not supported on %s", osInfo.PrettyName)
case distros.SwapAlreadyOnTarget:
fmt.Printf("%s is already on %s\n", name, channel)
case distros.SwapNoVariant:
if explicit {
fmt.Printf("%s has no separate beta build on %s\n", name, osInfo.PrettyName)
}
case distros.SwapNotInstalled:
if explicit {
fmt.Printf("%s is not installed from a package\n", name)
}
}
}
if len(swaps) == 0 {
fmt.Println("Nothing to switch.")
return nil
}
shellChanges := false
snapshotLabel := ""
fmt.Printf("\nSwitching to %s:\n", channel)
for _, swap := range swaps {
fmt.Printf(" %s → %s\n", switchLabel(swap.From), switchLabel(swap.To))
switch swap.Dep {
case switchDMSDep:
shellChanges = true
snapshotLabel = "stable"
if target == deps.VariantStable {
snapshotLabel = "beta"
}
case "quickshell":
shellChanges = true
}
}
if snapshotLabel != "" {
dir, _ := backup.SnapshotDir()
fmt.Printf("\nYour DMS settings will be backed up to %s first.\n", dir)
}
if shellChanges {
fmt.Println("The shell restarts when the switch is done.")
}
if !switchAssumeYes && !askSwitch("\nContinue? [y/N] ") {
return errors.New("cancelled")
}
if snapshotLabel != "" {
path, err := backup.Snapshot(snapshotLabel)
if err != nil {
return fmt.Errorf("backing up settings, nothing was changed: %w", err)
}
if path != "" {
fmt.Printf("Backed up settings to %s\n", path)
}
}
ctx := context.Background()
if privesc.CheckCached(ctx) != nil {
if err := privesc.Run(ctx, "", "true"); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
}
if err := runSwaps(ctx, distro.(distros.VariantSwapper), swaps); err != nil {
return err
}
restore := ""
if snapshotLabel == "beta" && !switchAssumeYes {
if path, ok := backup.LatestSnapshot("stable"); ok {
fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path)
if askSwitch("") {
restore = path
}
}
}
if shellChanges || restore != "" {
return restartShellAfterSwitch(restore)
}
fmt.Println("Done.")
return nil
}
func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error {
progressChan := make(chan distros.InstallProgressMsg, 100)
swapErr := make(chan error, 1)
go func() {
defer close(progressChan)
swapErr <- swapper.SwapVariants(ctx, swaps, "", progressChan)
}()
// runWithProgress repeats the step on every output line, so only print it when it changes.
lastStep := ""
for msg := range progressChan {
switch {
case msg.Error != nil:
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
case msg.Step != "" && msg.Step != lastStep:
fmt.Println(msg.Step)
lastStep = msg.Step
}
if msg.LogOutput != "" {
fmt.Printf(" %s\n", msg.LogOutput)
}
}
return <-swapErr
}
// restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the
// restored settings and save them back. A plain restart would also reload the old binary's QML.
func restartShellAfterSwitch(restore string) error {
managed := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit) == nil
if managed {
if err := runSystemctl("--user", "stop", dmsSystemdUnit); err != nil {
return fmt.Errorf("stopping the shell: %w", err)
}
} else {
stopShellForSwitch()
}
if restore != "" {
if err := backup.RestoreSnapshot(restore); err != nil {
fmt.Fprintf(os.Stderr, "Restoring settings failed: %v\n", err)
} else {
fmt.Printf("Restored settings from %s\n", restore)
}
}
if managed {
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return fmt.Errorf("starting the shell: %w", err)
}
} else {
dmsPath, err := exec.LookPath("dms")
if err != nil {
return fmt.Errorf("switch done, but dms was not found to start the shell: %w", err)
}
if err := exec.Command(dmsPath, "run", "-d").Run(); err != nil {
return fmt.Errorf("switch done, but starting the shell failed (run 'dms run -d'): %w", err)
}
}
fmt.Println("Done. The shell has restarted.")
return nil
}
// stopShellForSwitch terminates the supervisors and waits for them: Kill() would SIGKILL them,
// orphaning quickshell in its own session, which then keeps saving settings over the restore.
func stopShellForSwitch() {
for _, pid := range shellApp.PIDs() {
if proc, err := os.FindProcess(pid); err == nil {
proc.Signal(syscall.SIGTERM)
}
}
deadline := time.Now().Add(5 * time.Second)
for len(shellApp.PIDs()) > 0 && time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
}
shellApp.Kill()
}
func switchLabel(m distros.PackageMapping) string {
if m.RepoURL == "" {
return m.Name
}
return m.RepoURL + "/" + m.Name
}
func askSwitch(prompt string) bool {
if !stdinIsTTY() {
return false
}
fmt.Print(prompt)
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return false
}
answer := strings.ToLower(strings.TrimSpace(line))
return answer == "y" || answer == "yes"
}
+25 -32
View File
@@ -6,15 +6,13 @@ import (
"encoding/json"
"fmt"
"os"
"runtime"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/charmbracelet/lipgloss"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/lowprio"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
"github.com/spf13/cobra"
)
@@ -36,7 +34,6 @@ Examples:
dms system update --check # list available updates
dms system update # apply updates (interactive prompt)
dms system update --noconfirm # apply updates without prompting
dms system update --interactive # let the package manager ask its own questions
dms system update --dry # simulate without changing anything
dms system update --no-flatpak --noconfirm # apply system updates only
dms system update --interval 3600 # set the server poll interval to 1h`,
@@ -44,26 +41,24 @@ Examples:
}
var (
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateInteractive bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIgnore []string
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIgnore []string
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
)
func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
systemUpdateCmd.Flags().BoolVar(&sysUpdateInteractive, "interactive", false, "Let the package manager ask its own questions instead of answering yes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
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)")
@@ -71,19 +66,14 @@ func init() {
}
func runSystemUpdate(cmd *cobra.Command, args []string) {
if sysUpdateIntervalS >= 0 {
switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS)
return
}
// Package managers started from this thread inherit its priority.
runtime.LockOSThread()
lowprio.LowerThreadPriority()
if sysUpdateCheck {
case sysUpdateCheck:
runSystemUpdateCheck()
return
default:
runSystemUpdateApply()
}
runSystemUpdateApply()
}
func selectBackends(ctx context.Context) []sysupdate.Backend {
@@ -190,7 +180,7 @@ func runSystemUpdateApply() {
}
fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateInteractive && !sysUpdateDry {
if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
fmt.Println("Aborted.")
return
@@ -209,7 +199,6 @@ func runSystemUpdateApply() {
UseSudo: true,
}
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
opts.Interactive = sysUpdateInteractive && opts.AttachStdio
onLine := func(line string) { fmt.Println(line) }
ran := false
@@ -251,12 +240,16 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
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 sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
continue
}
if sysupdate.IsPackageIgnored(p.Name, sysUpdateIgnore) {
if ignored[p.Name] {
continue
}
out = append(out, p)
@@ -265,7 +258,7 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
}
func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(ipc.Request{
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "sysupdate.setInterval",
Params: map[string]any{"seconds": float64(seconds)},
-134
View File
@@ -1,134 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/spf13/cobra"
)
var systemdCompositorFlag string
var systemdCmd = &cobra.Command{
Use: "systemd",
Short: "Start DMS as a systemd user service with the compositor session",
Long: `Manage dms.service for the compositor session.
enable makes the compositor's own session unit want dms.service, so DMS starts
with that compositor only: hyprland-session.target, niri.service or
mango-session.target. The compositor is the running one, or --compositor.`,
}
var systemdEnableCmd = &cobra.Command{
Use: "enable",
Short: "Start dms.service with the compositor session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
wm, err := systemdCompositor()
if err != nil {
return err
}
unit, err := config.EnableSession(wm)
if err != nil {
return err
}
fmt.Printf("dms.service starts with %s\n", unit)
now, _ := cmd.Flags().GetBool("now")
if !now {
return nil
}
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return err
}
fmt.Println("dms.service started")
return nil
},
}
var systemdDisableCmd = &cobra.Command{
Use: "disable",
Short: "Stop starting dms.service with any compositor session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
removed, err := config.DisableSession()
if err != nil {
return err
}
if len(removed) == 0 {
fmt.Println("dms.service was not wanted by any compositor session")
} else {
fmt.Printf("dms.service no longer starts with %s\n", strings.Join(removed, ", "))
}
now, _ := cmd.Flags().GetBool("now")
if !now {
return nil
}
return runSystemctl("--user", "stop", dmsSystemdUnit)
},
}
var systemdStatusCmd = &cobra.Command{
Use: "status",
Short: "Show which compositor sessions start dms.service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
state := getServiceState("dms", true)
if !state.exists {
fmt.Println("dms.service: not installed")
return
}
fmt.Printf("dms.service: %s", state.enabled)
if state.active != "" {
fmt.Printf(", %s", state.active)
}
fmt.Println()
wanted := config.SessionWanted()
if len(wanted) == 0 {
fmt.Println("Starts with: nothing (run 'dms systemd enable')")
return
}
fmt.Printf("Starts with: %s\n", strings.Join(wanted, ", "))
},
}
func systemdLifecycleCmd(verb string) *cobra.Command {
return &cobra.Command{
Use: verb,
Short: strings.ToUpper(verb[:1]) + verb[1:] + " dms.service",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runSystemctl("--user", verb, dmsSystemdUnit)
},
}
}
func systemdCompositor() (deps.WindowManager, error) {
if systemdCompositorFlag == "" {
if wm, ok := config.RunningCompositor(); ok {
return wm, nil
}
name, err := detectCompositorForSetup()
if err != nil {
return 0, err
}
systemdCompositorFlag = name
}
switch strings.ToLower(systemdCompositorFlag) {
case "hyprland":
return deps.WindowManagerHyprland, nil
case "niri":
return deps.WindowManagerNiri, nil
case "mango", "mangowc":
return deps.WindowManagerMango, nil
}
return 0, fmt.Errorf("unknown compositor %q (hyprland, niri or mango)", systemdCompositorFlag)
}
func init() {
systemdEnableCmd.Flags().StringVar(&systemdCompositorFlag, "compositor", "", "hyprland, niri or mango (default: the running one)")
systemdEnableCmd.Flags().Bool("now", false, "Also start dms.service")
systemdDisableCmd.Flags().Bool("now", false, "Also stop dms.service")
systemdCmd.AddCommand(systemdEnableCmd, systemdDisableCmd, systemdStatusCmd, systemdLifecycleCmd("start"), systemdLifecycleCmd("stop"), systemdLifecycleCmd("restart"))
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt"
"os"
"github.com/duxlabs-dev/dux-go/trash"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra"
)
+6 -7
View File
@@ -8,11 +8,10 @@ import (
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules/providers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers"
"github.com/spf13/cobra"
)
@@ -217,7 +216,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus
case "mango", "mangowc":
configDir := mangoconf.Dir()
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
parseResult, err := providers.ParseMangoWindowRules(configDir)
if err != nil {
@@ -352,7 +351,7 @@ func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
return providers.NewHyprlandWritableProvider(configDir)
case "mango", "mangowc":
configDir := mangoconf.Dir()
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
return providers.NewMangoWritableProvider(configDir)
default:
return nil
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"sync"
"time"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_power"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_power"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type cmd struct {
+35 -8
View File
@@ -9,12 +9,15 @@ import (
"strings"
"sync"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/spf13/afero"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/spf13/cobra"
)
const (
cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json"
cliPolicyAdminPath = "/etc/dms/cli-policy.json"
)
var (
immutablePolicyOnce sync.Once
immutablePolicy immutableCommandPolicy
@@ -31,6 +34,13 @@ type immutableCommandPolicy struct {
Message string
}
type cliPolicyFile struct {
PolicyVersion int `json:"policy_version"`
ImmutableSystem *bool `json:"immutable_system"`
BlockedCommands *[]string `json:"blocked_commands"`
Message *string `json:"message"`
}
func normalizeCommandSpec(raw string) string {
normalized := strings.ToLower(strings.TrimSpace(raw))
normalized = strings.TrimPrefix(normalized, "dms ")
@@ -72,8 +82,25 @@ func commandBlockedByPolicy(commandPath string, blocked []string) bool {
return false
}
func loadPolicyFile(path string) (*cliPolicyFile, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read %s: %w", path, err)
}
var policy cliPolicyFile
if err := json.Unmarshal(data, &policy); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
}
return &policy, nil
}
func mergePolicyFile(base *immutableCommandPolicy, path string) error {
policyFile, err := clipolicy.LoadFile(afero.NewOsFs(), path)
policyFile, err := loadPolicyFile(path)
if err != nil {
return err
}
@@ -191,7 +218,7 @@ func getImmutablePolicy() (*immutableCommandPolicy, error) {
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
}
var defaultPolicy clipolicy.File
var defaultPolicy cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil {
immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err)
return
@@ -206,11 +233,11 @@ func getImmutablePolicy() (*immutableCommandPolicy, error) {
}
}
if err := mergePolicyFile(&immutablePolicy, clipolicy.PackagedPath); err != nil {
if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil {
immutablePolicyErr = err
return
}
if err := mergePolicyFile(&immutablePolicy, clipolicy.AdminPath); err != nil {
if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil {
immutablePolicyErr = err
return
}
@@ -241,7 +268,7 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
reason = "Detected immutable system: " + policy.ImmutableReason + "\n"
}
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, clipolicy.PackagedPath, clipolicy.AdminPath)
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
}
// preRunPrivileged combines the immutable-system check with a privesc tool
+1 -3
View File
@@ -3,12 +3,10 @@ package main
import (
"encoding/json"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
)
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
var policyFile clipolicy.File
var policyFile cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
t.Fatalf("failed to parse embedded CLI policy: %v", err)
}
+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"
}

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