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

Compare commits

...

208 Commits

Author SHA1 Message Date
purian23 aed731efb0 fix(clipboard): restore Save button targets in editor 2026-05-25 23:19:42 -04:00
purian23 cf0632c077 feat(Clipboard): Revive ClipboardEditor PR
- Original PR #1916 by @nabaco
2026-05-24 23:28:21 -04:00
Nachum Barcohen e92da4a15f Show full clipboard text in editor 2026-05-24 22:34:24 -04:00
Nachum Barcohen 8abdff3220 Add clipboard editor shortcuts and hints 2026-05-24 22:34:24 -04:00
Nachum Barcohen 584d57a8de Add split save menu for clipboard editor 2026-05-24 22:34:05 -04:00
Nachum Barcohen afb5e59c29 feat(clipboard): Add editing capability to clipboard entries 2026-05-24 22:34:05 -04:00
purian23 d9525908f1 refactor(Notifications): further support for duplicate notification logic
- New setting to stack or suppress identical alerts (on by default)
Closes #2334
2026-05-24 22:22:34 -04:00
Lucas 6093c37b41 settings: add descriptions for DankBar menu (#2490) 2026-05-24 18:56:42 -04:00
purian23 bb05cbb6c5 feat(sessions): implement local user session switching functionality
- Core user is logged in tty1 while user two is in tty3, you can now seamlessly switch bewteen them
New IPC options:
- `dms ipc call sessions list`
- `dms switch-user [target]`
- New Powermenu switch users option
2026-05-24 18:33:38 -04:00
purian23 4d4af8f549 feat(Users): add user management UI in DMS Settings 2026-05-24 18:15:41 -04:00
Feng Yu 0b55fbcb15 fix(DankBar): Resolve tray freeze and wallpaper loss after DPMS resume (#2457)
Fixes #2354

Root cause (tray freeze): In clickThrough mode, the PanelWindow mask uses
sectionRect() with mapToItem() to compute input regions. After DPMS resume,
the PanelWindow is recreated with width=0, and mapToItem() returns wrong
positions. The right section's implicitWidth doesn't change after creation
(fixed-size tray icons), so the mask binding is never re-evaluated when the
compositor sets the actual screen width. Adding barWindow.width as a binding
dependency ensures the mask recalculates on resize.

Root cause (wallpaper loss): Wallpaper PanelWindows are recreated by Variants
during screen reconnection before the compositor finishes output initialization.
The wallpaper Image renders at 0x0 dimensions, resulting in a black screen.

Changes:
- DankBarWindow: add barWindow.width dependency to clickThrough mask bindings
- DMSShell: add surface recovery mechanism (screen reconnect + session resume)
  with progressive 2-pass timer (800ms + 2800ms) that recreates bar, Frame,
  wallpaper, and dock surfaces after the compositor is ready
- WlrOutputService: re-request output state on session resume
2026-05-22 09:05:41 -04:00
Domen Kožar 7476a220b5 feat: Blink WiFi/Bluetooth icons while connecting (#2448)
Pulses the WiFi and Bluetooth status icons while a connection is in
progress (lock screen, DankBar control center button, control center
compound pill). The pulse is implemented as a reusable Widgets/DankBlink
component, and the wifi-connecting condition is centralized as
NetworkService.isWifiConnecting.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:03:25 -04:00
Huỳnh Thiện Lộc aaff1ab61e feat: implement interactive microphone volume OSD and IPC controls (#2406)
* feat: implement interactive microphone volume OSD and persistence

Addresses #2388

* refactor: reduce scope to interactive microphone OSD and IPC controls only
2026-05-22 09:00:12 -04:00
Cloud 39622eb62a fix(lock): avoid U2F PAM polling in OR mode (#2459) 2026-05-22 08:59:11 -04:00
Lucas eea039f575 feat(Launcher/Spotlight): improve context keyboard navigation and mode persistence (#2467)
* feat(Spotlight): fix submenu keyboard navigation

* feat(Launcher/Spotlight): disable persisting last mode when changed by triggers

* feat(Launcher/Spotlight): add option to disable last mode being persisted

* fix(Launcher/Spotlight): fix context menu keys navigation

* fix(NiriOverviewOverlay): fix context menu keys navigation and position
2026-05-22 08:53:45 -04:00
purian23 ef5de19f6b distros(dms-greeter): Add sysusers.d immutable distro support
- Closes #1975
2026-05-21 23:21:44 -04:00
bbedward f0c31bd7b3 launcher: add /d /f file search prefixes. Fix prefix not always
triggering
2026-05-21 21:10:30 -04:00
Domen Kožar 7ddd0ca90d fix(Network): Bucket WiFi signal for stable list order (#2449)
Sort networks by signal bucket of 25 with SSID as tiebreaker so minor
RSSI fluctuations between scans no longer reshuffle the list while the
user is selecting a network.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:04:31 -04:00
bbedward b84e5abc4a core: remove niri parse test 2026-05-21 10:33:12 -04:00
purian23 fb9ec8e721 feat: (Launcher/Spotlight): Updated w/New Settings & QOL features
- New Spotlight toggle to show/hide chips, off by default
- Updated blur effects on all launcher inputs and footers
- Fixed previous queries resurfacing
- Upated Spotlight keyboard navigation
- Added functionality to show and shortcut to keybinds from the Launcher tab
2026-05-21 01:05:56 -04:00
purian23 078c9b4890 refactor(niri): Normalize key bindings to be case insensitive within DMS 2026-05-21 00:38:31 -04:00
purian23 37c98220a9 refactor(Spotlight): Use Spotlight alongside OG Launcher
- Update to add DMS Action keys in Keyboard Shortcuts
- Defaulted in niri/hyprland includes file as `Alt+Space`
- New (IPC): `dms ipc call spotlight-bar toggle`
- Slight UI update to follow user radius
2026-05-20 17:21:03 -04:00
Klesh Wong fc07611b3b fix(osd): ensure OSD appears on all monitors after resume (#2453)
Some monitors, especially cheaper models, are slow to power on after sleep and may not be present in Quickshell.screens
when onScreensChanged is triggered. This change adds a 3-second delay before updating currentOSDsByScreen to ensure all
screens are detected, mitigating the issue of OSDs not appearing on certain monitors after resume.

Co-authored-by: Klesh Wong <kleshwong@gmail.com>
2026-05-20 11:58:13 -04:00
Graeme Foster a923308c09 networkd: classify links by Type instead of name prefix (#2447)
* networkd: classify links by Type instead of name prefix

The systemd-networkd backend decided wifi-vs-ethernet by checking
whether the interface name started with "wlan" or "wlp". Anything
else (that was not on a small virtual-prefix denylist) was treated
as wired ethernet. That misclassified two common cases:

* Nebula tunnels (kernel name like "nebula.homelab", Type=none,
  Kind=tun) showed up as a wired ethernet device — DMS rendered an
  "Ethernet connected" indicator whenever the overlay was up, even
  with no physical NIC plugged in.
* Renamed wifi interfaces (e.g. systemd link files that rename
  wlan0 to a friendlier name like "wifi") were also miscategorised
  as ethernet, because they no longer matched wlan*/wlp*.

networkd already publishes the real link kind in the JSON returned
by the per-link Describe method ("ether", "wlan", "loopback",
"none"). Fetch it during enumerateLinks, cache it on linkInfo, and
classify against that. The old prefix logic is kept as a fallback
for the case where Describe ever fails to populate Type.

The package-level looksVirtual() helper replaces the unexported
isVirtualInterface method so the new classification helpers and
their tests can use it without needing a live backend.

Tests cover both the Type-based and fallback paths, including the
Nebula-shaped Type=none/tun case that motivated this change.

* networkd: cache linkType across signal ticks and unit-test Describe fallback

enumerateLinks runs on every PropertiesChanged signal under
/org/freedesktop/network1, which fires on carrier flap, DHCP renew, and
each address change. The previous version rebuilt every linkInfo from
scratch on each tick, including a synchronous Describe D-Bus round-trip
per link, despite the link Type being fixed at netlink creation. Preserve
existing entries when the D-Bus path matches, refreshing only ifindex,
and only call fetchLinkType on a genuinely new entry. A link torn down
and re-created at a different path still triggers a refetch.

Extract parseDescribeType from fetchLinkType so the JSON failure path —
malformed payload, missing Type field, wrong type for Type — can be
exercised without a live D-Bus connection. The classifier's fallback to
name-prefix heuristics already had coverage; this locks in that the seam
between Describe and the classifier surfaces an empty string on every
failure mode rather than misclassifying a link.
2026-05-20 11:43:50 -04:00
Lichie 0990b43a43 feat(FocusedWindow): Improve content width calculation and add size options (#2444)
* use RowLayout in focusedapp widget for better width calculation

* Add context menu with additional size options for focused app widget
2026-05-20 11:43:14 -04:00
purian23 548c2305fb refactor(Settings): Rename fullscreen properties to overlay layer for consistency
- Updated property names from `showOverFullscreen` to `useOverlayLayer` across various components.
- Fixed Darken Modal Overlay in Standalone mode
2026-05-19 22:03:33 -04:00
purian23 4634763840 refactor(fullscreen): Refine fullscreen layering and frame overlay behavior
- Replaced fullscreen hide/reveal toggles with Show Over Fullscreen layer toggles
- Added Launcher opt to Show Over Fullscreen setting
- Kept fullscreen stacking compositor-owned via top/overlay layer choices
- Fixed Hyrland Special Workspaces
- Updated DMS Advanced Configuration docs
2026-05-19 18:42:45 -04:00
bbedward cdc1102092 popout: fix opening popouts across monitors
cc/brightness: fix delegate bindings and pinning
2026-05-19 11:23:03 -04:00
purian23 4845299cc2 fix(Spotlight): Update the new clipboard/settings merge w/cache & debouced refresh 2026-05-19 01:39:16 -04:00
purian23 81a1bb1cd7 fix(Hyprland): Respect legacy conf configs before migrating to lua 2026-05-18 16:51:25 -04:00
sima 4528552610 Fix Hyprland Lua dispatch helpers (#2443) 2026-05-18 13:34:49 -04:00
purian23 0b55bf5dac feat(Hyprland): Introduce Lua support for Hyprland configurations
- Note: We do not convert your existing conf configs to lua. This update only reflects DMS defaults state
- Updated README.md to reflect changes
- Updated Keyboard shortcut support
2026-05-18 13:06:58 -04:00
bbedward 8dd891f93a i18n: term sync 2026-05-18 09:43:25 -04:00
Josh Symonds 9bd68d44a1 notifications: honor freedesktop suppress-sound hint (#2440)
Senders that play their own audio for a notification can set the
standard org.freedesktop.Notifications "suppress-sound" boolean hint
to ask the server not to double up. NotificationService skipped its
sound only as a side effect of the dedup early-return (when an
identically-keyed popup was still visible), so transient notifications
double-sounded while lingering ones didn't — nondeterministic. Read
notif.hints["suppress-sound"] and gate the AudioService call on it.
2026-05-18 09:40:50 -04:00
Sheershak sharma 90ea136379 fixed batterylevel shown zero in case of unknown state (#2436) 2026-05-18 09:40:20 -04:00
Huỳnh Thiện Lộc 2f4a39f9eb fix(settings): ensure plugin browser install button resizes correctly (#2431)
Fixed a visual bug where the install button would overflow its container after a plugin was installed. This was caused by the width not re-evaluating correctly and StyledText's default elide/wrap properties interfering with implicit width calculations.
2026-05-18 09:40:00 -04:00
sima 5e558660c3 Fix Hyprland scrolling overview geometry (#2442) 2026-05-18 08:21:39 -04:00
purian23 c923c43322 feat(Clipboard): Implement clipboard/settings search functionality in DMS Launcher 2026-05-16 17:43:52 -04:00
purian23 9f2ae6241e feat(Spotlight): Add a New Lightweight Spotlight style launcher option 2026-05-16 17:34:56 -04:00
purian23 05c7a77c8b fix(Dock): Update Separate mode render to remove connected layer 2026-05-16 15:13:05 -04:00
purian23 a8ab0b55f3 (lint-qml): update output message 2026-05-16 15:11:54 -04:00
arfan fbcc28785a fix(HyprlandService): Correct window address format for focus dispatch (#2426) 2026-05-15 09:48:03 -04:00
bbedward 1c1ab1c7d5 text: change default rendering back to Qt 2026-05-15 09:45:21 -04:00
purian23 0a892a4a9e refactor(SysUpdate): Explicit Run on Startup option in settings
- Relocated DMS System Updater to System Setting section
- Removed forced auto refresh upon update & widget loading
2026-05-14 20:32:42 -04:00
bbedward e5cd9caba1 system update: fix App ID of terminal mode 2026-05-14 13:33:02 -04:00
bbedward 9c4aa06664 system update: fix local pacman DB mtime 2026-05-14 13:25:29 -04:00
Boris 66e38c5efe Support Hyprland lua dispatching (#2419) 2026-05-14 13:08:07 -04:00
bbedward 018795125e app picker: extend App Picker to integrate with mime overrides
- Adds "DMS Opener" as an option (dms-open.desktop)
- Add mime type GO utils
- Add rememberance to App Picker modal
2026-05-14 13:06:22 -04:00
Iris be4ea71756 feat(settings): Added Default Apps page to settings (#2416)
* feat(settings): Added Default Apps page to settings

Added a new page to settings. This page relies on xdg-mime and gio, so their existence is checked to show the page.
This logic and the mime type stuff was added to DesktopService.qml.
Slightly reordered the settings sidebar to include an Applications category

* fix(settings): read xdg-terminals.list directly

read the file directly instead of using xdg-terminal-exec which might not be installed
2026-05-14 10:45:18 -04:00
bbedward 39b90bb140 workspaces: cleanup dead connection 2026-05-14 10:38:47 -04:00
bbedward fb5198fd0b workspace/ext-ws: drop custom ext-workspace in favor of quickshell
WindowManager implementation
2026-05-14 10:34:35 -04:00
bbedward 71438530a8 dankinstall: fix gentoo parsing and use guru overlays for DMS/dgop 2026-05-14 10:13:37 -04:00
bbedward 79fe956058 workspaces/niri: fix scrolling to use ID instead of Index
fixes #2212
2026-05-14 10:03:11 -04:00
Divya Jain a33c7e0250 feat: add Display Profiles builtin control center widget (#2410)
* feat: add Display Profiles builtin control center widget

Adds a new built-in widget that lets users switch between their configured
display profiles directly from the Control Center, without opening Settings.

- Widget shows the active profile name and cycles profiles on pill click
- Detail panel shows all profiles as a button group for direct selection
- Integrates with existing DisplayConfigState/SettingsData singletons
- Follows the same loader/instance pattern as VPN, CUPS, and Tailscale widgets

* fix: refine display profiles widget behavior
2026-05-14 09:45:05 -04:00
Lucas 459ec47310 flake: use quickshell from nixpkgs-unstable pkgs (#2417) 2026-05-14 09:29:33 -04:00
Amaan Qureshi 4bb3dd8310 flake: expose mkDmsShell and buildDmsPkgs via lib output (#2409) 2026-05-14 02:40:55 -03:00
Domen Kožar 9a630fad92 fix(network): open Wi-Fi password modal upfront for enterprise networks (#2414)
NetworkManager rejects AddConnection for 802-1x without a non-empty
identity, so the new API v7 agent-prompt flow never reaches the
SecretAgent for fresh enterprise connections. Fall back to the existing
modal-upfront path (already wired to ask for username + password via
requiresEnterprise) when modelData.enterprise, mirroring the legacy
DMSService.apiVersion < 7 branch.

Fixes #2358

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:22:49 -04:00
purian23 5bde54fa89 refactor(dbar/dock): Update fullscreen hiding logic 2026-05-13 14:38:44 -04:00
Divya Jain 022f47a580 feat: add cycleProfile IPC command and default Mod+P keybind (#2411)
* feat: add cycleProfile IPC command for display outputs

Adds `dms ipc outputs cycleProfile` which cycles through configured display
profiles in order, wrapping around from the last back to the first.

Useful for binding to a compositor keybind (e.g. Super+P in niri/Hyprland)
without needing an external wrapper script.

* feat: add Mod+P default keybind for cycling display profiles

Binds Super+P to `dms ipc outputs cycleProfile` in both the niri and
Hyprland default keybind configs. Mod+Shift+P is already reserved for
powering off monitors on both compositors, so Mod+P is a natural fit.
2026-05-13 09:28:56 -04:00
Kilian Mio 0dfa95ffe4 feat: retrieve WiFi/VPN secrets from D-Bus Secret Service with unlock support (#2402)
* fix: preserve pre-existing connection profiles on cancelled WiFi activation

When a pre-existing WiFi connection activation is cancelled, do not
remove the connection profile — only remove profiles for newly created
connections.

* feat: retrieve WiFi/VPN secrets from D-Bus secret service with unlock

Wireless, 802.1x, VPN, and WireGuard secrets are now looked up from
org.freedesktop.Secret before prompting the user. If the keyring is
locked, the unlock prompt is triggered via Prompt.Prompt() and the
lookup retried after the vault is unlocked.
2026-05-13 09:27:44 -04:00
Youseffo13 e6da762870 Added Missing i18n Strings (#2398)
* Update hypr-colors.conf

* added i18n strings

* added missing i18n strings

* Update TypographyMotionTab.qml
2026-05-13 09:23:50 -04:00
purian23 8f958658dc (FrameMode): Update to load Connected Mode by default 2026-05-12 22:07:10 -04:00
Huỳnh Thiện Lộc 392eaea5fc refactor(ClipboardHistory): Sync toggle button between saved and recent tabs (#2407)
* fix(ClipboardHistory): toggle button now toggles between saved and recents tabs

- Change tooltip text to reflect current tab state ('Recent' when on saved tab, 'Saved' when on recents tab)
- Previously always switched to saved tab; now toggles between 'saved' and 'recents'

* refactor(ClipboardHistory): remove redundant History button, keep single toggle button

The Saved button now toggles between saved/recents tabs, so the separate History
button is redundant and has been removed for a cleaner UI.
2026-05-12 14:10:19 -04:00
bbedward 0aea542e8f i18n: term sync 2026-05-12 10:29:51 -04:00
bbedward 7a566e1088 tailscale: fix flaky test 2026-05-12 09:36:44 -04:00
Huỳnh Thiện Lộc 4bc62cc6ec feat: consolidate plugin directory management into a single dynamic button (#2400)
* feat: add Open Dir button to Plugins settings

* feat: consolidate plugin directory management into a single dynamic button
2026-05-12 09:29:17 -04:00
Bruno Rocha 9b68fc8213 add dms-plugin-dev agent skill for plugin development (#2394) 2026-05-12 09:26:38 -04:00
purian23 c878ffb7f9 fix(DankLauncher): Improve clipping logic for connected chrome
- Fixes #2397
2026-05-12 00:08:38 -04:00
purian23 3fc805ba53 distro(OBS): Bundled Go toolchain for dms-git Debian/openSUSE builds 2026-05-11 21:37:01 -04:00
euletheia 371a7d0cd1 fix(DankTextField): set TextInput 'fontFamily' (#2399)
This allows input fields created using the widget to inherit their font
family from Theme.
2026-05-11 18:25:10 -04:00
Bruno Rocha 189b7c84ce fix(ipc): dash and dankdash commands work without clock widget (#2392)
getPreferredBar("clockButtonRef") returns null when the clock widget
is removed from the bar. Fall back to getPreferredBar() (any bar) so
triggerDashTab can still open the popout with default positioning.
2026-05-11 14:18:07 -04:00
bbedward b8f4c350a8 quickshell: drop support for 0.2, require 0.3+
- Remove all compat code
- Rewire LegacyNetworkService to use Quickshell.Networking
- Add parentWindow to settings child windows
2026-05-11 13:05:24 -04:00
purian23 3989c7f801 fix(DisplayConfigState): Repair crash introduced in Automatic configs PR 2026-05-11 11:25:15 -04:00
bbedward 2690305724 fonts: native rendering + add settings to override renderType
fixes #2371
2026-05-11 10:49:29 -04:00
bbedward 676219bc09 screenshot: respect XDG_PICTURES_DIR
fixes #2383
2026-05-11 10:00:21 -04:00
bbedward b192b5f779 osd: fix positioning with multiple bars
fixes #2387
2026-05-11 09:51:18 -04:00
Kilian Mio a5352623fd Automatic Display Profiles Enhancement & Disabled Output Support (#2367)
* fix(niri): properly close KDL output block when disabled

* feat(display): parse off directive and sync disabled state from compositor configs

* feat(display): visual treatment and canvas filtering for disabled outputs

* feat(display): eager auto-profile generation with debounced auto-select

* refactor(display): pass target profile ID to confirmChanges and remove dead code

* fix(display): make profile dropdown reactive by binding to property directly

* i18n(display): add Disabled translation term for output state
2026-05-11 09:34:51 -04:00
Huỳnh Thiện Lộc 2b6ae58bff feat(settings): add expandable search bar to plugins tab (#2377) 2026-05-11 09:33:42 -04:00
Kostiantyn To b12511481d fix(RunningApps): add middle mouse click to close running applications in vertical orientation (#2386) 2026-05-11 09:21:45 -04:00
purian23 c7d44cfb12 fix(Settings): Update card registration logic to fix binded crash 2026-05-10 21:47:15 -04:00
purian23 4193cf51ff Refactor(DankBar): Restore previous autohide logic & add autoHideStrict setting 2026-05-10 21:14:09 -04:00
purian23 1ec0311086 feat(IPC): Add dbar toggleReveal logic for autohide modes 2026-05-09 23:49:25 -04:00
purian23 c6a1473d2f refactor(DankBar): Update dbar autohide mode reveal logic 2026-05-09 23:48:33 -04:00
purian23 ee16047e15 fix(Notepad): Blur handling on the header layer to remain in sync
- Fixes #2372
2026-05-09 21:59:08 -04:00
purian23 4968b80268 refactor(blur): Update blur layer sync handling w/Popout/Modals
- This results in better visuals so you will no longer get a brief flash of blur rending first upon load on certain displays
2026-05-09 21:21:16 -04:00
Josh Symonds e28b0c695e Theme: expose tertiary on the singleton (was undefined in dynamic mode) (#2375)
The dynamic-mode currentThemeData object at Theme.qml:447-468 omitted
tertiary entirely, even though matugen generates it and writes it to
the matugen colors JSON. As a result, Theme.tertiary returned undefined
in dynamic mode, and any QML binding through .r/.g/.b/.a defaulted to
white — visible as desaturated near-white highlights in the bar's
ChromeShader (which mixes Theme.tertiary at highlight peaks).

Two-line fix:
- Add tertiary to the dynamic-mode currentThemeData via getMatugenColor
- Add property color tertiary on the Theme singleton, falling back to
  secondary if tertiary is absent (covers the stock-theme path where
  tertiary is added separately via buildMatugenColorsFromTheme:1811)

Fallback hex #efb8c8 is the M3 baseline tertiary (light pink) for dark
mode, in case matugen output is missing.

Theme.tertiary was already referenced at LockScreenContent.qml:737 so
the property name is the right one.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:51:39 -04:00
purian23 7f6486b3e7 fix(Dock): Refactor & Fix Dock in FrameModes. Updates to Zone offset in Standalone & Connected modes
- Fixed hairline seam in some connected mode variants
- Moved dock-related properties to a new DockGeometry component for a future base
2026-05-09 17:37:42 -04:00
Josh Symonds faa30c4d48 bar: fix fullscreen-detection race during cross-monitor focus moves (#2373)
hasFullscreenToplevelOnScreen's fast-path used `NiriService.currentOutput
=== screenName` as a proxy for "the active toplevel is on screenName".
Those are two independent state channels (niri's workspace-activated
events drive currentOutput; wlr-foreign-toplevel-management drives
ToplevelManager.activeToplevel) and they don't update atomically.

When focus crosses from a fullscreen toplevel on monitor A to a
non-fullscreen toplevel on monitor B, currentOutput flips to B before
activeToplevel updates away from A's still-fullscreen-and-activated
window. For one tick, B's bar evaluates `active.fullscreen &&
active.activated && currentOutput === B` → true, hides itself, then
flips back when activeToplevel finally updates. Visible as a one-frame
bar flicker on focus changes between monitors when one has a fullscreen
window.

Replace the proxy with _toplevelOnScreen(active, screenName), the same
helper the X11 fallback path uses 25 lines below. The check now
inspects the toplevel's actual outputs instead of trusting a separate
state signal, so the race can't fire.

The per-workspace loop below was already correct; it would catch any
real fullscreen+activated toplevel on the bar's workspace regardless
of focused output. The fast-path was redundant when the assertion
held and wrong when it didn't.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:35:35 -04:00
purian23 cf641b4e08 fix(DockContextMenu): Restore jump windows 2026-05-09 01:54:23 -04:00
purian23 b75453c7d6 fix(IPC): Remove recent duplicate IPC bindings 2026-05-08 23:13:40 -04:00
bbedward 10872b5fc8 system tray: fix parent screen binding 2026-05-08 23:00:12 -04:00
purian23 80c853f16c fix(Settings): Restore Darken Modal Background ops in Standalone mode 2026-05-08 22:58:12 -04:00
purian23 6167f22837 feat(SystemUpdate): Implement system update IPC commands 2026-05-08 21:00:23 -04:00
bbedward d8835f2bc6 launcher: fix files tab actions
fixes #2357
2026-05-08 09:20:14 -04:00
purian23 1c01774fde fix(ConnectedMode): Update modal state management w/ownership Id 2026-05-07 23:03:31 -04:00
purian23 0d3eb774e2 (SystemUpdate): Ensure Polkit Prompts Top Level Auth layer 2026-05-07 16:17:12 -04:00
purian23 7fb4b6e0d9 refactor(SysUpdate): Flatpak & Cli command handling 2026-05-07 16:16:10 -04:00
bbedward 5df2b5fc33 launcher: also allow context switching with alt+1/2/3/4 2026-05-06 09:21:02 -04:00
purian23 d49c49cd99 refactor(sysupdate): Streamline DMS Updater command handling 2026-05-05 15:16:36 -04:00
purian23 b209827f38 distro:(Workflow) Save the resources 2026-05-04 23:10:12 -04:00
purian23 1b9d1c667c fix(Ubuntu): Workflow dput & SFTP 2026-05-04 22:25:45 -04:00
purian23 04d961ad0b distro(Ubuntu): Update Workflow & Dual Series Logic 2026-05-04 21:24:34 -04:00
Connor Welsh e60caf8028 feat(bar/dock): add 'Hide When Fullscreen' opt-out toggles (#2349)
Adds per-bar 'fullscreenDetection' and global 'dockHideOnFullscreen'
settings to gate CompositorService.hasFullscreenToplevelOnScreen()
2026-05-04 19:05:40 -04:00
dms-ci[bot] 1e67927f8a nix: update vendorHash for go.mod changes 2026-05-04 17:44:46 +00:00
bbedward e6e343dacb i18n: term sync 2026-05-04 13:42:27 -04:00
bbedward f87ad3d2ca core: update dependencies 2026-05-04 13:41:32 -04:00
bbedward a6ba4b1c79 dock: fix startup warning 2026-05-04 13:40:45 -04:00
dms-ci[bot] 7cf718cd50 nix: update vendorHash for go.mod changes 2026-05-04 17:39:40 +00:00
Giorgio De Trane d223a74740 feat(tailscale): add Tailscale control center widget (#1875)
* feat(tailscale): add Tailscale control center widget

Full-stack Tailscale integration for DMS control center:

Backend (Go):
- Event-driven manager via WatchIPNBus (no polling)
- Reconnects with exponential backoff when tailscaled unavailable
- Typed conversion from ipnstate.Status to QML-friendly IPC types
- Testable via tailscaleClient interface with mock watcher
- Manager cleanup in cleanupManagers()
- 19 unit tests

Frontend (QML):
- TailscaleService with WebSocket subscription
- TailscaleWidget with peer list, filter chips, search
- Copy-to-clipboard for IPs and DNS names
- Daemon lifecycle handling (offline/stopped states)

Dependencies:
- Add tailscale.com v1.96.1 (official local API client)
- Bump Go to 1.26.1 (required by tailscale.com)

* cleanups

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-05-04 13:37:25 -04:00
purian23 408beb202c refactor(sysupdate): improve cmd handling, formatted colors & progress indication
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 13:13:44 -04:00
DK cfe6e6867e feat(display): Fix and implement display auto-switching with JSON profile storage (#2275)
* feat(display): fix monitor auto config and add output disable guard

* feat(display): fixed some race conditions and sole display getting disabled.

Co-authored-by: Copilot <copilot@github.com>

* feat(display): changes console log to use new log service

* feat(display): fix trailing spaces

* prek run

* add migration, fix missing hyprland HDR parameters, use FileView >
python

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: bbedward <bbedward@gmail.com>
2026-05-04 13:07:24 -04:00
bbedward 7c991bc4e3 bluetooth: fix codec parsing names 2026-05-04 12:09:49 -04:00
bbedward 50f0cbb122 osd: fix inconsistent transparency
fixes #2330
2026-05-04 11:46:52 -04:00
Satvik Sharma 7ee0879103 Reverse session dir order so user configs override system configs as expected (#2341) 2026-05-04 11:19:01 -04:00
bbedward 56ef186ce8 notifications: add some de-duplication mechanism
related to #2334
2026-05-04 11:18:17 -04:00
purian23 5b507136e3 refactor(sysUpdate): Include the refresh flag in Fedora upgrade cmd 2026-05-04 11:06:26 -04:00
purian23 19c561da14 refactor: (Framemode) Added DeferredAction for dbar/dock state handling 2026-05-04 10:43:07 -04:00
bbedward cc47703d48 modals: fix sub-modals and power menu keyboard focus in connected mode
fixes #2346
2026-05-04 10:39:16 -04:00
purian23 31e60a3df5 fix(FrameMode): Update logic to hide the dbar/dock upon maximized windows/apps/games 2026-05-03 22:03:02 -04:00
purian23 082de6f1f0 refactor(FrameMode): Update modal background opts w/Connected Mode 2026-05-03 16:35:11 -04:00
purian23 fd24b4a36d feat(DMS FrameMode): A New Connected Unified Surface & Animation Overhaul
- Introduces Standalone & Connected Modes
- Updated Animations & Motion effects for both modes
- Numerous QOL tweaks and updates throughout the system
- Highly inspired to the OG Caelestia Shell / @Soramanew
2026-05-03 15:38:30 -04:00
ksp-apoc dd668469d7 fix: clean up orphaned idle inhibitor processes on startup (#2324)
When quickshell crashes or is force-killed, the child systemd-inhibit
process (used as fallback when native IdleInhibitor is unavailable) gets
reparented to PID 1 and continues to block idle indefinitely. This causes
hypridle to ignore all idle timeout rules even though the idle inhibitor
widget appears inactive after restart.

Add a cleanup step during initialization that kills any orphaned
systemd-inhibit processes from previous sessions.
2026-05-03 12:45:04 -04:00
bbedward 434490e100 audio: use typed PwNode list to assign audio devices
related to #2279
2026-05-03 10:04:31 -04:00
bbedward d2f6cb3ae4 process list: fix unloading
fixes #2284
2026-05-03 09:52:50 -04:00
bbedward c1cbd0994f settings: fix semvar signal moved to different service 2026-05-01 18:38:48 -04:00
bbedward c81645bacb add pre-commit hook for console.log 2026-04-30 16:59:26 -04:00
Archit Arora cdc4ca7e1f matugen: generate theme for Vencord (#2320) 2026-04-30 16:16:55 -04:00
gibbert 7d92842ff2 matugen: fix emacs template constant line number size (#2317)
Made it so line numbers don't stay a constant size when changing buffer
text scale.

See this thread:
<https://emacs.stackexchange.com/questions/74507/constant-font-size-in-display-line-numbers-mode-when-zooming-in-and-out>
2026-04-30 11:47:24 -04:00
Body d8bf3bdfe8 processes: fix list gaps and overlap when searching (#2315) 2026-04-30 11:45:46 -04:00
David Mireles 23ed795e85 Fix VPN UI for active transient entries (#2312)
Co-authored-by: louzt <18044171+louzt@users.noreply.github.com>
2026-04-30 11:41:41 -04:00
bbedward 2877c63c97 system update: make refresh synchronous 2026-04-30 11:41:07 -04:00
bbedward 86096db26b system update: general fixes to flatpak parsing 2026-04-29 16:14:19 -04:00
bbedward f76724f7cd logger: add a dedicated QML logging Singleton
- adds log.info/error/debug/warn/fatal
- adds ability to write logs to any file
- add CLI options in addition to env to set log levels
2026-04-29 15:42:30 -04:00
bbedward 3b96c6ab22 Revert "system updater: make all distros use terminal"
This reverts commit 1467f5dba9.
2026-04-29 14:56:54 -04:00
bbedward 1467f5dba9 system updater: make all distros use terminal 2026-04-29 14:41:24 -04:00
dms-ci[bot] baaa30c94e nix: update vendorHash for go.mod changes 2026-04-29 16:42:28 +00:00
bbedward 24a3cd5a3d core: update go dependencies 2026-04-29 12:40:24 -04:00
bbedward 65151dbfd7 i18n: term sync 2026-04-29 12:39:32 -04:00
bbedward 7bd9574868 system updater: complete overhaul
Move system update flow to GO, with a CLI (convenient AIO tool) and
server integration. All lifecycle, scheduling, execution occurs on
backend side.

Run some backends via pkexec, some via terminal like paru/yay.

Incorporate flatpak as an option to update.

Add terminal override setting in GUI, in addition to $TERMINAL env
variable.

fixes #2307
fixes #822
fixes #1102
fixes #1812
fixes #1087
fixes #1743
2026-04-29 12:33:57 -04:00
purian23 a4cfdf4a59 (dms): Add input group to dms setup
- Suppress fix/warnings
2026-04-28 14:03:37 -04:00
bbedward fd651dc943 niri overlay: fix state binding
fixes #2301
2026-04-28 13:19:34 -04:00
Kangheng Liu 919b09fc96 feat(desktop): expose screen var to desktop plugins (#2300) 2026-04-28 11:45:34 -04:00
bbedward aeb3fdd637 osd(media): workaround for firefox reporting youtube thumbnails as
players
fixes #2298
2026-04-28 11:27:16 -04:00
Amaan Qureshi dc5636bed5 flake: let module callers supply pkgs so overlays reach the build (#2244)
The nixosModule/homeModule path previously called `buildDmsPkgs pkgs` but
internally referenced `self.packages.${system}.default`, which was
instantiated via `nixpkgs.legacyPackages`, an unoverlayed pkgs. That
meant downstream flakes couldn't reach through their own overlays to
the dms-shell build (e.g. to swap `kdePackages.sonnet` or trim perl
out of the aspell closure).

Extract the derivation as `mkDmsShell = pkgs: ...` at the top-level
`let`, and call it from both `packages.${system}.dms-shell` (for
direct consumers of the flake) and `buildDmsPkgs pkgs` (for module
consumers, which now pass in the system's overlayed pkgs).

Also re-checks overrideAttrs / .override still work: `mkDmsShell pkgs`
is the same `pkgs.lib.makeOverridable` wrapper as before, just
parameterized on the caller's pkgs instance.

Co-authored-by: Lucas <43530291+LuckShiba@users.noreply.github.com>
2026-04-27 16:14:25 -03:00
bbedward 36a7692da7 dock: add trash CLI, refine implementation 2026-04-27 11:14:57 -04:00
Kangheng Liu c9b38023d5 feat(desktop): expose accept keyboard focus to desktop widgets (#2285)
Opt in by setting acceptsKeyboardFocus: true
2026-04-27 10:23:49 -04:00
Kangheng Liu 536e654b5e dock: add trash bin button (#2277)
* dock: add trash bin button

- icon reflects content- filled/empty
- multiple file manager support with nautilus as default, builtin as
fallback
- settingsspec at dock tab
- context menu

* fix: remove support for builtin filebrowser

needs specific adaptors at FB adhering the trash freedesktop spec

* fix: suppress auto-hide dock with trash context menu open

* feat: allow for custom file manager command

* feat: switch runner to proc.runcommand with toasts on command failures
2026-04-27 09:55:00 -04:00
Nic Ficca e805f6b5ac Fix: close notification center after clicking action buttons (#2276)
* Close notification center after clicking action buttons

When clicking action buttons (e.g., "View", "Activate") in the
notification center, the action fires but the popout stays open. Since
the center is a layer-shell surface, it blocks focus changes on Wayland
compositors like niri, making the action appear to do nothing.

The keyboard navigation path already closes the center after invoking
actions; this brings the mouse click path in line.

Also fix closeNotificationCenter() in PopoutService to set
notificationHistoryVisible = false (matching PopoutManager._closePopout)
instead of calling close() directly, which left the visibility property
stale and caused the bell toggle to require two presses to reopen.

Fixes #2178

* Sync notificationHistoryVisible with shouldBeVisible

NotificationCenterPopout has its own notificationHistoryVisible property
that drives open/close, but the PopoutService public API (open, close,
toggle) calls DankPopout methods directly, bypassing that property. This
leaves notificationHistoryVisible stale, causing the bell toggle to
require two presses to reopen after a programmatic close.

Sync the property from onShouldBeVisibleChanged so any caller going
through open()/close() gets the state corrected automatically.
2026-04-27 09:48:36 -04:00
Lucas 94f4b6d4a9 nix: add VM tests for flake modules (#2281)
* nix: add VM tests for flake modules

* ci: add NixOS tests
2026-04-27 09:37:28 -04:00
purian23 28f68ac702 refactor: update PPA upload script to handle series selection 2026-04-25 19:56:58 -04:00
purian23 441ec42ee0 (ubuntu): Update Workflow handling 2026-04-25 19:33:35 -04:00
Kangheng Liu 5415444e15 keybinds: add move workspace to monitor keybinds (#2268)
and distinguish with move columns
2026-04-25 12:07:18 -04:00
Archit Arora bd5276b40d feat(system-tray): add icon tinting (#2266) 2026-04-25 12:06:56 -04:00
Kangheng Liu dd3f17f51e clipboard: add keybind to switch tabs and toggle pinned (#2262)
* clipboard: add keybind to switch tabs

* clipboard: add bind to toggle pinned
2026-04-25 12:06:33 -04:00
purian23 a459b7d1b4 (dbar): Settings reorg 2026-04-25 00:40:33 -04:00
purian23 0f71c29776 dms(blur): Dank all the things 2026-04-24 22:52:14 -04:00
Lucas 4a32739d3f nix: update quickshell version (#2263)
Updated the quickshell revision to 783c95, matching the "stable" package in other DMS distributions.
2026-04-24 17:12:44 -04:00
bbedward 1abb221024 blur: revise general blur styling and refine it 2026-04-24 12:07:23 -04:00
Walid Salah b2668a2ffc Fix focused app when switching to empty workspace (#2259)
* Fix multiple screens on niri, when switching to an empty wokspace the other screen focused app widget would get confused

* Blank workspace fix
2026-04-24 10:48:24 -04:00
bbedward f4c11bc2ff clipboard: decode metadata only 2026-04-23 09:28:26 -04:00
bbedward 97fa86d8f0 loginctl: simplify event handling 2026-04-22 10:32:05 -04:00
Kristijan Ribarić b87c36d29e fix(quickshell): restore night mode and OSD surfaces after resume (#2254) 2026-04-22 10:08:50 -04:00
bbedward c6ed64b24e launcher: add elide helpers for RichText 2026-04-21 15:18:41 -04:00
bbedward cf382c0322 launcher: add indicators for flatpak/snap/appimage/nix
fixes #2251
2026-04-21 14:03:47 -04:00
bbedward 9139fd2fb1 doctor: add Miracle WM to checks 2026-04-20 09:27:59 -04:00
bbedward da3df9bb77 systray: fix missing import 2026-04-20 09:24:13 -04:00
Jos Dehaes e7834c981a Labwc service (#2248)
* services: add LabwcService with quit

labwc has a minimal IPC surface (no socket, no queries) but it does
expose `labwc --exit` as a clean shutdown path. Wrap that in a small
Singleton service following the same shape as DwlService/NiriService
so the compositor-specific dispatch in callers can stay uniform.

* session: dispatch labwc logout via LabwcService

CompositorService.isLabwc was detected but never dispatched in
_logout(); labwc sessions therefore fell through to the Hyprland
exit call, which silently no-ops under labwc. Users had to set
customPowerActionLogout to 'labwc --exit' as a workaround.

Add a labwc branch alongside the existing niri/dwl/sway branches
so the power menu logout works out of the box.
2026-04-20 09:22:20 -04:00
supposede 316428b14a Update color variables in zen-userchrome.css because it got broken again (#2246) 2026-04-20 09:16:04 -04:00
Walid Salah 6a9de8b423 Fix: Expand tilde from config paths (#2242)
* Expand tilde to the home directory for paths from config

* Remove extra line
2026-04-20 09:15:29 -04:00
Roni Laukkarinen f1e3452307 feat(system-tray): add optional monochrome icons setting (#2241)
Adds a 'Monochrome Icons' toggle to the system tray widget context menu.
When enabled, all system tray icons are desaturated using MultiEffect,
giving a cleaner monochrome bar aesthetic that matches minimal themes.

The setting is per-user (settings.json), defaults to false to preserve
existing behavior.
2026-04-20 09:15:02 -04:00
Sunny 4c2c193766 added non-flake nix compatibility with flake-compat (#2009)
* added non-flake nix compatibility with flake-compat

* nix: move flake-compat files to distro/nix

---------

Co-authored-by: LuckShiba <luckshiba@protonmail.com>
2026-04-17 22:42:19 -03:00
Lucas 112f2165f3 doctor: add blur support (#2236) 2026-04-17 18:57:13 -03:00
Lucas 40e3a22b99 nix: update flake.lock (#2237) 2026-04-17 18:57:02 -03:00
bbedward 7ced91ede1 notifications: add configurable durations for do not disturb
fixes #1481
2026-04-16 16:51:05 -04:00
bbedward c6e8067a22 core: add privesc package for privilege escalation
- Adds support for run0 and doas
fixes #998
2026-04-16 13:02:46 -04:00
bbedward d7fb75f7f9 keybinds(niri): add preprocessors to KDL parsing
fixes #2230
2026-04-16 10:36:55 -04:00
bbedward cf0fa7da6b fix(ddc): prevent negative WaitGroup counter on rapid brightness changes 2026-04-16 10:25:08 -04:00
purian23 787d213722 feat(Notepad): Add Expand/Collapse IPC handlers 2026-04-15 18:24:20 -04:00
purian23 2138fbf8b7 feat:(Notepad): Add blur & update animation track 2026-04-15 18:23:38 -04:00
bbedward 722b3fd1e8 audio: defensive checks on PwNode objects 2026-04-15 14:16:45 -04:00
dev 2728296cbd README.md - Update AUR badge to Arch (#2228)
The AUR dms-shell-bin package is replaced by dms-shell in the Arch Extra package repository. The AUR package has been removed.
2026-04-15 13:26:23 -04:00
Dimariqe fe1fd92953 fix: gate startup tray scan on prior suspend history (#2225)
The unconditional startup scan introduced duplicate tray icons on normal boot because apps were still registering their own SNI items when the scan ran.

Use CLOCK_BOOTTIME − CLOCK_MONOTONIC to detect whether the system has ever been suspended. The startup scan now only runs when the difference exceeds 5 s, meaning at least one suspend/resume cycle has occurred.
On a fresh boot the difference is ≈ 0 and the scan is skipped entirely.
2026-04-15 08:52:06 -04:00
bbedward 0ab9b1e4e9 idle/lock: add option to turn off monitors after lock explicitly
fixes #452
fixes #2156
2026-04-14 16:28:52 -04:00
bbedward 6d0953de68 i18n: sync terms 2026-04-14 11:51:39 -04:00
bbedward bc6bbdbe9d launcher: add ability to search files/folders in all tab
fixes #2032
2026-04-14 11:49:35 -04:00
DavutHaxor eff728fdf5 Fix ddc brightness not applying because process exits before debounce timer runs (#2217)
* Fix ddc brightness not applying because process exits before debounce timer runs

* Added sync.WaitGroup to DDCBackend and use it instead of loop in wait logic, added timeout in case i2c hangs.

* go fmt

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-04-14 10:27:36 -04:00
bbedward 8d415e9568 settings: re-work auth detection bindings 2026-04-13 09:46:17 -04:00
bbedward e6ed6a1cc2 network: report negotiated link rate when connected
fixes #2214
2026-04-13 09:11:42 -04:00
bbedward ca18174da5 gamma: more comprehensive IPCs 2026-04-13 09:06:23 -04:00
Particle_G 976b231b93 Add headless mode support with command-line flags (#2182)
* Add support for headless mode. Allow dankinstall run with command-line flags.

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146219

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146253

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146271

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146296

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146348

* FIx https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056146328

* Update headless mode instructions

* Add log dir config. Use DANKINSTALL_LOG env var, fallback to /var/tmp

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056737552

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056737572

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3056737592

* Add explanations for headless validating rules and log file location

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058087146 and https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058087234

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058087271

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058310408

* Enhance configuration deployment logic to support missing files and add corresponding unit tests

* Fix https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058310495

* Reworked the log channel handling logic to simplify the code and added the `drainLogChan` function to prevent blocking (https://github.com/AvengeMedia/DankMaterialShell/pull/2182#discussion_r3058609491)

* Added dependency-checking functionality to ensure installation requirements are met, and optimized the pre-installation logic for AUR packages

* feat: output log messages to stdout during installation

* Revert dependency-checking functionality due to official fix

* Revert compositor provider workaround due to upstream fix
2026-04-13 09:03:12 -04:00
bbedward 3d75a51378 gamma: add ipc call night getTemperature and enrich status function
fixes #1778
2026-04-12 21:46:41 -04:00
bbedward dc4b1529e6 gamma: reset lastAppliedTemp on suspend instead of destroying/recreating
outputs

fixes #2199 , possibly regresses #1235 - but I think the original bug
was lastAppliedTemp being incorrectly set, this allows compositors to
cache last applied gamma

gamma: add a bunch of defensive mechanisms for output changes
related to #2197

gamma: ensure gamma is re-evaluate on resume
fixes #1036
2026-04-12 21:40:39 -04:00
bbedward f61438e11f doctor: fix quickshell regex
fixes #2204
2026-04-11 12:44:58 -04:00
bbedward 8f78163941 dankinstall: workarounds for arch/extra change 2026-04-11 12:24:08 -04:00
mihem f894d338fc feat(running-apps): stronger active app highlight + indicator bar (#2190)
The focused app background used 20% primary opacity which was barely
visible. Increase to 45% to make the active window unambiguous at a glance.
2026-04-11 12:01:21 -04:00
bbedward f2df53afcd colorpicker: re-use Wayland buffer pools 2026-04-09 12:08:43 -04:00
Thomas Kroll 4179fcee83 fix(privacy): detect screen casting on Niri via PipeWire (#2185)
Screen sharing was not detected by PrivacyService on Niri because:

1. Niri creates the screencast as a Stream/Output/Video node, but
   screensharingActive only checked PwNodeType.VideoSource nodes.

2. looksLikeScreencast() only inspected application.name and
   node.name, missing Niri's node which has an empty application.name
   but identifies itself via media.name (niri-screen-cast-src).

Add Stream/Output/Video to the checked media classes and include
media.name in the screencast heuristic. Also add a forward-compatible
check for NiriService.hasActiveCast for when Niri gains cast tracking
in its IPC.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:50:39 -04:00
Andrey Yugai a0c9af1ee7 feature: persist last active player (#2184) 2026-04-09 11:30:04 -04:00
Thomas Kroll 049266271a fix(system-update): popout first-click and AUR package listing (#2183)
* fix(system-update): open popout on first click

The SystemUpdate widget required two clicks to open its popout.

On the first click, the LazyLoader was activated but popoutTarget
(bound to the loader's item) was still null in the MouseArea handler,
so setTriggerPosition was never called. The popout's open() then
returned early because screen was unset.

Restructure the onClicked handler to call setTriggerPosition directly
on the loaded item (matching the pattern used by Clock, Clipboard, and
other bar widgets) and use PopoutManager.requestPopout() instead of
toggle() for consistent popout management.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(system-update): include AUR packages in update list

When paru or yay is the package manager, the update list only showed
official repo packages (via checkupdates or -Qu) while the upgrade
command (paru/yay -Syu) also processes AUR packages. This mismatch
meant AUR updates appeared as a surprise during the upgrade.

Combine the repo update listing with the AUR helper's -Qua flag so
both official and AUR packages are shown in the popout before the
user triggers the upgrade. The output format is identical for both
sources, so the existing parser works unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:49:15 -04:00
Ron Harel 0eabda3164 Improve seek and scrub indicator/ animations in the media controls widget. (#2181) 2026-04-09 10:35:56 -04:00
bbedward 32c063aab8 Revert "qml: cut down on inline components for performance"
This reverts commit f6e590a518.
2026-04-07 15:22:46 -04:00
Ron Harel 37f92677cf Make the system tray overflow popup optional and have the widget expand inline as an alternative. (#2171) 2026-04-07 11:13:32 -04:00
Ron Harel 13e8130858 Add adaptive media width setting. (#2165) 2026-04-07 11:07:36 -04:00
bbedward f6e590a518 qml: cut down on inline components for performance 2026-04-07 10:57:11 -04:00
bbedward 3194fc3fbe core: allow RO commands to run as root 2026-04-06 18:19:17 -04:00
bbedward 3318864ece clipboard: make CLI keep CL item in-memory again 2026-04-06 16:09:10 -04:00
Iris e224417593 feature: add login sound functionality and settings entry (#2155)
* added login sound functionality and settings entry

* Removed debug warning that was accidentally left in

* loginSound is off by default, and fixed toggle not working

* Prevent login sound from playing in the same session

---------

Co-authored-by: Iris <iris@raidev.eu>
2026-04-06 14:11:00 -04:00
Marcus Ramberg 3f7f6c5d2c core(doctor): show all detected terminals (#2163) 2026-04-06 14:09:15 -04:00
596 changed files with 73060 additions and 20517 deletions
+104
View File
@@ -0,0 +1,104 @@
# Agent Skills
This directory contains agent skills following the [Agent Skills](https://agentskills.io) open standard - a portable, version-controlled format for giving AI agents specialized capabilities.
Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs, scripts, and templates. Agents load skills progressively: metadata at startup, full instructions on activation, and supporting files on demand.
## Available Skills
| Skill | Description |
|-------|-------------|
| [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
The `.agents/skills/` directory at the project root is the standard location defined by the agentskills.io spec. Many agents discover skills from this path automatically. Some agents use their own directory conventions and need a symlink or copy.
### Claude Code
Claude Code discovers skills from `.claude/skills/` (project-level) or `~/.claude/skills/` (personal). To make skills from `.agents/skills/` available, symlink them into the Claude Code skills directory:
**Project-level** (this repo only):
```bash
mkdir -p .claude/skills
ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
```
**Personal** (all your projects):
```bash
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.
See the [Claude Code skills docs](https://code.claude.com/docs/en/skills) for more on skill configuration, invocation control, and frontmatter options.
### Cursor
Cursor discovers skills from `.cursor/skills/` in the project root:
```bash
mkdir -p .cursor/skills
ln -s ../../.agents/skills/dms-plugin-dev .cursor/skills/dms-plugin-dev
```
See [Cursor skills docs](https://cursor.com/docs/context/skills) for details.
### VS Code (Copilot)
VS Code Copilot discovers skills from `.github/skills/` or `.vscode/skills/`:
```bash
mkdir -p .github/skills
ln -s ../../.agents/skills/dms-plugin-dev .github/skills/dms-plugin-dev
```
See [VS Code skills docs](https://code.visualstudio.com/docs/copilot/customization/agent-skills) for details.
### Gemini CLI
Gemini CLI discovers skills from `.gemini/skills/` in the project root:
```bash
mkdir -p .gemini/skills
ln -s ../../.agents/skills/dms-plugin-dev .gemini/skills/dms-plugin-dev
```
See [Gemini CLI skills docs](https://geminicli.com/docs/cli/skills/) for details.
### OpenAI Codex
Codex discovers skills from `.codex/skills/` in the project root:
```bash
mkdir -p .codex/skills
ln -s ../../.agents/skills/dms-plugin-dev .codex/skills/dms-plugin-dev
```
See [Codex skills docs](https://developers.openai.com/codex/skills/) for details.
### Other Agents
The Agent Skills standard is supported by 30+ tools including Goose, Roo Code, JetBrains Junie, Amp, OpenCode, OpenHands, Kiro, and more. Most discover skills from a dot-directory at the project root (e.g., `.goose/skills/`, `.roo/skills/`). Some read `.agents/skills/` directly.
Check the [Agent Skills client showcase](https://agentskills.io/clients) for setup instructions specific to your agent.
The general pattern is:
```bash
mkdir -p .<agent>/skills
ln -s ../../.agents/skills/dms-plugin-dev .<agent>/skills/dms-plugin-dev
```
## Adding New Skills
To add a new skill to this directory:
1. Create a subdirectory named with lowercase letters, numbers, and hyphens (e.g., `my-new-skill/`)
2. Add a `SKILL.md` file with YAML frontmatter (`name`, `description`) and markdown instructions
3. Optionally add `references/`, `scripts/`, and `assets/` subdirectories
4. Keep `SKILL.md` under 500 lines - move detailed content to reference files
See the [Agent Skills specification](https://agentskills.io/specification) for the full format.
+497
View File
@@ -0,0 +1,497 @@
---
name: dms-plugin-dev
description: >
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
manifest creation, QML component development, settings UI, data persistence, theme
integration, PopoutService usage, and external command execution. Use when the user wants
to create, modify, or debug a DMS plugin, or asks about the DMS plugin API.
compatibility: Designed for Claude Code (or similar products)
metadata:
author: DankMaterialShell
version: "1.0"
domain: qml-desktop-development
framework: DankMaterialShell
languages: qml, javascript
allowed-tools: Bash Read Write Edit
---
# DankMaterialShell Plugin Development
## Overview
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/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.danklinux.com/
**Four plugin types:**
| Type | Purpose | Base Component | Bar pills | CC integration |
|------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
## Step 1: Determine Plugin Type
Choose the type based on what the plugin does:
- **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.
## Step 2: Create the Manifest
Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md](references/plugin-manifest-reference.md) for the full schema.
**Minimal manifest:**
```json
{
"id": "yourPlugin",
"name": "Your Plugin Name",
"description": "Brief description of what your plugin does",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["your-capability"],
"component": "./YourWidget.qml"
}
```
**With settings and permissions:**
```json
{
"id": "yourPlugin",
"name": "Your Plugin Name",
"description": "Brief description",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["your-capability"],
"component": "./YourWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.0",
"permissions": ["settings_read", "settings_write"]
}
```
**Key rules:**
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
- `version` must be semver (e.g., `1.0.0`)
- `component` must start with `./` and end with `.qml`
- `type: "launcher"` requires a `trigger` field
- `settings_write` permission is **required** if the plugin has a settings component
## Step 3: Create the Main Component
### Widget
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: "Hello"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: "Hi"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
rotation: 90
}
}
}
}
```
See [widget-plugin-guide.md](references/widget-plugin-guide.md) for popouts, CC integration, and advanced features.
### Launcher
```qml
import QtQuick
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
function getItems(query) {
const items = [
{ name: "Item One", icon: "material:star", comment: "Description",
action: "toast:Hello!", categories: ["MyPlugin"] }
]
if (!query) return items
const q = query.toLowerCase()
return items.filter(i => i.name.toLowerCase().includes(q))
}
function executeItem(item) {
const [type, ...rest] = item.action.split(":")
const data = rest.join(":")
if (type === "toast") ToastService?.showInfo(data)
else if (type === "copy") Quickshell.execDetached(["dms", "cl", "copy", data])
}
}
```
See [launcher-plugin-guide.md](references/launcher-plugin-guide.md) for triggers, icon types, context menus, and image tiles.
### Desktop
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: 0.85
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
Text {
anchors.centerIn: parent
text: "Desktop Widget"
color: Theme.surfaceText
}
}
}
```
See [desktop-plugin-guide.md](references/desktop-plugin-guide.md) for sizing, persistence, and edit mode.
### Daemon
```qml
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
Connections {
target: SessionData
function onSomeSignal() {
console.log("Event received")
}
}
}
```
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
## Step 4: Add Settings (Optional)
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "yourPlugin"
StringSetting {
settingKey: "apiKey"
label: "API Key"
description: "Your API key"
placeholder: "sk-..."
}
ToggleSetting {
settingKey: "enabled"
label: "Enable Feature"
defaultValue: true
}
SelectionSetting {
settingKey: "interval"
label: "Refresh Interval"
options: [
{ label: "1 min", value: "60" },
{ label: "5 min", value: "300" }
]
defaultValue: "300"
}
}
```
**Available setting components:** StringSetting, ToggleSetting, SelectionSetting, SliderSetting, ColorSetting, ListSetting, ListSettingWithInput.
See [settings-components-reference.md](references/settings-components-reference.md) for full property lists.
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
## Step 5: Use Data Persistence
Three tiers of persistence:
| API | Persisted | Use case |
|-----|-----------|----------|
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
- Global vars sync across all instances (multi-monitor, multiple bar sections)
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
## Step 6: Theme Integration
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
**Essential properties:**
- Colors: `Theme.surfaceContainerHigh`, `Theme.surfaceText`, `Theme.primary`, `Theme.onPrimary`
- Fonts: `Theme.fontSizeSmall` (12), `Theme.fontSizeMedium` (14), `Theme.fontSizeLarge` (16), `Theme.fontSizeXLarge` (20)
- Spacing: `Theme.spacingXS`, `Theme.spacingS`, `Theme.spacingM`, `Theme.spacingL`, `Theme.spacingXL`
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
**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.
## Step 7: Add Popout Content (Widgets Only)
Add a popout that opens when the bar pill is clicked:
```qml
PluginComponent {
popoutWidth: 400
popoutHeight: 300
popoutContent: Component {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: "Content here"
color: Theme.surfaceText
}
}
}
}
horizontalBarPill: Component { /* ... */ }
verticalBarPill: Component { /* ... */ }
}
```
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
## Step 8: Control Center Integration (Widgets Only)
Add your widget to the Control Center grid:
```qml
PluginComponent {
ccWidgetIcon: "toggle_on"
ccWidgetPrimaryText: "My Feature"
ccWidgetSecondaryText: isActive ? "On" : "Off"
ccWidgetIsActive: isActive
onCcWidgetToggled: {
isActive = !isActive
pluginService?.savePluginData(pluginId, "active", isActive)
}
// Optional: expandable detail panel (for CompoundPill)
ccDetailContent: Component {
Rectangle {
implicitHeight: 200
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
}
}
}
```
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
## Step 9: External Commands and Clipboard
**Run commands and capture output:**
```qml
import qs.Common
Proc.runCommand(
"myPlugin.fetch",
["curl", "-s", "https://api.example.com/data"],
(stdout, exitCode) => {
if (exitCode === 0) processData(stdout)
},
500 // debounce ms
)
```
**Fire-and-forget (clipboard, notifications):**
```qml
import Quickshell
Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
```
**Long-running processes:** Use the `Process` QML component from `Quickshell.Io` with `StdioCollector`.
**Shell commands with pipes:** `["sh", "-c", "ps aux | grep foo"]`
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
## Step 10: 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
**Common issues:**
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
- Widget not showing: ensure it's enabled AND added to a DankBar section
- Settings error: verify `settings_write` permission is declared
- Data not persisting: check pluginService injection and permissions
## Common Mistakes
1. **Missing `settings_write` permission** - Settings UI shows error without it
2. **Missing `property var popoutService: null`** - Must declare for injection to work
3. **Missing vertical bar pill** - Widget disappears when bar is on left/right edge
4. **Hardcoded colors** - Use `Theme.*` properties, not hex values
5. **Using `globalThis.clipboard`** - Does not exist; use `Quickshell.execDetached(["dms", "cl", "copy", text])`
6. **Wrong Theme property names** - `Theme.fontSizeS` does not exist, use `Theme.fontSizeSmall`
7. **Wrong import for Quickshell** - Use `import Quickshell` (not `import QtQuick` for execDetached)
8. **Forgetting `categories` in launcher items** - Items won't display without it
9. **Not handling null pluginService** - Always use optional chaining or null checks
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
## Quick Reference: Imports
**Widget / Daemon:**
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
```
**Launcher:**
```qml
import QtQuick
import qs.Services
```
**Desktop:**
```qml
import QtQuick
import qs.Common
```
**For clipboard/exec:** `import Quickshell`
**For processes:** `import Quickshell.Io`
**For networking:** `import Quickshell.Networking`
**For toast notifications:** access `ToastService` from `qs.Services`
## Quick Reference: File Naming
- **Directory name:** PascalCase (e.g., `MyAwesomePlugin/`)
- **Plugin ID:** camelCase (e.g., `myAwesomePlugin`)
- **QML files:** PascalCase (e.g., `MyWidget.qml`, `Settings.qml`)
- **Component paths in manifest:** relative with `./` prefix (e.g., `"./MyWidget.qml"`)
- **JS utility files:** camelCase (e.g., `utils.js`, `apiAdapter.js`)
## Reference Files
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 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
- [settings-components-reference.md](references/settings-components-reference.md) - All 7 setting components with complete property lists
- [theme-reference.md](references/theme-reference.md) - Theme colors, spacing, fonts, radii, common patterns
- [data-persistence-guide.md](references/data-persistence-guide.md) - pluginData, state API, global variables
- [popout-service-reference.md](references/popout-service-reference.md) - PopoutService API for controlling shell popouts and modals
- [advanced-patterns.md](references/advanced-patterns.md) - Variants, JS utilities, qmldir, IPC, multi-file plugins
@@ -0,0 +1,115 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://danklinux.com/schemas/plugin.json",
"title": "DankMaterialShell Plugin Manifest",
"description": "Schema for DankMaterialShell plugin.json manifest files",
"type": "object",
"required": [
"id",
"name",
"description",
"version",
"author",
"type",
"capabilities",
"component"
],
"properties": {
"id": {
"type": "string",
"description": "Unique plugin identifier (camelCase, no spaces)",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
},
"name": {
"type": "string",
"description": "Human-readable plugin name",
"minLength": 1
},
"description": {
"type": "string",
"description": "Short description of plugin functionality",
"minLength": 1
},
"version": {
"type": "string",
"description": "Semantic version string (e.g., '1.0.0')",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
},
"author": {
"type": "string",
"description": "Plugin creator name or email",
"minLength": 1
},
"type": {
"type": "string",
"description": "Plugin type",
"enum": ["widget", "daemon", "launcher", "desktop"]
},
"capabilities": {
"type": "array",
"description": "Array of plugin capabilities",
"items": {
"type": "string"
},
"minItems": 1
},
"component": {
"type": "string",
"description": "Relative path to main QML component file",
"pattern": "^\\./.*\\.qml$"
},
"trigger": {
"type": "string",
"description": "Trigger string for launcher activation (required for launcher type)"
},
"icon": {
"type": "string",
"description": "Material Design icon name"
},
"settings": {
"type": "string",
"description": "Path to settings component QML file",
"pattern": "^\\./.*\\.qml$"
},
"requires_dms": {
"type": "string",
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
},
"requires": {
"type": "array",
"description": "Array of required system tools/dependencies",
"items": {
"type": "string"
}
},
"permissions": {
"type": "array",
"description": "Required capabilities",
"items": {
"type": "string",
"enum": [
"settings_read",
"settings_write",
"process",
"network"
]
}
}
},
"allOf": [
{
"if": {
"properties": {
"type": {
"const": "launcher"
}
}
},
"then": {
"required": ["trigger"]
}
}
],
"additionalProperties": true
}
@@ -0,0 +1,48 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// TODO: Read configuration from settings
property string configValue: pluginData?.configValue || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
configValue = pluginService.loadPluginData(pluginId, "configValue", "")
}
}
// TODO: Connect to the service events you need
// Connections {
// target: SessionData
// function onWallpaperPathChanged() {
// console.log("[MyDaemon] Wallpaper changed:", SessionData.wallpaperPath)
// handleEvent(SessionData.wallpaperPath)
// }
// }
function handleEvent(data) {
Proc.runCommand(
"myDaemon.handle",
["echo", "Event received:", data],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("[MyDaemon] Output:", stdout)
} else {
console.error("[MyDaemon] Failed:", exitCode)
ToastService?.showInfo("Daemon action failed")
}
}
)
}
Component.onCompleted: {
console.log("[MyDaemon] Started")
}
}
@@ -0,0 +1,16 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDaemon"
StringSetting {
settingKey: "configValue"
label: "Configuration"
description: "Value used by the daemon"
placeholder: "Enter value..."
defaultValue: ""
}
}
@@ -0,0 +1,13 @@
{
"id": "myDaemon",
"name": "My Daemon",
"description": "A background service that reacts to events",
"version": "1.0.0",
"author": "Your Name",
"type": "daemon",
"capabilities": ["background-service"],
"component": "./Daemon.qml",
"icon": "settings",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write", "process"]
}
@@ -0,0 +1,18 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDesktopWidget"
SliderSetting {
settingKey: "opacity"
label: "Opacity"
description: "Widget background opacity"
defaultValue: 85
minimum: 10
maximum: 100
unit: "%"
}
}
@@ -0,0 +1,47 @@
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
// TODO: Load settings reactively
property real bgOpacity: {
if (!pluginService) return 0.85
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
return val / 100
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
bgOpacity = val / 100
}
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: root.bgOpacity
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
// TODO: Add your widget content here
Text {
anchors.centerIn: parent
text: "Desktop Widget"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeLarge
}
}
}
@@ -0,0 +1,13 @@
{
"id": "myDesktopWidget",
"name": "My Desktop Widget",
"description": "A custom desktop widget",
"version": "1.0.0",
"author": "Your Name",
"type": "desktop",
"capabilities": ["desktop-widget"],
"component": "./Widget.qml",
"icon": "widgets",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,59 @@
import QtQuick
import Quickshell
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
// TODO: Define your items
property var allItems: [
{
name: "Example Item",
icon: "material:star",
comment: "An example launcher item",
action: "toast:Hello from my launcher!",
categories: ["MyLauncher"]
}
]
function getItems(query) {
if (!query || query.length === 0) return allItems
var q = query.toLowerCase()
return allItems.filter(function(item) {
return item.name.toLowerCase().includes(q) ||
item.comment.toLowerCase().includes(q)
})
}
function executeItem(item) {
var actionParts = item.action.split(":")
var actionType = actionParts[0]
var actionData = actionParts.slice(1).join(":")
switch (actionType) {
case "toast":
if (typeof ToastService !== "undefined")
ToastService.showInfo(actionData)
break
case "copy":
Quickshell.execDetached(["dms", "cl", "copy", actionData])
if (typeof ToastService !== "undefined")
ToastService.showInfo("Copied to clipboard")
break
default:
console.warn("Unknown action type:", actionType)
}
}
Component.onCompleted: {
if (pluginService) {
trigger = pluginService.loadPluginData("myLauncher", "trigger", "#")
}
}
}
@@ -0,0 +1,23 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myLauncher"
StringSetting {
settingKey: "trigger"
label: "Trigger"
description: "Type this prefix in the launcher to activate the plugin"
placeholder: "#"
defaultValue: "#"
}
ToggleSetting {
settingKey: "noTrigger"
label: "Always Visible"
description: "Show items alongside regular apps without needing a trigger"
defaultValue: false
}
}
@@ -0,0 +1,14 @@
{
"id": "myLauncher",
"name": "My Launcher",
"description": "Custom launcher plugin with searchable items",
"version": "1.0.0",
"author": "Your Name",
"type": "launcher",
"capabilities": ["launcher"],
"component": "./Launcher.qml",
"trigger": "#",
"icon": "search",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,23 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myWidget"
StringSetting {
settingKey: "text"
label: "Display Text"
description: "Text shown in the bar widget"
placeholder: "Hello"
defaultValue: "Hello"
}
ToggleSetting {
settingKey: "showIcon"
label: "Show Icon"
description: "Display an icon next to the text"
defaultValue: true
}
}
@@ -0,0 +1,75 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// TODO: Read settings reactively
property string displayText: pluginData?.text || "Hello"
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
displayText = pluginService.loadPluginData(pluginId, "text", "Hello")
}
}
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
rotation: 90
}
}
}
// TODO: Uncomment and customize popout content
// popoutWidth: 350
// popoutHeight: 300
// popoutContent: Component {
// PopoutComponent {
// headerText: "My Widget"
// showCloseButton: true
//
// Column {
// width: parent.width
// spacing: Theme.spacingM
//
// StyledText {
// text: "Popout content here"
// color: Theme.surfaceText
// }
// }
// }
// }
}
@@ -0,0 +1,13 @@
{
"id": "myWidget",
"name": "My Widget",
"description": "A custom bar widget",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["dankbar-widget"],
"component": "./Widget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,320 @@
# Advanced Patterns
Patterns observed in production DMS plugins that go beyond the basics.
## Plugin Variants
Create multiple widget instances from a single plugin definition. Each variant has its own configuration.
### Manifest
No special manifest changes needed - the variant system is built into PluginComponent.
### Widget with Variant Support
```qml
PluginComponent {
property string variantId: ""
property var variantData: ({})
property string displayText: variantData?.text || "Default"
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
}
}
}
}
```
Widget format in bar config: `pluginId:variantId` (e.g., `exampleVariants:variant_1234567890`)
### Settings with Variant Management
```qml
PluginSettings {
pluginId: "exampleVariants"
// Variant creation UI
DankButton {
text: "Add New Instance"
onClicked: {
var id = "variant_" + Date.now()
root.createVariant(id, { name: "New Instance", text: "Hello" })
}
}
// Per-variant configuration
Repeater {
model: root.variants
delegate: Column {
StringSetting {
settingKey: modelData.id + "_text"
label: modelData.name || modelData.id
}
}
}
}
```
## JavaScript Utility Files
For complex logic, split into `.js` files:
### utils.js
```javascript
.pragma library
function formatDuration(ms) {
if (ms < 60000) return "just now"
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"
return Math.floor(ms / 3600000) + "h ago"
}
function parseResponse(json) {
try {
return JSON.parse(json)
} catch (e) {
return null
}
}
```
### Using in QML
```qml
import "utils.js" as Utils
Item {
StyledText {
text: Utils.formatDuration(Date.now() - timestamp)
}
}
```
The `.pragma library` directive makes the JS file a shared singleton - it is loaded once and shared across all QML instances that import it.
## qmldir for Singleton Services
For plugins with internal singleton services:
### qmldir
```
singleton MyService 1.0 MyService.qml
```
### MyService.qml
```qml
pragma Singleton
import QtQuick
QtObject {
property var cache: ({})
function getData(key) {
return cache[key] || null
}
function setData(key, value) {
cache[key] = value
}
}
```
### Using the singleton
```qml
import "." as Local
Item {
Component.onCompleted: {
Local.MyService.setData("key", "value")
}
}
```
## Inline Component Declarations
Reusable sub-components defined inline:
```qml
Item {
component StatusBadge: Rectangle {
property string label: ""
property color badgeColor: Theme.primary
width: badgeText.implicitWidth + Theme.spacingM * 2
height: 24
radius: 12
color: badgeColor
StyledText {
id: badgeText
anchors.centerIn: parent
text: label
color: Theme.onPrimary
font.pixelSize: Theme.fontSizeSmall
}
}
Row {
spacing: Theme.spacingS
StatusBadge { label: "Running"; badgeColor: Theme.success }
StatusBadge { label: "Stopped"; badgeColor: Theme.error }
}
}
```
## Multi-Provider Adapter Pattern
For plugins supporting multiple backends (AI providers, API services):
### apiAdapters.js
```javascript
.pragma library
function createAdapter(provider) {
switch (provider) {
case "openai": return {
url: "https://api.openai.com/v1/chat/completions",
headers: (key) => ({ "Authorization": "Bearer " + key }),
formatRequest: (messages) => JSON.stringify({ model: "gpt-4", messages: messages }),
parseResponse: (text) => JSON.parse(text).choices[0].message.content
}
case "anthropic": return {
url: "https://api.anthropic.com/v1/messages",
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
formatRequest: (messages) => JSON.stringify({ model: "claude-sonnet-4-20250514", messages: messages }),
parseResponse: (text) => JSON.parse(text).content[0].text
}
default: return null
}
}
```
## IPC Integration
For plugins that respond to keyboard shortcuts or external commands:
```qml
PluginComponent {
Connections {
target: DMSIpc
function onCommandReceived(command, args) {
if (command === "myPlugin.toggle") {
doToggle()
} else if (command === "myPlugin.next") {
goNext()
}
}
}
}
```
External trigger: `dms ipc call myPlugin.toggle`
## Networking with Quickshell.Networking
For API calls using the built-in networking module:
```qml
import Quickshell.Networking
Item {
NetworkRequest {
id: request
url: "https://api.example.com/data"
method: "GET"
onResponseReceived: (response) => {
const data = JSON.parse(response.body)
processData(data)
}
onErrorOccurred: (error) => {
console.error("Network error:", error)
}
}
function fetchData() {
request.send()
}
}
```
## Toast Notifications
Show user feedback:
```qml
import qs.Services
// Info toast
ToastService?.showInfo("Operation completed")
// With title
ToastService?.showInfo("Plugin Name", "Data refreshed successfully")
```
Always use optional chaining since ToastService may not be available in all contexts.
## Clipboard Operations
```qml
import Quickshell
function copyToClipboard(text) {
Quickshell.execDetached(["dms", "cl", "copy", text])
ToastService?.showInfo("Copied to clipboard")
}
```
Do NOT use `globalThis.clipboard`, `navigator.clipboard`, or any browser API - they do not exist in the QML runtime.
## Multi-File Plugin Architecture
Large plugins can be split across multiple files:
```
MyPlugin/
plugin.json
Main.qml # Main widget component
Settings.qml # Settings UI
DetailView.qml # Popout detail view
utils.js # Utility functions
apiAdapter.js # API adapter layer
qmldir # Optional: singleton registrations
```
Import sibling files:
```qml
// In Main.qml
import "." as Local
Item {
Loader {
source: "DetailView.qml"
}
}
```
## Performance Tips
1. Use `Proc.runCommand` with appropriate debounce for external commands
2. Pre-cache images and thumbnails for image-heavy plugins
3. Limit concurrent network requests
4. Use `Timer` with reasonable intervals (don't poll faster than needed)
5. Lazy-load heavy content (use `Loader` for complex popout content)
6. Avoid blocking the UI thread with synchronous operations
@@ -0,0 +1,272 @@
# Daemon Plugin Guide
Daemon plugins are invisible background services that react to events and execute actions. They have no bar pills or desktop presence.
## Base Component
Daemons use `PluginComponent` with no bar pills:
```qml
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// Event-driven logic goes here
}
```
## When to Use Daemons
- Monitor system events (wallpaper changes, battery level, notifications)
- Run periodic background tasks (polling APIs, checking system state)
- Execute scripts in response to events
- Control shell UI via PopoutService based on conditions
## Event-Driven Pattern
Use `Connections` to react to service signals:
```qml
PluginComponent {
property var popoutService: null
Connections {
target: SessionData
function onWallpaperPathChanged() {
console.log("Wallpaper changed to:", SessionData.wallpaperPath)
runScript(SessionData.wallpaperPath)
}
}
Connections {
target: BatteryService
function onPercentageChanged() {
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
popoutService?.openBattery()
}
}
}
}
```
## Available Services
Common services daemons can connect to:
| Service | Signals/Properties | Description |
|---------|-------------------|-------------|
| `SessionData` | `wallpaperPath`, `onWallpaperPathChanged` | Desktop session state |
| `BatteryService` | `percentage`, `isCharging`, `batteryAvailable` | Battery status |
| `NotificationService` | `onNotificationReceived(notification)` | Desktop notifications |
| `PluginService` | `onPluginLoaded`, `onGlobalVarChanged` | Plugin lifecycle |
Import services from `qs.Services`.
## Process Execution
### Simple command with Proc
```qml
import qs.Common
PluginComponent {
function runScript(arg) {
Proc.runCommand(
"myDaemon.script",
["bash", "-c", "echo 'Processing: " + arg + "'"],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("Script output:", stdout)
} else {
ToastService?.showInfo("Script failed: exit " + exitCode)
}
}
)
}
}
```
### Long-running process with Process component
```qml
import Quickshell.Io
PluginComponent {
property string scriptPath: ""
Process {
id: proc
command: ["bash", scriptPath]
running: false
stdout: StdioCollector {
onTextReceived: (text) => {
console.log("stdout:", text)
}
}
stderr: StdioCollector {
onTextReceived: (text) => {
console.error("stderr:", text)
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
ToastService?.showInfo("Process failed: exit " + exitCode)
}
}
}
function startProcess() {
if (scriptPath && !proc.running) {
proc.running = true
}
}
}
```
## Timer-Based Polling
```qml
PluginComponent {
Timer {
interval: 60000 // every minute
running: true
repeat: true
onTriggered: checkStatus()
}
function checkStatus() {
Proc.runCommand(
"myDaemon.check",
["sh", "-c", "systemctl is-active myservice"],
(stdout, exitCode) => {
const active = stdout.trim() === "active"
PluginService.setGlobalVar("myDaemon", "serviceActive", active)
}
)
}
}
```
## Data Persistence
Daemons access PluginService directly (it's injected via PluginComponent):
```qml
PluginComponent {
property string configuredScript: pluginData?.scriptPath || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId) {
configuredScript = pluginService.loadPluginData(pluginId, "scriptPath", "")
}
}
}
}
```
## PopoutService Usage
Daemons can control shell UI via the injected popoutService:
```qml
PluginComponent {
property var popoutService: null
function showAlert() {
popoutService?.openNotificationCenter()
}
function openSettings() {
popoutService?.openSettings()
}
}
```
See [popout-service-reference.md](popout-service-reference.md) for the full API.
## Complete Example
Based on the WallpaperWatcherDaemon:
```qml
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
property string scriptPath: pluginData?.scriptPath || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId) {
scriptPath = pluginService.loadPluginData(pluginId, "scriptPath", "")
}
}
}
Connections {
target: SessionData
function onWallpaperPathChanged() {
if (scriptPath) {
runWallpaperScript(SessionData.wallpaperPath)
}
}
}
function runWallpaperScript(wallpaperPath) {
console.log("[WallpaperWatcher] Running script:", scriptPath, wallpaperPath)
Proc.runCommand(
"wallpaperWatcher.run",
["bash", scriptPath, wallpaperPath],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("[WallpaperWatcher] Script output:", stdout)
} else {
console.error("[WallpaperWatcher] Script failed:", exitCode)
ToastService?.showInfo("Wallpaper script failed")
}
}
)
}
Component.onCompleted: {
console.log("[WallpaperWatcher] Daemon started")
}
}
```
## Manifest Example
```json
{
"id": "wallpaperWatcher",
"name": "Wallpaper Watcher",
"description": "Runs a script when the wallpaper changes",
"version": "1.0.0",
"author": "Developer",
"type": "daemon",
"capabilities": ["wallpaper-automation"],
"component": "./WallpaperWatcher.qml",
"icon": "wallpaper",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write", "process"]
}
```
@@ -0,0 +1,176 @@
# Data Persistence Guide
DMS plugins have three tiers of data persistence, each suited for different use cases.
## Tier 1: Plugin Data (Settings)
Persisted to `settings.json`. Use for user preferences and configuration.
### Saving
```qml
pluginService.savePluginData(pluginId, "key", value)
```
### Loading
```qml
var value = pluginService.loadPluginData(pluginId, "key", defaultValue)
```
### Reactive Access via pluginData
`PluginComponent` has a reactive `pluginData` property that auto-loads from settings:
```qml
PluginComponent {
property string displayText: pluginData?.text || "Default"
property bool showIcon: pluginData?.showIcon !== undefined ? pluginData.showIcon : true
}
```
### Reacting to Settings Changes
When settings are changed (e.g., from the settings UI), react with `Connections`:
```qml
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
showIcon = pluginService.loadPluginData(pluginId, "showIcon", true)
}
}
```
## Tier 2: Plugin State
Persisted to a separate state file. Use for runtime state that should survive restarts but is not user-configurable (history, cache, counters).
### Saving
```qml
pluginService.savePluginState(pluginId, "key", value)
```
### Loading
```qml
var state = pluginService.loadPluginState(pluginId, "key", defaultValue)
```
### Additional Methods
```qml
pluginService.clearPluginState(pluginId)
pluginService.removePluginStateKey(pluginId, "key")
```
### Example: Persistent History
```qml
Item {
property var history: []
Component.onCompleted: {
history = pluginService?.loadPluginState(pluginId, "history", []) || []
}
function addToHistory(entry) {
history.unshift({
text: entry,
timestamp: Date.now()
})
if (history.length > 100) history = history.slice(0, 100)
pluginService?.savePluginState(pluginId, "history", history)
}
function clearHistory() {
history = []
pluginService?.removePluginStateKey(pluginId, "history")
}
}
```
## Tier 3: Global Variables (Runtime Only)
NOT persisted. Shared across all instances of a plugin. Use for cross-instance state synchronization (multi-monitor consistency, multi-instance widgets).
### Using PluginGlobalVar Component
```qml
import qs.Modules.Plugins
PluginComponent {
PluginGlobalVar {
id: globalCounter
varName: "counter"
defaultValue: 0
}
horizontalBarPill: Component {
StyledRect {
// ...
StyledText {
text: "Count: " + globalCounter.value
}
MouseArea {
onClicked: globalCounter.set(globalCounter.value + 1)
}
}
}
}
```
**PluginGlobalVar properties:**
| Property | Type | Description |
|----------|------|-------------|
| `varName` | string | Required: name of the global variable |
| `defaultValue` | any | Optional: default if not set |
| `value` | any | Readonly: current value |
**Methods:**
- `set(newValue)` - update the value (triggers reactivity across all instances)
### Using PluginService API Directly
```qml
import qs.Services
property int counter: PluginService.getGlobalVar("myPlugin", "counter", 0)
Connections {
target: PluginService
function onGlobalVarChanged(pluginId, varName) {
if (pluginId === "myPlugin" && varName === "counter") {
counter = PluginService.getGlobalVar("myPlugin", "counter", 0)
}
}
}
function increment() {
var current = PluginService.getGlobalVar("myPlugin", "counter", 0)
PluginService.setGlobalVar("myPlugin", "counter", current + 1)
}
```
## Decision Matrix
| Need | API | Persisted | Scope |
|------|-----|-----------|-------|
| User preferences (API keys, themes, intervals) | `savePluginData` / `loadPluginData` | Yes (settings.json) | Per plugin |
| Runtime state (history, cache, counters) | `savePluginState` / `loadPluginState` | Yes (state file) | Per plugin |
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
## Important Notes
1. **pluginData is reactive** - bindings update automatically when data changes
2. **Global vars are NOT persistent** - they reset when the shell restarts
3. **State vs Data** - data is for user-facing settings, state is for internal runtime data
4. **Null safety** - always check `pluginService` is not null before calling methods
5. **Signal namespacing** - global var signals include `pluginId` to filter for your plugin
6. **Performance** - global vars are efficient for frequent updates; settings writes are batched
@@ -0,0 +1,240 @@
# Desktop Plugin Guide
Desktop plugins are widgets that appear on the desktop background layer. They support drag-and-drop positioning and resize via corner handles.
## Base Component
Desktop widgets use a plain `Item` with injected properties:
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: 0.85
// Your content here
}
}
```
## Injected Properties
These are set automatically by the DesktopPluginWrapper:
| Property | Type | Description |
|----------|------|-------------|
| `pluginService` | object | PluginService reference for data persistence |
| `pluginId` | string | Plugin's unique identifier |
| `editMode` | bool | `true` when user is dragging/resizing |
| `widgetWidth` | real | Current widget container width |
| `widgetHeight` | real | Current widget container height |
## Optional Properties
Define these on your root item to customize behavior:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `minWidth` | real | 100 | Minimum allowed width during resize |
| `minHeight` | real | 100 | Minimum allowed height during resize |
## Position and Size Persistence
Position (`desktopX`, `desktopY`) and size (`desktopWidth`, `desktopHeight`) are automatically managed by the DesktopPluginWrapper. You do not need to handle persistence for positioning.
## Edit Mode
When `editMode` is true, the user is repositioning or resizing. Use this to:
- Show visual indicators (borders, handles)
- Disable interactive elements to prevent accidental actions
- Display additional controls
```qml
Rectangle {
anchors.fill: parent
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
MouseArea {
anchors.fill: parent
enabled: !root.editMode
onClicked: doSomething()
}
}
```
## Loading and Saving Data
Use the injected `pluginService` for data persistence:
```qml
property string displayMode: {
if (!pluginService) return "default"
return pluginService.loadPluginData(pluginId, "displayMode", "default")
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
root.displayMode = pluginService.loadPluginData(pluginId, "displayMode", "default")
}
}
function saveMode(mode) {
pluginService?.savePluginData(pluginId, "displayMode", mode)
}
```
## Settings Component
Desktop plugin settings use the same `PluginSettings` wrapper as other types:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDesktopWidget"
SliderSetting {
settingKey: "opacity"
label: "Opacity"
description: "Widget background opacity"
defaultValue: 85
minimum: 10
maximum: 100
unit: "%"
}
SelectionSetting {
settingKey: "style"
label: "Display Style"
options: [
{ label: "Compact", value: "compact" },
{ label: "Expanded", value: "expanded" }
]
defaultValue: "compact"
}
}
```
## User Interaction
Desktop widgets support:
1. **Drag** - click and drag anywhere (in edit mode)
2. **Resize** - drag bottom-right corner handle (in edit mode)
3. **Edit mode toggle** - via the desktop edit button
## Complete Example
Based on the ExampleDesktopClock pattern:
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 250
property real widgetHeight: 250
property real minWidth: 150
property real minHeight: 150
property string clockStyle: {
if (!pluginService) return "digital"
return pluginService.loadPluginData(pluginId, "clockStyle", "digital")
}
property real bgOpacity: {
if (!pluginService) return 0.85
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
return val / 100
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
clockStyle = pluginService.loadPluginData(pluginId, "clockStyle", "digital")
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
bgOpacity = val / 100
}
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: root.bgOpacity
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatTime(new Date(), "hh:mm:ss")
color: Theme.surfaceText
font.pixelSize: root.widgetWidth * 0.15
font.weight: Font.Bold
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatDate(new Date(), "ddd, MMM d")
color: Theme.onSurfaceVariant
font.pixelSize: Theme.fontSizeMedium
}
}
}
Timer {
interval: 1000
running: true
repeat: true
onTriggered: root.widgetWidth = root.widgetWidth // force update
}
}
```
## Manifest Example
```json
{
"id": "myDesktopClock",
"name": "Desktop Clock",
"description": "Analog and digital clock for the desktop",
"version": "1.0.0",
"author": "Developer",
"type": "desktop",
"capabilities": ["desktop-widget"],
"component": "./ClockWidget.qml",
"icon": "schedule",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
@@ -0,0 +1,308 @@
# Launcher Plugin Guide
Launcher plugins extend the DMS launcher with custom searchable items and actions. They use trigger-based filtering and integrate directly into the app drawer.
## Base Component
Launchers use a plain `Item` (not PluginComponent):
```qml
import QtQuick
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
function getItems(query) {
// Return array of items
return []
}
function executeItem(item) {
// Handle item selection
}
}
```
## Required Interface
| Member | Type | Description |
|--------|------|-------------|
| `pluginService` | property | Injected PluginService reference (declare as `null`) |
| `trigger` | property | Trigger string for activation |
| `itemsChanged` | signal | Emit when item list changes (triggers UI refresh) |
| `getItems(query)` | function | Return array of items matching query |
| `executeItem(item)` | function | Handle item selection |
## Item Structure
Each item returned by `getItems()`:
```javascript
{
name: "Item Display Name", // Required: shown in launcher
icon: "material:star", // Optional: icon specification
comment: "Description text", // Required: subtitle text
action: "type:data", // Required: action identifier
categories: ["MyPlugin"], // Required: array with plugin category
imageUrl: "https://..." // Optional: image for tile view
}
```
## Icon Types
### 1. Material Design Icons
```javascript
{ icon: "material:lightbulb" }
{ icon: "material:terminal" }
{ icon: "material:translate" }
```
Uses the Material Symbols Rounded font.
### 2. Unicode / Emoji Icons
```javascript
{ icon: "unicode:smile_face" }
```
Rendered at 70-80% of icon size with theming.
### 3. Desktop Theme Icons
```javascript
{ icon: "firefox" }
{ icon: "folder" }
```
Uses the user's installed icon theme.
### 4. No Icon
Omit the `icon` field entirely. The launcher hides the icon area and gives full width to the item name.
## Trigger System
**Custom trigger** (items only appear when trigger is typed):
```json
{ "trigger": "#" }
```
- Type `#` alone: shows all plugin items
- Type `# query`: filters plugin items by query
- The query string (without trigger) is passed to `getItems(query)`
**No trigger** (items always visible alongside regular apps):
```json
{ "trigger": "" }
```
Save empty trigger at runtime:
```qml
Component.onCompleted: {
trigger = pluginService?.loadPluginData(pluginId, "trigger", "#") ?? "#"
}
```
## Action Execution
Parse action strings in `executeItem()`:
```qml
function executeItem(item) {
const actionParts = item.action.split(":")
const actionType = actionParts[0]
const actionData = actionParts.slice(1).join(":")
switch (actionType) {
case "toast":
ToastService?.showInfo(actionData)
break
case "copy":
Quickshell.execDetached(["dms", "cl", "copy", actionData])
ToastService?.showInfo("Copied to clipboard")
break
case "exec":
Quickshell.execDetached(actionData.split(" "))
break
case "url":
Quickshell.execDetached(["xdg-open", actionData])
break
default:
console.warn("Unknown action type:", actionType)
}
}
```
## Search / Filtering
The `query` parameter in `getItems()` contains the user's search text (without the trigger prefix).
```qml
function getItems(query) {
const allItems = [
{ name: "Calculator", icon: "material:calculate",
comment: "Open calculator", action: "exec:gnome-calculator",
categories: ["Tools"] },
{ name: "Terminal", icon: "material:terminal",
comment: "Open terminal", action: "exec:alacritty",
categories: ["Tools"] }
]
if (!query || query.length === 0) return allItems
const q = query.toLowerCase()
return allItems.filter(item =>
item.name.toLowerCase().includes(q) ||
item.comment.toLowerCase().includes(q)
)
}
```
## Context Menu Actions
Add right-click actions to launcher items:
```qml
function getContextMenuActions(item) {
return [
{ name: "Copy", icon: "material:content_copy",
action: "copy:" + item.name },
{ name: "Open in Browser", icon: "material:open_in_new",
action: "url:" + item.url }
]
}
```
Context menu actions use the same `executeItem()` handler.
## Image Tile View
For image-heavy launchers (GIF search, sticker pickers), use tile view:
In `plugin.json`:
```json
{
"viewMode": "tile",
"viewModeEnforced": true
}
```
In items:
```javascript
{
name: "Image Title",
imageUrl: "https://example.com/image.png",
comment: "Description",
action: "copy:https://example.com/image.png",
categories: ["MyPlugin"]
}
```
## State Persistence
For plugins with persistent state (notes, history, favorites):
```qml
property var notes: []
Component.onCompleted: {
const saved = pluginService?.loadPluginState(pluginId, "notes", [])
if (saved) notes = saved
}
function addNote(text) {
notes.push({ text: text, timestamp: Date.now() })
pluginService?.savePluginState(pluginId, "notes", notes)
itemsChanged()
}
```
Use `savePluginState/loadPluginState` for runtime data and `savePluginData/loadPluginData` for user preferences.
## Settings for Trigger Configuration
Provide a PluginSettings component for trigger customization:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myLauncher"
StringSetting {
settingKey: "trigger"
label: "Trigger"
description: "Type this prefix to activate the launcher plugin"
placeholder: "#"
defaultValue: "#"
}
ToggleSetting {
settingKey: "noTrigger"
label: "Always Visible"
description: "Show items alongside regular apps (no trigger needed)"
defaultValue: false
}
}
```
## Complete Example
```qml
import QtQuick
import Quickshell
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "!"
signal itemsChanged()
property var commands: [
{ name: "Lock Screen", icon: "material:lock",
comment: "Lock the session", action: "exec:loginctl lock-session" },
{ name: "Screenshot", icon: "material:screenshot_monitor",
comment: "Take a screenshot", action: "exec:grim" },
{ name: "File Manager", icon: "material:folder",
comment: "Open file manager", action: "exec:nautilus" }
]
function getItems(query) {
if (!query) return commands
const q = query.toLowerCase()
return commands.filter(c =>
c.name.toLowerCase().includes(q) ||
c.comment.toLowerCase().includes(q)
)
}
function executeItem(item) {
const [type, ...rest] = item.action.split(":")
const data = rest.join(":")
if (type === "exec") {
Quickshell.execDetached(data.split(" "))
}
}
Component.onCompleted: {
if (pluginService) {
trigger = pluginService.loadPluginData("quickCommands", "trigger", "!")
}
}
}
```
@@ -0,0 +1,119 @@
# Plugin Manifest Reference (plugin.json)
## Required Fields
| Field | Type | Description | Validation |
|-------|------|-------------|------------|
| `id` | string | Unique plugin identifier | camelCase, pattern `^[a-zA-Z][a-zA-Z0-9]*$` |
| `name` | string | Human-readable name | Non-empty |
| `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` |
| `capabilities` | array | Plugin capabilities | At least 1 string item |
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
## Conditional Requirements
| Condition | Required Field | Description |
|-----------|---------------|-------------|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
## Optional Fields
| Field | Type | Description |
|-------|------|-------------|
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
| `permissions` | array | Required permissions |
| `trigger` | string | Launcher trigger string (required for launcher type) |
## Permissions
| Permission | Description | Enforced |
|------------|-------------|----------|
| `settings_read` | Read plugin configuration | No (not currently enforced) |
| `settings_write` | Write plugin configuration / use PluginSettings | **Yes** |
| `process` | Execute system commands | No (not currently enforced) |
| `network` | Network access | No (not currently enforced) |
If your plugin has a `settings` component but does not declare `settings_write`, users will see an error instead of the settings UI.
## Capabilities
Capabilities are free-form strings that describe what the plugin does. Common values:
- `dankbar-widget` - general bar widget
- `control-center` - integrates with Control Center
- `monitoring` - system/service monitoring
- `launcher` - launcher search provider
- `desktop-widget` - desktop background widget
- `ai` - AI/LLM integration
- `slideout` - uses slideout panel
## Complete Example
```json
{
"id": "myPlugin",
"name": "My Plugin",
"description": "A sample plugin demonstrating all fields",
"version": "1.0.0",
"author": "Developer Name",
"type": "widget",
"capabilities": ["dankbar-widget", "control-center"],
"component": "./MyWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.18",
"requires": ["curl", "jq"],
"permissions": ["settings_read", "settings_write", "process", "network"]
}
```
## Launcher Example
```json
{
"id": "myLauncher",
"name": "My Launcher",
"description": "Search and execute custom actions",
"version": "1.0.0",
"author": "Developer Name",
"type": "launcher",
"capabilities": ["launcher"],
"component": "./MyLauncher.qml",
"trigger": "#",
"icon": "search",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.18",
"permissions": ["settings_read", "settings_write"]
}
```
## JSON Schema
The complete JSON schema is available at `assets/plugin-schema.json` in this skill. Validate with:
```bash
# Using python
python3 -c "
import json, jsonschema
schema = json.load(open('path/to/plugin-schema.json'))
manifest = json.load(open('plugin.json'))
jsonschema.validate(manifest, schema)
print('Valid!')
"
# Using jq (syntax check only)
jq . plugin.json
```
## Additional Properties
The schema allows additional properties (`"additionalProperties": true`), so plugins can include custom fields. Common custom fields seen in production plugins:
- `viewMode` - launcher display mode (`"tile"` for image grids)
- `viewModeEnforced` - lock launcher to specific view mode (`true`/`false`)
@@ -0,0 +1,120 @@
# PopoutService Reference
The `PopoutService` singleton lets plugins control all DMS popouts and modals. It is automatically injected into widget, daemon, and settings components.
## Setup
Declare the property in your component for injection to work:
```qml
property var popoutService: null
```
Without this declaration, injection fails with: `Cannot assign to non-existent property "popoutService"`
## Popouts (DankPopout-based)
| Component | Open | Close | Toggle |
|-----------|------|-------|--------|
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
## Modals (DankModal-based)
| Modal | Show | Hide | Notes |
|-------|------|------|-------|
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
| Launcher | `openDankLauncherV2()` | `closeDankLauncherV2()` | Also has `toggleDankLauncherV2()` |
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Has `toggleProcessListModal()` |
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network auth |
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
## Slideouts
| Component | Open | Close | Toggle |
|-----------|------|-------|--------|
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
## Usage Examples
### Simple toggle
```qml
MouseArea {
onClicked: popoutService?.toggleControlCenter()
}
```
### Conditional popout
```qml
Connections {
target: BatteryService
function onPercentageChanged() {
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
popoutService?.openBattery()
}
}
}
```
### Context menu with multiple actions
```qml
MouseArea {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) contextMenu.popup()
else popoutService?.toggleControlCenter()
}
}
Menu {
id: contextMenu
MenuItem { text: "Settings"; onClicked: popoutService?.openSettings() }
MenuItem { text: "Notifications"; onClicked: popoutService?.toggleNotificationCenter() }
MenuItem { text: "Power"; onClicked: popoutService?.openPowerMenu() }
}
```
### Position-aware toggle (from bar pill)
Some toggle functions accept position parameters for proper popout placement:
```qml
pillClickAction: (x, y, width, section, screen) => {
popoutService?.toggleControlCenter(x, y, width, section, screen)
}
```
## Best Practices
1. **Always use optional chaining** (`?.`) - the service may not be injected yet
2. **Check feature availability** before opening feature-specific popouts:
```qml
if (BatteryService.batteryAvailable) {
popoutService?.openBattery()
}
```
3. **Lazy loading** - first access may activate lazy loaders; this is normal
4. **Popouts are shared** - avoid opening conflicting popouts simultaneously
5. **User intent** - only trigger popouts from user actions or critical system events
6. **Multi-monitor** - positioned popouts are screen-aware when using position parameters
## Injection Locations
The service is injected at these points:
- `DMSShell.qml` - daemon plugins
- `WidgetHost.qml` - widget plugins in left/right bar sections
- `CenterSection.qml` - center bar widgets
- `PluginsTab.qml` - settings components
@@ -0,0 +1,273 @@
# Settings Components Reference
All plugin settings use the `PluginSettings` wrapper. Setting components auto-save on change and auto-load on creation.
## PluginSettings Wrapper
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "yourPlugin" // Required: must match plugin.json id
// Setting components go here
}
```
**Important:** The plugin must declare `"permissions": ["settings_write"]` in plugin.json for the settings UI to render. Without it, users see an error.
**PluginSettings provides to children:**
- `saveValue(key, value)` - save a setting value
- `loadValue(key, defaultValue)` - load a setting value
- `saveState(key, value)` - save plugin state (separate file)
- `loadState(key, defaultValue)` - load plugin state
- `clearState()` - clear all plugin state
- Variant management functions (for variant plugins)
## StringSetting
Text input field.
```qml
StringSetting {
settingKey: "apiKey" // Required: storage key
label: "API Key" // Required: display label
description: "Your API key" // Optional: help text
placeholder: "sk-..." // Optional: input placeholder
defaultValue: "" // Optional: default (default: "")
}
```
**Layout:** Vertical stack - label, description, input field.
## ToggleSetting
Boolean toggle switch.
```qml
ToggleSetting {
settingKey: "notifications" // Required: storage key
label: "Enable Notifications" // Required: display label
description: "Show alerts" // Optional: help text
defaultValue: true // Optional: default (default: false)
}
```
**Layout:** Horizontal - label/description on left, toggle on right.
## SelectionSetting
Dropdown menu.
```qml
SelectionSetting {
settingKey: "theme" // Required: storage key
label: "Theme" // Required: display label
description: "Color scheme" // Optional: help text
options: [ // Required: array of options
{ label: "Dark", value: "dark" },
{ label: "Light", value: "light" },
{ label: "Auto", value: "auto" }
]
defaultValue: "dark" // Optional: default value
}
```
Options can be `{ label, value }` objects or simple strings. Stores the `value` field, displays the `label` field.
**Layout:** Horizontal - label/description on left, dropdown on right.
**Reacting to changes:**
```qml
SelectionSetting {
settingKey: "updateInterval"
label: "Update Interval"
options: [
{ label: "1 minute", value: "60" },
{ label: "5 minutes", value: "300" }
]
defaultValue: "300"
onValueChanged: (newValue) => {
console.log("Interval changed to:", newValue)
}
}
```
## SliderSetting
Numeric slider with min/max.
```qml
SliderSetting {
settingKey: "opacity" // Required: storage key
label: "Opacity" // Required: display label
description: "Background" // Optional: help text
defaultValue: 85 // Optional: default value
minimum: 0 // Required: min value
maximum: 100 // Required: max value
unit: "%" // Optional: unit label shown after value
leftIcon: "dark_mode" // Optional: Material icon on left
rightIcon: "light_mode" // Optional: Material icon on right
}
```
## ColorSetting
Color picker.
```qml
ColorSetting {
settingKey: "accentColor" // Required: storage key
label: "Accent Color" // Required: display label
description: "Custom accent" // Optional: help text
defaultValue: "#ff5722" // Optional: default hex color
}
```
Displays a color swatch that opens a color picker dialog.
## ListSetting
Manage a list of items with manual add/remove. Use when you need custom UI for adding items.
```qml
ListSetting {
id: itemList
settingKey: "items" // Required: storage key
label: "Saved Items" // Required: display label
description: "Your items" // Optional: help text
defaultValue: [] // Optional: default array
delegate: Component { // Optional: custom item display
StyledRect {
width: parent.width
height: 40
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.surfaceText
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: 60
height: 28
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
radius: Theme.cornerRadius
StyledText {
anchors.centerIn: parent
text: "Remove"
color: Theme.errorText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: removeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: itemList.removeItem(index)
}
}
}
}
}
```
**Methods:**
- `addItem(item)` - add an item to the list
- `removeItem(index)` - remove item at index
## ListSettingWithInput
Complete list management with built-in form. Best for collecting structured data.
```qml
ListSettingWithInput {
settingKey: "locations" // Required: storage key
label: "Locations" // Required: display label
description: "Track zones" // Optional: help text
defaultValue: [] // Optional: default array
fields: [ // Required: field definitions
{
id: "name", // Required: key in saved object
label: "Name", // Required: column header
placeholder: "Home", // Optional: input placeholder
width: 150, // Optional: column width (default: 200)
required: true // Optional: must have value to add
},
{
id: "timezone",
label: "Timezone",
placeholder: "America/New_York",
width: 200,
required: true
}
]
}
```
Automatically generates: column headers, input fields, add button with validation, list display, remove buttons.
## Mixing Custom UI with Settings
You can interleave regular QML elements with setting components:
```qml
PluginSettings {
pluginId: "myPlugin"
StyledText {
width: parent.width
text: "General Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
StringSetting {
settingKey: "name"
label: "Display Name"
}
StyledText {
width: parent.width
text: "Advanced Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
topPadding: Theme.spacingL
}
ToggleSetting {
settingKey: "debug"
label: "Debug Mode"
defaultValue: false
}
}
```
## Default Values
Define sensible defaults in every setting component. The default is used when no saved value exists:
```qml
StringSetting { settingKey: "text"; defaultValue: "Hello" }
ToggleSetting { settingKey: "enabled"; defaultValue: true }
SelectionSetting { settingKey: "mode"; defaultValue: "auto" }
SliderSetting { settingKey: "opacity"; defaultValue: 85 }
ColorSetting { settingKey: "color"; defaultValue: "#ff5722" }
ListSetting { settingKey: "items"; defaultValue: [] }
ListSettingWithInput { settingKey: "data"; defaultValue: [] }
```
@@ -0,0 +1,216 @@
# Theme Property Reference
All theme properties are accessed via the `Theme` singleton from `qs.Common`. Always use these instead of hardcoded values.
## Font Sizes
```qml
Theme.fontSizeSmall // 12px (scaled by SettingsData.fontScale)
Theme.fontSizeMedium // 14px (scaled)
Theme.fontSizeLarge // 16px (scaled)
Theme.fontSizeXLarge // 20px (scaled)
```
## Icon Sizes
```qml
Theme.iconSizeSmall // 16px
Theme.iconSize // 24px (default)
Theme.iconSizeLarge // 32px
```
## Spacing
```qml
Theme.spacingXS // Extra small
Theme.spacingS // Small
Theme.spacingM // Medium
Theme.spacingL // Large
Theme.spacingXL // Extra large
```
## Border Radius
```qml
Theme.cornerRadius // Standard
Theme.cornerRadiusSmall // Smaller
Theme.cornerRadiusLarge // Larger
```
## Surface Colors
```qml
Theme.surface
Theme.surfaceContainerLow
Theme.surfaceContainer
Theme.surfaceContainerHigh
Theme.surfaceContainerHighest
```
## Text Colors
```qml
Theme.onSurface // Primary text on surface
Theme.onSurfaceVariant // Secondary text on surface
Theme.surfaceText // Alias for primary surface text
Theme.surfaceVariantText // Alias for secondary surface text
Theme.outline // Border/divider color
```
## Semantic Colors
```qml
Theme.primary
Theme.onPrimary
Theme.secondary
Theme.onSecondary
Theme.error
Theme.errorHover
Theme.errorText
Theme.warning
Theme.success
```
## Special Functions
```qml
Theme.popupBackground() // Popup background with proper opacity
```
## Common Widget Patterns
### Icon with Text
```qml
import qs.Widgets
Row {
spacing: Theme.spacingS
DankIcon {
name: "icon_name"
color: Theme.onSurface
font.pixelSize: Theme.iconSize
}
StyledText {
text: "Label"
color: Theme.onSurface
font.pixelSize: Theme.fontSizeMedium
}
}
```
### Container with Border
```qml
Rectangle {
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
}
```
### Hover Effect
```qml
Rectangle {
id: container
color: Theme.surfaceContainerHigh
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: container.color = Qt.lighter(Theme.surfaceContainerHigh, 1.1)
onExited: container.color = Theme.surfaceContainerHigh
}
}
```
### Clickable Pill
```qml
StyledRect {
width: content.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: mouseArea.containsMouse
? Qt.lighter(Theme.surfaceContainerHigh, 1.1)
: Theme.surfaceContainerHigh
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
Row {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Label"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
anchors.verticalCenter: parent.verticalCenter
}
}
}
```
## Common Mistakes
**Wrong property names** (these do NOT exist):
```qml
Theme.fontSizeS // Use Theme.fontSizeSmall
Theme.iconSizeS // Use Theme.iconSizeSmall
Theme.spacingSmall // Use Theme.spacingS
Theme.borderRadius // Use Theme.cornerRadius
```
**Hardcoded values** (do NOT do this):
```qml
color: "#1e1e1e" // Use Theme.surfaceContainerHigh
color: "white" // Use Theme.surfaceText
font.pixelSize: 14 // Use Theme.fontSizeMedium
```
## Available Widgets from qs.Widgets
| Widget | Description |
|--------|-------------|
| `StyledText` | Themed text with proper color defaults |
| `StyledRect` | Themed rectangle |
| `DankIcon` | Material Symbols icon renderer |
| `DankNFIcon` | Nerd Font icon renderer |
| `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 |
| `DankFilterChips` | Filter chip row |
| `CachingImage` | Image with disk cache |
| `NumericText` | Fixed-width numeric display |
## Checking All Properties
```bash
grep "property" Common/Theme.qml
```
@@ -0,0 +1,369 @@
# Widget Plugin Guide
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
## Base Component
Widgets use `PluginComponent` from `qs.Modules.Plugins`.
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
horizontalBarPill: Component { /* ... */ }
verticalBarPill: Component { /* ... */ }
popoutContent: Component { /* ... */ }
popoutWidth: 400
popoutHeight: 300
}
```
## Injected Properties
These are automatically set by the plugin host:
| Property | Type | Description |
|----------|------|-------------|
| `axis` | object | Bar axis info (horizontal/vertical) |
| `section` | string | Bar section: `"left"`, `"center"`, or `"right"` |
| `parentScreen` | object | Screen reference for multi-monitor |
| `widgetThickness` | real | Widget size perpendicular to bar edge |
| `barThickness` | real | Bar thickness parallel to edge |
| `pluginId` | string | This plugin's ID |
| `pluginService` | object | PluginService reference |
| `pluginData` | object | Reactive plugin settings data |
## Bar Pills
Define `horizontalBarPill` (for top/bottom bars) and `verticalBarPill` (for left/right bars).
### Horizontal Bar Pill
```qml
horizontalBarPill: Component {
StyledRect {
width: content.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Row {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Label"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
```
### Vertical Bar Pill
```qml
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: content.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSizeSmall
}
}
}
}
```
**Important:** Always define both pills. If a pill is missing, the widget disappears when the bar is on that orientation's edge.
## Popout Content
Open a popout window when the bar pill is clicked:
```qml
PluginComponent {
popoutWidth: 400
popoutHeight: 300
popoutContent: Component {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: "Content here"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
}
}
```
**PopoutComponent properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `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). |
**Content sizing:** Content children render below the header/details. Calculate available height: `popoutHeight - headerHeight - detailsHeight - spacing`
## Custom Click Actions
Override the default popout behavior:
```qml
PluginComponent {
// Simple no-args handler
pillClickAction: () => {
popoutService?.toggleControlCenter()
}
// With position params (x, y, width, section, screen)
pillClickAction: (x, y, width, section, screen) => {
popoutService?.toggleControlCenter(x, y, width, section, screen)
}
pillRightClickAction: () => {
popoutService?.openSettings()
}
}
```
## Control Center Integration
Add CC properties to show your widget in the Control Center grid:
```qml
PluginComponent {
ccWidgetIcon: "toggle_on"
ccWidgetPrimaryText: "Feature Name"
ccWidgetSecondaryText: isActive ? "Active" : "Off"
ccWidgetIsActive: isActive
onCcWidgetToggled: {
isActive = !isActive
pluginService?.savePluginData(pluginId, "active", isActive)
}
}
```
**CC properties:**
| Property | Type | Description |
|----------|------|-------------|
| `ccWidgetIcon` | string | Material icon name |
| `ccWidgetPrimaryText` | string | Main label |
| `ccWidgetSecondaryText` | string | Subtitle / status text |
| `ccWidgetIsActive` | bool | Active state (changes styling) |
**CC signals:**
| Signal | When fired |
|--------|-----------|
| `ccWidgetToggled()` | Icon area clicked |
| `ccWidgetExpanded()` | Expand area clicked (CompoundPill only) |
**CC sizing rules:**
- 25% width - SmallToggleButton (icon only)
- 50% width - ToggleButton (no detail) or CompoundPill (with ccDetailContent)
- Users can resize in CC edit mode
### Detail Content (CompoundPill)
Add an expandable panel below the CC widget:
```qml
ccDetailContent: Component {
Rectangle {
implicitHeight: 200
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
// Detail UI here
}
}
}
```
## Visibility Control
Conditionally show/hide the bar pill:
```qml
PluginComponent {
visibilityCommand: "pgrep -x myapp"
visibilityInterval: 5000 // check every 5 seconds
}
```
## Popout Namespace
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
```qml
PluginComponent {
layerNamespacePlugin: true
}
```
## Reading Plugin Data
Access saved settings reactively via the injected `pluginData`:
```qml
PluginComponent {
property string displayText: pluginData?.text || "Default"
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId)
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
}
}
}
```
## Complete Example
Based on the ExampleEmojiPlugin pattern:
```qml
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
property var emojis: ["star", "heart", "smile"]
property int currentIndex: 0
Timer {
interval: 2000
running: true
repeat: true
onTriggered: currentIndex = (currentIndex + 1) % emojis.length
}
popoutWidth: 350
popoutHeight: 400
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.emojis[root.currentIndex]
font.pixelSize: Theme.fontSizeLarge
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.emojis[root.currentIndex]
font.pixelSize: Theme.fontSizeMedium
}
}
}
popoutContent: Component {
PopoutComponent {
headerText: "Emoji Picker"
showCloseButton: true
DankGridView {
width: parent.width
height: 300
cellWidth: 50
cellHeight: 50
model: root.emojis
delegate: Rectangle {
width: 48
height: 48
radius: Theme.cornerRadius
color: mouseArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
Text {
anchors.centerIn: parent
text: modelData
font.pixelSize: 24
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Quickshell.execDetached(["dms", "cl", "copy", modelData])
ToastService?.showInfo("Copied " + modelData)
}
}
}
}
}
}
}
```
+22 -2
View File
@@ -1,4 +1,4 @@
name: Check nix flake
name: Nix flake and NixOS tests
on:
pull_request:
@@ -9,6 +9,7 @@ on:
jobs:
check-flake:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Checkout
@@ -18,6 +19,25 @@ jobs:
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
enable_kvm: true
extra_nix_config: |
system-features = nixos-test benchmark big-parallel kvm
- name: Check the flake
run: nix flake check
run: nix flake check -L
- name: Run NixOS module test
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
- 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
- name: Run niri home-manager module test
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
+10
View File
@@ -367,6 +367,16 @@ jobs:
EOF
chmod 600 ~/.config/osc/oscrc
# Cache OBS bundled Go toolchains
- name: Cache OBS bundled Go toolchains (dms-git)
if: contains(steps.packages.outputs.packages, 'dms-git')
uses: actions/cache@v4
with:
path: /home/runner/.cache/dms-obs-go-toolchain
key: dms-obs-go-toolchain-${{ runner.os }}-${{ hashFiles('core/go.mod') }}
restore-keys: |
dms-obs-go-toolchain-${{ runner.os }}-
- name: Upload to OBS
id: upload
env:
+65 -205
View File
@@ -22,12 +22,13 @@ on:
jobs:
check-updates:
name: Check for updates
name: Check package/series updates
runs-on: ubuntu-latest
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
packages: ${{ steps.check.outputs.packages }}
targets: ${{ steps.check.outputs.targets }}
targets_json: ${{ steps.check.outputs.targets_json }}
steps:
- name: Checkout
@@ -35,125 +36,57 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq curl git
- name: Check for updates
id: check
run: |
# Helper function to check dms-git commit
check_dms_git() {
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/dms-git?ws.op=getPublishedSources&source_name=dms-git&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
local PPA_COMMIT=$(echo "$PPA_VERSION" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
if [[ -n "$PPA_COMMIT" && "$CURRENT_COMMIT" == "$PPA_COMMIT" ]]; then
echo "📋 dms-git: Commit $CURRENT_COMMIT already exists, skipping"
return 1 # No update needed
else
echo "📋 dms-git: New commit $CURRENT_COMMIT (PPA has ${PPA_COMMIT:-none})"
return 0 # Update needed
fi
}
# Helper function to check stable package tag
check_stable_package() {
local PKG="$1"
local PPA_NAME="$2"
# Use git ls-remote to find the latest tag, sorted by version (descending)
local LATEST_TAG=$(git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git | head -n1 | awk -F/ '{print $NF}' | sed 's/^v//')
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$PKG&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
local PPA_BASE_VERSION=$(echo "$PPA_VERSION" | sed 's/ppa[0-9]*$//')
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$PPA_BASE_VERSION" ]]; then
echo "📋 $PKG: Tag $LATEST_TAG already exists, skipping"
return 1 # No update needed
else
echo "📋 $PKG: New tag ${LATEST_TAG:-unknown} (PPA has ${PPA_BASE_VERSION:-none})"
return 0 # Update needed
fi
}
# Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}"
chmod +x distro/scripts/ppa-sync-plan.sh
if [[ "${{ github.event_name }}" == "schedule" ]]; then
# Scheduled run - check dms-git only
echo "packages=dms-git" >> $GITHUB_OUTPUT
if check_dms_git; then
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
# Manual workflow trigger
PKG="${{ github.event.inputs.package }}"
if [[ -n "$REBUILD" ]]; then
# Rebuild requested - always proceed
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "🔄 Manual rebuild requested: $PKG (ppa$REBUILD)"
elif [[ "$PKG" == "all" ]]; then
# Check each package and build list of those needing updates
PACKAGES_TO_UPDATE=()
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
check_stable_package "dms" "dms" && PACKAGES_TO_UPDATE+=("dms")
check_stable_package "dms-greeter" "danklinux" && PACKAGES_TO_UPDATE+=("dms-greeter")
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}"
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
echo "✓ All packages up to date"
fi
elif [[ "$PKG" == "dms-git" ]]; then
if check_dms_git; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms" ]]; then
if check_stable_package "dms" "dms"; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms-greeter" ]]; then
if check_stable_package "dms-greeter" "danklinux"; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
else
# Unknown package - proceed anyway
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "Manual trigger: $PKG"
fi
PACKAGE="dms-git"
else
# Fallback
echo "packages=dms" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
PACKAGE="${{ github.event.inputs.package }}"
fi
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
ARGS=(--package "$PACKAGE" --json)
if [[ -n "$REBUILD_RELEASE" ]]; then
ARGS+=(--rebuild "$REBUILD_RELEASE")
fi
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
cat ppa-audit.log
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
if [[ "$TARGETS_JSON" != "[]" ]]; then
echo "has_updates=true" >> "$GITHUB_OUTPUT"
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
echo "targets_json=$TARGETS_JSON" >> "$GITHUB_OUTPUT"
echo "Package/series targets: $TARGETS"
else
echo "has_updates=false" >> "$GITHUB_OUTPUT"
echo "targets=" >> "$GITHUB_OUTPUT"
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
echo "No package/series uploads needed"
fi
upload-ppa:
name: Upload to PPA
name: Upload ${{ matrix.target }}
needs: check-updates
runs-on: ubuntu-latest
if: needs.check-updates.outputs.has_updates == 'true'
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
concurrency:
group: ppa-dms-${{ matrix.target }}
cancel-in-progress: false
steps:
- name: Checkout
@@ -177,7 +110,8 @@ jobs:
lftp \
build-essential \
fakeroot \
dpkg-dev
dpkg-dev \
openssh-client
- name: Configure GPG
env:
@@ -185,106 +119,32 @@ jobs:
run: |
echo "$GPG_KEY" | gpg --import
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
- name: Determine packages to upload
id: packages
- name: Upload target
env:
TARGET: ${{ matrix.target }}
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
run: |
# Use packages determined by check-updates job
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
if [[ "${{ github.event_name }}" == "schedule" ]]; then
echo "Triggered by schedule: uploading git package"
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
fi
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
- name: Upload to PPA
run: |
PACKAGES="${{ steps.packages.outputs.packages }}"
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
if [[ -z "$PACKAGES" ]]; then
echo "✓ No packages need uploading. All up to date!"
exit 0
fi
# Export REBUILD_RELEASE so ppa-build.sh can use it
if [[ -n "$REBUILD_RELEASE" ]]; then
export REBUILD_RELEASE
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
fi
# PACKAGES can be space-separated list (e.g., "dms-git dms dms-greeter" from "all" check)
# Loop through each package and upload
for PKG in $PACKAGES; do
# Map package to PPA name
case "$PKG" in
dms)
PPA_NAME="dms"
;;
dms-git)
PPA_NAME="dms-git"
;;
dms-greeter)
PPA_NAME="danklinux"
;;
*)
echo "⚠️ Unknown package: $PKG, skipping"
continue
;;
esac
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Uploading $PKG to PPA $PPA_NAME..."
if [[ -n "$REBUILD_RELEASE" ]]; then
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ppa-upload.sh uploads to questing + resolute when series is omitted
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
echo "::error::Upload failed for $PKG"
exit 1
fi
done
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
- name: Summary
if: always()
run: |
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
PACKAGES="${{ steps.packages.outputs.packages }}"
if [[ -z "$PACKAGES" ]]; then
echo "**Status:** ✅ All packages up to date (no uploads needed)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "All packages are current. Run will complete successfully." >> $GITHUB_STEP_SUMMARY
else
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
for PKG in $PACKAGES; do
case "$PKG" in
dms)
echo "- ✅ **dms** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages)" >> $GITHUB_STEP_SUMMARY
;;
dms-git)
echo "- ✅ **dms-git** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages)" >> $GITHUB_STEP_SUMMARY
;;
dms-greeter)
echo "- ✅ **dms-greeter** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages)" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
fi
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
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"
+3
View File
@@ -107,6 +107,9 @@ vim/
bin/
# Core dumps
core.*
# direnv
.envrc
.direnv/
+8
View File
@@ -20,3 +20,11 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
pass_filenames: false
- repo: local
hooks:
- id: no-console-in-qml
name: no console.* in QML (use Log service)
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/)
+1 -1
View File
@@ -13,7 +13,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![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)
[![AUR version](https://img.shields.io/aur/version/dms-shell-bin?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://aur.archlinux.org/packages/dms-shell-bin)
[![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%2Fdanklinux)](https://ko-fi.com/danklinux)
+4 -17
View File
@@ -1,26 +1,13 @@
repos:
- repo: local
- repo: https://github.com/golangci/golangci-lint
rev: v2.10.1
hooks:
- id: golangci-lint-fmt
name: golangci-lint-fmt
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt
language: system
require_serial: true
types: [go]
pass_filenames: false
- id: golangci-lint-full
name: golangci-lint-full
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix
language: system
require_serial: true
types: [go]
pass_filenames: false
- id: golangci-lint-config-verify
name: golangci-lint-config-verify
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 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 ./...
+41 -1
View File
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
Command-line interface and daemon for shell management and system control.
**dankinstall**
Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
## System Integration
@@ -147,10 +147,50 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
## Installation via dankinstall
**Interactive (TUI):**
```bash
curl -fsSL https://install.danklinux.com | sh
```
**Headless (unattended):**
Headless mode requires cached sudo credentials. Run `sudo -v` first:
```bash
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>` | `-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 |
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.
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
When no flags are provided, `dankinstall` launches the interactive TUI.
### Headless mode validation rules
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 (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
- Positional arguments are not accepted.
### Log file location
`dankinstall` writes logs to `/tmp` by default.
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory.
## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
+167 -3
View File
@@ -3,20 +3,152 @@ package main
import (
"fmt"
"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/spf13/cobra"
)
var Version = "dev"
// Flag variables bound via pflag
var (
compositor string
term string
includeDeps []string
excludeDeps []string
replaceConfigs []string
replaceConfigsAll bool
yes bool
)
var rootCmd = &cobra.Command{
Use: "dankinstall",
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 sudo credentials. Run 'sudo -v' beforehand, or
configure passwordless sudo for your user.`,
Args: cobra.NoArgs,
RunE: runDankinstall,
SilenceErrors: true,
SilenceUsage: true,
}
func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
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)")
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
}
func main() {
if os.Getuid() == 0 {
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
os.Exit(1)
}
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runDankinstall(cmd *cobra.Command, args []string) error {
headlessMode := compositor != "" || term != ""
if !headlessMode {
// Reject headless-only flags when running in TUI mode.
headlessOnly := []string{
"include-deps",
"exclude-deps",
"replace-configs",
"replace-configs-all",
"yes",
}
var set []string
for _, name := range headlessOnly {
if cmd.Flags().Changed(name) {
set = append(set, "--"+name)
}
}
if len(set) > 0 {
return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", "))
}
}
if headlessMode {
return runHeadless()
}
return runTUI()
}
func runHeadless() error {
// Validate required flags
if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
}
if term == "" {
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
}
cfg := headless.Config{
Compositor: compositor,
Terminal: term,
IncludeDeps: includeDeps,
ExcludeDeps: excludeDeps,
ReplaceConfigs: replaceConfigs,
ReplaceConfigsAll: replaceConfigsAll,
Yes: yes,
}
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)
}
if fileLogger != nil {
fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath())
fileLogger.StartListening(runner.GetLogChan())
defer func() {
if err := fileLogger.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err)
}
}()
} 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())()
}
if err := runner.Run(); err != nil {
if fileLogger != nil {
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath())
}
return err
}
if fileLogger != nil {
fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath())
}
return nil
}
func runTUI() error {
fileLogger, err := log.NewFileLogger()
if err != nil {
fmt.Printf("Warning: Failed to create log file: %v\n", err)
@@ -38,18 +170,50 @@ func main() {
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())()
}
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath)
}
os.Exit(1)
return fmt.Errorf("error running program: %w", err)
}
if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
}
return nil
}
// 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{})
go func() {
defer close(drainDone)
for {
select {
case <-drainStop:
return
case _, ok := <-logCh:
if !ok {
return
}
}
}
}()
return func() {
close(drainStop)
<-drainDone
}
}
+4 -3
View File
@@ -16,9 +16,10 @@ var authCmd = &cobra.Command{
}
var authSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS authentication configuration",
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
Use: "sync",
Short: "Sync DMS authentication configuration",
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
+1
View File
@@ -236,6 +236,7 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
defer ddc.Close()
time.Sleep(100 * time.Millisecond)
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
ddc.WaitPending()
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
}
-10
View File
@@ -53,7 +53,6 @@ var (
clipCopyPasteOnce bool
clipCopyType string
clipCopyDownload bool
clipCopyCacheFile string
clipJSONOutput bool
)
@@ -192,8 +191,6 @@ func init() {
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type")
clipCopyCmd.Flags().BoolVarP(&clipCopyDownload, "download", "d", false, "Download URL as image and copy as file")
clipCopyCmd.Flags().StringVar(&clipCopyCacheFile, "cache-file", "", "")
clipCopyCmd.Flags().MarkHidden("cache-file")
clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
@@ -224,13 +221,6 @@ func init() {
}
func runClipCopy(cmd *cobra.Command, args []string) {
if clipCopyCacheFile != "" {
if err := clipboard.ServeCacheFile(clipCopyCacheFile, clipCopyType, clipCopyPasteOnce); err != nil {
log.Fatalf("serve cache file: %v", err)
}
return
}
var data []byte
copyFromStdin := false
+16
View File
@@ -6,6 +6,7 @@ import (
"regexp"
"strings"
"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"
@@ -26,6 +27,18 @@ var runCmd = &cobra.Command{
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 {
@@ -526,5 +539,8 @@ func getCommonCommands() []*cobra.Command {
dlCmd,
randrCmd,
blurCmd,
trashCmd,
systemCmd,
switchUserCmd,
}
}
+41 -8
View File
@@ -8,6 +8,7 @@ import (
"strings"
"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"
)
@@ -27,7 +28,21 @@ var resolveIncludeCmd = &cobra.Command{
case 0:
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
case 1:
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
return []string{
"binds.lua",
"binds-user.lua",
"colors.lua",
"layout.lua",
"outputs.lua",
"cursor.lua",
"windowrules.lua",
"cursor.kdl",
"outputs.kdl",
"binds.kdl",
"cursor.conf",
"outputs.conf",
"binds.conf",
}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -82,17 +97,35 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil {
processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
return result, nil
}
}
mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil {
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
return result, nil
}
}
processed := make(map[string]bool)
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
@@ -141,7 +174,7 @@ func hyprlandFindInclude(filePath, target string, processed map[string]bool) boo
continue
}
if hyprlandFindInclude(expanded, target, processed) {
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
return true
}
}
+38 -7
View File
@@ -11,6 +11,7 @@ import (
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
@@ -82,7 +83,7 @@ func (ds *DoctorStatus) OKCount() int {
}
var (
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
@@ -90,6 +91,7 @@ var (
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
)
var doctorCmd = &cobra.Command{
@@ -468,6 +470,7 @@ func checkWindowManagers() []checkResult {
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
}
var results []checkResult
@@ -500,7 +503,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, or Wayfire",
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
doctorDocsURL + "#compositor-checks",
})
}
@@ -509,9 +512,24 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
}
results = append(results, checkCompositorBlurSupport())
return results
}
func checkCompositorBlurSupport() checkResult {
supported, err := blur.ProbeSupport()
if err != nil {
return checkResult{catCompositor, "Background Blur", statusInfo, "Unable to verify", err.Error(), doctorDocsURL + "#compositor-checks"}
}
if supported {
return checkResult{catCompositor, "Background Blur", statusOK, "Supported", "Compositor supports ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 {
@@ -535,6 +553,8 @@ func detectRunningWM() string {
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
return "niri"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP")
}
@@ -553,6 +573,7 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
qmlContent := `
import QtQuick
import Quickshell
import Quickshell.Wayland
ShellRoot {
id: root
@@ -561,6 +582,7 @@ ShellRoot {
property bool idleMonitorAvailable: false
property bool idleInhibitorAvailable: false
property bool shortcutInhibitorAvailable: false
property bool backgroundBlurAvailable: false
Timer {
interval: 50
@@ -578,16 +600,18 @@ ShellRoot {
try {
var testItem = Qt.createQmlObject(
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
'import Quickshell; import Quickshell.Wayland; import QtQuick; QtObject { ' +
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined"; ' +
'readonly property bool hasBackgroundBlur: typeof BackgroundEffect !== "undefined" ' +
'}',
root
)
root.idleMonitorAvailable = testItem.hasIdleMonitor
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
testItem.destroy()
} catch (e) {}
@@ -596,6 +620,8 @@ ShellRoot {
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
console.warn(root.backgroundBlurAvailable ? "FEATURE:BackgroundBlur:OK" : "FEATURE:BackgroundBlur:UNAVAILABLE")
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
}
}
@@ -616,6 +642,7 @@ ShellRoot {
{"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
}
var results []checkResult
@@ -820,10 +847,14 @@ func checkOptionalDependencies() []checkResult {
results = append(results, checkImageFormatPlugins()...)
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
terminals = slices.DeleteFunc(terminals, func(t string) bool {
return !utils.CommandExists(t)
})
if len(terminals) > 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
} else {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL})
}
networkResult, err := network.DetectNetworkStack()
+5 -11
View File
@@ -4,6 +4,7 @@ package main
import (
"bufio"
"context"
"errors"
"fmt"
"os"
@@ -15,6 +16,7 @@ import (
"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"
@@ -130,12 +132,8 @@ func updateArchLinux() error {
return errdefs.ErrUpdateCancelled
}
fmt.Printf("\nRunning: sudo pacman -S %s\n", packageName)
cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", packageName)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("\nRunning: pacman -S %s\n", packageName)
if err := privesc.Run(context.Background(), "", "pacman", "-S", "--noconfirm", packageName); err != nil {
fmt.Printf("Error: Failed to update using pacman: %v\n", err)
return err
}
@@ -479,11 +477,7 @@ func updateDMSBinary() error {
fmt.Printf("Installing to %s...\n", currentPath)
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
replaceCmd.Stdin = os.Stdin
replaceCmd.Stdout = os.Stdout
replaceCmd.Stderr = os.Stderr
if err := replaceCmd.Run(); err != nil {
if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
return fmt.Errorf("failed to replace binary: %w", err)
}
+26 -51
View File
@@ -2,6 +2,7 @@ package main
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
@@ -14,6 +15,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
@@ -35,7 +37,7 @@ var greeterInstallCmd = &cobra.Command{
Use: "install",
Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface",
PreRunE: requireMutableSystemCommand,
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -57,9 +59,10 @@ var greeterInstallCmd = &cobra.Command{
}
var greeterSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth")
@@ -88,7 +91,7 @@ var greeterEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter",
PreRunE: requireMutableSystemCommand,
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -124,7 +127,7 @@ var greeterUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Remove DMS greeter configuration and restore previous display manager",
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
PreRunE: requireMutableSystemCommand,
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -306,10 +309,7 @@ func uninstallGreeter(nonInteractive bool) error {
}
fmt.Println("\nDisabling greetd...")
disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd")
disableCmd.Stdout = os.Stdout
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
} else {
fmt.Println(" ✓ greetd disabled")
@@ -375,10 +375,10 @@ func restorePreDMSGreetdConfig(sudoPassword string) error {
}
tmp.Close()
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to restore %s: %w", candidate, err)
}
if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath); err != nil {
return err
}
fmt.Printf(" ✓ Restored greetd config from %s\n", candidate)
@@ -406,21 +406,14 @@ command = "agreety --cmd /bin/bash"
}
tmp.Close()
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to write fallback greetd config: %w", err)
}
_ = runSudoCommand(sudoPassword, "chmod", "644", configPath)
_ = privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath)
fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)")
return nil
}
func runSudoCommand(_ string, command string, args ...string) error {
cmd := exec.Command("sudo", append([]string{command}, args...)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// suggestDisplayManagerRestore scans for installed DMs and re-enables one
func suggestDisplayManagerRestore(nonInteractive bool) {
knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
@@ -439,10 +432,7 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
enableDM := func(dm string) {
fmt.Printf(" Enabling %s...\n", dm)
cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
} else {
fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm)
@@ -641,10 +631,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if response != "n" && response != "no" {
fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username)
addUserCmd.Stdout = os.Stdout
addUserCmd.Stderr = os.Stderr
if err := addUserCmd.Run(); err != nil {
if err := privesc.Run(context.Background(), "", "usermod", "-aG", greeterGroup, currentUser.Username); err != nil {
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
}
fmt.Printf("✓ User added to %s group\n", greeterGroup)
@@ -869,22 +856,19 @@ func disableDisplayManager(dmName string) (bool, error) {
actionTaken := false
if state.NeedsDisable {
var disableCmd *exec.Cmd
var actionVerb string
if state.EnabledState == "static" {
var action, actionVerb string
switch state.EnabledState {
case "static":
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
disableCmd = exec.Command("sudo", "systemctl", "mask", dmName)
action = "mask"
actionVerb = "masked"
} else {
default:
fmt.Printf(" Disabling %s...\n", dmName)
disableCmd = exec.Command("sudo", "systemctl", "disable", dmName)
action = "disable"
actionVerb = "disabled"
}
disableCmd.Stdout = os.Stdout
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
if err := privesc.Run(context.Background(), "", "systemctl", action, dmName); err != nil {
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
}
@@ -925,10 +909,7 @@ func ensureGreetdEnabled() error {
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
fmt.Println(" Unmasking greetd...")
unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd")
unmaskCmd.Stdout = os.Stdout
unmaskCmd.Stderr = os.Stderr
if err := unmaskCmd.Run(); err != nil {
if err := privesc.Run(context.Background(), "", "systemctl", "unmask", "greetd"); err != nil {
return fmt.Errorf("failed to unmask greetd: %w", err)
}
fmt.Println(" ✓ Unmasked greetd")
@@ -940,10 +921,7 @@ func ensureGreetdEnabled() error {
fmt.Println(" Enabling greetd service...")
}
enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd")
enableCmd.Stdout = os.Stdout
enableCmd.Stderr = os.Stderr
if err := enableCmd.Run(); err != nil {
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", "greetd"); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err)
}
@@ -973,10 +951,7 @@ func ensureGraphicalTarget() error {
currentTargetStr := strings.TrimSpace(string(currentTarget))
if currentTargetStr != "graphical.target" {
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target")
setDefaultCmd.Stdout = os.Stdout
setDefaultCmd.Stderr = os.Stderr
if err := setDefaultCmd.Run(); err != nil {
if err := privesc.Run(context.Background(), "", "systemctl", "set-default", "graphical.target"); err != nil {
fmt.Println("⚠ Warning: Failed to set graphical.target as default")
fmt.Println(" Greeter may not start on boot. Run manually:")
fmt.Println(" sudo systemctl set-default graphical.target")
+27 -2
View File
@@ -51,12 +51,20 @@ var keybindsSetCmd = &cobra.Command{
var keybindsRemoveCmd = &cobra.Command{
Use: "remove <provider> <key>",
Short: "Remove a keybind override",
Long: "Remove a keybind override from the specified provider",
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: runKeybindsRemove,
}
var keybindsResetCmd = &cobra.Command{
Use: "reset <provider> <key>",
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: runKeybindsReset,
}
func init() {
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
@@ -72,6 +80,7 @@ func init() {
keybindsCmd.AddCommand(keybindsShowCmd)
keybindsCmd.AddCommand(keybindsSetCmd)
keybindsCmd.AddCommand(keybindsRemoveCmd)
keybindsCmd.AddCommand(keybindsResetCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath)
@@ -263,3 +272,19 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
}, "", " ")
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))
}
+36 -97
View File
@@ -28,9 +28,9 @@ with flags to handle different MIME types or application categories.
Examples:
dms open https://example.com # Open URL with browser picker
dms open file.pdf --mime application/pdf # Open PDF with compatible apps
dms open document.odt --category Office # Open with office applications
dms open --mime image/png image.png # Open image with image viewers`,
dms open file.pdf # Open file (MIME auto-detected)
dms open file.pdf --mime application/pdf # Override MIME detection
dms open document.odt --category Office # Open with office applications`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runOpen(args[0])
@@ -47,123 +47,58 @@ func init() {
})
}
// mimeTypeToCategories maps MIME types to desktop file categories
func mimeTypeToCategories(mimeType string) []string {
// Split MIME type to get the main type
parts := strings.Split(mimeType, "/")
if len(parts) < 1 {
return nil
func detectMimeFromPath(path string) string {
ext := filepath.Ext(path)
if ext == "" {
return ""
}
mainType := parts[0]
switch mainType {
case "image":
return []string{"Graphics", "Viewer"}
case "video":
return []string{"Video", "AudioVideo"}
case "audio":
return []string{"Audio", "AudioVideo"}
case "text":
if strings.Contains(mimeType, "html") {
return []string{"WebBrowser"}
}
return []string{"TextEditor", "Office"}
case "application":
if strings.Contains(mimeType, "pdf") {
return []string{"Office", "Viewer"}
}
if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") ||
strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") ||
strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") ||
strings.Contains(mimeType, "opendocument") {
return []string{"Office"}
}
if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") ||
strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") {
return []string{"Archiving", "Utility"}
}
return []string{"Office", "Viewer"}
}
return nil
return mime.TypeByExtension(ext)
}
func runOpen(target string) {
// Parse file:// URIs to extract the actual file path
actualTarget := target
detectedMimeType := openMimeType
detectedCategories := openCategories
detectedRequestType := openRequestType
log.Infof("Processing target: %s", target)
if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" {
// Extract file path from file:// URI and convert to absolute path
actualTarget = parsedURL.Path
if absPath, err := filepath.Abs(actualTarget); err == nil {
actualTarget = absPath
switch {
case isScheme(target, "file://"):
parsedURL, err := url.Parse(target)
if err == nil {
actualTarget = parsedURL.Path
}
if abs, err := filepath.Abs(actualTarget); err == nil {
actualTarget = abs
}
if detectedRequestType == "url" || detectedRequestType == "" {
detectedRequestType = "file"
}
log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget)
// Auto-detect MIME type if not provided
if detectedMimeType == "" {
ext := filepath.Ext(actualTarget)
if ext != "" {
detectedMimeType = mime.TypeByExtension(ext)
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
}
detectedMimeType = detectMimeFromPath(actualTarget)
}
log.Infof("Detected file:// URI, absolute path: %s", actualTarget)
// Auto-detect categories based on MIME type if not provided
if len(detectedCategories) == 0 && detectedMimeType != "" {
detectedCategories = mimeTypeToCategories(detectedMimeType)
log.Infof("Detected categories from MIME type: %v", detectedCategories)
}
} else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
// Handle HTTP(S) URLs
case isScheme(target, "http://"), isScheme(target, "https://"), isScheme(target, "dms://"):
if detectedRequestType == "" {
detectedRequestType = "url"
}
log.Infof("Detected HTTP(S) URL")
} else if strings.HasPrefix(target, "dms://") {
// Handle DMS internal URLs (theme/plugin install, etc.)
if detectedRequestType == "" {
detectedRequestType = "url"
}
log.Infof("Detected DMS internal URL")
} else if _, err := os.Stat(target); err == nil {
// Handle local file paths directly (not file:// URIs)
// Convert to absolute path
if absPath, err := filepath.Abs(target); err == nil {
actualTarget = absPath
}
log.Infof("Detected URL: %s", target)
default:
if _, err := os.Stat(target); err != nil {
break
}
if abs, err := filepath.Abs(target); err == nil {
actualTarget = abs
}
if detectedRequestType == "url" || detectedRequestType == "" {
detectedRequestType = "file"
}
log.Infof("Detected local file path, converted to absolute: %s", actualTarget)
// Auto-detect MIME type if not provided
if detectedMimeType == "" {
ext := filepath.Ext(actualTarget)
if ext != "" {
detectedMimeType = mime.TypeByExtension(ext)
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
}
}
// Auto-detect categories based on MIME type if not provided
if len(detectedCategories) == 0 && detectedMimeType != "" {
detectedCategories = mimeTypeToCategories(detectedMimeType)
log.Infof("Detected categories from MIME type: %v", detectedCategories)
detectedMimeType = detectMimeFromPath(actualTarget)
}
log.Infof("Detected local file path: %s", actualTarget)
}
params := map[string]any{
@@ -174,8 +109,8 @@ func runOpen(target string) {
params["mimeType"] = detectedMimeType
}
if len(detectedCategories) > 0 {
params["categories"] = detectedCategories
if len(openCategories) > 0 {
params["categories"] = openCategories
}
if detectedRequestType != "" {
@@ -183,7 +118,7 @@ func runOpen(target string) {
}
method := "apppicker.open"
if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) {
if detectedMimeType == "" && len(openCategories) == 0 && (isScheme(target, "http://") || isScheme(target, "https://") || isScheme(target, "dms://")) {
method = "browser.open"
params["url"] = target
}
@@ -203,3 +138,7 @@ func runOpen(target string) {
log.Infof("Request sent successfully")
}
func isScheme(target, prefix string) bool {
return strings.HasPrefix(target, prefix)
}
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var switchUserCmd = &cobra.Command{
Use: "switch-user [target]",
Short: "Switch to another active session on this seat",
Long: `Switch the active VT to another running session.
With no target, prints the list of switchable sessions. Pass a username or a
numeric session ID to switch directly. Requires the target to already be a
running session on the same seat (use the greeter for a fresh login).`,
Args: cobra.MaximumNArgs(1),
Run: runSwitchUser,
}
type sessionInfo struct {
ID string
Name string
Seat string
TTY string
Type string
Class string
Active bool
State string
Current bool
}
func runSwitchUser(cmd *cobra.Command, args []string) {
currentID := os.Getenv("XDG_SESSION_ID")
sessions, err := listSessions(currentID)
if err != nil {
log.Fatalf("%v", err)
}
switchable := make([]sessionInfo, 0, len(sessions))
for _, s := range sessions {
if s.Class != "user" || s.State == "closing" || s.Current {
continue
}
switchable = append(switchable, s)
}
if len(args) == 0 {
if len(switchable) == 0 {
fmt.Println("No other active sessions on this seat.")
return
}
printSessions(switchable)
return
}
target := args[0]
picked, err := pickSession(switchable, target)
if err != nil {
fmt.Fprintln(os.Stderr, err)
if len(switchable) == 0 {
fmt.Fprintln(os.Stderr, "No other active sessions on this seat. Only already-running sessions can be switched to.")
} else {
fmt.Fprintln(os.Stderr, "\nSwitchable sessions:")
printSessions(switchable)
}
os.Exit(1)
}
if err := activateSession(picked.ID); err != nil {
log.Fatalf("loginctl activate %s: %v", picked.ID, err)
}
}
func listSessions(currentID string) ([]sessionInfo, error) {
listOut, err := exec.Command("loginctl", "list-sessions", "--no-legend").Output()
if err != nil {
return nil, fmt.Errorf("loginctl list-sessions: %w", err)
}
var ids []string
scanner := bufio.NewScanner(strings.NewReader(string(listOut)))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) == 0 {
continue
}
ids = append(ids, fields[0])
}
out := make([]sessionInfo, 0, len(ids))
for _, id := range ids {
s, err := showSession(id)
if err != nil {
continue
}
s.Current = currentID != "" && s.ID == currentID
out = append(out, s)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].ID < out[j].ID
})
return out, nil
}
func showSession(id string) (sessionInfo, error) {
out, err := exec.Command("loginctl", "show-session", id,
"-p", "Id", "-p", "Name", "-p", "Seat", "-p", "TTY",
"-p", "Type", "-p", "Class", "-p", "Active", "-p", "State").Output()
if err != nil {
return sessionInfo{}, err
}
fields := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
idx := strings.IndexByte(line, '=')
if idx <= 0 {
continue
}
fields[line[:idx]] = line[idx+1:]
}
if fields["Id"] == "" {
return sessionInfo{}, fmt.Errorf("session %s: no Id", id)
}
return sessionInfo{
ID: fields["Id"],
Name: fields["Name"],
Seat: fields["Seat"],
TTY: fields["TTY"],
Type: fields["Type"],
Class: fields["Class"],
Active: fields["Active"] == "yes",
State: fields["State"],
}, nil
}
func pickSession(sessions []sessionInfo, target string) (sessionInfo, error) {
for _, s := range sessions {
if s.ID == target {
return s, nil
}
}
matches := make([]sessionInfo, 0, 2)
for _, s := range sessions {
if s.Name == target {
matches = append(matches, s)
}
}
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.ID
}
return sessionInfo{}, fmt.Errorf("%s has multiple active sessions (%s); pass a session ID instead", target, strings.Join(ids, ", "))
}
return sessionInfo{}, fmt.Errorf("no switchable session matches %q", target)
}
func activateSession(id string) error {
return exec.Command("loginctl", "activate", id).Run()
}
func printSessions(sessions []sessionInfo) {
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", "ID", "USER", "TYPE", "SEAT", "TTY")
for _, s := range sessions {
tty := s.TTY
if tty == "" {
tty = "-"
}
seat := s.Seat
if seat == "" {
seat = "-"
}
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", s.ID, s.Name, s.Type, seat, tty)
}
}
+59 -18
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -11,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"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"
)
@@ -19,7 +21,7 @@ var setupCmd = &cobra.Command{
Use: "setup",
Short: "Deploy DMS configurations",
Long: "Deploy compositor and terminal configurations with interactive prompts",
PersistentPreRunE: requireMutableSystemCommand,
PersistentPreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
if err := runSetup(); err != nil {
log.Fatalf("Error during setup: %v", err)
@@ -107,25 +109,25 @@ type dmsConfigSpec struct {
var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": {
niriFile: "binds.kdl",
hyprFile: "binds.conf",
hyprFile: "binds.lua",
niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
hyprContent: func(t string) string {
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t)
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
},
},
"layout": {
niriFile: "layout.kdl",
hyprFile: "layout.conf",
hyprFile: "layout.lua",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
},
"colors": {
niriFile: "colors.kdl",
hyprFile: "colors.conf",
hyprFile: "colors.lua",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.HyprColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
},
"alttab": {
niriFile: "alttab.kdl",
@@ -133,21 +135,21 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.conf",
hyprFile: "outputs.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
},
"cursor": {
niriFile: "cursor.kdl",
hyprFile: "cursor.conf",
hyprFile: "cursor.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
},
"windowrules": {
niriFile: "windowrules.kdl",
hyprFile: "windowrules.conf",
hyprFile: "windowrules.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
},
}
@@ -267,6 +269,8 @@ func runSetupDmsConfig(name string) error {
func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===")
ensureInputGroup()
wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd()
@@ -340,6 +344,37 @@ func runSetup() error {
return nil
}
// Add user to the input group for the evdev manager for inut state tracking.
// Caps Lock OSD and the Caps Lock bar indicator.
func ensureInputGroup() {
if !utils.HasGroup("input") {
return
}
currentUser := os.Getenv("USER")
if currentUser == "" {
currentUser = os.Getenv("LOGNAME")
}
if currentUser == "" {
return
}
out, err := execGroups(currentUser)
if err == nil && strings.Contains(out, "input") {
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
return
}
fmt.Println("Adding user to input group for Caps Lock OSD support...")
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
} else {
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
}
}
func execGroups(user string) (string, error) {
out, err := exec.Command("groups", user).Output()
return string(out), err
}
func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")
@@ -403,16 +438,22 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
willBackup := false
if wmSelected {
var configPath string
var configPaths []string
switch wm {
case deps.WindowManagerNiri:
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl")
configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland:
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf")
configPaths = []string{
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
for _, configPath := range configPaths {
if _, err := os.Stat(configPath); err == nil {
willBackup = true
break
}
}
}
+365
View File
@@ -0,0 +1,365 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"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/spf13/cobra"
)
var systemCmd = &cobra.Command{
Use: "system",
Short: "System operations",
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
}
var systemUpdateCmd = &cobra.Command{
Use: "update",
Short: "Apply or list system updates",
Long: `Apply or list system updates across detected package managers.
Default behavior is to apply available updates after prompting for confirmation.
Use --check to list updates without applying.
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 --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`,
Run: runSystemUpdate,
}
var (
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
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(&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 the AUR (paru/yay only)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
systemCmd.AddCommand(systemUpdateCmd)
}
func runSystemUpdate(cmd *cobra.Command, args []string) {
switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS)
case sysUpdateCheck:
runSystemUpdateCheck()
default:
runSystemUpdateApply()
}
}
func selectBackends(ctx context.Context) []sysupdate.Backend {
sel := sysupdate.Select(ctx)
backends := sel.All()
if !sysUpdateNoFlatpak {
return backends
}
out := backends[:0]
for _, b := range backends {
if b.Repo() == sysupdate.RepoFlatpak {
continue
}
out = append(out, b)
}
return out
}
func runSystemUpdateCheck() {
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer cancel()
backends := selectBackends(ctx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
stopSpin := startSpinner("Checking for updates… ")
allPkgs, firstErr := collectUpdates(ctx, backends)
stopSpin()
allPkgs = filterUpdateTargets(allPkgs)
if sysUpdateJSON {
out, _ := json.MarshalIndent(map[string]any{
"backends": backendResults(backends, allPkgs),
"packages": allPkgs,
"error": errOrEmpty(firstErr),
"count": len(allPkgs),
}, "", " ")
fmt.Println(string(out))
return
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(allPkgs))
if firstErr != nil {
fmt.Printf("Error: %v\n", firstErr)
}
if len(allPkgs) == 0 {
return
}
fmt.Println()
for _, p := range allPkgs {
printPackage(p)
}
}
type backendResult struct {
ID string `json:"id"`
Display string `json:"displayName"`
Packages []sysupdate.Package `json:"packages"`
}
func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult {
results := make([]backendResult, 0, len(backends))
for _, b := range backends {
var backendPkgs []sysupdate.Package
for _, p := range pkgs {
if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) {
backendPkgs = append(backendPkgs, p)
}
}
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs})
}
return results
}
func runSystemUpdateApply() {
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer checkCancel()
backends := selectBackends(checkCtx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
stopSpin := startSpinner("Checking for updates…")
pkgs, firstErr := collectUpdates(checkCtx, backends)
stopSpin()
pkgs = filterUpdateTargets(pkgs)
if firstErr != nil {
fmt.Printf("Warning: %v\n\n", firstErr)
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(pkgs))
if len(pkgs) == 0 {
fmt.Println("Nothing to upgrade.")
return
}
fmt.Println()
for _, p := range pkgs {
printPackage(p)
}
fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
fmt.Println("Aborted.")
return
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
opts := sysupdate.UpgradeOptions{
Targets: pkgs,
IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR,
DryRun: sysUpdateDry,
UseSudo: true,
}
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
onLine := func(line string) { fmt.Println(line) }
ran := false
for _, b := range backends {
if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) {
continue
}
ran = true
fmt.Printf("\n== %s ==\n", b.DisplayName())
if err := b.Upgrade(ctx, opts, onLine); err != nil {
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
}
}
if !ran {
fmt.Println("Nothing to upgrade.")
return
}
if sysUpdateDry {
fmt.Println("\nDry run complete (no changes applied).")
return
}
fmt.Println("\nUpgrade complete.")
}
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
var all []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
all = append(all, pkgs...)
}
return all, firstErr
}
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR {
return pkgs
}
out := pkgs[:0]
for _, p := range pkgs {
if p.Repo == sysupdate.RepoAUR {
continue
}
out = append(out, p)
}
return out
}
func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "sysupdate.setInterval",
Params: map[string]any{"seconds": float64(seconds)},
})
if err != nil {
log.Fatalf("Failed: %v (is dms server running?)", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
fmt.Printf("Interval set to %d seconds.\n", seconds)
}
func promptYesNo(prompt string) bool {
if !stdinIsTTY() {
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
}
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "n", "no":
return false
default:
return true
}
}
func printBackends(backends []sysupdate.Backend) {
if len(backends) == 0 {
return
}
names := make([]string, 0, len(backends))
for _, b := range backends {
names = append(names, b.DisplayName())
}
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
}
func stdinIsTTY() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
func stdoutIsTTY() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
// startSpinner prints an animated spinner to stdout for progress indication
func startSpinner(msg string) func() {
if !stdoutIsTTY() {
return func() {}
}
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
done := make(chan struct{})
go func() {
for i := 0; ; i++ {
select {
case <-done:
fmt.Print("\r\033[K")
return
case <-time.After(80 * time.Millisecond):
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
}
}
}()
return func() { close(done) }
}
var (
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
)
func printPackage(p sysupdate.Package) {
if !stdoutIsTTY() {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
return
}
fmt.Printf(" %s %s %s %s %s\n",
styleRepo.Render("["+string(p.Repo)+"]"),
styleName.Render(p.Name),
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
styleArrow.Render("->"),
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
)
}
func errOrEmpty(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func defaultIfEmpty(s, def string) string {
if s == "" {
return def
}
return s
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra"
)
var trashCmd = &cobra.Command{
Use: "trash",
Short: "Manage the user's trash (XDG Trash spec 1.0)",
}
var trashPutCmd = &cobra.Command{
Use: "put <path...>",
Short: "Move files or directories into the trash",
Args: cobra.MinimumNArgs(1),
Run: runTrashPut,
}
var trashListCmd = &cobra.Command{
Use: "list",
Short: "List trashed items across all known trash directories",
Run: runTrashList,
}
var trashCountCmd = &cobra.Command{
Use: "count",
Short: "Print the total number of trashed items",
Run: runTrashCount,
}
var trashEmptyCmd = &cobra.Command{
Use: "empty",
Short: "Permanently delete every trashed item",
Run: runTrashEmpty,
}
var trashRestoreCmd = &cobra.Command{
Use: "restore <name>",
Short: "Restore a trashed item to its original location",
Args: cobra.ExactArgs(1),
Run: runTrashRestore,
}
var (
trashJSONOutput bool
trashRestoreDir string
)
func init() {
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
}
func runTrashPut(cmd *cobra.Command, args []string) {
var failed int
for _, p := range args {
if _, err := trash.Put(p); err != nil {
log.Errorf("trash %s: %v", p, err)
failed++
continue
}
fmt.Println(p)
}
if failed > 0 {
os.Exit(1)
}
}
func runTrashList(cmd *cobra.Command, args []string) {
entries, err := trash.List()
if err != nil {
log.Fatalf("list trash: %v", err)
}
if trashJSONOutput {
if entries == nil {
entries = []trash.Entry{}
}
out, _ := json.MarshalIndent(entries, "", " ")
fmt.Println(string(out))
return
}
if len(entries) == 0 {
fmt.Println("Trash is empty")
return
}
for _, e := range entries {
marker := "F"
if e.IsDir {
marker = "D"
}
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
}
}
func runTrashCount(cmd *cobra.Command, args []string) {
n, err := trash.Count()
if err != nil {
log.Fatalf("count trash: %v", err)
}
fmt.Println(n)
}
func runTrashEmpty(cmd *cobra.Command, args []string) {
if err := trash.Empty(); err != nil {
log.Fatalf("empty trash: %v", err)
}
}
func runTrashRestore(cmd *cobra.Command, args []string) {
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
log.Fatalf("restore: %v", err)
}
}
+8 -10
View File
@@ -26,7 +26,7 @@ var windowrulesListCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -40,8 +40,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -55,7 +54,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -69,7 +68,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -83,7 +82,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -118,9 +117,9 @@ func getCompositor(args []string) string {
if os.Getenv("NIRI_SOCKET") != "" {
return "niri"
}
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
// return "hyprland"
// }
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland"
}
return ""
}
@@ -183,7 +182,6 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus
case "hyprland":
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err)
+14
View File
@@ -9,6 +9,7 @@ import (
"strings"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/spf13/cobra"
)
@@ -269,3 +270,16 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
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
// selection prompt (shown only when multiple tools are available and the
// $DMS_PRIVESC env var isn't set).
func preRunPrivileged(cmd *cobra.Command, args []string) error {
if err := requireMutableSystemCommand(cmd, args); err != nil {
return err
}
if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil {
return err
}
return nil
}
+6 -1
View File
@@ -5,6 +5,7 @@ package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
@@ -14,6 +15,8 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
@@ -30,7 +33,9 @@ func init() {
}
func main() {
if os.Geteuid() == 0 {
clipboard.MaybeServeAndExit()
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
log.Fatal("This program should not be run as root. Exiting.")
}
+6 -1
View File
@@ -5,6 +5,7 @@ package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
@@ -14,6 +15,8 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
@@ -27,7 +30,9 @@ func init() {
}
func main() {
if os.Geteuid() == 0 {
clipboard.MaybeServeAndExit()
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
log.Fatal("This program should not be run as root. Exiting.")
}
+14 -3
View File
@@ -80,6 +80,16 @@ func getRuntimeDir() string {
return os.TempDir()
}
func appendLogEnv(env []string) []string {
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
env = append(env, "DMS_LOG_LEVEL="+v)
}
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
env = append(env, "DMS_LOG_FILE="+v)
}
return env
}
func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run")
return err == nil
@@ -192,9 +202,6 @@ func runShellInteractive(session bool) {
}
}
// ! TODO - remove when QS 0.3 is up and we can use the pragma
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
}
@@ -216,6 +223,8 @@ func runShellInteractive(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -459,6 +468,8 @@ func runShellDaemon(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil {
log.Fatalf("Error opening /dev/null: %v", err)
+16
View File
@@ -7,6 +7,22 @@ import (
"strings"
)
// isReadOnlyCommand returns true if the CLI args indicate a command that is
// safe to run as root (e.g. shell completion, help).
func isReadOnlyCommand(args []string) bool {
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "-") {
continue
}
switch arg {
case "completion", "help", "__complete", "system":
return true
}
return false
}
return false
}
func isArchPackageInstalled(packageName string) bool {
cmd := exec.Command("pacman", "-Q", packageName)
err := cmd.Run()
+45 -29
View File
@@ -1,84 +1,100 @@
module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.0
toolchain go1.26.1
go 1.26.1
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/alecthomas/chroma/v2 v2.24.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/fsnotify/fsnotify v1.9.0
github.com/charmbracelet/log v1.0.0
github.com/fsnotify/fsnotify v1.10.1
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
golang.org/x/image v0.36.0
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
tailscale.com v1.96.5
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/mdlayher/netlink v1.11.1 // indirect
github.com/mdlayher/socket v0.6.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
github.com/go-git/go-git/v6 v6.0.0-alpha.2
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-isatty v0.0.22
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/muesli/termenv v0.16.0
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
gopkg.in/yaml.v3 v3.0.1
)
+103 -55
View File
@@ -1,14 +1,18 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
@@ -24,54 +28,71 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -84,12 +105,16 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -101,14 +126,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -117,12 +148,13 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -146,6 +178,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
@@ -155,37 +193,47 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
+99 -123
View File
@@ -12,35 +12,95 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
const envServe = "_DMS_CLIPBOARD_SERVE"
const envMime = "_DMS_CLIPBOARD_MIME"
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
// child. Reads source data into memory, deletes any cache file, then serves.
func MaybeServeAndExit() {
if os.Getenv(envServe) == "" {
return
}
mimeType := os.Getenv(envMime)
pasteOnce := os.Getenv(envPasteOnce) == "1"
cachePath := os.Getenv(envCacheFile)
var data []byte
var err error
switch {
case cachePath != "":
data, err = os.ReadFile(cachePath)
os.Remove(cachePath)
default:
data, err = io.ReadAll(os.Stdin)
}
if err != nil {
fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err)
os.Exit(1)
}
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
func Copy(data []byte, mimeType string) error {
return copyForkCached(data, mimeType, false)
}
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground {
return copyServeWithWriter(func(writer io.Writer) error {
total := 0
for total < len(data) {
n, err := writer.Write(data[total:])
total += n
if err != nil {
return err
}
}
if total != len(data) {
return io.ErrShortWrite
}
return nil
}, mimeType, pasteOnce)
return serveClipboard(data, mimeType, pasteOnce)
}
return copyForkCached(data, mimeType, pasteOnce)
}
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if !foreground {
return copyFork(data, mimeType, pasteOnce)
if foreground {
buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
}
return copyServeReader(data, mimeType, pasteOnce)
return copyFork(data, mimeType, pasteOnce)
}
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
cmd := exec.Command(os.Args[0])
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
cmd.Env = append(os.Environ(),
envServe+"=1",
envMime+"="+mimeType,
)
if pasteOnce {
cmd.Env = append(cmd.Env, envPasteOnce+"=1")
}
cmd.Env = append(cmd.Env, extra...)
return cmd
}
func waitReady(cmd *exec.Cmd) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
@@ -60,65 +120,34 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
return fmt.Errorf("close cache file: %w", err)
}
args := []string{os.Args[0], "cl", "copy", "--foreground", "--cache-file", cachePath}
if pasteOnce {
args = append(args, "--paste-once")
}
args = append(args, "--type", mimeType)
cmd := exec.Command(args[0], args[1:]...)
cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath)
cmd.Stdin = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
cmd.Env = append(os.Environ(), "DMS_CLIP_FORKED=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
if err := waitReady(cmd); err != nil {
os.Remove(cachePath)
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
os.Remove(cachePath)
return fmt.Errorf("start: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
return err
}
return nil
}
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground"}
if pasteOnce {
args = append(args, "--paste-once")
}
args = append(args, "--type", mimeType)
cmd := exec.Command(args[0], args[1:]...)
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
cmd.Env = append(os.Environ(), "DMS_CLIP_FORKED=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
cmd := newForkCmd(mimeType, pasteOnce)
switch src := data.(type) {
case *os.File:
cmd.Stdin = src
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
return waitReady(cmd)
default:
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
@@ -129,63 +158,22 @@ func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
return nil
}
func signalReady() {
if os.Getenv("DMS_CLIP_FORKED") == "" {
if os.Getenv(envServe) == "" {
return
}
os.Stdout.Write([]byte{1})
}
func ServeCacheFile(path, mimeType string, pasteOnce bool) error {
defer os.Remove(path)
return copyServeWithWriter(func(writer io.Writer) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open cache file: %w", err)
}
defer f.Close()
_, err = io.Copy(writer, f)
return err
}, mimeType, pasteOnce)
}
func copyServeReader(data io.Reader, mimeType string, pasteOnce bool) error {
cachedData, err := createClipboardCacheFile()
if err != nil {
return fmt.Errorf("create clipboard cache file: %w", err)
}
defer os.Remove(cachedData.Name())
if _, err := io.Copy(cachedData, data); err != nil {
return fmt.Errorf("cache clipboard data: %w", err)
}
if err := cachedData.Close(); err != nil {
return fmt.Errorf("close temp cache file: %w", err)
}
return copyServeWithWriter(func(writer io.Writer) error {
cachedFile, err := os.Open(cachedData.Name())
if err != nil {
return fmt.Errorf("open temp cache file: %w", err)
}
defer cachedFile.Close()
if _, err := io.Copy(writer, cachedFile); err != nil {
return fmt.Errorf("write clipboard data: %w", err)
}
return nil
}, mimeType, pasteOnce)
}
func createClipboardCacheFile() (*os.File, error) {
preferredDirs := []string{}
@@ -206,7 +194,7 @@ func createClipboardCacheFile() (*os.File, error) {
return os.CreateTemp("", "dms-clipboard-*")
}
func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOnce bool) error {
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
@@ -248,12 +236,10 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
@@ -292,18 +278,12 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
cancelled := make(chan struct{})
pasted := make(chan struct{}, 1)
sendErr := make(chan error, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
defer syscall.Close(e.Fd)
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if err := writeTo(file); err != nil {
select {
case sendErr <- err:
default:
}
}
_, _ = file.Write(data)
select {
case pasted <- struct{}{}:
default:
@@ -325,8 +305,6 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
select {
case <-cancelled:
return nil
case err := <-sendErr:
return err
case <-pasted:
if pasteOnce {
return nil
@@ -580,12 +558,10 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
@@ -613,12 +589,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
defer syscall.Close(e.Fd)
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerMap[e.MimeType]; ok {
file.Write(data)
_, _ = file.Write(data)
}
select {
+53 -50
View File
@@ -39,11 +39,10 @@ type LayerSurface struct {
wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport
wlPool *client.ShmPool
wlBuffer *client.Buffer
bufferBusy bool
oldPool *client.ShmPool
oldBuffer *client.Buffer
wlPools [2]*client.ShmPool
wlBuffers [2]*client.Buffer
slotBusy [2]bool
needsRedraw bool
scopyBuffer *client.Buffer
configured bool
hidden bool
@@ -136,6 +135,7 @@ func (p *Picker) Run() (*Color, error) {
break
}
p.flushRedraws()
p.checkDone()
}
@@ -164,6 +164,15 @@ func (p *Picker) checkDone() {
}
}
func (p *Picker) flushRedraws() {
for _, ls := range p.surfaces {
if !ls.needsRedraw {
continue
}
p.redrawSurface(ls)
}
}
func (p *Picker) connect() error {
display, err := client.Connect("")
if err != nil {
@@ -507,47 +516,45 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
}
func (p *Picker) redrawSurface(ls *LayerSurface) {
slot := ls.state.FrontIndex()
if ls.slotBusy[slot] {
ls.needsRedraw = true
return
}
var renderBuf *ShmBuffer
if ls.hidden {
switch {
case ls.hidden:
renderBuf = ls.state.RedrawScreenOnly()
} else {
default:
renderBuf = ls.state.Redraw()
}
if renderBuf == nil {
return
}
if ls.oldBuffer != nil {
ls.oldBuffer.Destroy()
ls.oldBuffer = nil
}
if ls.oldPool != nil {
ls.oldPool.Destroy()
ls.oldPool = nil
ls.needsRedraw = false
if ls.wlPools[slot] == nil {
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
if err != nil {
return
}
ls.wlPools[slot] = pool
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
if err != nil {
return
}
ls.wlBuffers[slot] = wlBuffer
s := slot
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
ls.slotBusy[s] = false
})
}
ls.oldPool = ls.wlPool
ls.oldBuffer = ls.wlBuffer
ls.wlPool = nil
ls.wlBuffer = nil
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
if err != nil {
return
}
ls.wlPool = pool
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
if err != nil {
return
}
ls.wlBuffer = wlBuffer
lsRef := ls
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
lsRef.bufferBusy = false
})
ls.bufferBusy = true
ls.slotBusy[slot] = true
logicalW, logicalH := ls.state.LogicalSize()
if logicalW == 0 || logicalH == 0 {
@@ -566,7 +573,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
}
_ = ls.wlSurface.SetBufferScale(bufferScale)
}
_ = ls.wlSurface.Attach(wlBuffer, 0, 0)
_ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0)
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
_ = ls.wlSurface.Commit()
@@ -634,7 +641,7 @@ func (p *Picker) setupPointerHandlers() {
}
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
p.redrawSurface(p.activeSurface)
p.activeSurface.needsRedraw = true
})
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
@@ -655,7 +662,7 @@ func (p *Picker) setupPointerHandlers() {
return
}
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
p.redrawSurface(p.activeSurface)
p.activeSurface.needsRedraw = true
})
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
@@ -679,17 +686,13 @@ func (p *Picker) cleanup() {
if ls.scopyBuffer != nil {
ls.scopyBuffer.Destroy()
}
if ls.oldBuffer != nil {
ls.oldBuffer.Destroy()
}
if ls.oldPool != nil {
ls.oldPool.Destroy()
}
if ls.wlBuffer != nil {
ls.wlBuffer.Destroy()
}
if ls.wlPool != nil {
ls.wlPool.Destroy()
for i := range ls.wlBuffers {
if ls.wlBuffers[i] != nil {
ls.wlBuffers[i].Destroy()
}
if ls.wlPools[i] != nil {
ls.wlPools[i].Destroy()
}
}
if ls.viewport != nil {
ls.viewport.Destroy()
+6
View File
@@ -274,6 +274,12 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
return s.renderBufs[s.front]
}
func (s *SurfaceState) FrontIndex() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.front
}
func (s *SurfaceState) SwapBuffers() {
s.mu.Lock()
s.front ^= 1
+191 -103
View File
@@ -12,6 +12,8 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
const hyprlandBackupDirName = ".dms-backups"
type ConfigDeployer struct {
logChan chan<- string
}
@@ -62,12 +64,49 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
var results []DeploymentResult
// Primary config file paths used to detect fresh installs.
configPrimaryPaths := map[string][]string{
"Niri": {
filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
},
"Hyprland": {
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
},
"Ghostty": {
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
},
"Kitty": {
filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
},
"Alacritty": {
filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
},
}
shouldReplaceConfig := func(configType string) bool {
if replaceConfigs == nil {
return true
}
replace, exists := replaceConfigs[configType]
return !exists || replace
if !exists || replace {
return true
}
// Config is explicitly set to "don't replace" — but still deploy
// if the config file doesn't exist yet (fresh install scenario).
if primaryPaths, ok := configPrimaryPaths[configType]; ok {
exists := false
for _, primaryPath := range primaryPaths {
if _, err := os.Stat(primaryPath); err == nil {
exists = true
break
}
}
if !exists {
return true
}
}
return false
}
switch wm {
@@ -476,7 +515,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Hyprland",
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
}
configDir := filepath.Dir(result.Path)
@@ -491,20 +530,20 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp)
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Hyprland configuration")
existingData, existingPath, err := readExistingHyprlandConfig(configDir)
if err != nil {
result.Error = err
return result, result.Error
}
if existingData != "" {
existingConfig = existingData
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
@@ -523,10 +562,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
terminalCommand = "ghostty"
}
newConfig := strings.ReplaceAll(HyprlandConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if !useSystemd {
newConfig = cd.transformHyprlandConfigForNonSystemd(newConfig, terminalCommand)
newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
}
if existingConfig != "" {
@@ -544,6 +583,18 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
if err != nil {
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
return result, result.Error
}
if movedLegacy > 0 {
if result.BackupPath == "" {
result.BackupPath = backupDir
}
cd.log(fmt.Sprintf("Moved %d legacy hyprlang config(s) to %s", movedLegacy, backupDir))
}
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
@@ -554,29 +605,118 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, nil
}
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
if removeSource {
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
if err != nil {
return 0, err
}
legacyPaths = append(legacyPaths, dmsConfPaths...)
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
if err != nil {
return 0, err
}
legacyPaths = append(legacyPaths, backupPaths...)
moved := 0
for _, src := range legacyPaths {
info, err := os.Lstat(src)
if os.IsNotExist(err) {
continue
}
if err != nil {
return moved, err
}
if info.IsDir() {
continue
}
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(backupDir, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
return moved, err
}
moved++
}
return moved, nil
}
func moveHyprlandConfigFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Rename(src, dst)
}
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
var paths []string
patterns := []string{
filepath.Join(configDir, "hyprland.conf.backup.*"),
filepath.Join(configDir, "hyprland.lua.backup.*"),
filepath.Join(dmsDir, "*.conf.backup.*"),
filepath.Join(dmsDir, "*.lua.backup.*"),
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
paths = append(paths, matches...)
}
return paths, nil
}
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
configs := []struct {
name string
content string
name string
content string
overwrite bool
}{
{"colors.conf", HyprColorsConfig},
{"layout.conf", HyprLayoutConfig},
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.conf", ""},
{"cursor.conf", ""},
{"windowrules.conf", ""},
{name: "colors.lua", content: DMSColorsLuaConfig},
{name: "layout.lua", content: DMSLayoutLuaConfig},
{name: "binds.lua", content: strings.ReplaceAll(DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand), overwrite: true},
{name: "binds-user.lua", content: DMSBindsUserLuaConfig},
{name: "outputs.lua", content: DMSOutputsLuaConfig},
{name: "cursor.lua", content: DMSCursorLuaConfig},
{name: "windowrules.lua", content: DMSWindowRulesLuaConfig},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
// Skip if file already exists and is not empty to preserve user modifications
existed := false
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
existed = true
}
if existed && !cfg.overwrite {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
if existed {
cd.log(fmt.Sprintf("Updated %s", cfg.name))
continue
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
@@ -584,94 +724,42 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
}
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
if len(existingMonitors) == 0 {
_ = newConfig
lines := extractHyprlangMonitorLines(existingConfig)
if len(lines) == 0 {
return newConfig, nil
}
outputsPath := filepath.Join(dmsDir, "outputs.conf")
if _, err := os.Stat(outputsPath); err != nil {
var outputsContent strings.Builder
for _, monitor := range existingMonitors {
outputsContent.WriteString(monitor)
outputsContent.WriteString("\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
} else {
cd.log("Migrated monitor sections to dms/outputs.conf")
}
outputsPath := filepath.Join(dmsDir, "outputs.lua")
if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 {
cd.log("Skipping monitor migration: dms/outputs.lua already exists")
return newConfig, nil
}
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")
monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`)
headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig)
if headerMatch == nil {
return "", fmt.Errorf("could not find MONITOR CONFIG section")
}
insertPos := headerMatch[1] + 1
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("# Monitors from existing configuration\n")
for _, monitor := range existingMonitors {
builder.WriteString(monitor)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}
func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalCommand string) string {
lines := strings.Split(config, "\n")
var result []string
startupSectionFound := false
var b strings.Builder
b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n")
ok := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "exec-once = dbus-update-activation-environment") {
lua, err := hyprlangMonitorLineToLua(line)
if err != nil {
cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err))
continue
}
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
startupSectionFound = true
result = append(result, "exec-once = dms run")
result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb")
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
result = append(result, fmt.Sprintf("env = TERMINAL,%s", terminalCommand))
continue
}
result = append(result, line)
b.WriteString(lua)
b.WriteByte('\n')
ok++
}
if !startupSectionFound {
for i, line := range result {
if strings.Contains(line, "STARTUP APPS") {
insertLines := []string{
"exec-once = dms run",
"env = QT_QPA_PLATFORM,wayland;xcb",
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
"env = QT_QPA_PLATFORMTHEME,gtk3",
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
fmt.Sprintf("env = TERMINAL,%s", terminalCommand),
}
result = append(result[:i+2], append(insertLines, result[i+2:]...)...)
break
}
}
if ok == 0 {
return newConfig, nil
}
return strings.Join(result, "\n")
b.WriteByte('\n')
b.WriteString("-- Default fallback\n")
b.WriteString("hl.monitor({ output = \"\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })\n")
if err := os.WriteFile(outputsPath, []byte(b.String()), 0o644); err != nil {
return newConfig, err
}
cd.log("Migrated monitor sections to dms/outputs.lua")
return newConfig, nil
}
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
+374 -132
View File
@@ -1,6 +1,7 @@
package config
import (
"context"
"os"
"path/filepath"
"testing"
@@ -10,6 +11,46 @@ import (
"github.com/stretchr/testify/require"
)
func TestCleanupStrayHyprlandConfFile(t *testing.T) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "test-signature")
}
t.Run("leaves conf alone when no hyprland.lua present", func(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
})
t.Run("moves stray conf into backup when hyprland.lua exists", func(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath)
assert.FileExists(t, luaPath)
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
require.NoError(t, err)
require.Len(t, entries, 1)
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
})
}
func TestMergeNiriOutputSections(t *testing.T) {
cd := &ConfigDeployer{}
@@ -258,130 +299,56 @@ func getGhosttyPath() string {
func TestMergeHyprlandMonitorSections(t *testing.T) {
cd := &ConfigDeployer{}
tests := []struct {
name string
newConfig string
existingConfig string
wantError bool
wantContains []string
wantNotContains []string
}{
{
name: "no existing monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
t.Run("no monitors in existing", func(t *testing.T) {
tmp := t.TempDir()
out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp)
require.NoError(t, err)
assert.Equal(t, `hl.config({})`, out)
_, e := os.Stat(filepath.Join(tmp, "outputs.lua"))
assert.True(t, os.IsNotExist(e))
})
# ==================
# ENVIRONMENT VARS
# ==================
env = XDG_CURRENT_DESKTOP,niri`,
existingConfig: `# Some other config
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"},
},
{
name: "merge single monitor",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `# My config
monitor = DP-1, 1920x1080@144, 0x0, 1
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{
"MONITOR CONFIG",
"monitor = DP-1, 1920x1080@144, 0x0, 1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "merge multiple monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1
t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) {
tmp := t.TempDir()
existing := `monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`,
wantError: false,
wantContains: []string{
"monitor = DP-1",
"# monitor = HDMI-A-1", // Commented monitor preserved
"monitor = eDP-1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "preserve commented monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`
out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp)
require.NoError(t, err)
assert.Equal(t, `return`, out)
b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua"))
require.NoError(t, err)
s := string(b)
assert.Contains(t, s, "hl.monitor")
assert.Contains(t, s, "DP-1")
assert.Contains(t, s, "HDMI-A-1")
assert.Contains(t, s, "eDP-1")
assert.Contains(t, s, "preferred") // fallback rule at end
})
# ==================`,
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`,
wantError: false,
wantContains: []string{
"# monitor = DP-1",
"# monitor = HDMI-A-1",
"Monitors from existing configuration",
},
},
{
name: "no monitor config section",
newConfig: `# Some config without monitor section
input {
kb_layout = us
}`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`,
wantError: true,
},
}
t.Run("skips when outputs lua already exists", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "outputs.lua")
require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644))
_, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp)
require.NoError(t, err)
b, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "-- keep\n", string(b))
})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
got, err := hyprlangMonitorLineToLua(`monitor = DP-1, 1920x1080@144, 0x0, 1, transform, 1, vrr, 2, bitdepth, 10, cm, hdr, sdrbrightness, 1.2, sdrsaturation, 0.98`)
require.NoError(t, err)
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
for _, notWant := range tt.wantNotContains {
assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant)
}
})
}
assert.Contains(t, got, `output = "DP-1"`)
assert.Contains(t, got, `transform = 1`)
assert.Contains(t, got, `vrr = 2`)
assert.Contains(t, got, `bitdepth = 10`)
assert.Contains(t, got, `cm = "hdr"`)
assert.Contains(t, got, `sdrbrightness = 1.2`)
assert.Contains(t, got, `sdrsaturation = 0.98`)
}
func TestHyprlandConfigDeployment(t *testing.T) {
@@ -397,6 +364,10 @@ func TestHyprlandConfigDeployment(t *testing.T) {
cd := NewConfigDeployer(logChan)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
require.NoError(t, err)
@@ -407,12 +378,16 @@ func TestHyprlandConfigDeployment(t *testing.T) {
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "source = ./dms/binds.conf")
assert.Contains(t, string(content), "exec-once = ")
assert.Contains(t, string(content), `require("dms.binds")`)
assert.Contains(t, string(content), "DMS_STARTUP_BEGIN")
assert.Contains(t, string(content), "hl.config(")
})
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
existingContent := `# My existing Hyprland config
monitor = DP-1, 1920x1080@144, 0x0, 1
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
@@ -421,11 +396,17 @@ general {
gaps_in = 10
}
`
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
err := os.MkdirAll(filepath.Dir(hyprPath), 0o755)
hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf")
err = os.MkdirAll(filepath.Dir(hyprPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
require.NoError(t, err)
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err)
@@ -439,13 +420,76 @@ general {
backupContent, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
assert.NoFileExists(t, hyprPath)
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
newContent, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "source = ./dms/binds.conf")
assert.NotContains(t, string(newContent), "monitor = eDP-2")
assert.Contains(t, string(newContent), `require("dms.binds")`)
outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua")
outBytes, err := os.ReadFile(outputsPath)
require.NoError(t, err)
outs := string(outBytes)
assert.Contains(t, outs, `hl.monitor`)
assert.Contains(t, outs, "DP-1")
assert.Contains(t, outs, "HDMI-A-1")
})
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-lua-conf-symlink")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
confPath := filepath.Join(configDir, "hyprland.conf")
require.NoError(t, os.WriteFile(luaPath, []byte(`require("dms.binds")`+"\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join(configDir, "missing-legacy.conf"), confPath))
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err)
assert.Equal(t, luaPath, result.Path)
_, err = os.Lstat(confPath)
assert.True(t, os.IsNotExist(err), "root hyprland.conf symlink should be moved out of the live config directory")
_, err = os.Lstat(filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"))
assert.NoError(t, err)
})
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte("-- stale managed binds\n"), 0o644))
userBinds := "-- custom user binds\n"
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(userBinds), 0o644))
_, err = cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err)
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
require.NoError(t, err)
assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`)
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err)
assert.Equal(t, userBinds, string(user))
})
}
@@ -458,10 +502,10 @@ func TestNiriConfigStructure(t *testing.T) {
}
func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf")
assert.Contains(t, HyprlandLuaConfig, `require("dms.binds")`)
assert.Contains(t, HyprlandLuaConfig, "DMS_STARTUP_BEGIN")
assert.Contains(t, HyprlandLuaConfig, "hl.config(")
assert.Contains(t, HyprlandLuaConfig, "input =")
}
func TestGhosttyConfigStructure(t *testing.T) {
@@ -624,3 +668,201 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
assert.Contains(t, string(newContent), "decorations = \"None\"")
})
}
func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
allFalse := map[string]bool{
"Niri": false,
"Hyprland": false,
"Ghostty": false,
"Kitty": false,
"Alacritty": false,
}
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
nil, // replaceConfigs
nil, // reinstallItems
)
require.NoError(t, err)
// With replaceConfigs=nil, all configs should be deployed
hasDeployed := false
for _, r := range results {
if r.Deployed {
hasDeployed = true
break
}
}
assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil")
})
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
allFalse, // replaceConfigs — all false
nil, // reinstallItems
)
require.NoError(t, err)
// Config files don't exist on disk, so they should still be deployed
hasDeployed := false
for _, r := range results {
if r.Deployed {
hasDeployed = true
break
}
}
assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false")
})
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
// Create the Ghostty primary config file so shouldReplaceConfig returns false
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
require.NoError(t, err)
// Also create the Niri primary config file
niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl")
err = os.MkdirAll(filepath.Dir(niriPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644)
require.NoError(t, err)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
allFalse, // replaceConfigs — all false
nil, // reinstallItems
)
require.NoError(t, err)
// Both Niri and Ghostty config files exist, so with all false they should be skipped
for _, r := range results {
assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType)
}
})
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
// Create the Ghostty primary config file
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
require.NoError(t, err)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
replaceConfigs := map[string]bool{
"Niri": false,
"Hyprland": false,
"Ghostty": true, // explicitly true
"Kitty": false,
"Alacritty": false,
}
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
replaceConfigs, // Ghostty=true, rest=false
nil, // reinstallItems
)
require.NoError(t, err)
// Ghostty should be deployed because replaceConfigs["Ghostty"]=true
foundGhostty := false
for _, r := range results {
if r.ConfigType == "Ghostty" && r.Deployed {
foundGhostty = true
}
}
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
})
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
hyprConf := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
require.NoError(t, os.MkdirAll(filepath.Dir(hyprConf), 0o755))
require.NoError(t, os.WriteFile(hyprConf, []byte("monitor = , preferred, auto, 1\n"), 0o644))
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.deployConfigurationsInternal(
context.Background(),
deps.WindowManagerHyprland,
deps.TerminalGhostty,
nil,
allFalse,
nil,
true,
)
require.NoError(t, err)
for _, r := range results {
if r.ConfigType == "Hyprland" && r.Deployed {
t.Fatalf("expected Hyprland deployment to be skipped when legacy config exists and replace=false")
}
}
})
}
@@ -0,0 +1 @@
-- Optional per-user keybind overrides (managed by DMS). Loaded after default binds.
@@ -1,162 +0,0 @@
# === Application Launchers ===
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
bind = SUPER, space, exec, dms ipc call spotlight toggle
bind = SUPER, V, exec, dms ipc call clipboard toggle
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
bind = SUPER, N, exec, dms ipc call notifications toggle
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
bind = SUPER, X, exec, dms ipc call powermenu toggle
# === Cheat sheet
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = SUPER ALT, L, exec, dms ipc call lock lock
bind = SUPER SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
bind = SUPER, down, movefocus, d
bind = SUPER, up, movefocus, u
bind = SUPER, right, movefocus, r
bind = SUPER, H, movefocus, l
bind = SUPER, J, movefocus, d
bind = SUPER, K, movefocus, u
bind = SUPER, L, movefocus, r
# === Window Movement ===
bind = SUPER SHIFT, left, movewindow, l
bind = SUPER SHIFT, down, movewindow, d
bind = SUPER SHIFT, up, movewindow, u
bind = SUPER SHIFT, right, movewindow, r
bind = SUPER SHIFT, H, movewindow, l
bind = SUPER SHIFT, J, movewindow, d
bind = SUPER SHIFT, K, movewindow, u
bind = SUPER SHIFT, L, movewindow, r
# === Column Navigation ===
bind = SUPER, Home, focuswindow, first
bind = SUPER, End, focuswindow, last
# === Monitor Navigation ===
bind = SUPER CTRL, left, focusmonitor, l
bind = SUPER CTRL, right, focusmonitor, r
bind = SUPER CTRL, H, focusmonitor, l
bind = SUPER CTRL, J, focusmonitor, d
bind = SUPER CTRL, K, focusmonitor, u
bind = SUPER CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = SUPER, Page_Down, workspace, e+1
bind = SUPER, Page_Up, workspace, e-1
bind = SUPER, U, workspace, e+1
bind = SUPER, I, workspace, e-1
bind = SUPER CTRL, down, movetoworkspace, e+1
bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1
# === Workspace Management ===
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
# === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
bind = SUPER SHIFT, U, movetoworkspace, e+1
bind = SUPER SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = SUPER, mouse_down, workspace, e+1
bind = SUPER, mouse_up, workspace, e-1
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = SUPER, 1, workspace, 1
bind = SUPER, 2, workspace, 2
bind = SUPER, 3, workspace, 3
bind = SUPER, 4, workspace, 4
bind = SUPER, 5, workspace, 5
bind = SUPER, 6, workspace, 6
bind = SUPER, 7, workspace, 7
bind = SUPER, 8, workspace, 8
bind = SUPER, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = SUPER SHIFT, 1, movetoworkspace, 1
bind = SUPER SHIFT, 2, movetoworkspace, 2
bind = SUPER SHIFT, 3, movetoworkspace, 3
bind = SUPER SHIFT, 4, movetoworkspace, 4
bind = SUPER SHIFT, 5, movetoworkspace, 5
bind = SUPER SHIFT, 6, movetoworkspace, 6
bind = SUPER SHIFT, 7, movetoworkspace, 7
bind = SUPER SHIFT, 8, movetoworkspace, 8
bind = SUPER SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = SUPER, bracketleft, layoutmsg, preselect l
bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow
bindmd = SUPER, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = SUPER, minus, resizeactive, -10% 0
binde = SUPER, equal, resizeactive, 10% 0
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
binde = SUPER SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = SUPER SHIFT, P, dpms, toggle
@@ -0,0 +1,167 @@
-- DMS default keybinds (Hyprland 0.55+ Lua)
-- === Application Launchers ===
hl.bind("SUPER + T", hl.dsp.exec_cmd("{{TERMINAL_COMMAND}}"))
hl.bind("SUPER + space", hl.dsp.exec_cmd("dms ipc call spotlight toggle"))
hl.bind("ALT + space", hl.dsp.exec_cmd("dms ipc call spotlight-bar toggle"))
hl.bind("SUPER + V", hl.dsp.exec_cmd("dms ipc call clipboard toggle"))
hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
-- === Cheat sheet
hl.bind("SUPER + SHIFT + Slash", hl.dsp.exec_cmd("dms ipc call keybinds toggle hyprland"))
-- === Security ===
hl.bind("SUPER + ALT + L", hl.dsp.exec_cmd("dms ipc call lock lock"))
hl.bind("SUPER + SHIFT + E", hl.dsp.exit())
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
-- === Audio Controls ===
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call audio increment 3"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call audio decrement 3"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("dms ipc call audio mute"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("dms ipc call audio micmute"), { locked = true })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("dms ipc call mpris previous"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("dms ipc call mpris next"), { locked = true })
hl.bind("CTRL + XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call mpris increment 3"), { locked = true, repeating = true })
hl.bind("CTRL + XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call mpris decrement 3"), { locked = true, repeating = true })
-- === Brightness Controls ===
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]]), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
-- === Window Management ===
hl.bind("SUPER + Q", hl.dsp.window.kill())
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
hl.bind("SUPER + W", hl.dsp.group.toggle())
hl.bind("SUPER + SHIFT + W", hl.dsp.exec_cmd("dms ipc call window-rules toggle"))
-- === Focus Navigation ===
hl.bind("SUPER + left", hl.dsp.focus({ direction = "l" }))
hl.bind("SUPER + down", hl.dsp.focus({ direction = "d" }))
hl.bind("SUPER + up", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + right", hl.dsp.focus({ direction = "r" }))
hl.bind("SUPER + H", hl.dsp.focus({ direction = "l" }))
hl.bind("SUPER + J", hl.dsp.focus({ direction = "d" }))
hl.bind("SUPER + K", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + L", hl.dsp.focus({ direction = "r" }))
-- === Window Movement ===
hl.bind("SUPER + SHIFT + left", hl.dsp.window.move({ direction = "l" }))
hl.bind("SUPER + SHIFT + down", hl.dsp.window.move({ direction = "d" }))
hl.bind("SUPER + SHIFT + up", hl.dsp.window.move({ direction = "u" }))
hl.bind("SUPER + SHIFT + right", hl.dsp.window.move({ direction = "r" }))
hl.bind("SUPER + SHIFT + H", hl.dsp.window.move({ direction = "l" }))
hl.bind("SUPER + SHIFT + J", hl.dsp.window.move({ direction = "d" }))
hl.bind("SUPER + SHIFT + K", hl.dsp.window.move({ direction = "u" }))
hl.bind("SUPER + SHIFT + L", hl.dsp.window.move({ direction = "r" }))
-- === Column Navigation ===
hl.bind("SUPER + Home", hl.dsp.focus({ window = "first" }))
hl.bind("SUPER + End", hl.dsp.focus({ window = "last" }))
-- === Monitor Navigation ===
hl.bind("SUPER + CTRL + left", hl.dsp.focus({ monitor = "l" }))
hl.bind("SUPER + CTRL + right", hl.dsp.focus({ monitor = "r" }))
hl.bind("SUPER + CTRL + H", hl.dsp.focus({ monitor = "l" }))
hl.bind("SUPER + CTRL + J", hl.dsp.focus({ monitor = "d" }))
hl.bind("SUPER + CTRL + K", hl.dsp.focus({ monitor = "u" }))
hl.bind("SUPER + CTRL + L", hl.dsp.focus({ monitor = "r" }))
-- === Move to Monitor ===
hl.bind("SUPER + SHIFT + CTRL + left", hl.dsp.window.move({ monitor = "l" }))
hl.bind("SUPER + SHIFT + CTRL + down", hl.dsp.window.move({ monitor = "d" }))
hl.bind("SUPER + SHIFT + CTRL + up", hl.dsp.window.move({ monitor = "u" }))
hl.bind("SUPER + SHIFT + CTRL + right", hl.dsp.window.move({ monitor = "r" }))
hl.bind("SUPER + SHIFT + CTRL + H", hl.dsp.window.move({ monitor = "l" }))
hl.bind("SUPER + SHIFT + CTRL + J", hl.dsp.window.move({ monitor = "d" }))
hl.bind("SUPER + SHIFT + CTRL + K", hl.dsp.window.move({ monitor = "u" }))
hl.bind("SUPER + SHIFT + CTRL + L", hl.dsp.window.move({ monitor = "r" }))
-- === Workspace Navigation ===
hl.bind("SUPER + Page_Down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + Page_Up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + U", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + up", hl.dsp.window.move({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + U", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Workspace Management ===
hl.bind("CTRL + SHIFT + R", hl.dsp.exec_cmd("dms ipc call workspace-rename open"))
-- === Move Workspaces ===
hl.bind("SUPER + SHIFT + Page_Down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + Page_Up", hl.dsp.window.move({ workspace = "e-1" }))
hl.bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Mouse Wheel Navigation ===
hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
-- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
hl.bind("SUPER + 3", hl.dsp.focus({ workspace = "3" }))
hl.bind("SUPER + 4", hl.dsp.focus({ workspace = "4" }))
hl.bind("SUPER + 5", hl.dsp.focus({ workspace = "5" }))
hl.bind("SUPER + 6", hl.dsp.focus({ workspace = "6" }))
hl.bind("SUPER + 7", hl.dsp.focus({ workspace = "7" }))
hl.bind("SUPER + 8", hl.dsp.focus({ workspace = "8" }))
hl.bind("SUPER + 9", hl.dsp.focus({ workspace = "9" }))
-- === Move to Numbered Workspaces ===
hl.bind("SUPER + SHIFT + 1", hl.dsp.window.move({ workspace = "1" }))
hl.bind("SUPER + SHIFT + 2", hl.dsp.window.move({ workspace = "2" }))
hl.bind("SUPER + SHIFT + 3", hl.dsp.window.move({ workspace = "3" }))
hl.bind("SUPER + SHIFT + 4", hl.dsp.window.move({ workspace = "4" }))
hl.bind("SUPER + SHIFT + 5", hl.dsp.window.move({ workspace = "5" }))
hl.bind("SUPER + SHIFT + 6", hl.dsp.window.move({ workspace = "6" }))
hl.bind("SUPER + SHIFT + 7", hl.dsp.window.move({ workspace = "7" }))
hl.bind("SUPER + SHIFT + 8", hl.dsp.window.move({ workspace = "8" }))
hl.bind("SUPER + SHIFT + 9", hl.dsp.window.move({ workspace = "9" }))
-- === Column Management ===
hl.bind("SUPER + bracketleft", hl.dsp.layout("preselect l"))
hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]]))
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window" })
hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { description = "Expand window left" })
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true })
-- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
hl.bind("CTRL + Print", hl.dsp.exec_cmd("dms screenshot full"))
hl.bind("ALT + Print", hl.dsp.exec_cmd("dms screenshot window"))
-- === Display Profiles ===
hl.bind("SUPER + P", hl.dsp.exec_cmd("dms ipc outputs cycleProfile"))
-- === System Controls ===
hl.bind("SUPER + SHIFT + P", hl.dsp.dpms({ action = "toggle" }))
@@ -1,25 +0,0 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
$primary = rgb(d0bcff)
$outline = rgb(948f99)
$error = rgb(f2b8b5)
general {
col.active_border = $primary
col.inactive_border = $outline
}
group {
col.border_active = $primary
col.border_inactive = $outline
col.border_locked_active = $error
col.border_locked_inactive = $outline
groupbar {
col.active = $primary
col.inactive = $outline
col.locked_active = $error
col.locked_inactive = $outline
}
}
@@ -0,0 +1,27 @@
-- ! Auto-generated file. Do not edit directly.
-- Regenerate via DMS theme tools or remove require("dms.colors") from hyprland.lua to override.
hl.config({
general = {
col = {
active_border = "rgb(d0bcff)",
inactive_border = "rgb(948f99)",
},
},
group = {
col = {
border_active = "rgb(d0bcff)",
border_inactive = "rgb(948f99)",
border_locked_active = "rgb(f2b8b5)",
border_locked_inactive = "rgb(948f99)",
},
groupbar = {
col = {
active = "rgb(d0bcff)",
inactive = "rgb(948f99)",
locked_active = "rgb(f2b8b5)",
locked_inactive = "rgb(948f99)",
},
},
},
})
@@ -0,0 +1 @@
-- Cursor theme overrides. Deploy writes ~/.config/hypr/dms/cursor.lua
@@ -1,11 +0,0 @@
# Auto-generated by DMS - do not edit manually
general {
gaps_in = 4
gaps_out = 4
border_size = 2
}
decoration {
rounding = 12
}
@@ -0,0 +1,12 @@
-- Auto-generated by DMS — do not edit manually
hl.config({
general = {
gaps_in = 4,
gaps_out = 4,
border_size = 2,
},
decoration = {
rounding = 12,
},
})
@@ -0,0 +1,3 @@
-- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
@@ -0,0 +1 @@
-- Window rules. Deploy writes ~/.config/hypr/dms/windowrules.lua
-121
View File
@@ -1,121 +0,0 @@
# Hyprland Configuration
# https://wiki.hypr.land/Configuring/
# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = , preferred,auto,auto
# ==================
# STARTUP APPS
# ==================
exec-once = dbus-update-activation-environment --systemd --all
exec-once = systemctl --user start hyprland-session.target
# ==================
# INPUT CONFIG
# ==================
input {
kb_layout = us
numlock_by_default = true
}
# ==================
# GENERAL LAYOUT
# ==================
general {
gaps_in = 5
gaps_out = 5
border_size = 2
layout = dwindle
}
# ==================
# DECORATION
# ==================
decoration {
rounding = 12
active_opacity = 1.0
inactive_opacity = 1.0
shadow {
enabled = true
range = 30
render_power = 5
offset = 0 5
color = rgba(00000070)
}
}
# ==================
# ANIMATIONS
# ==================
animations {
enabled = true
animation = windowsIn, 1, 3, default
animation = windowsOut, 1, 3, default
animation = workspaces, 1, 5, default
animation = windowsMove, 1, 4, default
animation = fade, 1, 3, default
animation = border, 1, 3, default
}
# ==================
# LAYOUTS
# ==================
dwindle {
preserve_split = true
}
master {
mfact = 0.5
}
# ==================
# MISC
# ==================
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
}
# ==================
# WINDOW RULES
# ==================
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = rounding 12, match:class ^(org\.gnome\.)
windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
windowrule = float on, match:class ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$
# DMS windows floating by default
# ! Hyprland doesn't size these windows correctly so disabling by default here
# windowrule = float on, match:class ^(org.quickshell)$
layerrule = no_anim on, match:namespace ^(quickshell)$
layerrule = no_anim on, match:namespace ^dms:.*
source = ./dms/colors.conf
source = ./dms/outputs.conf
source = ./dms/layout.conf
source = ./dms/cursor.conf
source = ./dms/binds.conf
@@ -0,0 +1,84 @@
-- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/
hl.config({ autogenerated = false })
-- DMS_STARTUP_BEGIN
hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
hl.exec_cmd("systemctl --user start hyprland-session.target")
end)
-- DMS_STARTUP_END
hl.config({
input = {
kb_layout = "us",
numlock_by_default = true,
},
general = {
gaps_in = 5,
gaps_out = 5,
border_size = 2,
layout = "dwindle",
},
decoration = {
rounding = 12,
active_opacity = 1.0,
inactive_opacity = 1.0,
shadow = {
enabled = true,
range = 30,
render_power = 5,
offset = "0 5",
color = "rgba(00000070)",
},
},
misc = {
disable_hyprland_logo = true,
disable_splash_rendering = true,
},
dwindle = {
preserve_split = true,
},
master = {
mfact = 0.5,
},
})
hl.animation({ leaf = "windowsIn", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "windowsOut", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "workspaces", enabled = true, speed = 5, bezier = "default" })
hl.animation({ leaf = "windowsMove", enabled = true, speed = 4, bezier = "default" })
hl.animation({ leaf = "fade", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "border", enabled = true, speed = 3, bezier = "default" })
hl.window_rule({ match = { class = "^(org\\.wezfurlong\\.wezterm)$" }, tile = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.)" }, rounding = 12 })
hl.window_rule({ match = { class = "^(gnome-control-center)$" }, tile = true })
hl.window_rule({ match = { class = "^(pavucontrol)$" }, tile = true })
hl.window_rule({ match = { class = "^(nm-connection-editor)$" }, tile = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.Calculator)$" }, float = true })
hl.window_rule({ match = { class = "^(gnome-calculator)$" }, float = true })
hl.window_rule({ match = { class = "^(galculator)$" }, float = true })
hl.window_rule({ match = { class = "^(blueman-manager)$" }, float = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.Nautilus)$" }, float = true })
hl.window_rule({ match = { class = "^(xdg-desktop-portal)$" }, float = true })
hl.window_rule({
match = { class = "^(steam)$", title = "^(notificationtoasts)" },
no_initial_focus = true,
pin = true,
})
hl.window_rule({
match = { class = "^(firefox)$", title = "^(Picture-in-Picture)$" },
float = true,
})
hl.window_rule({ match = { class = "^(zoom)$" }, float = true })
hl.layer_rule({ match = { namespace = "^(quickshell)$" }, no_anim = true })
hl.layer_rule({ match = { namespace = "^dms:.*" }, no_anim = true })
require("dms.colors")
require("dms.outputs")
require("dms.layout")
require("dms.cursor")
require("dms.binds")
require("dms.binds-user")
require("dms.windowrules")
@@ -9,6 +9,9 @@ binds {
Mod+Space hotkey-overlay-title="Application Launcher" {
spawn "dms" "ipc" "call" "spotlight" "toggle";
}
Alt+Space hotkey-overlay-title="Spotlight Bar" {
spawn "dms" "ipc" "call" "spotlight-bar" "toggle";
}
Mod+V hotkey-overlay-title="Clipboard Manager" {
spawn "dms" "ipc" "call" "clipboard" "toggle";
}
@@ -215,6 +218,11 @@ binds {
Print { screenshot; }
Ctrl+Print { screenshot-screen; }
Alt+Print { screenshot-window; }
// === Display Profiles ===
Mod+P hotkey-overlay-title="Cycle Display Profile" {
spawn "dms" "ipc" "outputs" "cycleProfile";
}
// === System Controls ===
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
Mod+Shift+P { power-off-monitors; }
-6
View File
@@ -250,12 +250,6 @@ window-rule {
match app-id="zoom"
open-floating true
}
// Open dms windows as floating by default
window-rule {
match app-id=r#"org.quickshell$"#
match app-id=r#"com.danklinux.dms$"#
open-floating true
}
debug {
honor-xdg-activation-with-invalid-serial
}
+20 -8
View File
@@ -2,14 +2,26 @@ package config
import _ "embed"
//go:embed embedded/hyprland.conf
var HyprlandConfig string
//go:embed embedded/hyprland.lua
var HyprlandLuaConfig string
//go:embed embedded/hypr-colors.conf
var HyprColorsConfig string
//go:embed embedded/hypr-colors.lua
var DMSColorsLuaConfig string
//go:embed embedded/hypr-layout.conf
var HyprLayoutConfig string
//go:embed embedded/hypr-layout.lua
var DMSLayoutLuaConfig string
//go:embed embedded/hypr-binds.conf
var HyprBindsConfig string
//go:embed embedded/hypr-binds.lua
var DMSBindsLuaConfig string
//go:embed embedded/hypr-outputs.lua
var DMSOutputsLuaConfig string
//go:embed embedded/hypr-cursor.lua
var DMSCursorLuaConfig string
//go:embed embedded/hypr-windowrules.lua
var DMSWindowRulesLuaConfig string
//go:embed embedded/hypr-binds-user.lua
var DMSBindsUserLuaConfig string
+174
View File
@@ -0,0 +1,174 @@
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
hyprlandStartupBegin = "-- DMS_STARTUP_BEGIN"
hyprlandStartupEnd = "-- DMS_STARTUP_END"
)
func extractHyprlangMonitorLines(hyprlang string) []string {
re := regexp.MustCompile(`(?m)^\s*#?\s*monitor\s*=.*$`)
return re.FindAllString(hyprlang, -1)
}
func hyprlangMonitorLineToLua(line string) (string, error) {
re := regexp.MustCompile(`(?i)^\s*#?\s*monitor\s*=\s*(.*)\s*$`)
m := re.FindStringSubmatch(line)
if m == nil {
return "", fmt.Errorf("not a monitor line")
}
rest := strings.TrimSpace(m[1])
parts := strings.Split(rest, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 4 {
if len(parts) == 2 && strings.EqualFold(parts[1], "disable") {
return fmt.Sprintf(`hl.monitor({ output = %s, disabled = true })`, strconv.Quote(parts[0])), nil
}
return "", fmt.Errorf("expected at least 4 comma-separated fields")
}
out := parts[0]
mode := parts[1]
pos := parts[2]
scaleStr := parts[3]
scaleField := formatMonitorScaleLua(scaleStr)
fields := []string{
fmt.Sprintf("output = %s", strconv.Quote(out)),
fmt.Sprintf("mode = %s", strconv.Quote(mode)),
fmt.Sprintf("position = %s", strconv.Quote(pos)),
scaleField,
}
for i := 4; i < len(parts); i += 2 {
key := strings.ToLower(strings.TrimSpace(parts[i]))
if key == "" {
continue
}
if i+1 >= len(parts) {
fields = append(fields, fmt.Sprintf("%s = true", hyprlangMonitorOptionToLuaKey(key)))
continue
}
val := strings.TrimSpace(parts[i+1])
if converted, ok := formatMonitorOptionLua(key, val); ok {
fields = append(fields, converted)
}
}
return fmt.Sprintf(`hl.monitor({ %s })`, strings.Join(fields, ", ")), nil
}
func formatMonitorScaleLua(scaleStr string) string {
if scaleStr == "auto" {
return `scale = "auto"`
}
if f, err := strconv.ParseFloat(scaleStr, 64); err == nil {
return fmt.Sprintf(`scale = %g`, f)
}
return fmt.Sprintf(`scale = %s`, strconv.Quote(scaleStr))
}
func hyprlangMonitorOptionToLuaKey(key string) string {
switch strings.ToLower(strings.TrimSpace(key)) {
case "10bit":
return "bitdepth"
default:
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_")
}
}
func formatMonitorOptionLua(key, val string) (string, bool) {
luaKey := hyprlangMonitorOptionToLuaKey(key)
switch luaKey {
case "transform", "vrr", "bitdepth", "supports_wide_color", "supports_hdr", "sdr_max_luminance", "max_luminance", "max_avg_luminance":
if _, err := strconv.Atoi(val); err == nil {
return fmt.Sprintf("%s = %s", luaKey, val), true
}
case "sdrbrightness", "sdrsaturation", "sdr_min_luminance", "min_luminance":
if _, err := strconv.ParseFloat(val, 64); err == nil {
return fmt.Sprintf("%s = %s", luaKey, val), true
}
case "cm", "sdr_eotf", "icc", "mirror":
return fmt.Sprintf("%s = %s", luaKey, strconv.Quote(val)), true
}
return "", false
}
func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
start := strings.Index(config, hyprlandStartupBegin)
end := strings.Index(config, hyprlandStartupEnd)
if start == -1 || end == -1 || end <= start {
return config
}
endClose := end + len(hyprlandStartupEnd)
replacement := hyprlandStartupBegin + "\n" +
`hl.env("QT_QPA_PLATFORM", "wayland;xcb")` + "\n" +
`hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")` + "\n" +
`hl.env("QT_QPA_PLATFORMTHEME", "gtk3")` + "\n" +
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
`hl.on("hyprland.start", function()` + "\n" +
` hl.exec_cmd("dms run")` + "\n" +
`end)` + "\n" +
hyprlandStartupEnd
return config[:start] + replacement + config[endClose:]
}
func readExistingHyprlandConfig(configDir string) (data string, sourcePath string, err error) {
luaPath := filepath.Join(configDir, "hyprland.lua")
if b, e := os.ReadFile(luaPath); e == nil {
return string(b), luaPath, nil
} else if !os.IsNotExist(e) {
return "", "", e
}
confPath := filepath.Join(configDir, "hyprland.conf")
if b, e := os.ReadFile(confPath); e == nil {
return string(b), confPath, nil
} else if !os.IsNotExist(e) {
return "", "", e
}
return "", "", nil
}
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf
// into .dms-backups/<timestamp>/ only when hyprland.lua also exists, which
// proves Lua is the live config and the .conf is an autogen Hyprland 0.55
// produced when launched without -c. If only hyprland.conf exists, the user
// has not migrated and we must leave their config alone.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return
}
home := os.Getenv("HOME")
if home == "" {
return
}
configDir := filepath.Join(home, ".config", "hypr")
luaPath := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(luaPath); err != nil {
return
}
confPath := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(confPath); err != nil {
return
}
ts := time.Now().Format("2006-01-02_15-04-05")
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
if logFn != nil {
logFn("Could not move stray hyprland.conf: %v", err)
}
return
}
if logFn != nil {
logFn("Moved stray hyprland.conf to %s", dst)
}
}
+203
View File
@@ -0,0 +1,203 @@
package desktop
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type Entry struct {
ID string
Path string
Name string
Exec string
Icon string
Categories []string
MimeTypes []string
NoDisplay bool
Hidden bool
Terminal bool
}
type cachedEntry struct {
entry *Entry
modTime time.Time
size int64
}
var (
entryCache = make(map[string]cachedEntry)
entryCacheMu sync.Mutex
listingCache []*Entry
listingExpires time.Time
listingCacheMu sync.Mutex
)
const listingTTL = 5 * time.Second
func applicationDirs() []string {
seen := make(map[string]bool)
var dirs []string
add := func(path string) {
if path == "" {
return
}
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
if seen[abs] {
return
}
seen[abs] = true
dirs = append(dirs, abs)
}
add(filepath.Join(utils.XDGDataHome(), "applications"))
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
for d := range strings.SplitSeq(env, ":") {
add(filepath.Join(strings.TrimSpace(d), "applications"))
}
} else {
add("/usr/local/share/applications")
add("/usr/share/applications")
}
home, _ := os.UserHomeDir()
if home != "" {
add(filepath.Join(home, ".local", "share", "flatpak", "exports", "share", "applications"))
}
add("/var/lib/flatpak/exports/share/applications")
add("/var/lib/snapd/desktop/applications")
return dirs
}
func parseEntry(path string, id string) (*Entry, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
entryCacheMu.Lock()
if c, ok := entryCache[path]; ok && c.modTime.Equal(info.ModTime()) && c.size == info.Size() {
entryCacheMu.Unlock()
return c.entry, nil
}
entryCacheMu.Unlock()
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
groups := parseGroups(data)
g, ok := groups["Desktop Entry"]
if !ok {
return nil, nil
}
entry := &Entry{
ID: id,
Path: path,
Name: g.keys["Name"],
Exec: g.keys["Exec"],
Icon: g.keys["Icon"],
Categories: splitList(g.keys["Categories"]),
MimeTypes: splitList(g.keys["MimeType"]),
NoDisplay: parseBool(g.keys["NoDisplay"]),
Hidden: parseBool(g.keys["Hidden"]),
Terminal: parseBool(g.keys["Terminal"]),
}
if t := g.keys["Type"]; t != "" && t != "Application" {
return nil, nil
}
entryCacheMu.Lock()
entryCache[path] = cachedEntry{entry: entry, modTime: info.ModTime(), size: info.Size()}
entryCacheMu.Unlock()
return entry, nil
}
func relativeID(root, path string) string {
rel, err := filepath.Rel(root, path)
if err != nil {
return filepath.Base(path)
}
return strings.ReplaceAll(rel, string(filepath.Separator), "-")
}
func AllEntries() []*Entry {
listingCacheMu.Lock()
if time.Now().Before(listingExpires) && listingCache != nil {
out := listingCache
listingCacheMu.Unlock()
return out
}
listingCacheMu.Unlock()
seen := make(map[string]bool)
var entries []*Entry
for _, dir := range applicationDirs() {
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".desktop") {
return nil
}
id := relativeID(dir, path)
if seen[id] {
return nil
}
seen[id] = true
entry, err := parseEntry(path, id)
if err != nil || entry == nil {
return nil
}
entries = append(entries, entry)
return nil
})
}
listingCacheMu.Lock()
listingCache = entries
listingExpires = time.Now().Add(listingTTL)
listingCacheMu.Unlock()
return entries
}
func EntryByID(id string) *Entry {
if !strings.HasSuffix(id, ".desktop") {
id += ".desktop"
}
for _, entry := range AllEntries() {
if entry.ID == id {
return entry
}
}
return nil
}
func InvalidateCache() {
entryCacheMu.Lock()
entryCache = make(map[string]cachedEntry)
entryCacheMu.Unlock()
listingCacheMu.Lock()
listingCache = nil
listingExpires = time.Time{}
listingCacheMu.Unlock()
}
+311
View File
@@ -0,0 +1,311 @@
package desktop
import (
"bufio"
"bytes"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
var (
aliasMap map[string]string
subclassMap map[string][]string
aliasLoaded time.Time
aliasReloadMu sync.Mutex
mimeCacheMap map[string][]string
mimeCacheLoaded time.Time
mimeCacheReloadMu sync.Mutex
)
const aliasTTL = 60 * time.Second
const mimeCacheTTL = 10 * time.Second
func mimeDataDirs() []string {
var dirs []string
seen := make(map[string]bool)
add := func(p string) {
if p == "" || seen[p] {
return
}
seen[p] = true
dirs = append(dirs, p)
}
add(filepath.Join(utils.XDGDataHome(), "mime"))
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
for d := range strings.SplitSeq(env, ":") {
add(filepath.Join(strings.TrimSpace(d), "mime"))
}
} else {
add("/usr/local/share/mime")
add("/usr/share/mime")
}
return dirs
}
func loadAliasTables() {
aliases := make(map[string]string)
subclasses := make(map[string][]string)
for _, dir := range mimeDataDirs() {
readKV(filepath.Join(dir, "aliases"), func(k, v string) {
if _, ok := aliases[k]; !ok {
aliases[k] = v
}
})
readKV(filepath.Join(dir, "subclasses"), func(k, v string) {
subclasses[k] = append(subclasses[k], v)
})
}
aliasMap = aliases
subclassMap = subclasses
aliasLoaded = time.Now()
}
func readKV(path string, fn func(k, v string)) {
data, err := os.ReadFile(path)
if err != nil {
return
}
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' {
continue
}
sp := strings.IndexByte(line, ' ')
if sp <= 0 {
continue
}
fn(strings.TrimSpace(line[:sp]), strings.TrimSpace(line[sp+1:]))
}
}
func ensureAliasTables() {
aliasReloadMu.Lock()
defer aliasReloadMu.Unlock()
if aliasMap == nil || time.Since(aliasLoaded) > aliasTTL {
loadAliasTables()
}
}
func loadMimeCache() {
merged := make(map[string][]string)
seen := make(map[string]map[string]bool)
for _, dir := range applicationDirs() {
data, err := os.ReadFile(filepath.Join(dir, "mimeinfo.cache"))
if err != nil {
continue
}
groups := parseGroups(data)
g := groups["MIME Cache"]
if g == nil {
continue
}
for mime, val := range g.keys {
ids := splitList(val)
if len(ids) == 0 {
continue
}
if seen[mime] == nil {
seen[mime] = make(map[string]bool)
}
for _, id := range ids {
if seen[mime][id] {
continue
}
seen[mime][id] = true
merged[mime] = append(merged[mime], id)
}
}
}
mimeCacheMap = merged
mimeCacheLoaded = time.Now()
}
func ensureMimeCache() {
mimeCacheReloadMu.Lock()
defer mimeCacheReloadMu.Unlock()
if mimeCacheMap == nil || time.Since(mimeCacheLoaded) > mimeCacheTTL {
loadMimeCache()
}
}
func cacheAppsForMime(mimeType string) []string {
ensureMimeCache()
return mimeCacheMap[mimeType]
}
func StripMimeParams(mimeType string) string {
if semi := strings.IndexByte(mimeType, ';'); semi >= 0 {
mimeType = mimeType[:semi]
}
return strings.TrimSpace(mimeType)
}
func canonicalMime(mimeType string) string {
ensureAliasTables()
mimeType = StripMimeParams(mimeType)
if target, ok := aliasMap[mimeType]; ok {
return target
}
return mimeType
}
func mimeChain(mimeType string) []string {
ensureAliasTables()
root := canonicalMime(mimeType)
visited := map[string]bool{root: true}
chain := []string{root}
queue := []string{root}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for _, parent := range subclassMap[cur] {
if visited[parent] {
continue
}
visited[parent] = true
chain = append(chain, parent)
queue = append(queue, parent)
}
}
return chain
}
func entrySupportsMime(entry *Entry, chain []string) bool {
for _, m := range entry.MimeTypes {
canonical := canonicalMime(m)
if slices.Contains(chain, canonical) || slices.Contains(chain, m) {
return true
}
}
return false
}
func GetDefault(mimeType string) string {
merged := mergedAssociations()
chain := mimeChain(mimeType)
for _, m := range chain {
if id, ok := merged.Defaults[m]; ok {
if !slices.Contains(merged.Removed[m], id) {
return id
}
}
}
for _, m := range chain {
for _, id := range merged.Added[m] {
if !slices.Contains(merged.Removed[m], id) {
return id
}
}
}
for _, m := range chain {
for _, id := range cacheAppsForMime(m) {
if !slices.Contains(merged.Removed[m], id) {
return id
}
}
}
for _, m := range chain {
for _, entry := range AllEntries() {
if entry.Hidden || entry.NoDisplay {
continue
}
if slices.Contains(merged.Removed[m], entry.ID) {
continue
}
if entrySupportsMime(entry, []string{m}) {
return entry.ID
}
}
}
return ""
}
func SetDefault(mimeType, desktopID string) error {
return setDefaultAssociation(mimeType, desktopID)
}
func SetDefaults(mimeTypes []string, desktopID string) error {
return setDefaultAssociations(mimeTypes, desktopID)
}
func AppsForMime(mimeType string) []string {
merged := mergedAssociations()
chain := mimeChain(mimeType)
removed := make(map[string]bool)
for _, m := range chain {
for _, id := range merged.Removed[m] {
removed[id] = true
}
}
seen := make(map[string]bool)
var out []string
add := func(id string) {
if id == "" || removed[id] || seen[id] {
return
}
seen[id] = true
out = append(out, id)
}
for _, m := range chain {
if id := merged.Defaults[m]; id != "" {
add(id)
}
for _, id := range merged.Added[m] {
add(id)
}
}
for _, m := range chain {
for _, id := range cacheAppsForMime(m) {
add(id)
}
}
for _, entry := range AllEntries() {
if entry.Hidden {
continue
}
if entrySupportsMime(entry, chain) {
add(entry.ID)
}
}
return out
}
func QueryDefaults(mimeTypes []string) map[string]string {
out := make(map[string]string, len(mimeTypes))
for _, m := range mimeTypes {
out[m] = GetDefault(m)
}
return out
}
+233
View File
@@ -0,0 +1,233 @@
package desktop
import (
"os"
"path/filepath"
"sync"
"testing"
)
func setupFakeXDG(t *testing.T) (configHome, dataHome string) {
t.Helper()
tmp := t.TempDir()
configHome = filepath.Join(tmp, "config")
dataHome = filepath.Join(tmp, "data")
if err := os.MkdirAll(filepath.Join(dataHome, "applications"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(configHome, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("XDG_DATA_HOME", dataHome)
t.Setenv("XDG_DATA_DIRS", dataHome)
t.Setenv("XDG_CONFIG_DIRS", configHome)
InvalidateCache()
mimeCacheReloadMu.Lock()
mimeCacheMap = nil
mimeCacheReloadMu.Unlock()
aliasReloadMu.Lock()
aliasMap = nil
subclassMap = nil
aliasReloadMu.Unlock()
return configHome, dataHome
}
func writeFile(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 TestParseDesktopEntry(t *testing.T) {
_, dataHome := setupFakeXDG(t)
writeFile(t, filepath.Join(dataHome, "applications", "test.desktop"), `[Desktop Entry]
Type=Application
Name=Test
Exec=test %f
Icon=test
MimeType=application/pdf;image/png;
Categories=Office;Viewer;
NoDisplay=false
`)
entry := EntryByID("test.desktop")
if entry == nil {
t.Fatal("entry not found")
}
if entry.Name != "Test" {
t.Errorf("Name = %q", entry.Name)
}
if len(entry.MimeTypes) != 2 || entry.MimeTypes[0] != "application/pdf" {
t.Errorf("MimeTypes = %v", entry.MimeTypes)
}
if len(entry.Categories) != 2 || entry.Categories[1] != "Viewer" {
t.Errorf("Categories = %v", entry.Categories)
}
}
func TestSetGetDefault(t *testing.T) {
configHome, dataHome := setupFakeXDG(t)
writeFile(t, filepath.Join(dataHome, "applications", "foo.desktop"), `[Desktop Entry]
Type=Application
Name=Foo
MimeType=application/pdf;
`)
writeFile(t, filepath.Join(dataHome, "applications", "bar.desktop"), `[Desktop Entry]
Type=Application
Name=Bar
MimeType=application/pdf;
`)
if err := SetDefault("application/pdf", "bar.desktop"); err != nil {
t.Fatal(err)
}
if got := GetDefault("application/pdf"); got != "bar.desktop" {
t.Errorf("GetDefault = %q want bar.desktop", got)
}
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
if err != nil {
t.Fatal(err)
}
if !contains(string(data), "application/pdf=bar.desktop") {
t.Errorf("mimeapps.list missing default:\n%s", string(data))
}
}
func TestSetDefaultBypassesMimeSupportCheck(t *testing.T) {
configHome, dataHome := setupFakeXDG(t)
writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry]
Type=Application
Name=DMS
MimeType=x-scheme-handler/http;
`)
if err := SetDefault("application/pdf", "dms-open.desktop"); err != nil {
t.Fatal(err)
}
if got := GetDefault("application/pdf"); got != "dms-open.desktop" {
t.Errorf("GetDefault = %q, want dms-open.desktop (native impl must not enforce MimeType= check)", got)
}
data, _ := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
if !contains(string(data), "application/pdf=dms-open.desktop") {
t.Errorf("mimeapps.list missing override:\n%s", string(data))
}
}
func TestAliasResolution(t *testing.T) {
_, dataHome := setupFakeXDG(t)
writeFile(t, filepath.Join(dataHome, "mime", "aliases"), "text/javascript application/javascript\n")
writeFile(t, filepath.Join(dataHome, "applications", "editor.desktop"), `[Desktop Entry]
Type=Application
Name=Editor
MimeType=application/javascript;
`)
writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache]
application/javascript=editor.desktop;
`)
if got := GetDefault("text/javascript"); got != "editor.desktop" {
t.Errorf("GetDefault(text/javascript) = %q want editor.desktop (alias resolution)", got)
}
}
func TestSetDefaultsBatch(t *testing.T) {
configHome, dataHome := setupFakeXDG(t)
writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry]
Type=Application
Name=DMS
MimeType=x-scheme-handler/http;
`)
mimes := []string{
"text/plain", "text/x-csrc", "text/x-python",
"text/x-shellscript", "application/json",
}
if err := SetDefaults(mimes, "dms-open.desktop"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
if err != nil {
t.Fatal(err)
}
for _, m := range mimes {
if !contains(string(data), m+"=dms-open.desktop") {
t.Errorf("missing %s default in mimeapps.list:\n%s", m, string(data))
}
}
}
func TestConcurrentSetDefaultNoCorruption(t *testing.T) {
configHome, dataHome := setupFakeXDG(t)
writeFile(t, filepath.Join(dataHome, "applications", "app.desktop"), `[Desktop Entry]
Type=Application
Name=App
`)
mimes := []string{
"a/1", "a/2", "a/3", "a/4", "a/5", "a/6", "a/7",
}
var wg sync.WaitGroup
for _, m := range mimes {
wg.Add(1)
go func(m string) {
defer wg.Done()
if err := SetDefault(m, "app.desktop"); err != nil {
t.Errorf("SetDefault(%s) failed: %v", m, err)
}
}(m)
}
wg.Wait()
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
if err != nil {
t.Fatal(err)
}
for _, m := range mimes {
if !contains(string(data), m+"=app.desktop") {
t.Errorf("lost write for %s — concurrent writes corrupted file:\n%s", m, string(data))
}
}
}
func TestMimeCacheOrdering(t *testing.T) {
_, dataHome := setupFakeXDG(t)
writeFile(t, filepath.Join(dataHome, "applications", "a.desktop"), `[Desktop Entry]
Type=Application
Name=A
MimeType=image/png;
`)
writeFile(t, filepath.Join(dataHome, "applications", "b.desktop"), `[Desktop Entry]
Type=Application
Name=B
MimeType=image/png;
`)
writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache]
image/png=b.desktop;a.desktop;
`)
if got := GetDefault("image/png"); got != "b.desktop" {
t.Errorf("GetDefault should follow mimeinfo.cache order: got %q want b.desktop", got)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
+226
View File
@@ -0,0 +1,226 @@
package desktop
import (
"bufio"
"bytes"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
var mimeappsWriteMu sync.Mutex
const (
groupDefaults = "Default Applications"
groupAdded = "Added Associations"
groupRemoved = "Removed Associations"
)
type MimeAssociations struct {
Defaults map[string]string
Added map[string][]string
Removed map[string][]string
}
func newAssociations() *MimeAssociations {
return &MimeAssociations{
Defaults: make(map[string]string),
Added: make(map[string][]string),
Removed: make(map[string][]string),
}
}
func mimeappsSearchPaths() []string {
var paths []string
seen := make(map[string]bool)
add := func(p string) {
if p == "" || seen[p] {
return
}
seen[p] = true
paths = append(paths, p)
}
add(filepath.Join(utils.XDGConfigHome(), "mimeapps.list"))
if env := os.Getenv("XDG_CONFIG_DIRS"); env != "" {
for d := range strings.SplitSeq(env, ":") {
add(filepath.Join(strings.TrimSpace(d), "mimeapps.list"))
}
} else {
add("/etc/xdg/mimeapps.list")
}
add(filepath.Join(utils.XDGDataHome(), "applications", "mimeapps.list"))
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
for d := range strings.SplitSeq(env, ":") {
add(filepath.Join(strings.TrimSpace(d), "applications", "mimeapps.list"))
}
} else {
add("/usr/local/share/applications/mimeapps.list")
add("/usr/share/applications/mimeapps.list")
}
return paths
}
func mimeappsWritePath() string {
return filepath.Join(utils.XDGConfigHome(), "mimeapps.list")
}
func readAssociations(path string) (*MimeAssociations, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
groups := parseGroups(data)
assoc := newAssociations()
if g := groups[groupDefaults]; g != nil {
for mime, val := range g.keys {
parts := splitList(val)
if len(parts) > 0 {
assoc.Defaults[mime] = parts[0]
}
}
}
if g := groups[groupAdded]; g != nil {
for mime, val := range g.keys {
assoc.Added[mime] = splitList(val)
}
}
if g := groups[groupRemoved]; g != nil {
for mime, val := range g.keys {
assoc.Removed[mime] = splitList(val)
}
}
return assoc, nil
}
func mergedAssociations() *MimeAssociations {
merged := newAssociations()
for _, path := range mimeappsSearchPaths() {
assoc, err := readAssociations(path)
if err != nil {
continue
}
for mime, app := range assoc.Defaults {
if _, ok := merged.Defaults[mime]; !ok {
merged.Defaults[mime] = app
}
}
for mime, apps := range assoc.Added {
merged.Added[mime] = append(merged.Added[mime], apps...)
}
for mime, apps := range assoc.Removed {
merged.Removed[mime] = append(merged.Removed[mime], apps...)
}
}
return merged
}
func writeUserMimeapps(update func(*MimeAssociations)) error {
mimeappsWriteMu.Lock()
defer mimeappsWriteMu.Unlock()
path := mimeappsWritePath()
assoc, err := readAssociations(path)
if err != nil {
if !os.IsNotExist(err) {
return err
}
assoc = newAssociations()
}
update(assoc)
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
writeSection := func(name string, entries map[string]string) {
fmt.Fprintf(w, "[%s]\n", name)
keys := make([]string, 0, len(entries))
for k := range entries {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
}
fmt.Fprintln(w)
}
flatten := func(m map[string][]string) map[string]string {
out := make(map[string]string, len(m))
for k, list := range m {
out[k] = strings.Join(list, ";") + ";"
}
return out
}
writeSection(groupDefaults, assoc.Defaults)
writeSection(groupAdded, flatten(assoc.Added))
writeSection(groupRemoved, flatten(assoc.Removed))
if err := w.Flush(); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}
func setDefaultAssociation(mimeType, desktopID string) error {
return setDefaultAssociations([]string{mimeType}, desktopID)
}
func setDefaultAssociations(mimeTypes []string, desktopID string) error {
if !strings.HasSuffix(desktopID, ".desktop") {
desktopID += ".desktop"
}
return writeUserMimeapps(func(assoc *MimeAssociations) {
for _, mimeType := range mimeTypes {
if mimeType == "" {
continue
}
assoc.Defaults[mimeType] = desktopID
existing := assoc.Added[mimeType]
if !slices.Contains(existing, desktopID) {
assoc.Added[mimeType] = append(existing, desktopID)
}
removed, ok := assoc.Removed[mimeType]
if !ok {
continue
}
filtered := removed[:0]
for _, id := range removed {
if id != desktopID {
filtered = append(filtered, id)
}
}
switch {
case len(filtered) == 0:
delete(assoc.Removed, mimeType)
default:
assoc.Removed[mimeType] = filtered
}
}
})
}
+80
View File
@@ -0,0 +1,80 @@
package desktop
import (
"bufio"
"bytes"
"strings"
)
type group struct {
keys map[string]string
}
func parseGroups(data []byte) map[string]*group {
groups := make(map[string]*group)
var current *group
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' {
continue
}
if line[0] == '[' && strings.HasSuffix(line, "]") {
name := line[1 : len(line)-1]
g, ok := groups[name]
if !ok {
g = &group{keys: make(map[string]string)}
groups[name] = g
}
current = g
continue
}
if current == nil {
continue
}
eq := strings.IndexByte(line, '=')
if eq <= 0 {
continue
}
key := strings.TrimSpace(line[:eq])
if bracket := strings.IndexByte(key, '['); bracket > 0 {
key = key[:bracket]
}
if _, ok := current.keys[key]; ok {
continue
}
current.keys[key] = strings.TrimSpace(line[eq+1:])
}
return groups
}
func splitList(value string) []string {
if value == "" {
return nil
}
parts := strings.Split(value, ";")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
return out
}
func parseBool(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "true", "1", "yes":
return true
}
return false
}
+66 -5
View File
@@ -11,6 +11,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -207,8 +208,7 @@ func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) Pac
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
}
// ! TODO - for now we're only forcing quickshell-git on ARCH, as other distros use DL repos which pin a newer quickshell
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
@@ -292,7 +292,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
LogOutput: "Installing base-devel development tools",
}
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
return fmt.Errorf("failed to install base-devel: %w", err)
}
@@ -324,6 +324,19 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
}
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
}
if slices.Contains(systemPkgs, "quickshell") && a.packageInstalled("quickshell-git") {
if err := a.removeQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to remove quickshell-git: %w", err)
}
}
// Phase 3: System Packages
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
@@ -441,6 +454,51 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
return systemPkgs, aurPkgs, manualPkgs, variantMap
}
func (a *ArchDistribution) removeQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.33,
Step: "Removing quickshell-git...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell-git",
LogOutput: "Removing quickshell-git so stable quickshell can be installed",
}
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell-git")
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.33, 0.35)
}
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if a.packageInstalled("quickshell-git") {
return nil
}
if a.packageInstalled("quickshell") {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.15,
Step: "Removing stable quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
}
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
return fmt.Errorf("failed to remove stable quickshell: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.18,
Step: "Building quickshell-git before system packages...",
IsComplete: false,
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
}
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
}
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
@@ -449,6 +507,9 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
if slices.Contains(packages, "dms-shell") {
args = append(args, "--assume-installed", "dms-shell-compositor=1")
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
@@ -460,7 +521,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
@@ -738,7 +799,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
installArgs := []string{"pacman", "-U", "--noconfirm"}
installArgs = append(installArgs, files...)
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
fileNames := make([]string, len(files))
for i, f := range files {
+3 -23
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
)
@@ -55,27 +56,6 @@ func (b *BaseDistribution) logError(message string, err error) {
b.log(errorMsg)
}
// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings.
// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote.
// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes.
func escapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
// MakeSudoCommand creates a command string that safely passes password to sudo.
// This helper escapes special characters in the password to prevent shell injection
// and syntax errors when passwords contain single quotes, apostrophes, or other special chars.
func MakeSudoCommand(sudoPassword string, command string) string {
return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command)
}
// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password.
// The password is properly escaped to prevent shell injection and syntax errors.
func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd {
cmdStr := MakeSudoCommand(sudoPassword, command)
return exec.CommandContext(ctx, "bash", "-c", cmdStr)
}
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
status := deps.StatusMissing
if b.commandExists(name) {
@@ -252,7 +232,7 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency {
}
versionStr := string(output)
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
versionRegex := regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
matches := versionRegex.FindStringSubmatch(versionStr)
if len(matches) < 2 {
@@ -710,7 +690,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
}
// Install to /usr/local/bin
installCmd := ExecSudoCommand(ctx, sudoPassword,
installCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install DMS binary: %w", err)
+12 -11
View File
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -182,7 +183,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
@@ -199,7 +200,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
cmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
cmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
@@ -215,7 +216,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Installing additional development tools",
}
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
@@ -441,7 +442,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
// Create keyrings directory if it doesn't exist
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
if err := mkdirCmd.Run(); err != nil {
d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err))
}
@@ -455,7 +456,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
}
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath)
cmd := ExecSudoCommand(ctx, sudoPassword, keyCmd)
cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd)
if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
@@ -471,7 +472,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
}
addRepoCmd := ExecSudoCommand(ctx, sudoPassword,
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err)
@@ -491,7 +492,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: "sudo apt-get update",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err)
}
@@ -537,7 +538,7 @@ func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress)
}
@@ -625,7 +626,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
@@ -643,7 +644,7 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
@@ -682,7 +683,7 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
}
+5 -4
View File
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -254,7 +255,7 @@ func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
args := []string{"dnf", "install", "-y"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to install prerequisites", err)
@@ -437,7 +438,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
output, err := cmd.CombinedOutput()
if err != nil {
@@ -461,7 +462,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
}
priorityCmd := ExecSudoCommand(ctx, sudoPassword,
priorityCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
priorityOutput, err := priorityCmd.CombinedOutput()
if err != nil {
@@ -537,7 +538,7 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
}
+33 -18
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
var GentooGlobalUseFlags = []string{
@@ -115,6 +116,20 @@ func (g *GentooDistribution) detectXDGPortal() deps.Dependency {
return g.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", g.packageInstalled("sys-apps/xdg-desktop-portal-gtk"))
}
func (g *GentooDistribution) detectDMS() deps.Dependency {
dep := deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: deps.StatusMissing,
Description: "Desktop Management System configuration",
Required: true,
CanToggle: false,
}
if g.packageInstalled("gui-apps/dankmaterialshell") {
dep.Status = deps.StatusInstalled
}
return dep
}
func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency {
return g.detectPackage("xwayland-satellite", "Xwayland support", g.packageInstalled("gui-apps/xwayland-satellite"))
}
@@ -149,8 +164,8 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"dms (DankMaterialShell)": g.getDmsMapping(),
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
}
switch wm {
@@ -170,8 +185,8 @@ func (g *GentooDistribution) getQuickshellMapping(_ deps.PackageVariant) Package
return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"}
}
func (g *GentooDistribution) getDmsMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"}
func (g *GentooDistribution) getDmsMapping() PackageMapping {
return PackageMapping{Name: "gui-apps/dankmaterialshell", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
@@ -201,9 +216,9 @@ func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword
var cmd *exec.Cmd
if hasUse {
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags))
cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags))
} else {
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags))
cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags))
}
output, err := cmd.CombinedOutput()
@@ -281,7 +296,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Syncing Portage tree with emerge --sync",
}
syncCmd := ExecSudoCommand(ctx, sudoPassword, "emerge --sync --quiet")
syncCmd := privesc.ExecCommand(ctx, sudoPassword, "emerge --sync --quiet")
syncOutput, syncErr := syncCmd.CombinedOutput()
if syncErr != nil {
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput)))
@@ -302,7 +317,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
g.logError("failed to install prerequisites", err)
@@ -503,14 +518,14 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
}
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
packageUseDir := "/etc/portage/package.use"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", packageUseDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
@@ -524,7 +539,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
replaceCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
@@ -532,7 +547,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName
}
}
appendCmd := ExecSudoCommand(ctx, sudoPassword,
appendCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
output, err := appendCmd.CombinedOutput()
@@ -557,7 +572,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
}
// Enable GURU repository
enableCmd := ExecSudoCommand(ctx, sudoPassword,
enableCmd := privesc.ExecCommand(ctx, sudoPassword,
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
output, err := enableCmd.CombinedOutput()
@@ -589,7 +604,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
LogOutput: "Syncing GURU repository",
}
syncCmd := ExecSudoCommand(ctx, sudoPassword,
syncCmd := privesc.ExecCommand(ctx, sudoPassword,
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput()
@@ -622,7 +637,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
acceptKeywordsDir := "/etc/portage/package.accept_keywords"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
@@ -636,7 +651,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
replaceCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
@@ -644,7 +659,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
}
}
appendCmd := ExecSudoCommand(ctx, sudoPassword,
appendCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
output, err := appendCmd.CombinedOutput()
@@ -695,6 +710,6 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
}
+10 -18
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
// ManualPackageInstaller provides methods for installing packages from source
@@ -143,7 +144,7 @@ func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword s
CommandInfo: "sudo make install",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
m.logError("failed to install dgop", err)
@@ -213,7 +214,7 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
CommandInfo: "dpkg -i niri.deb",
}
installDebCmd := ExecSudoCommand(ctx, sudoPassword,
installDebCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
output, err := installDebCmd.CombinedOutput()
@@ -324,7 +325,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
CommandInfo: "sudo cmake --install build",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
@@ -387,7 +388,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
CommandInfo: "sudo make install",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Hyprland: %w", err)
@@ -453,7 +454,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
installCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
@@ -492,16 +493,11 @@ func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPasswor
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
}
// Make it executable
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
return fmt.Errorf("failed to make matugen executable: %w", err)
}
@@ -646,15 +642,11 @@ func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, s
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
}
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
}
+8 -7
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -250,7 +251,7 @@ func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPas
args := []string{"zypper", "install", "-y"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
o.logError("failed to install prerequisites", err)
@@ -486,7 +487,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("zypper addrepo -f %s", repoURL))
if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err))
@@ -507,7 +508,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: "sudo zypper --gpg-auto-import-keys refresh",
}
refreshCmd := ExecSudoCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
refreshCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to refresh repositories: %w", err)
}
@@ -588,7 +589,7 @@ func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sud
}
for _, alias := range aliases {
cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", escapeSingleQuotes(alias)))
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", privesc.EscapeSingleQuotes(alias)))
repoOutput, err := cmd.CombinedOutput()
if err != nil {
o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
@@ -646,7 +647,7 @@ func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packag
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
}
@@ -774,7 +775,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
CommandInfo: "sudo cmake --install build",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
@@ -798,7 +799,7 @@ func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword str
CommandInfo: "sudo zypper install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup")
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper install -y rustup")
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
+1 -1
View File
@@ -56,7 +56,7 @@ func GetOSInfo() (*OSInfo, error) {
}
key := parts[0]
value := strings.Trim(parts[1], "\"")
value := strings.Trim(parts[1], "\"'")
switch key {
case "ID":
+13 -12
View File
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -177,7 +178,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
@@ -195,7 +196,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
// Not installed, install it
cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
cmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
@@ -211,7 +212,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Installing additional development tools",
}
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev")
if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
@@ -398,7 +399,7 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
installPPACmd := ExecSudoCommand(ctx, sudoPassword,
installPPACmd := privesc.ExecCommand(ctx, sudoPassword,
"apt-get install -y software-properties-common")
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
return fmt.Errorf("failed to install software-properties-common: %w", err)
@@ -416,7 +417,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
@@ -437,7 +438,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: "sudo apt-get update",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
}
@@ -504,7 +505,7 @@ func (u *UbuntuDistribution) installAPTGroups(ctx context.Context, packages []st
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
}
@@ -591,7 +592,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
@@ -609,7 +610,7 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
@@ -649,7 +650,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
}
addPPACmd := ExecSudoCommand(ctx, sudoPassword,
addPPACmd := privesc.ExecCommand(ctx, sudoPassword,
"add-apt-repository -y ppa:longsleep/golang-backports")
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
return fmt.Errorf("failed to add Go PPA: %w", err)
@@ -664,7 +665,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get update",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
}
@@ -678,7 +679,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
}
+80 -172
View File
@@ -17,6 +17,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document"
@@ -327,56 +328,17 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
switch config.Family {
case distros.FamilyArch:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"pacman -S --needed --noconfirm greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd")
}
installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm greetd")
case distros.FamilyFedora:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"dnf install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd")
}
installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y greetd")
case distros.FamilySUSE:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"zypper install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd")
}
case distros.FamilyUbuntu:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
}
case distros.FamilyDebian:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
}
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y greetd")
case distros.FamilyUbuntu, distros.FamilyDebian:
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
case distros.FamilyGentoo:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"emerge --ask n sys-apps/greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-apps/greetd")
}
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
default:
return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family)
}
@@ -437,56 +399,56 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug))
if _, err := exec.LookPath("gpg"); err != nil {
logFunc("Installing gnupg for OBS repository key import...")
installGPGCmd := exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "gnupg")
installGPGCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y gnupg")
installGPGCmd.Stdout = os.Stdout
installGPGCmd.Stderr = os.Stderr
if err := installGPGCmd.Run(); err != nil {
logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err))
}
}
mkdirCmd := exec.CommandContext(ctx, "sudo", "mkdir", "-p", "/etc/apt/keyrings")
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
mkdirCmd.Stdout = os.Stdout
mkdirCmd.Stderr = os.Stderr
mkdirCmd.Run()
addKeyCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf(`curl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg`, keyURL))
addKeyCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf(`bash -c "curl -fsSL %s | gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg"`, keyURL))
addKeyCmd.Stdout = os.Stdout
addKeyCmd.Stderr = os.Stderr
addKeyCmd.Run()
addRepoCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf(`echo '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list`, repoLine))
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf(`bash -c "echo '%s' > /etc/apt/sources.list.d/danklinux.list"`, repoLine))
addRepoCmd.Stdout = os.Stdout
addRepoCmd.Stderr = os.Stderr
addRepoCmd.Run()
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run()
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter")
privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
case distros.FamilySUSE:
repoURL := getOpenSUSEOBSRepoURL(osInfo)
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL)
logFunc("Adding DankLinux OBS repository...")
addRepoCmd := exec.CommandContext(ctx, "sudo", "zypper", "addrepo", repoURL)
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper addrepo %s", repoURL))
addRepoCmd.Stdout = os.Stdout
addRepoCmd.Stderr = os.Stderr
addRepoCmd.Run()
exec.CommandContext(ctx, "sudo", "zypper", "refresh").Run()
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "dms-greeter")
privesc.ExecCommand(ctx, sudoPassword, "zypper refresh").Run()
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y dms-greeter")
case distros.FamilyUbuntu:
failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter"
logFunc("Enabling PPA ppa:avengemedia/danklinux...")
ppacmd := exec.CommandContext(ctx, "sudo", "add-apt-repository", "-y", "ppa:avengemedia/danklinux")
ppacmd := privesc.ExecCommand(ctx, sudoPassword, "add-apt-repository -y ppa:avengemedia/danklinux")
ppacmd.Stdout = os.Stdout
ppacmd.Stderr = os.Stderr
ppacmd.Run()
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run()
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter")
privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
case distros.FamilyFedora:
failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter"
logFunc("Enabling COPR avengemedia/danklinux...")
coprcmd := exec.CommandContext(ctx, "sudo", "dnf", "copr", "enable", "-y", "avengemedia/danklinux")
coprcmd := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y avengemedia/danklinux")
coprcmd.Stdout = os.Stdout
coprcmd.Stderr = os.Stderr
coprcmd.Run()
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "dms-greeter")
installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y dms-greeter")
case distros.FamilyArch:
aurHelper := ""
for _, helper := range []string{"paru", "yay"} {
@@ -539,25 +501,25 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() {
action = "Updated"
}
if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil {
return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err)
}
logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst))
if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "+x", wrapperDst); err != nil {
return fmt.Errorf("failed to make wrapper executable: %w", err)
}
osInfo, err := distros.GetOSInfo()
if err == nil {
if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) {
if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err))
} else {
logFunc("✓ Set SELinux fcontext for dms-greeter")
}
if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-v", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err))
} else {
logFunc("✓ Restored SELinux context for dms-greeter")
@@ -583,7 +545,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
if !os.IsNotExist(err) {
return fmt.Errorf("failed to stat cache directory: %w", err)
}
if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", cacheDir); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
created = true
@@ -595,17 +557,17 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
daemonUser := DetectGreeterUser()
preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group)
owner := preferredOwner
if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, cacheDir); err != nil {
// Some setups may not have a matching daemon user at this moment; fall back
// to root:<group> while still allowing group-writable greeter runtime access.
fallbackOwner := fmt.Sprintf("root:%s", group)
if fallbackErr := runSudoCmd(sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil {
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil {
return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr)
}
owner = fallbackOwner
}
if err := runSudoCmd(sudoPassword, "chmod", "2770", cacheDir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", cacheDir); err != nil {
return fmt.Errorf("failed to set cache directory permissions: %w", err)
}
@@ -616,13 +578,13 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
filepath.Join(cacheDir, ".cache"),
}
for _, dir := range runtimeDirs {
if err := runSudoCmd(sudoPassword, "mkdir", "-p", dir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", dir); err != nil {
return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err)
}
if err := runSudoCmd(sudoPassword, "chown", owner, dir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, dir); err != nil {
return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err)
}
if err := runSudoCmd(sudoPassword, "chmod", "2770", dir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", dir); err != nil {
return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err)
}
}
@@ -634,7 +596,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
}
if isSELinuxEnforcing() && utils.CommandExists("restorecon") {
if err := runSudoCmd(sudoPassword, "restorecon", "-Rv", cacheDir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-Rv", cacheDir); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err))
}
}
@@ -659,13 +621,13 @@ func ensureGreeterMemoryCompatLink(logFunc func(string), sudoPassword, legacyPat
info, err := os.Lstat(legacyPath)
if err == nil && info.Mode().IsRegular() {
if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) {
if copyErr := runSudoCmd(sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil {
if copyErr := privesc.Run(context.Background(), sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr))
}
}
}
if err := runSudoCmd(sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil {
return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err)
}
@@ -692,7 +654,7 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
return nil
}
if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil {
return fmt.Errorf("failed to create /etc/apparmor.d: %w", err)
}
@@ -709,15 +671,15 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
}
tmp.Close()
if err := runSudoCmd(sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil {
return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err)
}
if err := runSudoCmd(sudoPassword, "chmod", "644", appArmorProfileDest); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", appArmorProfileDest); err != nil {
return fmt.Errorf("failed to set AppArmor profile permissions: %w", err)
}
if utils.CommandExists("apparmor_parser") {
if err := runSudoCmd(sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil {
logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err))
logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest)
} else {
@@ -745,9 +707,9 @@ func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
}
if utils.CommandExists("apparmor_parser") {
_ = runSudoCmd(sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest)
_ = privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest)
}
if err := runSudoCmd(sudoPassword, "rm", "-f", appArmorProfileDest); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", appArmorProfileDest); err != nil {
return fmt.Errorf("failed to remove AppArmor profile: %w", err)
}
logFunc(" ✓ Removed DMS AppArmor profile")
@@ -777,50 +739,17 @@ func EnsureACLInstalled(logFunc func(string), sudoPassword string) error {
switch config.Family {
case distros.FamilyArch:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "acl")
}
installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
case distros.FamilyFedora:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "dnf install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "acl")
}
installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y acl")
case distros.FamilySUSE:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "zypper install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "acl")
}
case distros.FamilyUbuntu:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl")
}
case distros.FamilyDebian:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl")
}
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y acl")
case distros.FamilyUbuntu, distros.FamilyDebian:
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y acl")
case distros.FamilyGentoo:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-fs/acl")
}
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix")
default:
return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family)
}
@@ -877,7 +806,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
}
// Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora).
if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err))
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path))
continue
@@ -934,7 +863,7 @@ func RemediateStaleACLs(logFunc func(string), sudoPassword string) {
continue
}
for _, user := range existingUsers {
_ = runSudoCmd(sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir)
_ = privesc.Run(context.Background(), sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir)
cleaned = true
}
}
@@ -974,7 +903,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
// Create the group if it doesn't exist yet (e.g. before greetd package is installed).
if !utils.HasGroup(group) {
if err := runSudoCmd(sudoPassword, "groupadd", "-r", group); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "groupadd", "-r", group); err != nil {
return fmt.Errorf("failed to create %s group: %w", group, err)
}
logFunc(fmt.Sprintf("✓ Created system group %s", group))
@@ -985,7 +914,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
if err == nil && strings.Contains(string(groupsOutput), group) {
logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group))
} else {
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, currentUser); err != nil {
return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err)
}
logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group))
@@ -1000,7 +929,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
if strings.Contains(string(daemonGroupsOutput), group) {
logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group))
} else {
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, daemonUser); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, daemonUser); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err))
} else {
logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group))
@@ -1030,12 +959,12 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
}
}
if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chgrp", "-R", group, dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
continue
}
if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err))
continue
}
@@ -1247,8 +1176,8 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
}
target := filepath.Join(cacheDir, "colors.json")
_ = runSudoCmd(sudoPassword, "rm", "-f", target)
if err := runSudoCmd(sudoPassword, "ln", "-sf", source, target); err != nil {
_ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", target)
if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", source, target); err != nil {
return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err)
}
@@ -1300,9 +1229,9 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
}
}
_ = runSudoCmd(sudoPassword, "rm", "-f", link.target)
_ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", link.target)
if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", link.source, link.target); err != nil {
return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err)
}
@@ -1340,13 +1269,13 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error {
destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
if state.ResolvedGreeterWallpaperPath == "" {
if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil {
return fmt.Errorf("failed to clear override file %s: %w", destPath, err)
}
logFunc("✓ Cleared greeter wallpaper override")
return nil
}
if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil {
return fmt.Errorf("failed to remove old override file %s: %w", destPath, err)
}
src := state.ResolvedGreeterWallpaperPath
@@ -1357,17 +1286,17 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas
if st.IsDir() {
return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src)
}
if err := runSudoCmd(sudoPassword, "cp", src, destPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "cp", src, destPath); err != nil {
return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err)
}
greeterGroup := DetectGreeterGroup()
daemonUser := DetectGreeterUser()
if err := runSudoCmd(sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil {
if fallbackErr := runSudoCmd(sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil {
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil {
return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err)
}
}
if err := runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", destPath); err != nil {
return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err)
}
logFunc("✓ Synced greeter wallpaper override")
@@ -1422,13 +1351,13 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
greeterDir := "/etc/greetd/niri"
greeterGroup := DetectGreeterGroup()
if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", greeterDir); err != nil {
return fmt.Errorf("failed to create greetd niri directory: %w", err)
}
if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil {
return fmt.Errorf("failed to set greetd niri directory ownership: %w", err)
}
if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "755", greeterDir); err != nil {
return fmt.Errorf("failed to set greetd niri directory permissions: %w", err)
}
@@ -1450,7 +1379,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup %s: %w", dmsPath, err)
}
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
return fmt.Errorf("failed to install greetd niri dms config: %w", err)
}
@@ -1473,7 +1402,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup %s: %w", mainPath, err)
}
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil {
return fmt.Errorf("failed to install greetd niri main config: %w", err)
}
@@ -1549,7 +1478,7 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi
return fmt.Errorf("failed to close temp greetd config: %w", err)
}
if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to update greetd config: %w", err)
}
@@ -1565,10 +1494,10 @@ func backupFileIfExists(sudoPassword string, path string, suffix string) error {
}
backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405"))
if err := runSudoCmd(sudoPassword, "cp", path, backupPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "cp", path, backupPath); err != nil {
return err
}
return runSudoCmd(sudoPassword, "chmod", "644", backupPath)
return privesc.Run(context.Background(), sudoPassword, "chmod", "644", backupPath)
}
func (s *niriGreeterSync) processFile(filePath string) error {
@@ -1804,11 +1733,11 @@ vt = 1
return fmt.Errorf("failed to close temp greetd config: %w", err)
}
if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err)
}
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err)
}
@@ -1912,27 +1841,6 @@ func getOpenSUSEOBSRepoURL(osInfo *distros.OSInfo) string {
return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug)
}
func runSudoCmd(sudoPassword string, command string, args ...string) error {
var cmd *exec.Cmd
if sudoPassword != "" {
fullArgs := append([]string{command}, args...)
quotedArgs := make([]string, len(fullArgs))
for i, arg := range fullArgs {
quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
cmdStr := strings.Join(quotedArgs, " ")
cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr)
} else {
cmd = exec.Command("sudo", append([]string{command}, args...)...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func checkSystemdEnabled(service string) (string, error) {
cmd := exec.Command("systemctl", "is-enabled", service)
output, _ := cmd.Output()
@@ -1949,7 +1857,7 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
switch state {
case "enabled", "enabled-runtime", "static", "indirect", "alias":
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
if err := runSudoCmd(sudoPassword, "systemctl", "disable", dm); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "disable", dm); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
} else {
logFunc(fmt.Sprintf("✓ Disabled %s", dm))
@@ -1970,13 +1878,13 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
}
if state == "masked" || state == "masked-runtime" {
logFunc(" Unmasking greetd...")
if err := runSudoCmd(sudoPassword, "systemctl", "unmask", "greetd"); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "unmask", "greetd"); err != nil {
return fmt.Errorf("failed to unmask greetd: %w", err)
}
logFunc(" ✓ Unmasked greetd")
}
logFunc(" Enabling greetd service (--force)...")
if err := runSudoCmd(sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err)
}
logFunc("✓ greetd enabled")
@@ -1996,7 +1904,7 @@ func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
return nil
}
logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current))
if err := runSudoCmd(sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil {
if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil {
return fmt.Errorf("failed to set graphical target: %w", err)
}
logFunc("✓ Default target set to graphical.target")
+439
View File
@@ -0,0 +1,439 @@
package headless
import (
"context"
"fmt"
"os"
"strings"
"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/privesc"
)
// ErrConfirmationRequired is returned when --yes is not set and the user
// must explicitly confirm the operation.
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
// validConfigNames maps lowercase CLI input to the deployer key used in
// replaceConfigs. Keep in sync with the config types checked by
// shouldReplaceConfig in deployer.go.
var validConfigNames = map[string]string{
"niri": "Niri",
"hyprland": "Hyprland",
"ghostty": "Ghostty",
"kitty": "Kitty",
"alacritty": "Alacritty",
}
// orderedConfigNames defines the canonical order for config names in output.
// Must be kept in sync with validConfigNames.
var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"}
// Config holds all CLI parameters for unattended installation.
type Config struct {
Compositor string // "niri" or "hyprland"
Terminal string // "ghostty", "kitty", or "alacritty"
IncludeDeps []string
ExcludeDeps []string
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
ReplaceConfigsAll bool // deploy/replace all configurations
Yes bool
}
// Runner orchestrates unattended (headless) installation.
type Runner struct {
cfg Config
logChan chan string
}
// NewRunner creates a new headless runner.
func NewRunner(cfg Config) *Runner {
return &Runner{
cfg: cfg,
logChan: make(chan string, 1000),
}
}
// GetLogChan returns the log channel for file logging.
func (r *Runner) GetLogChan() <-chan string {
return r.logChan
}
// Run executes the full unattended installation flow.
func (r *Runner) Run() error {
r.log("Starting headless installation")
// 1. Parse compositor and terminal selections
wm, err := r.parseWindowManager()
if err != nil {
return err
}
terminal, err := r.parseTerminal()
if err != nil {
return err
}
// 2. Build replace-configs map
replaceConfigs, err := r.buildReplaceConfigs()
if err != nil {
return err
}
// 3. Detect OS
r.log("Detecting operating system...")
osInfo, err := distros.GetOSInfo()
if err != nil {
return fmt.Errorf("OS detection failed: %w", err)
}
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
}
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
// 4. Create distribution instance
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
if err != nil {
return fmt.Errorf("failed to initialize distribution: %w", err)
}
// 5. Detect dependencies
r.log("Detecting dependencies...")
fmt.Fprintln(os.Stdout, "Detecting dependencies...")
dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
if err != nil {
return fmt.Errorf("dependency detection failed: %w", err)
}
// 5. Apply include/exclude filters and build the disabled-items map.
// Headless mode does not currently collect any explicit reinstall selections,
// so keep reinstallItems nil instead of constructing an always-empty map.
disabledItems, err := r.buildDisabledItems(dependencies)
if err != nil {
return err
}
var reinstallItems map[string]bool
// Print dependency summary
fmt.Fprintln(os.Stdout, "\nDependencies:")
for _, dep := range dependencies {
marker := " "
status := ""
if disabledItems[dep.Name] {
marker = " SKIP "
status = "(disabled)"
} else {
switch dep.Status {
case deps.StatusInstalled:
marker = " OK "
status = "(installed)"
case deps.StatusMissing:
marker = " NEW "
status = "(will install)"
case deps.StatusNeedsUpdate:
marker = " UPD "
status = "(will update)"
case deps.StatusNeedsReinstall:
marker = " RE "
status = "(will reinstall)"
}
}
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
}
fmt.Fprintln(os.Stdout)
// 6b. Require explicit confirmation unless --yes is set
if !r.cfg.Yes {
if replaceConfigs == nil {
// --replace-configs-all
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
} else if r.anyConfigEnabled(replaceConfigs) {
var names []string
for _, cliName := range orderedConfigNames {
deployerKey := validConfigNames[cliName]
if replaceConfigs[deployerKey] {
names = append(names, deployerKey)
}
}
fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", "))
} else {
fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.")
}
fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.")
r.log("Aborted: --yes not set")
return ErrConfirmationRequired
}
// 7. Authenticate sudo
sudoPassword, err := r.resolveSudoPassword()
if err != nil {
return err
}
// 8. Install packages
fmt.Fprintln(os.Stdout, "Installing packages...")
r.log("Starting package installation")
progressChan := make(chan distros.InstallProgressMsg, 100)
installErr := make(chan error, 1)
go func() {
defer close(progressChan)
installErr <- distro.InstallPackages(
context.Background(),
dependencies,
wm,
sudoPassword,
reinstallItems,
disabledItems,
false, // skipGlobalUseFlags
progressChan,
)
}()
// Consume progress messages and print them
for msg := range progressChan {
if msg.Error != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
} else if msg.Step != "" {
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
}
if msg.LogOutput != "" {
r.log(msg.LogOutput)
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
}
}
if err := <-installErr; err != nil {
return fmt.Errorf("package installation failed: %w", err)
}
// 9. Greeter setup (if dms-greeter was included)
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
compositorName := "niri"
if wm == deps.WindowManagerHyprland {
compositorName = "Hyprland"
}
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
logFunc := func(line string) {
r.log(line)
fmt.Fprintf(os.Stdout, " greeter: %s\n", line)
}
if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil {
// Non-fatal, matching TUI behavior
fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err)
}
}
// 10. Deploy configurations
fmt.Fprintln(os.Stdout, "Deploying configurations...")
r.log("Starting configuration deployment")
deployer := config.NewConfigDeployer(r.logChan)
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
wm,
terminal,
dependencies,
replaceConfigs,
reinstallItems,
)
if err != nil {
return fmt.Errorf("configuration deployment failed: %w", err)
}
for _, result := range results {
if result.Deployed {
msg := fmt.Sprintf(" Deployed: %s", result.ConfigType)
if result.BackupPath != "" {
msg += fmt.Sprintf(" (backup: %s)", result.BackupPath)
}
fmt.Fprintln(os.Stdout, msg)
}
if result.Error != nil {
fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error)
}
}
fmt.Fprintln(os.Stdout, "\nInstallation complete!")
r.log("Headless installation completed successfully")
return nil
}
// buildDisabledItems computes the set of dependencies that should be skipped
// during installation, applying the --include-deps and --exclude-deps filters.
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
disabledItems := make(map[string]bool)
// dms-greeter is opt-in (disabled by default), matching TUI behavior
for i := range dependencies {
if dependencies[i].Name == "dms-greeter" {
disabledItems["dms-greeter"] = true
break
}
}
// Process --include-deps (enable items that are disabled by default)
for _, name := range r.cfg.IncludeDeps {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !r.depExists(dependencies, name) {
return nil, fmt.Errorf("--include-deps: unknown dependency %q", name)
}
delete(disabledItems, name)
}
// Process --exclude-deps (disable items)
for _, name := range r.cfg.ExcludeDeps {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !r.depExists(dependencies, name) {
return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name)
}
// Don't allow excluding DMS itself
if name == "dms (DankMaterialShell)" {
return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name)
}
disabledItems[name] = true
}
return disabledItems, nil
}
// buildReplaceConfigs converts the --replace-configs / --replace-configs-all
// flags into the map[string]bool consumed by the config deployer.
//
// Returns:
// - nil when --replace-configs-all is set (deployer treats nil as "replace all")
// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk)
// - a map with requested configs true, all others false for --replace-configs
// - an error when both flags are set or an invalid config name is given
func (r *Runner) buildReplaceConfigs() (map[string]bool, error) {
hasSpecific := len(r.cfg.ReplaceConfigs) > 0
if hasSpecific && r.cfg.ReplaceConfigsAll {
return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive")
}
if r.cfg.ReplaceConfigsAll {
return nil, nil
}
// Build a map with all known configs explicitly set to false
result := make(map[string]bool, len(validConfigNames))
for _, cliName := range orderedConfigNames {
result[validConfigNames[cliName]] = false
}
// Enable only the requested configs
for _, name := range r.cfg.ReplaceConfigs {
name = strings.TrimSpace(name)
if name == "" {
continue
}
deployerKey, ok := validConfigNames[strings.ToLower(name)]
if !ok {
return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name)
}
result[deployerKey] = true
}
return result, nil
}
func (r *Runner) log(message string) {
select {
case r.logChan <- message:
default:
}
}
func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
switch strings.ToLower(r.cfg.Compositor) {
case "niri":
return deps.WindowManagerNiri, nil
case "hyprland":
return deps.WindowManagerHyprland, nil
default:
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
}
}
func (r *Runner) parseTerminal() (deps.Terminal, error) {
switch strings.ToLower(r.cfg.Terminal) {
case "ghostty":
return deps.TerminalGhostty, nil
case "kitty":
return deps.TerminalKitty, nil
case "alacritty":
return deps.TerminalAlacritty, nil
default:
return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal)
}
}
func (r *Runner) resolveSudoPassword() (string, error) {
tool, err := privesc.Detect()
if err != nil {
return "", err
}
if err := privesc.CheckCached(context.Background()); err == nil {
r.log(fmt.Sprintf("%s cache is valid, no password needed", tool.Name()))
fmt.Fprintf(os.Stdout, "%s: using cached credentials\n", tool.Name())
return "", nil
}
switch tool {
case privesc.ToolSudo:
return "", fmt.Errorf(
"sudo authentication required but no cached credentials found\n" +
"Options:\n" +
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
" 2. Configure passwordless sudo for your user",
)
case privesc.ToolDoas:
return "", fmt.Errorf(
"doas authentication required but no cached credentials found\n" +
"Options:\n" +
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
)
case privesc.ToolRun0:
return "", fmt.Errorf(
"run0 authentication required but no cached credentials found\n" +
"Configure a polkit rule granting your user passwordless privilege\n" +
"(see `man polkit` for details on rules in /etc/polkit-1/rules.d/)",
)
default:
return "", fmt.Errorf("unsupported privilege tool: %s", tool)
}
}
func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
for _, v := range m {
if v {
return true
}
}
return false
}
func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
for _, dep := range dependencies {
if dep.Name == name {
return true
}
}
return false
}
+459
View File
@@ -0,0 +1,459 @@
package headless
import (
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func TestParseWindowManager(t *testing.T) {
tests := []struct {
name string
input string
want deps.WindowManager
wantErr bool
}{
{"niri lowercase", "niri", deps.WindowManagerNiri, false},
{"niri mixed case", "Niri", deps.WindowManagerNiri, false},
{"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false},
{"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false},
{"invalid", "sway", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{Compositor: tt.input})
got, err := r.parseWindowManager()
if (err != nil) != tt.wantErr {
t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseWindowManager() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseTerminal(t *testing.T) {
tests := []struct {
name string
input string
want deps.Terminal
wantErr bool
}{
{"ghostty lowercase", "ghostty", deps.TerminalGhostty, false},
{"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false},
{"kitty lowercase", "kitty", deps.TerminalKitty, false},
{"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false},
{"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false},
{"invalid", "wezterm", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{Terminal: tt.input})
got, err := r.parseTerminal()
if (err != nil) != tt.wantErr {
t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseTerminal() = %v, want %v", got, tt.want)
}
})
}
}
func TestDepExists(t *testing.T) {
dependencies := []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
{Name: "ghostty", Status: deps.StatusMissing},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
{Name: "dms-greeter", Status: deps.StatusMissing},
}
tests := []struct {
name string
dep string
want bool
}{
{"existing dep", "niri", true},
{"existing dep with special chars", "dms (DankMaterialShell)", true},
{"existing optional dep", "dms-greeter", true},
{"non-existing dep", "firefox", false},
{"empty name", "", false},
}
r := NewRunner(Config{})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := r.depExists(dependencies, tt.dep); got != tt.want {
t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want)
}
})
}
}
func TestNewRunner(t *testing.T) {
cfg := Config{
Compositor: "niri",
Terminal: "ghostty",
IncludeDeps: []string{"dms-greeter"},
ExcludeDeps: []string{"some-pkg"},
Yes: true,
}
r := NewRunner(cfg)
if r == nil {
t.Fatal("NewRunner returned nil")
}
if r.cfg.Compositor != "niri" {
t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri")
}
if r.cfg.Terminal != "ghostty" {
t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty")
}
if !r.cfg.Yes {
t.Error("cfg.Yes = false, want true")
}
if r.logChan == nil {
t.Error("logChan is nil")
}
}
func TestGetLogChan(t *testing.T) {
r := NewRunner(Config{})
ch := r.GetLogChan()
if ch == nil {
t.Fatal("GetLogChan returned nil")
}
// Verify the channel is readable by sending a message
go func() {
r.logChan <- "test message"
}()
msg := <-ch
if msg != "test message" {
t.Errorf("received %q, want %q", msg, "test message")
}
}
func TestLog(t *testing.T) {
r := NewRunner(Config{})
// log should not block even if channel is full
for i := 0; i < 1100; i++ {
r.log("message")
}
// If we reach here without hanging, the non-blocking send works
}
func TestRunRequiresYes(t *testing.T) {
// Verify that ErrConfirmationRequired is a distinct sentinel error
if ErrConfirmationRequired == nil {
t.Fatal("ErrConfirmationRequired should not be nil")
}
expected := "confirmation required: pass --yes to proceed"
if ErrConfirmationRequired.Error() != expected {
t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected)
}
}
func TestConfigYesStoredCorrectly(t *testing.T) {
// Yes=false (default) should be stored
rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false})
if rNo.cfg.Yes {
t.Error("cfg.Yes = true, want false")
}
// Yes=true should be stored
rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true})
if !rYes.cfg.Yes {
t.Error("cfg.Yes = false, want true")
}
}
func TestValidConfigNamesCompleteness(t *testing.T) {
// orderedConfigNames and validConfigNames must stay in sync.
if len(orderedConfigNames) != len(validConfigNames) {
t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d",
len(orderedConfigNames), len(validConfigNames))
}
// Every entry in orderedConfigNames must exist in validConfigNames.
for _, name := range orderedConfigNames {
if _, ok := validConfigNames[name]; !ok {
t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name)
}
}
// validConfigNames must have no extra keys not in orderedConfigNames.
ordered := make(map[string]bool, len(orderedConfigNames))
for _, name := range orderedConfigNames {
ordered[name] = true
}
for key := range validConfigNames {
if !ordered[key] {
t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key)
}
}
}
func TestBuildReplaceConfigs(t *testing.T) {
allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"}
tests := []struct {
name string
replaceConfigs []string
replaceAll bool
wantNil bool // expect nil (replace all)
wantEnabled []string // deployer keys that should be true
wantErr bool
}{
{
name: "neither flag set",
wantNil: false,
wantEnabled: nil, // all should be false
},
{
name: "replace-configs-all",
replaceAll: true,
wantNil: true,
},
{
name: "specific configs",
replaceConfigs: []string{"niri", "ghostty"},
wantNil: false,
wantEnabled: []string{"Niri", "Ghostty"},
},
{
name: "both flags set",
replaceConfigs: []string{"niri"},
replaceAll: true,
wantErr: true,
},
{
name: "invalid config name",
replaceConfigs: []string{"foo"},
wantErr: true,
},
{
name: "case insensitive",
replaceConfigs: []string{"NIRI", "Ghostty"},
wantNil: false,
wantEnabled: []string{"Niri", "Ghostty"},
},
{
name: "single config",
replaceConfigs: []string{"kitty"},
wantNil: false,
wantEnabled: []string{"Kitty"},
},
{
name: "whitespace entry",
replaceConfigs: []string{" ", "niri"},
wantNil: false,
wantEnabled: []string{"Niri"},
},
{
name: "duplicate entry",
replaceConfigs: []string{"niri", "niri"},
wantNil: false,
wantEnabled: []string{"Niri"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{
ReplaceConfigs: tt.replaceConfigs,
ReplaceConfigsAll: tt.replaceAll,
})
got, err := r.buildReplaceConfigs()
if (err != nil) != tt.wantErr {
t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if tt.wantNil {
if got != nil {
t.Fatalf("buildReplaceConfigs() = %v, want nil", got)
}
return
}
if got == nil {
t.Fatal("buildReplaceConfigs() = nil, want non-nil map")
}
// All known deployer keys must be present
for _, key := range allDeployerKeys {
if _, exists := got[key]; !exists {
t.Errorf("missing deployer key %q in result map", key)
}
}
// Build enabled set for easy lookup
enabledSet := make(map[string]bool)
for _, k := range tt.wantEnabled {
enabledSet[k] = true
}
for _, key := range allDeployerKeys {
want := enabledSet[key]
if got[key] != want {
t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want)
}
}
})
}
}
func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
r := NewRunner(Config{
Compositor: "niri",
Terminal: "ghostty",
ReplaceConfigs: []string{"niri", "ghostty"},
ReplaceConfigsAll: false,
})
if len(r.cfg.ReplaceConfigs) != 2 {
t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs))
}
if r.cfg.ReplaceConfigsAll {
t.Error("ReplaceConfigsAll = true, want false")
}
r2 := NewRunner(Config{
Compositor: "niri",
Terminal: "ghostty",
ReplaceConfigsAll: true,
})
if !r2.cfg.ReplaceConfigsAll {
t.Error("ReplaceConfigsAll = false, want true")
}
if len(r2.cfg.ReplaceConfigs) != 0 {
t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs))
}
}
func TestBuildDisabledItems(t *testing.T) {
dependencies := []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
{Name: "ghostty", Status: deps.StatusMissing},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
{Name: "dms-greeter", Status: deps.StatusMissing},
{Name: "waybar", Status: deps.StatusMissing},
}
tests := []struct {
name string
includeDeps []string
excludeDeps []string
deps []deps.Dependency // nil means use the shared fixture
wantErr bool
errContains string // substring expected in error message
wantDisabled []string // dep names that should be in disabledItems
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
}{
{
name: "no flags set, dms-greeter disabled by default",
wantDisabled: []string{"dms-greeter"},
wantEnabled: []string{"niri", "ghostty", "waybar"},
},
{
name: "include dms-greeter enables it",
includeDeps: []string{"dms-greeter"},
wantEnabled: []string{"dms-greeter"},
},
{
name: "exclude a regular dep",
excludeDeps: []string{"waybar"},
wantDisabled: []string{"dms-greeter", "waybar"},
},
{
name: "include unknown dep returns error",
includeDeps: []string{"nonexistent"},
wantErr: true,
errContains: "--include-deps",
},
{
name: "exclude unknown dep returns error",
excludeDeps: []string{"nonexistent"},
wantErr: true,
errContains: "--exclude-deps",
},
{
name: "exclude DMS itself is forbidden",
excludeDeps: []string{"dms (DankMaterialShell)"},
wantErr: true,
errContains: "cannot exclude required package",
},
{
name: "include and exclude same dep",
includeDeps: []string{"dms-greeter"},
excludeDeps: []string{"dms-greeter"},
wantDisabled: []string{"dms-greeter"},
},
{
name: "whitespace entries are skipped",
includeDeps: []string{" ", "dms-greeter"},
wantEnabled: []string{"dms-greeter"},
},
{
name: "no dms-greeter in deps, nothing disabled by default",
deps: []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
},
wantEnabled: []string{"niri"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{
IncludeDeps: tt.includeDeps,
ExcludeDeps: tt.excludeDeps,
})
d := tt.deps
if d == nil {
d = dependencies
}
got, err := r.buildDisabledItems(d)
if (err != nil) != tt.wantErr {
t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
return
}
if got == nil {
t.Fatal("buildDisabledItems() returned nil map, want non-nil")
}
// Check expected disabled items
for _, name := range tt.wantDisabled {
if !got[name] {
t.Errorf("expected %q to be disabled, but it is not", name)
}
}
// Check expected enabled items (should not be in the map or be false)
for _, name := range tt.wantEnabled {
if got[name] {
t.Errorf("expected %q to NOT be disabled, but it is", name)
}
}
// If wantDisabled is empty, the map should have length 0
if len(tt.wantDisabled) == 0 && len(got) != 0 {
t.Errorf("expected empty disabledItems map, got %v", got)
}
})
}
}
+214 -175
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
@@ -48,7 +49,7 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
h.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs)
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys)
sheet := &keybinds.CheatSheet{
Title: "Hyprland Keybinds",
@@ -88,7 +89,7 @@ func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
return h.dmsBindsIncluded
}
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) {
currentSubcat := subcategory
if section.Name != "" {
currentSubcat = section.Name
@@ -96,12 +97,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat, conflicts)
bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
for _, child := range section.Children {
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts)
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts, defaultKeys)
}
}
@@ -133,7 +134,7 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
}
}
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind {
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) keybinds.Keybind {
keyStr := h.formatKey(kb)
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
desc := kb.Comment
@@ -143,8 +144,15 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
}
source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") {
if isDMSBindsUserOverridePath(kb.Source) {
source = "dms"
} else if isDMSBindsPrimarySourcePath(kb.Source) {
source = "dms-default"
}
hasDefault := false
if source == "dms" && defaultKeys != nil {
hasDefault = defaultKeys[strings.ToLower(keyStr)]
}
bind := keybinds.Keybind{
@@ -154,9 +162,10 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
Subcategory: subcategory,
Source: source,
Flags: kb.Flags,
HasDefault: hasDefault,
}
if source == "dms" && conflicts != nil {
if (source == "dms" || source == "dms-default") && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
@@ -188,9 +197,9 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
func (h *HyprlandProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
return filepath.Join(h.configPath, "dms", "binds.conf")
return filepath.Join(h.configPath, "dms", "binds-user.lua")
}
return filepath.Join(expanded, "dms", "binds.conf")
return filepath.Join(expanded, "dms", "binds-user.lua")
}
func (h *HyprlandProvider) validateAction(action string) error {
@@ -250,7 +259,16 @@ func (h *HyprlandProvider) RemoveBind(key string) error {
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true}
return h.writeOverrideBinds(existingBinds)
}
func (h *HyprlandProvider) ResetBind(key string) error {
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds)
@@ -262,116 +280,12 @@ type hyprlandOverrideBind struct {
Description string
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
Options map[string]any
// Unbind: negative override (hl.unbind only, no rebind).
Unbind bool
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
overridePath := h.GetOverridePath()
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
// Extract flags from bind type
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
// For bindd, format is: mods, key, description, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3
dispatcherIndex = 2
}
fields := strings.SplitN(bindContent, ",", minFields+2)
if len(fields) < minFields {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
var dispatcher, params string
if hasDescFlag {
if comment == "" {
comment = strings.TrimSpace(fields[descIndex])
}
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
keyStr := h.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := dispatcher
if params != "" {
action = dispatcher + " " + params
}
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
Flags: flags,
}
}
return binds, nil
}
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
if mods == "" {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
return readLuaOrHyprlangOverride(h.GetOverridePath())
}
func (h *HyprlandProvider) getBindSortPriority(action string) int {
@@ -420,78 +334,203 @@ func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverri
})
var sb strings.Builder
sb.WriteString("-- DMS user keybind overrides (edit via Control Center or dms; do not remove this header)\n\n")
for _, bind := range bindList {
h.writeBindLine(&sb, bind)
writeLuaBindLine(&sb, bind)
}
return sb.String()
}
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
mods, key := h.parseKeyString(bind.Key)
dispatcher, params := h.parseAction(bind.Action)
// Write bind type with flags (e.g., "bind", "binde", "bindel")
sb.WriteString("bind")
if bind.Flags != "" {
sb.WriteString(bind.Flags)
func formatLuaBindKey(internalKey string) string {
internalKey = strings.TrimSpace(internalKey)
parts := strings.Split(internalKey, "+")
for i := range parts {
parts[i] = normalizeLuaBindKeyPart(strings.TrimSpace(parts[i]))
}
sb.WriteString(" = ")
sb.WriteString(mods)
sb.WriteString(", ")
sb.WriteString(key)
sb.WriteString(", ")
// For bindd (description flag), include description before dispatcher
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
sb.WriteString(bind.Description)
sb.WriteString(", ")
}
sb.WriteString(dispatcher)
if params != "" {
sb.WriteString(", ")
sb.WriteString(params)
}
// Only add comment if not using bindd (which has inline description)
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
return strings.Join(parts, " + ")
}
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
case 0:
return "", keyStr
case 1:
return "", parts[0]
func normalizeLuaBindKeyPart(part string) string {
switch strings.ToLower(part) {
case "super", "mod4", "mainmod":
return "SUPER"
case "ctrl", "control":
return "CTRL"
case "shift":
return "SHIFT"
case "alt", "mod1":
return "ALT"
}
if len(part) == 1 {
return strings.ToUpper(part)
}
return part
}
func luaActionStringFromHyprlangAction(action string) string {
action = strings.TrimSpace(action)
if strings.HasPrefix(action, "spawn ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn "))))
}
if strings.HasPrefix(action, "exec ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec ")))
}
switch action {
case "killactive":
return `hl.dsp.window.kill()`
case "togglefloating":
return `hl.dsp.window.float({ action = "toggle" })`
case "exit":
return `hl.dsp.exit()`
default:
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
}
}
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
parts := strings.SplitN(action, " ", 2)
switch len(parts) {
case 0:
return action, ""
case 1:
dispatcher = parts[0]
default:
dispatcher = parts[0]
params = parts[1]
func luaExprToInternalAction(expr string) string {
d, p := luaExprToDispatcherParams(expr)
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
return "exec " + p
}
// Convert internal spawn format to Hyprland's exec
if dispatcher == "spawn" {
dispatcher = "exec"
if p != "" {
return d + " " + p
}
return dispatcher, params
return d
}
func luaBindOptions(bind *hyprlandOverrideBind) []string {
var opts []string
if strings.Contains(bind.Flags, "l") {
opts = append(opts, "locked = true")
}
if strings.Contains(bind.Flags, "e") {
opts = append(opts, "repeating = true")
}
if bind.Description != "" && strings.Contains(bind.Flags, "d") {
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
}
return opts
}
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
key := formatLuaBindKey(bind.Key)
if bind.Unbind {
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
sb.WriteByte('\n')
return
}
expr := luaActionStringFromHyprlangAction(bind.Action)
opts := luaBindOptions(bind)
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
sb.WriteByte('\n')
if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else {
if bind.Description != "" {
fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description)
} else {
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
}
}
sb.WriteByte('\n')
}
func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "--") {
return nil, false
}
kbc, actionExpr, optSuffix, ok := parseLuaBindInvocation(line)
if !ok {
return nil, false
}
internalKey := luaKeyComboToInternalKey(kbc)
action := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix)
return &hyprlandOverrideBind{
Key: internalKey,
Action: action,
Description: description,
Flags: flags,
}, true
}
func parseLuaUnbindLine(line string) (string, bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "hl.unbind") {
return "", false
}
rest := strings.TrimSpace(line[len("hl.unbind"):])
if !strings.HasPrefix(rest, "(") {
return "", false
}
rest = rest[1:]
combo, _, ok := parseLuaStringLiteral(rest, 0)
if !ok {
return "", false
}
return luaKeyComboToInternalKey(combo), true
}
func luaKeyComboToInternalKey(combo string) string {
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " "))
return strings.Join(parts, "+")
}
func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) {
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
parser := NewHyprlandParser("")
pendingUnbinds := make(map[string]string)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "--") {
continue
}
if key, ok := parseLuaUnbindLine(line); ok {
pendingUnbinds[strings.ToLower(key)] = key
continue
}
if kb, ok := parseLuaBindOverrideLine(line); ok {
normalizedKey := strings.ToLower(kb.Key)
binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey)
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
kb := parser.parseBindLine(line)
if kb == nil {
continue
}
keyStr := parser.formatBindKey(kb)
action := kb.Dispatcher
if kb.Params != "" {
action = kb.Dispatcher + " " + kb.Params
}
flags := kb.Flags
normalizedKey := strings.ToLower(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: kb.Comment,
Flags: flags,
}
delete(pendingUnbinds, normalizedKey)
}
for normKey, origKey := range pendingUnbinds {
binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true}
}
return binds, nil
}
@@ -4,8 +4,10 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
@@ -50,6 +52,8 @@ type HyprlandParser struct {
bindOrder []string
processedFiles map[string]bool
dmsProcessed bool
removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
}
func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -64,6 +68,8 @@ func NewHyprlandParser(configDir string) *HyprlandParser {
bindMap: make(map[string]*HyprlandKeyBinding),
bindOrder: []string{},
processedFiles: make(map[string]bool),
removedKeys: make(map[string]bool),
defaultDMSKeys: make(map[string]bool),
}
}
@@ -292,6 +298,7 @@ type HyprlandParseResult struct {
DMSBindsIncluded bool
DMSStatus *HyprlandDMSStatus
ConflictingConfigs map[string]*HyprlandKeyBinding
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf}
}
type HyprlandDMSStatus struct {
@@ -317,10 +324,10 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
switch {
case !p.dmsBindsExists:
status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist"
status.StatusMessage = "dms/binds.lua (or legacy binds.conf) does not exist"
case !p.dmsBindsIncluded:
status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config"
status.StatusMessage = "dms binds are not loaded from Hyprland config (require / source)"
case p.bindsAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.bindsAfterDMS
@@ -347,8 +354,11 @@ func (p *HyprlandParser) normalizeKey(key string) string {
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
isDMSBind := isDMSBindsSourcePath(kb.Source)
if isDMSBindsPrimarySourcePath(kb.Source) {
p.defaultDMSKeys[normalizedKey] = true
}
if isDMSBind {
p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] {
@@ -373,12 +383,21 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
return nil, err
}
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
if _, err := os.Stat(dmsBindsPath); err == nil {
dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua")
dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf")
dmsBindsPath := ""
if _, err := os.Stat(dmsBindsLua); err == nil {
p.dmsBindsExists = true
dmsBindsPath = dmsBindsLua
} else if _, err := os.Stat(dmsBindsConf); err == nil {
p.dmsBindsExists = true
dmsBindsPath = dmsBindsConf
}
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
mainConfig, err := hyprlandMainConfigPath(p.configDir)
if err != nil {
return nil, err
}
section, err := p.parseFileWithSource(mainConfig, "")
if err != nil {
return nil, err
@@ -387,10 +406,65 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath, section)
}
p.removeShadowedDMSBinds(section)
p.removeUnboundDMSBinds(section)
return section, nil
}
func (p *HyprlandParser) removeUnboundDMSBinds(section *HyprlandSection) {
if len(p.removedKeys) == 0 {
return
}
filtered := section.Keybinds[:0]
for i := range section.Keybinds {
kb := section.Keybinds[i]
if isDMSBindsSourcePath(kb.Source) && p.removedKeys[p.normalizeKey(p.formatBindKey(&kb))] {
continue
}
filtered = append(filtered, kb)
}
section.Keybinds = filtered
for i := range section.Children {
p.removeUnboundDMSBinds(&section.Children[i])
}
}
func (p *HyprlandParser) removeShadowedDMSBinds(section *HyprlandSection) {
counts := make(map[string]int)
p.countDMSBinds(section, counts)
p.filterShadowedDMSBinds(section, counts)
}
func (p *HyprlandParser) countDMSBinds(section *HyprlandSection, counts map[string]int) {
for i := range section.Keybinds {
kb := &section.Keybinds[i]
if isDMSBindsSourcePath(kb.Source) {
counts[p.normalizeKey(p.formatBindKey(kb))]++
}
}
for i := range section.Children {
p.countDMSBinds(&section.Children[i], counts)
}
}
func (p *HyprlandParser) filterShadowedDMSBinds(section *HyprlandSection, counts map[string]int) {
filtered := section.Keybinds[:0]
for i := range section.Keybinds {
kb := section.Keybinds[i]
key := p.normalizeKey(p.formatBindKey(&kb))
if isDMSBindsSourcePath(kb.Source) && counts[key] > 1 {
counts[key]--
continue
}
filtered = append(filtered, kb)
}
section.Keybinds = filtered
for i := range section.Children {
p.filterShadowedDMSBinds(&section.Children[i], counts)
}
}
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
@@ -407,6 +481,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp
return nil, err
}
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
}
prevSource := p.currentSource
p.currentSource = absPath
@@ -446,7 +524,7 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
isDMSSource := isDMSBindsPrimarySourcePath(sourcePath)
p.includeCount++
if isDMSSource {
@@ -474,6 +552,17 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
}
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
if strings.EqualFold(filepath.Ext(dmsBindsPath), ".lua") {
sub, err := p.parseLuaLinesFromPath(dmsBindsPath)
if err != nil {
return
}
section.Keybinds = append(section.Keybinds, sub.Keybinds...)
section.Children = append(section.Children, sub.Children...)
p.dmsProcessed = true
return
}
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return
@@ -503,6 +592,124 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp
p.dmsProcessed = true
}
func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) {
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, "")
}
// parseLuaLines reads a Hyprland Lua config fragment: require() includes and hl.bind keybinds.
func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, sectionName string) (*HyprlandSection, error) {
section := &HyprlandSection{Name: sectionName}
prevSource := p.currentSource
p.currentSource = absPath
lines := strings.Split(content, "\n")
boundInFile := make(map[string]bool)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") || !strings.Contains(trimmed, "hl.bind") {
continue
}
if kbc, _, _, ok := parseLuaBindInvocation(trimmed); ok {
boundInFile[strings.ToLower(luaKeyComboToInternalKey(kbc))] = true
}
}
rootDir := baseDir
if expanded, err := utils.ExpandPath(p.configDir); err == nil && expanded != "" {
rootDir = expanded
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
continue
}
if modules := luaconfig.Requires(trimmed); len(modules) > 0 {
for _, mod := range modules {
rel := luaconfig.ModuleToRelPath(mod)
if rel == "" {
continue
}
isDMS := isDMSBindsPrimarySourcePath(rel)
p.includeCount++
if isDMS {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := luaconfig.ModuleToPath(rootDir, mod)
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
includedSection, err := p.parseFileWithSource(expanded, "")
if err != nil {
continue
}
section.Children = append(section.Children, *includedSection)
}
continue
}
if strings.HasPrefix(trimmed, "hl.unbind") {
if key, ok := parseLuaUnbindLine(trimmed); ok {
normalized := strings.ToLower(key)
if !boundInFile[normalized] {
p.removedKeys[normalized] = true
}
}
continue
}
if !strings.Contains(trimmed, "hl.bind") {
continue
}
kbc, action, optSuffix, ok := parseLuaBindInvocation(trimmed)
if !ok {
continue
}
flags := luaBindOptFlags(optSuffix)
desc := luaBindOptDescription(optSuffix)
if desc == "" {
desc = luaLineTrailingComment(line)
}
kb := luaKeyComboToBinding(kbc, action, p.currentSource, desc)
kb.Flags = flags
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
return section, nil
}
func luaBindOptFlags(optSuffix string) string {
optSuffix = strings.TrimSpace(optSuffix)
if optSuffix == "" {
return ""
}
var flags string
if strings.Contains(optSuffix, "repeating") {
flags += "e"
}
if strings.Contains(optSuffix, "locked") {
flags += "l"
}
if strings.Contains(optSuffix, "description") {
flags += "d"
}
return flags
}
func luaBindOptDescription(optSuffix string) string {
return luaTableStringField(optSuffix, "description")
}
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
@@ -623,5 +830,356 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
DefaultDMSKeys: parser.defaultDMSKeys,
}, nil
}
func skipLuaWS(s string, i int) int {
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r') {
i++
}
return i
}
// parseLuaStringLiteral reads a Lua "..." or '...' starting at i (first quote).
func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool) {
if i >= len(line) {
return "", i, false
}
q := line[i]
if q != '"' && q != '\'' {
return "", i, false
}
i++
var sb strings.Builder
for i < len(line) {
c := line[i]
if c == '\\' && i+1 < len(line) {
i++
sb.WriteByte(line[i])
i++
continue
}
if c == q {
return sb.String(), i + 1, true
}
sb.WriteByte(c)
i++
}
return "", i, false
}
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings).
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
start = skipLuaWS(line, start)
if start >= len(line) {
return "", start, false
}
// Find first '(' of the call (e.g. hl.dsp.exec_cmd(...)
firstParen := strings.IndexByte(line[start:], '(')
if firstParen < 0 {
return "", start, false
}
i := start + firstParen
depth := 0
inStr := byte(0)
esc := false
exprStart := start
for ; i < len(line); i++ {
c := line[i]
if inStr != 0 {
if esc {
esc = false
continue
}
if c == '\\' && inStr == '"' {
esc = true
continue
}
if c == inStr {
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '(':
depth++
case ')':
depth--
if depth == 0 {
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true
}
}
}
return "", start, false
}
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
func parseLuaBindInvocation(line string) (keyCombo, actionExpr, optSuffix string, ok bool) {
idx := strings.Index(line, "hl.bind")
if idx < 0 {
return "", "", "", false
}
i := idx + len("hl.bind")
i = skipLuaWS(line, i)
if i >= len(line) || line[i] != '(' {
return "", "", "", false
}
i++
i = skipLuaWS(line, i)
keyCombo, i, ok = parseLuaStringLiteral(line, i)
if !ok {
return "", "", "", false
}
i = skipLuaWS(line, i)
if i >= len(line) || line[i] != ',' {
return "", "", "", false
}
i++
i = skipLuaWS(line, i)
actionExpr, i, ok = parseLuaFirstArgExpr(line, i)
if !ok {
return "", "", "", false
}
i = skipLuaWS(line, i)
if i < len(line) && line[i] == ',' {
optSuffix = strings.TrimSpace(line[i:])
}
return keyCombo, strings.TrimSpace(actionExpr), optSuffix, true
}
func luaKeyComboToBinding(keyCombo, actionExpr, source, lineComment string) *HyprlandKeyBinding {
keyCombo = strings.TrimSpace(keyCombo)
mods, leaf := luaKeyComboToModsKey(keyCombo)
dispatcher, params := luaExprToDispatcherParams(actionExpr)
comment := lineComment
if comment == "" {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
return &HyprlandKeyBinding{
Mods: mods,
Key: leaf,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
Source: source,
Flags: "",
}
}
func luaKeyComboToModsKey(combo string) (mods []string, leaf string) {
parts := strings.Split(combo, "+")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
switch len(parts) {
case 0:
return nil, ""
case 1:
return nil, parts[0]
default:
return parts[:len(parts)-1], parts[len(parts)-1]
}
}
func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
expr = strings.TrimSpace(expr)
switch {
case strings.HasPrefix(expr, "hl.dsp.exec_cmd("):
arg := extractLuaCallStringArg(expr, "hl.dsp.exec_cmd")
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
if strings.HasPrefix(u, "hyprctl dispatch ") {
rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch "))
parts := strings.SplitN(rest, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], parts[1]
}
return "exec", u
}
}
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
case strings.Contains(expr, "hl.dsp.window.kill()"):
return "killactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
switch luaTableStringField(expr, "mode") {
case "maximized", "maximize":
return "fullscreen", "1"
case "fullscreen":
return "fullscreen", "0"
}
return "fullscreen", luaTableStringField(expr, "mode")
case strings.HasPrefix(expr, "hl.dsp.window.float("):
return "togglefloating", ""
case strings.Contains(expr, "hl.dsp.group.toggle()"):
return "togglegroup", ""
case strings.HasPrefix(expr, "hl.dsp.focus("):
switch {
case luaTableStringField(expr, "direction") != "":
return "movefocus", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "":
return "focusmonitor", luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
return "workspace", luaTableStringField(expr, "workspace")
case luaTableStringField(expr, "window") != "":
return "focuswindow", luaTableStringField(expr, "window")
}
case strings.HasPrefix(expr, "hl.dsp.window.move("):
switch {
case luaTableStringField(expr, "direction") != "":
return "movewindow", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "":
return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
return "movetoworkspace", luaTableStringField(expr, "workspace")
}
case expr == "hl.dsp.window.drag()":
return "movewindow", ""
case expr == "hl.dsp.window.resize()":
return "resizewindow", ""
case strings.HasPrefix(expr, "hl.dsp.window.resize("):
x := luaStringValue(luaTableScalarField(expr, "x"))
y := luaStringValue(luaTableScalarField(expr, "y"))
if x != "" || y != "" {
if x == "" {
x = "0"
}
if y == "" {
y = "0"
}
return "resizeactive", x + " " + y
}
case strings.HasPrefix(expr, "hl.dsp.layout("):
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
return "layoutmsg", u
}
}
case strings.HasPrefix(expr, "hl.dsp.dpms("):
if action := luaTableStringField(expr, "action"); action != "" {
return "dpms", action
}
case strings.Contains(expr, "hl.dsp.exit()"):
return "exit", ""
default:
return "exec", "hyprctl dispatch lua:" + expr
}
return "exec", "hyprctl dispatch lua:" + expr
}
func extractLuaCallStringArg(callExpr, funcName string) string {
callExpr = strings.TrimSpace(callExpr)
prefix := funcName + "("
if !strings.HasPrefix(callExpr, prefix) {
return ""
}
inner := callExpr[len(prefix):]
inner = strings.TrimSpace(inner)
if len(inner) == 0 {
return ""
}
switch inner[0] {
case '"', '\'':
s, _, ok := parseLuaStringLiteral(inner, 0)
if ok {
return strconv.Quote(s)
}
case '[':
if strings.HasPrefix(inner, "[[") {
if end := strings.Index(inner[2:], "]]"); end >= 0 {
return strconv.Quote(inner[2 : 2+end])
}
}
}
return ""
}
func luaTableStringField(expr, field string) string {
return luaStringValue(luaTableScalarField(expr, field))
}
func luaTableScalarField(expr, field string) string {
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
m := re.FindStringSubmatch(expr)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func luaStringValue(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "[[") && strings.HasSuffix(raw, "]]") {
return raw[2 : len(raw)-2]
}
if len(raw) >= 2 {
q := raw[0]
if (q == '"' || q == '\'') && raw[len(raw)-1] == q {
if q == '"' {
if u, err := strconv.Unquote(raw); err == nil {
return u
}
}
return strings.ReplaceAll(raw[1:len(raw)-1], `\'`, `'`)
}
}
return raw
}
func luaLineTrailingComment(line string) string {
if idx := strings.Index(line, "--"); idx >= 0 {
return strings.TrimSpace(line[idx+2:])
}
return ""
}
func isDMSBindsSourcePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p))
if isDMSBindsPrimarySourcePath(p) {
return true
}
return isDMSBindsUserOverridePath(p)
}
func isDMSBindsUserOverridePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p))
return p == "dms/binds-user.lua" || p == "./dms/binds-user.lua" ||
strings.HasSuffix(p, "/dms/binds-user.lua")
}
func isDMSBindsPrimarySourcePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p))
if strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") || p == "dms/binds.lua" || p == "./dms/binds.lua" {
return true
}
if strings.Contains(p, "/dms/binds.conf") || strings.HasSuffix(p, "dms/binds.conf") {
return true
}
return p == "dms/binds.conf" || p == "./dms/binds.conf"
}
// hyprlandMainConfigPath returns hyprland.lua if present, else hyprland.conf if present.
func hyprlandMainConfigPath(dir string) (string, error) {
expandedDir, err := utils.ExpandPath(dir)
if err != nil {
return "", err
}
luaPath := filepath.Join(expandedDir, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return luaPath, nil
}
confPath := filepath.Join(expandedDir, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return confPath, nil
}
return "", os.ErrNotExist
}
@@ -3,7 +3,10 @@ package providers
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
)
func TestHyprlandAutogenerateComment(t *testing.T) {
@@ -60,6 +63,341 @@ func TestHyprlandAutogenerateComment(t *testing.T) {
}
}
func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
tests := []struct {
expr string
wantDispatcher string
wantParams string
}{
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"},
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
}
for _, tt := range tests {
t.Run(tt.expr, func(t *testing.T) {
gotDispatcher, gotParams := luaExprToDispatcherParams(tt.expr)
if gotDispatcher != tt.wantDispatcher || gotParams != tt.wantParams {
t.Fatalf("luaExprToDispatcherParams() = %q, %q; want %q, %q", gotDispatcher, gotParams, tt.wantDispatcher, tt.wantParams)
}
})
}
}
func TestWriteLuaBindLineOptionsInsideCall(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+k",
Action: "exec kitty",
Description: "Open terminal",
Flags: "led",
})
want := `hl.unbind("SUPER + K")
hl.bind("SUPER + K", hl.dsp.exec_cmd("kitty"), { locked = true, repeating = true, description = "Open terminal" })`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+n",
Action: "spawn dms ipc call notepad toggle",
Description: "Notepad: Toggle",
})
want := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"), { description = "User terminal" })`), 0o644); err != nil {
t.Fatal(err)
}
result, err := ParseHyprlandKeysWithDMS(tmpDir)
if err != nil {
t.Fatal(err)
}
var found []HyprlandKeyBinding
var walk func(HyprlandSection)
walk = func(section HyprlandSection) {
for _, kb := range section.Keybinds {
if strings.EqualFold(strings.Join(append(kb.Mods, kb.Key), "+"), "SUPER+T") {
found = append(found, kb)
}
}
for _, child := range section.Children {
walk(child)
}
}
walk(*result.Section)
if len(found) != 1 {
t.Fatalf("expected one effective SUPER+T bind, got %d: %#v", len(found), found)
}
if found[0].Params != "foot" || found[0].Comment != "User terminal" {
t.Fatalf("expected user override bind, got %#v", found[0])
}
}
func TestWriteLuaBindLineEmitsUnbindOnlyForNegativeOverride(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{Key: "Super+i", Unbind: true})
want := `hl.unbind("SUPER + I")`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestReadLuaOverrideRecognizesLoneUnbindAsNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
overridePath := filepath.Join(tmpDir, "binds-user.lua")
contents := `-- DMS user keybind overrides
hl.unbind("SUPER + I")
hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
binds, err := readLuaOrHyprlangOverride(overridePath)
if err != nil {
t.Fatal(err)
}
got, ok := binds["super+i"]
if !ok {
t.Fatalf("expected SUPER+I entry in override map, got: %#v", binds)
}
if !got.Unbind {
t.Fatalf("expected SUPER+I to be marked Unbind, got: %#v", got)
}
if rebind, ok := binds["super+n"]; !ok || rebind.Unbind {
t.Fatalf("expected SUPER+N to be a normal rebind override, got: %#v", rebind)
}
}
func TestParserDropsDMSDefaultsSuppressedByBindsUserUnbind(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
`hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.unbind("SUPER + I")`), 0o644); err != nil {
t.Fatal(err)
}
result, err := ParseHyprlandKeysWithDMS(tmpDir)
if err != nil {
t.Fatal(err)
}
var keys []string
var walk func(HyprlandSection)
walk = func(section HyprlandSection) {
for _, kb := range section.Keybinds {
keys = append(keys, strings.ToUpper(strings.Join(append(kb.Mods, kb.Key), "+")))
}
for _, child := range section.Children {
walk(child)
}
}
walk(*result.Section)
for _, k := range keys {
if k == "SUPER+I" {
t.Fatalf("expected SUPER+I to be suppressed by binds-user.lua unbind, got: %v", keys)
}
}
foundT := false
for _, k := range keys {
if k == "SUPER+T" {
foundT = true
}
}
if !foundT {
t.Fatalf("expected SUPER+T to remain (only SUPER+I was unbound), got: %v", keys)
}
}
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.RemoveBind("SUPER+I"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `hl.unbind("SUPER + I")`) {
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
}
if strings.Contains(string(data), `hl.bind("SUPER + I"`) {
t.Fatalf("expected NO hl.bind for SUPER+I, got:\n%s", string(data))
}
}
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.RemoveBind("SUPER+N"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `hl.unbind("SUPER + N")`) {
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
}
if strings.Contains(string(data), `hl.bind("SUPER + N"`) {
t.Fatalf("expected NO hl.bind for SUPER+N after remove, got:\n%s", string(data))
}
}
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.ResetBind("SUPER+N"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), `SUPER + N`) {
t.Fatalf("expected SUPER+N to be fully removed (revert to default), got:\n%s", string(data))
}
}
func TestHyprlandHasDefaultSetForOverrideOfDefaultKey(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(
`hl.unbind("SUPER + T")
hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"))
hl.bind("SUPER + Z", hl.dsp.exec_cmd("custom"))`,
), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatal(err)
}
var foundT, foundZ *keybinds.Keybind
for _, group := range sheet.Binds {
for i := range group {
kb := group[i]
keyUpper := strings.ToUpper(kb.Key)
if keyUpper == "SUPER+T" {
foundT = &group[i]
}
if keyUpper == "SUPER+Z" {
foundZ = &group[i]
}
}
}
if foundT == nil {
t.Fatalf("expected SUPER+T override in cheatsheet")
}
if !foundT.HasDefault {
t.Fatalf("expected SUPER+T HasDefault=true (default exists in binds.lua), got %+v", foundT)
}
if foundZ == nil {
t.Fatalf("expected SUPER+Z (user-only) in cheatsheet")
}
if foundZ.HasDefault {
t.Fatalf("expected SUPER+Z HasDefault=false (no default), got %+v", foundZ)
}
}
func TestHyprlandGetKeybindAtLine(t *testing.T) {
tests := []struct {
name string
+6 -2
View File
@@ -141,7 +141,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
source = "dms"
source = "dms-default"
}
bind := keybinds.Keybind{
@@ -151,7 +151,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
Source: source,
}
if source == "dms" && conflicts != nil {
if source == "dms-default" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
@@ -249,6 +249,10 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
return m.writeOverrideBinds(existingBinds)
}
func (m *MangoWCProvider) ResetBind(key string) error {
return m.RemoveBind(key)
}
type mangowcOverrideBind struct {
Key string
Action string
+11 -8
View File
@@ -10,7 +10,6 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document"
)
@@ -150,7 +149,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
source := "config"
if strings.Contains(kb.Source, "dms/binds.kdl") {
source = "dms"
source = "dms-default"
}
bind := keybinds.Keybind{
@@ -166,8 +165,8 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
Repeat: kb.Repeat,
}
if source == "dms" && conflicts != nil {
if conflictKb, ok := conflicts[keyStr]; ok {
if source == "dms-default" && conflicts != nil {
if conflictKb, ok := conflicts[normalizeNiriBindKey(keyStr)]; ok {
bind.Conflict = &keybinds.Keybind{
Key: keyStr,
Description: conflictKb.Description,
@@ -250,7 +249,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
existingBinds = make(map[string]*overrideBind)
}
existingBinds[key] = &overrideBind{
existingBinds[normalizeNiriBindKey(key)] = &overrideBind{
Key: key,
Action: action,
Description: description,
@@ -266,10 +265,14 @@ func (n *NiriProvider) RemoveBind(key string) error {
return nil
}
delete(existingBinds, key)
delete(existingBinds, normalizeNiriBindKey(key))
return n.writeOverrideBinds(existingBinds)
}
func (n *NiriProvider) ResetBind(key string) error {
return n.RemoveBind(key)
}
type overrideBind struct {
Key string
Action string
@@ -292,7 +295,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
parser := NewNiriParser(filepath.Dir(overridePath))
parser.currentSource = overridePath
doc, err := kdl.Parse(strings.NewReader(string(data)))
doc, err := parseKDL(data)
if err != nil {
return nil, err
}
@@ -313,7 +316,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
action = n.formatRawAction(kb.Action, kb.Args)
}
binds[keyStr] = &overrideBind{
binds[normalizeNiriBindKey(keyStr)] = &overrideBind{
Key: keyStr,
Action: action,
Description: kb.Description,
+117 -11
View File
@@ -50,6 +50,103 @@ type NiriParser struct {
conflictingConfigs map[string]*NiriKeyBinding
}
func parseKDL(data []byte) (*document.Document, error) {
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data))))
}
func normalizeKDLBraces(input string) string {
var sb strings.Builder
sb.Grow(len(input))
var prev byte
n := len(input)
for i := 0; i < n; {
c := input[i]
switch {
case c == '"':
end := findStringEnd(input, i)
sb.WriteString(input[i:end])
prev = '"'
i = end
case c == '/' && i+1 < n && input[i+1] == '/':
end := findLineCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = '\n'
i = end
case c == '/' && i+1 < n && input[i+1] == '*':
end := findBlockCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = '/'
i = end
case c == '{' && prev != 0 && !isBraceAdjacentSpace(prev):
sb.WriteByte(' ')
sb.WriteByte(c)
prev = c
i++
default:
sb.WriteByte(c)
prev = c
i++
}
}
return sb.String()
}
func findStringEnd(s string, start int) int {
n := len(s)
for i := start + 1; i < n; {
switch s[i] {
case '\\':
i += 2
case '"':
return i + 1
default:
i++
}
}
return n
}
func findLineCommentEnd(s string, start int) int {
for i := start + 2; i < len(s); i++ {
if s[i] == '\n' {
return i
}
}
return len(s)
}
func findBlockCommentEnd(s string, start int) int {
n := len(s)
depth := 1
for i := start + 2; i < n && depth > 0; {
switch {
case i+1 < n && s[i] == '/' && s[i+1] == '*':
depth++
i += 2
case i+1 < n && s[i] == '*' && s[i+1] == '/':
depth--
i += 2
if depth == 0 {
return i
}
default:
i++
}
}
return n
}
func isBraceAdjacentSpace(b byte) bool {
switch b {
case ' ', '\t', '\n', '\r', '{':
return true
}
return false
}
func NewNiriParser(configDir string) *NiriParser {
return &NiriParser{
configDir: configDir,
@@ -65,6 +162,14 @@ func NewNiriParser(configDir string) *NiriParser {
}
}
func normalizeNiriBindKey(key string) string {
parts := strings.Split(key, "+")
for i := range parts {
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
}
return strings.Join(parts, "+")
}
func (p *NiriParser) Parse() (*NiriSection, error) {
dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl")
if _, err := os.Stat(dmsBindsPath); err == nil {
@@ -91,7 +196,7 @@ func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSec
return
}
doc, err := kdl.Parse(strings.NewReader(string(data)))
doc, err := parseKDL(data)
if err != nil {
return
}
@@ -116,24 +221,25 @@ func (p *NiriParser) finalizeBinds() []NiriKeyBinding {
func (p *NiriParser) addBind(kb *NiriKeyBinding) {
key := p.formatBindKey(kb)
normalizedKey := normalizeNiriBindKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl")
if isDMSBind {
p.dmsBindKeys[key] = true
p.dmsBindMap[key] = kb
} else if p.dmsBindKeys[key] {
p.dmsBindKeys[normalizedKey] = true
p.dmsBindMap[normalizedKey] = kb
} else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++
p.conflictingConfigs[key] = kb
p.configBindKeys[key] = true
p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[normalizedKey] = true
return
} else {
p.configBindKeys[key] = true
p.configBindKeys[normalizedKey] = true
}
if _, exists := p.bindMap[key]; !exists {
p.bindOrder = append(p.bindOrder, key)
if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, normalizedKey)
}
p.bindMap[key] = kb
p.bindMap[normalizedKey] = kb
}
func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string {
@@ -159,7 +265,7 @@ func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, erro
return nil, fmt.Errorf("failed to read %s: %w", absPath, err)
}
doc, err := kdl.Parse(strings.NewReader(string(data)))
doc, err := parseKDL(data)
if err != nil {
return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
}

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