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

Compare commits

..

137 Commits

Author SHA1 Message Date
bbedward 4203148cab ci: add settings index check to pre-commit 2026-06-18 18:42:33 -04:00
bbedward 5adc0c464a toast: fix blur 2026-06-18 15:49:53 -04:00
Rocho 097290f7da fix(lock): show the faillock lockout reason instead of "incorrect password" (#2669)
When pam_faillock locks the account (DMS authenticates through the system PAM
stack: /etc/pam.d/login -> system-auth), the lock screen kept showing the
generic "Incorrect password - try again" even though the real cause is a
lockout, so a correct password looks rejected and only a reboot (which clears
the tmpfs /run/faillock tally) appears to help. See #2647.

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

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

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

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

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

* ui: shorten notification type labels

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

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

* launcher pills: switch to buttonBg buttonText

---------

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

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

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

* chore: change default notification settings to false

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

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

* feat(clipboard): add clipboard filtering logic

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

* feat(clipboard): add filter dropdown

* feat(clipboard): move filter dropdown beside search

* refactor(clipboard): update filter defaults

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(clipboard): require confirmation for clear-all shortcut
2026-06-12 11:34:16 -04:00
purian23 d53809cf2b refactor(framemode): unify connected surface chrome via SDF pipeline
- Shadow system rewrite with SDF quads
- Replace ConnectedShape/layer FBOs w/frame & chrome SDF shaders
- Improve frame blur performance
- Plugin performance gate
2026-06-12 11:03:39 -04:00
Klesh Wong 08fd6e26d8 feat(notifications): user-configurable font size for notification summary and body (#2461)
* feat(notifications): add user-configurable font size for summary and body in notification popups

* feat: add Unset for falling back to previous default values

* fix: prek hook errors

---------

Co-authored-by: Klesh Wong <kleshwong@gmail.com>
2026-06-11 15:40:33 -04:00
Youseffo13 29e8470f2e fix(settings): fix text truncation in some section of settings and update icons (#2618)
* fixed spacing issues

* added one missing icon and replaced two
2026-06-11 15:35:51 -04:00
Bogdan Velicu 573785d4ce feat(notifications): add opt-in timeout progress bar on popups (#2587)
Adds a thin bar pinned to the bottom of the notification card that drains
full->empty over the auto-dismiss timer, as a visual countdown to
dismissal. Opt-in via notificationShowTimeoutBar (default off), with a
toggle in Settings > Notifications. Shown for any timed notification
(timer.interval > 0, including timed criticals); inset by the corner
radius, and frozen while hovered or during the exit animation. Plain
Rectangle - no offscreen textures or shader passes. A Connections on the
timer resets the bar on every (re)start, including the in-place restart
on a deduped notification.

Co-authored-by: bogdan-velicu <hydrotech074@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:22:22 -04:00
jbwfu 5483303714 Fix/clipboard pinned recents dedupe (#2605)
* fix(clipboard): unpin pinned duplicates from history entries

* fix(clipboard): dedupe recents when using pinned entries
2026-06-11 15:05:28 -04:00
David Mireles 5a5cc4f4e9 feat(plugins): expose scan/rescan/reload IPC handlers for runtime plugin discovery (#2611)
* feat(plugins): expose IPC handlers for runtime plugin discovery

Follow-up to #1659. That issue landed hot-reload for settings.json via
FileView.watchChanges + a 1ms Timer to skirt the JSON parse race. It does
not cover plugin discovery in runtime: adding a new plugin directory to
~/.config/DankMaterialShell/plugins/ while the shell is running is not
consistently picked up by the existing FolderListModel watcher in
PluginService.qml, and there is no IPC handle for forcing a rescan from
outside the shell.

Adds an IpcHandler on PluginService with five small functions:

- scan(): wraps existing scanPlugins(), returns count snapshot
- rescan(pluginId): wraps existing forceRescanPlugin(id), validates id
- reload(pluginId): wraps existing reloadPlugin(id), validates id
- list(): newline-joined id\tloaded\ttype\tname for every known plugin
- status(pluginId): loaded\ttype\terror for one plugin

Scope intentionally small: no file-watcher changes, no new daemons, no
schema additions. Target string "plugins" does not collide with any
existing target in DMSShellIPC.qml.

Validation:
- qs ipc --pid <PID> call plugins list returns one row per known plugin
- qs ipc --pid <PID> call plugins scan returns SCAN_TRIGGERED with count
- qs ipc --pid <PID> call plugins rescan <id> returns RESCAN_TRIGGERED
- Empty-arg paths return ERROR strings instead of throwing
- git merge-tree against origin/master is clean

* hardening(plugins): fix 7 review findings in scan-ipc IPC handlers

Follow-up to commit 43603f56 which ported PR #2601 (AvengeMedia scan-ipc)
to the fork. The original port was functionally correct but had seven
review issues that would block upstream adoption. This patch addresses
each one with a minimal, focused change.

* B1 IPC target collision: renamed `target: "plugins"` to
  `target: "plugin-scan"`. The original name collided with the
  existing IpcHandler in DMSShellIPC.qml:1180 which already registers
  enable/disable/toggle/list/status under "plugins". The split keeps
  both APIs discoverable without one shadowing the other.

* H1 Fire-and-forget scan: documented that scan() returns the
  pre-debounce count and that callers must poll list/status (or wait
  ~200ms) to observe the post-debounce state. A proper requestId +
  await mechanism was considered and rejected for scope reasons.

* H2 TOCTOU in rescan(): the handler now reads availablePlugins[id]
  inside forceRescanPlugin via the id string only — no captured
  object reference. A parallel resyncDebounce tick can otherwise
  mutate the entry between the read and the use.

* M1 list() cap: added a 256-entry cap and a leading header line
  (`# count=N returned=M`) so callers can detect truncation. A
  hostile / buggy plugin mass-creating entries could otherwise
  allocate 80 KB+ per IPC call.

* M2 status() prefix: "unknown\t\t" became
  `ERROR: unknown pluginId '...'` to match the rest of the
  handlers' prefix convention. Empty trailing field means no error.

* M3 id sanitization: every handler that takes pluginId now
  validates against `/^[a-zA-Z0-9_\-:]{1,64}$` before use. This
  rejects shell-injection payloads ("foo\tmalicious") and prototype
  pollution attempts ("__proto__", "constructor"). The list() and
  status() handlers also sanitize \t/\n in name and error fields
  so callers can rely on the TSV structure.

Verification: brace count balanced (252/252). Manual read of all
five handlers confirms no logic regression. QML runtime tests are
not part of the DMS test suite, so end-to-end validation requires
rebuilding the shell — deferred to the user.

Not pushed. Stage-local-first rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(plugins): strip inline comments per review feedback

Purian23 in PR #2611 review: 'let's address the amount of line
comments in the code, there's not a need for all of them to exist.'

Removed 48 comment lines. The substantive justification (why the
regex, why fire-and-forget, why re-read inside forceRescanPlugin,
why the 256 cap, why the target rename) now lives in the PR body
under 'Review-driven fixes in this iteration' and 'What changed'
where the reviewer already reads it.

No code logic changed. Brace count 252/252. Diff is -48/+0 on
quickshell/Services/PluginService.qml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------
2026-06-11 14:44:41 -04:00
bbedward cd672c341f settings: add DankSpinner, re-org some settings 2026-06-10 18:53:43 -04:00
bbedward 12438d63c2 mango: remove legacy dwl service 2026-06-10 17:01:03 -04:00
Ralph Zhou 35255e4053 fix(lock): bypass IME for password input (#2609) 2026-06-10 16:29:05 -04:00
goatnath 8856d45887 add local to-do planner / tasks to calendar overvie… (#2583)
* feat(quickshell): add local to-do planner / tasks to calendar overview card

* feat(quickshell): add auto-focus and task reordering support in calendar planner

* feat(quickshell): implement smooth drag-and-drop task reordering and inline editing

* fix(quickshell): resolve overlap and jitter in task drag-and-drop

* fix(quickshell): fix boundary swaps and prevent task list scrambling on reload

* fix(quickshell): resolve race, fix qml error, simplify dragging, and remove python dependency

* fix(quickshell): use Log service instead of console.warn in CalendarService

* style: format QML files w/qmlformat-qt6

---------
2026-06-09 00:43:41 -04:00
jbwfu 38af56c6fd fix(clipboard): exit saved filter when pinned entries are empty (#2604) 2026-06-08 23:54:06 -04:00
jbwfu 9111e4809d fix(powermenu): close control center on lock and power actions (#2598) 2026-06-08 23:53:43 -04:00
purian23 d08c7c5e55 refactor(frame): improve connected mode surface recovery
- share modal and launcher ownership handling
- recover missing background and blur layers
2026-06-07 17:47:24 -04:00
purian23 69f3dee25a feat(settings): add compositor section & restructured settings
- add dedicated Compositor pages for comp specifc features
- add Dank Bar Appearance subsection
- improve lazy loading, caching, search routing, & IPC navigation
- standardized responsive Setting categories from global animations
2026-06-07 03:52:00 -04:00
purian23 8155970ba2 fix(fonts): auto-rebuild font cache when configured fonts are missing
- Add Fonts category to dms doctor for manual diagnostics
- Fix a default font setting warning
2026-06-06 19:24:52 -04:00
pathmann d356957dad fix: ignore keyboard shortcuts of disabled powermenu actions (#2580)
* fix: ignore keyboard shortcuts of disabled powermenu actions

* fix typo when checking for lock shortcut

* ignore shortcuts for hidden powermenu actions in grid navigation

* ignore keyboard shortcuts of disabled actions in lock power menu

* ignore keyboard shortcuts of disabled actions in lock power menu (list navigation)
2026-06-06 18:28:38 -04:00
purian23 e7ccb702a3 refactor: update KeybindsModal dynamic sizing 2026-06-05 23:17:14 -04:00
Connor Welsh bf3ce6deb2 fix(osd): size from PanelWindow.screen (#2582) 2026-06-05 23:14:51 -04:00
purian23 f5295fb35d fix(greeter): remove auto-login state resolution from first install configuration
- Update auto-login command to:
`dms greeter sync --autologin`
2026-06-05 23:05:56 -04:00
purian23 6c5836722a fix(authModals): enable overlay layer for for auth popups 2026-06-05 21:27:37 -04:00
Youseffo13 5716249bd9 (Control Center): revamp of 25% pill option (#2568)
* revamp of control center

* update comment of SmallCompoundButton.qml
2026-06-05 19:46:01 -04:00
purian23 4d0aab773b fix(wallpaper): external management toggle & partial monitor DPMS recovery
Fixes #2579, #2581
2026-06-05 19:36:23 -04:00
purian23 e50ac208e3 feat(mangowm): add live config reloads & misc QOL updates
- Hide workspace tags during Mango overview
- Add HJKL focus/move defaults
- Add Mango natural touchpad scrolling &  cursor configs
- Fix Mango startup
2026-06-05 10:53:26 -04:00
bbedward bcb5617194 plugins: add support for composite plugins
- single plugin can register multiple types - e.g. daemon, bar widget,
  desktop widget
2026-06-05 10:33:34 -04:00
bbedward d3c23ba737 settings: add missing tabs to index and tweak search scoring 2026-06-05 09:49:49 -04:00
purian23 e0ab0a6b90 feat(keybinds): new mango overview workspace pill & keybind updates 2026-06-04 23:23:03 -04:00
purian23 713ce5f430 fix(display): unify compositor output profiles 2026-06-04 22:25:56 -04:00
purian23 8eb23bcc29 feat(mango): first-class MangoWM support across DMS, dankinstaller & UI tools
- Bring up Mango to parity with niri/hyprland via a native JSON-IPC w/Native MangoServic., replaces the legacy dwl/`mmsg` path and recent breaking changes
- Dankinstall: mango supported installer, config/binds templates, and packaging (Arch AUR, Fedora Terra auto-enable, Gentoo GURU)
- Window rules: Go provider + CLI + Settings GUI editor
- Keybinds + config reload on edit (mmsg dispatch reload_config)
- Misc new supported options in DMS settings
2026-06-04 18:45:04 -04:00
CoastlineSec 4181343ef3 feat(keybinds): add default app IPC actions (#2546) 2026-06-04 10:16:02 -04:00
bbedward d16566aa8d gh: add PR template 2026-06-04 10:15:44 -04:00
Nachum Barcohen 45eb101f40 fix(lock): wake monitors on input when powered off by lock (#2572)
The "power off monitors on lock" path relies on wake handlers
(MouseArea/Keys) at Lock.qml's root Scope, which sit outside the
WlSessionLock surface and never receive input while locked. The feature
only appeared to work on compositors that auto-restore output power on
input; compositors that fully tear down the output (e.g. MangoWC
disable_monitor) leave the screen off until blind-unlock.

Add a seat-level IdleMonitor (wlr idle-notify, surface-independent) in
IdleService that restores monitor power on any keyboard/pointer
activity. Gated on isShellLocked + monitorsOff + the setting, so it is
inert unless that path actually powered the monitors off.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:05:59 -04:00
purian23 59431869dc fix(keybinds): guard missing fields in cheatsheet search filter
Fixes #2116
2026-06-03 20:06:14 -04:00
purian23 6e7aca8b15 feat(window-rules): add niri default-floating-position rule
- Closes #2018
2026-06-03 19:43:23 -04:00
purian23 6f387b0481 fix(windowrules): honor $XDG_CONFIG_HOME for config paths
- Window rule read/write/includes detection and setup hardcoded
`$HOME/.config` so rules weren't applied when `XDG_CONFIG_HOME` pointed
elsewhere.
- Fixes #2289
2026-06-03 19:01:25 -04:00
purian23 82d4364032 feat(hyprland): add Resize on Border layout option 2026-06-03 18:45:33 -04:00
purian23 e3de54c941 feat(workspace): optionally group app icons on the active workspace
Closes #1208
2026-06-03 18:32:53 -04:00
purian23 6991b45fbe fix(settings): surface Windows Rules Tab for Hyprland
- small hyprland parser update
2026-06-03 17:39:44 -04:00
bbedward e5fff91ae6 notification: fix modal escape key handling in connected mode
fixes #2566
2026-06-03 10:49:51 -04:00
bbedward 2f2d4c9d9b i18n: remove redundant terms and sync 2026-06-03 10:32:51 -04:00
bbedward bfca1b46a6 greeter: support lua hyprland configs
fixes #2565
2026-06-03 09:52:45 -04:00
bbedward b117c80e47 settings: fix blur error message 2026-06-03 09:46:15 -04:00
purian23 d20aa3b80a feat(window-rules): view & convert external rules to DMS
- Read and convert external compositor rules into editable DMS rules
- Preserve niri multi-match rules and add match editor
- niri background-effect (blur/xray/noise/saturation) support
2026-06-03 08:59:51 -04:00
purian23 a34fda984d fix(Clipboard): stale image entry handling
- Resolved random DMS API errors & QML Warnings
2026-06-03 03:17:02 -04:00
purian23 510269dda9 fix(keybinds): show percentage amount in titles & performance improvements 2026-06-03 00:35:19 -04:00
purian23 d51b34797c refactor(Hyprland): updated the default close/kill keybinds for lua 2026-06-02 23:16:05 -04:00
arfan d2905072c0 feat(settings): Added Settings Tab Autostart App (XDG Autostart) (#2535)
* feat(Autostart): add Autostart tab and application selection popup

* fix(AutoStartTab): update systemdUserDir property to use XDG_CONFIG_HOME

* fix(AutoStartTab): update autostartDir and systemdUserDir to use StandardPaths for config home

* refactor(AutoStartTab): use FileView & FolderListModel

* refactor(AutoStartTab): implement systemd override generation for autostart applications using FileView

* feat(AutoStartTab): add systemd check to determine environment and update tray icon visibility

* feat(SettingsSidebar, AutoStartTab, DesktopService): add autostart functionality and systemd checks

* feat(AutoStartTab): add hidden property support for desktop entries and toggle functionality

* feat(AutoStartTab): add initialize autostart directory and add toast if writer failed

* add(AutoStartTab): logging for scoped log tracking

---------
2026-06-02 22:52:58 -04:00
purian23 1ee42506b6 refactor(control-center): consolidate detail section height rules 2026-06-02 15:48:47 -04:00
Guilherme Pagano 84fe2d751f fix(control-center): honor plugin ccDetailHeight instead of fixed 250px slot (#2559) 2026-06-02 15:31:55 -04:00
euletheia 5d0fc48706 Add DoNotDisturb & IdleInhibitor Indicators for the ControlCenterButton widget + Add optional icon to KeyboardLayoutName widget (#2513)
* feat(ControlCenterButton): add IdleInhibitor icon

* feat(ControlCenterButton): add DoNotDisturb icon

* fix(WidgetTabSection): adjust text width for the SystemTray Widget popup

* feat(KeyboardLayoutName): add optional icon

* refactor(KeyboardLayoutName): simplify icon visibility logic by using root.showIcon

---------
2026-06-02 14:25:35 -04:00
purian23 335c5b4ac5 feat(Greeter): add auto-login feature for startup settings
- Introduced a new cli flag:
`dms greeter sync --autologin-only`
and updated UI toggle in Greeter settings
2026-06-02 02:03:02 -04:00
bbedward 8c20f448ed control center: improve drag handling
misc: fix layer shell enum usage
2026-06-01 13:16:47 -04:00
jbwfu 0a668df138 fix(clipboard): prefer image MIME types over text fallbacks (#2551) 2026-06-01 11:44:10 -04:00
Guilherme Pagano 3e4d2b4d46 feat(control-center): add DiskUsage widget config overlay with showMountPath toggle and standardized tile sizing (#2507)
* feat(control-center): add widget config overlay with showMountPath toggle for DiskUsage

Introduces WidgetConfigOverlay and DiskUsageWidgetConfigMenu components, allowing
users to toggle mount path visibility per DiskUsage widget in edit mode

* refactor(control-center): use Theme.iconSizeLarge and Theme.fontSizeLarge for small tiles

Standardize SmallDiskUsageButton and SmallBatteryButton sizing with Theme.iconSizeLarge
and Theme.fontSizeLarge, and unify font weight to Font.Bold on both tile widgets.

* fix(control-center): adjust SmallDiskUsageButton font size based on showMountPath

* refactor(control-center): simplify DiskUsage config menu i18n strings

Remove the redundant "Disk Usage Widget" title and the toggle description
to reduce translatable strings
2026-06-01 11:35:14 -04:00
jbwfu 12e43d120e fix(clipboard): raise CLI IPC scanner limit for large entry payloads (#2550) 2026-06-01 11:01:34 -04:00
jbwfu a9845bf3cd fix(wallpaper): redraw wallpaper layers when fill mode changes (#2542) 2026-06-01 10:36:50 -04:00
Graeme Foster e51ceed175 fix(network): exclude virtual ether links and prune stale ones from networkd (#2505)
The networkd backend treated any link reporting Type=ether as a wired uplink.
Podman bridges and veth pairs report Type=ether, so they were classified as
ethernet: isWired() short-circuited on Type and never consulted looksVirtual(),
which also lacked a podman prefix.

The link map was also never pruned. Links discovered at enumeration or via
signals were kept forever, so torn-down container interfaces lingered as
routable and could win the wired-uplink slot over the real NIC -- leaving the
indicator showing WiFi while a wired connection was active and default-routed.

- isWired()/isWireless() exclude virtual interfaces before consulting Type, and
  looksVirtual() now recognises podman.
- enumerateLinks() reconciles the cached map against ListLinks via syncLinks(),
  pruning links that no longer appear so dead interfaces don't accumulate.
2026-06-01 09:45:49 -04:00
Karel "Angerion" Čeleda 304baf6f60 fix(CavaService): prevent 100% CPU EOF spin loop by using temp file (#2471)
* fix(CavaService): prevent 100% CPU EOF spin loop by using temp config file

* cava: make tmp file non-deterministic

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-06-01 09:45:16 -04:00
purian23 6b141a9b06 refactor(Hyprland): updates to Lua syntax/dispatchers 2026-06-01 09:31:19 -04:00
purian23 0c3659a612 feat(PluginBrowser): add sorting and filtering options for plugins
- Introduced sorting and filtering by installed, default, category, name, and author
2026-05-31 23:58:13 -04:00
purian23 a44bef5796 fix(Notifications): restore long message content from overflowing
- Addtional security escape patch
- Tweak Notification Center width
2026-05-31 22:38:50 -04:00
Youseffo13 b1ac6b0ef9 Add context to font weight keys, wrap render settings, fix uptime prefix (#2529)
* fix: i18n inconsistencies

* Update UserInfoCard.qml
2026-05-31 18:10:43 -04:00
purian23 98844a3b85 fix(Clipboard): prevent security risk HTML in window titles from fetching remote URLs
- Default StyledText to PlainText; keep RichText only on notification content
2026-05-31 17:41:55 -04:00
purian23 a32b8911c7 feat(Hyprland): add touchpad gesture support via DMS default configs 2026-05-31 15:45:17 -04:00
purian23 3118e7b9c3 fix(Hyprland): correct Lua keybind writes
- Write titles as Lua description metadata
- Use hl.dispatch for custom dispatcher actions
- Preserve legacy trailing comment titles on rewrite
- Update option edits before saving keybinds
2026-05-31 15:35:13 -04:00
Miguel Saliba 2ca2bc5fb8 fix: make matugen switch pywalfox dark/light mode (#2537)
Currently, when switching dark/light theme in DMS, pywalfox's mode does not get updated. This commit adds `pywalfox light/dark` to the matugen post_hook to fix that.
2026-05-31 00:35:06 -04:00
purian23 4bfb08f6ef fix(Hyprland): Lua config for display setup writes
- Check display include status on startup from legacy to lua
2026-05-31 00:11:30 -04:00
purian23 0689339780 feat(Hyprland): add fractional scaling display presets
- Show Hyprland scale presets that fit the active mode
- Preserve current dms setup values
2026-05-31 00:10:22 -04:00
purian23 a265625851 refactor(Hyprland): Update Lua migration and keybind writes
- emit native hl.dsp.* dispatchers for generated Lua keybinds
- keep legacy hyprland.conf installs read-only but preserved until dms setup migration
2026-05-30 23:07:06 -04:00
Body 389fffaf64 feat(Clipboard-Bar-Hist): Add search/filter to saved clipboard entries & animation states (#2464)
* Fix gaps and overlaps when filtering clipboard history

* feat(Clipboard-Bar-Hist): Add search/filter to saved clipboard entries as well. Change title on toggle between recent/saved.

* keep Pinned/Saved icon highlighted when selected

* add back filter animations

* Implement snap state for list views based on animation settings

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-05-30 15:10:58 -04:00
purian23 b7daf3f64a feat(ipc): add powerprofile status & shared profile helpers
- Follow-up to PR #2515
2026-05-30 14:57:01 -04:00
Huỳnh Thiện Lộc 461da22b08 feat(ipc): add native powerprofile target for power profiles management (#2515)
* feat: add native powerprofile IPC target for power profiles management

* feat: show centered PowerProfileModal with 3 square buttons for powerprofile IPC toggle

* style: enhance PowerProfileModal size, icons, description, and keyboard hints

* feat: add Space key binding to select highlighted power profile
2026-05-30 14:51:19 -04:00
jbwfu 2b661e241d fix(settings): support localized settings search (#2521) 2026-05-30 01:55:46 -04:00
Kyunghyun Park d7df3800c2 keybinds: add missing XF86 key mappings (#2500) 2026-05-30 01:31:58 -04:00
Bogdan f2961f9b6a feat(DiskUsage): updated dynamic width options for DiskUsage widget (#2517)
* feat(WidgetsTabSection.qml): added dynamic width and static padding for DiskUsage

* feat(DiskUsage.qml):added functionality for dynamic width and static padding, also changed spacing to work like in cpu and ram monitor. Now they look complete and same

* fix(DiskUsage): restore display modes & formatting

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-05-30 01:02:15 -04:00
purian23 f2d5ee4692 fix(animation): adjust the Popout/Control Center motion 2026-05-29 22:04:46 -04:00
purian23 7c2d5ce15e fix(Screenshot): allow region capture over shell overlays 2026-05-29 17:03:19 -04:00
Lucas 5ceb908b8b greeter: remove keep-max-bpc-unchanged option (#2528) 2026-05-29 15:14:41 -04:00
Paul d819865853 fix: Display Configurator in Hyprland (#2506)
* fix: display configurator

* fix: replace
2026-05-28 17:26:14 -04:00
jbwfu 38176ab543 fix(settings): make desktop widget group delete button clickable (#2512) 2026-05-28 12:19:02 -04:00
purian23 53936d7034 Revert "fix(IconTheme): apply stored icon theme at startup (#2511)"
This reverts commit aafc2ea4d7.
2026-05-28 11:42:07 -04:00
lingdianshiren aafc2ea4d7 fix(IconTheme): apply stored icon theme at startup (#2511)
Add applyStoredIconTheme() calls alongside existing applyStoredTheme()
calls in loadSettings(), onLoaded, and onLoadFailed, ensuring the stored
icon theme is synced to GTK/Qt/Cosmic configs on every startup and reload.

The applyStoredIconTheme() function and its _hooks entry already existed
in the codebase but were never invoked during initial settings loading.

Co-authored-by: lingdiansr <2077258365@qq.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:18:44 -04:00
Connor Welsh 8a4be4936a Hide MPRIS players that have nothing to play (#2509)
* fix(Mpris): exclude idle players from active-player selection

Add MprisController.isIdle() (player Stopped with empty title and
artist). _resolveActivePlayer excludes idle players from every
selection path, and re-resolves when the active player itself goes
idle. Existing triggers (availablePlayers change, isPlaying becoming
true) do not fire for a player merely stopping.

The fallback now gates on !isIdle(p) instead of p.canPlay. canPlay
describes whether Play() would succeed; !isIdle describes whether
there is anything to surface. canControl unchanged.

When no eligible player remains, activePlayer becomes null and
consumers that gate on it unload (the bar media widget via
WidgetHost; the dash via the companion fix).

* fix(DankDash): show no-player state when active player resolves to null

showNoPlayerNow gated on _noneAvailable (player count === 0) or
activePlayer being idle. Neither covers activePlayer being null
while players remain registered, which is now possible (an
always-on player sitting stopped with empty metadata).

Key showNoPlayerNow on !activePlayer; drop the unreachable
_trulyIdle.

* add(MprisController): add track artist change handling to active player resolution

---------
2026-05-28 00:19:34 -04:00
purian23 af097d0f33 feat(Greeter): Enhance login experience & manual username fallback support 2026-05-27 22:31:54 -04:00
Ethan Todd 44867e7b43 fix: awk in new greeter (#2508) 2026-05-27 22:02:37 -04:00
406 changed files with 58270 additions and 22374 deletions
@@ -235,7 +235,7 @@ Conditionally show/hide the bar pill:
```qml ```qml
PluginComponent { PluginComponent {
visibilityCommand: "pgrep -x myapp" visibilityCommand: "pgrep -x myapp"
visibilityInterval: 5000 // check every 5 seconds visibilityInterval: 5 // seconds between checks; polling pauses while the bar is hidden
} }
``` ```
+31
View File
@@ -0,0 +1,31 @@
## Description
<!-- What does this PR do and why? -->
## Type of change
<!-- Check all that apply. -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that changes existing behavior)
- [ ] Refactor / internal cleanup
- [ ] Documentation
- [ ] Other
## Related issues
<!-- e.g. "Fixes #123", "Closes #123". Leave blank if none. -->
## Screenshots / video
<!-- Include screenshots or a video for any user-facing or visual change. -->
## Checklist
- [ ] My code follows the conventions in CONTRIBUTING.md
- [ ] I have tested my changes locally
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
- [ ] QML changes: ran `make lint-qml` with no new warnings
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/AvengeMedia/DankLinux-Docs
+2
View File
@@ -115,3 +115,5 @@ core.*
.direnv/ .direnv/
quickshell/dms-plugins quickshell/dms-plugins
__pycache__ __pycache__
.vscode/
+8
View File
@@ -20,6 +20,14 @@ repos:
language: system language: system
files: ^core/.*\.(go|mod|sum)$ files: ^core/.*\.(go|mod|sum)$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: settings-search-index
name: settings search index is up to date
entry: bash -c 'python3 quickshell/translations/extract_settings_index.py >/dev/null || exit 1; if ! git diff --exit-code -- quickshell/translations/settings_search_index.json; then echo "settings_search_index.json is out of date; run quickshell/translations/extract_settings_index.py and stage the result" >&2; exit 1; fi'
language: system
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false
- repo: local - repo: local
hooks: hooks:
- id: no-console-in-qml - id: no-console-in-qml
+2 -2
View File
@@ -42,7 +42,7 @@ configure passwordless sudo for your user.`,
} }
func init() { func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)") rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)") rootCmd.Flags().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(&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(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
@@ -95,7 +95,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
func runHeadless() error { func runHeadless() error {
// Validate required flags // Validate required flags
if compositor == "" { if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)") return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
} }
if term == "" { if term == "" {
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)") return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
+9 -1
View File
@@ -19,7 +19,12 @@ var (
var colorCmd = &cobra.Command{ var colorCmd = &cobra.Command{
Use: "color", Use: "color",
Short: "Color utilities", Short: "Color utilities",
Long: "Color utilities including picking colors from the screen", Long: `Color utilities including picking colors from the screen.
This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle
See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
} }
var colorPickCmd = &cobra.Command{ var colorPickCmd = &cobra.Command{
@@ -29,6 +34,9 @@ var colorPickCmd = &cobra.Command{
Click on any pixel to capture its color, or press Escape to cancel. Click on any pixel to capture its color, or press Escape to cancel.
This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle
Output format flags (mutually exclusive, default: --hex): Output format flags (mutually exclusive, default: --hex):
--hex - Hexadecimal (#RRGGBB) --hex - Hexadecimal (#RRGGBB)
--rgb - RGB values (R G B) --rgb - RGB values (R G B)
+16 -3
View File
@@ -77,10 +77,15 @@ var killCmd = &cobra.Command{
} }
var ipcCmd = &cobra.Command{ var ipcCmd = &cobra.Command{
Use: "ipc [target] [function] [args...]", Use: "ipc",
Short: "Send IPC commands to running DMS shell", Short: "Send IPC commands to running DMS shell",
Long: `Send IPC commands to the running DMS shell.
dms ipc call <target> <function> [args...] invoke a command
dms ipc list list all targets and functions
Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = findConfig(cmd, args)
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
@@ -88,9 +93,17 @@ var ipcCmd = &cobra.Command{
}, },
} }
var ipcListCmd = &cobra.Command{
Use: "list",
Short: "List all IPC targets and functions",
Run: func(cmd *cobra.Command, args []string) {
printIPCHelp()
},
}
func init() { func init() {
ipcCmd.AddCommand(ipcListCmd)
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
_ = findConfig(cmd, args)
printIPCHelp() printIPCHelp()
}) })
} }
+14 -15
View File
@@ -54,8 +54,10 @@ func init() {
} }
type IncludeResult struct { type IncludeResult struct {
Exists bool `json:"exists"` Exists bool `json:"exists"`
Included bool `json:"included"` Included bool `json:"included"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
func runResolveInclude(cmd *cobra.Command, args []string) { func runResolveInclude(cmd *cobra.Command, args []string) {
@@ -70,7 +72,7 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
result, err = checkHyprlandInclude(filename) result, err = checkHyprlandInclude(filename)
case "niri": case "niri":
result, err = checkNiriInclude(filename) result, err = checkNiriInclude(filename)
case "mangowc", "dwl", "mango": case "mangowc", "mango":
result, err = checkMangoWCInclude(filename) result, err = checkMangoWCInclude(filename)
default: default:
log.Fatalf("Unknown compositor: %s", compositor) log.Fatalf("Unknown compositor: %s", compositor)
@@ -85,10 +87,7 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
} }
func checkHyprlandInclude(filename string) (IncludeResult, error) { func checkHyprlandInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/hypr") configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename) targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{} result := IncludeResult{}
@@ -106,6 +105,8 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainLua := filepath.Join(configDir, "hyprland.lua") mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil { if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua"
result.ReadOnly = false
processedLua := make(map[string]bool) processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) { if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true result.Included = true
@@ -115,6 +116,10 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainConf := filepath.Join(configDir, "hyprland.conf") mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil { if _, err := os.Stat(mainConf); err == nil {
if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool) processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) { if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true result.Included = true
@@ -183,10 +188,7 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
} }
func checkNiriInclude(filename string) (IncludeResult, error) { func checkNiriInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/niri") configDir := filepath.Join(utils.XDGConfigHome(), "niri")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename) targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{} result := IncludeResult{}
@@ -262,10 +264,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
} }
func checkMangoWCInclude(filename string) (IncludeResult, error) { func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/mango") configDir := filepath.Join(utils.XDGConfigHome(), "mango")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename) targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{} result := IncludeResult{}
+101
View File
@@ -125,6 +125,7 @@ const (
catConfigFiles catConfigFiles
catServices catServices
catEnvironment catEnvironment
catFonts
) )
func (c category) String() string { func (c category) String() string {
@@ -147,6 +148,8 @@ func (c category) String() string {
return "Services" return "Services"
case catEnvironment: case catEnvironment:
return "Environment" return "Environment"
case catFonts:
return "Fonts"
default: default:
return "Unknown" return "Unknown"
} }
@@ -213,6 +216,7 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkConfigurationFiles(), checkConfigurationFiles(),
checkSystemdServices(), checkSystemdServices(),
checkEnvironmentVars(), checkEnvironmentVars(),
checkFonts(),
) )
switch { switch {
@@ -1135,3 +1139,100 @@ func formatResultsPlain(results []checkResult) string {
return sb.String() return sb.String()
} }
func checkFonts() []checkResult {
var results []checkResult
url := doctorDocsURL + "#fonts"
configDir, err := os.UserConfigDir()
if err != nil {
return nil
}
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
fontFamily := "Inter Variable"
monoFontFamily := "Fira Code"
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
fontFamily = settings.FontFamily
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
}
}
if !utils.CommandExists("fc-list") {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify if fonts are cached.", url})
return results
}
// Retrieve font list
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
return results
}
outStr := string(output)
if len(strings.TrimSpace(outStr)) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
return results
}
lowerFonts := strings.ToLower(outStr)
// Helper to check if a font exists
hasFont := func(name string) bool {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
return false
}
for _, line := range strings.Split(lowerFonts, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Each line can have comma-separated families
families := strings.Split(line, ",")
for _, fam := range families {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
// Normal Font Check
if hasFont(fontFamily) {
results = append(results, checkResult{catFonts, "Normal Font", statusOK, fontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Normal Font", statusWarn,
fmt.Sprintf("'%s' not found", fontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
// Monospace Font Check
if hasFont(monoFontFamily) {
results = append(results, checkResult{catFonts, "Monospace Font", statusOK, monoFontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Monospace Font", statusWarn,
fmt.Sprintf("'%s' not found", monoFontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
return results
}
+64 -4
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -74,13 +75,20 @@ var greeterSyncCmd = &cobra.Command{
auth, _ := cmd.Flags().GetBool("auth") auth, _ := cmd.Flags().GetBool("auth")
local, _ := cmd.Flags().GetBool("local") local, _ := cmd.Flags().GetBool("local")
profile, _ := cmd.Flags().GetBool("profile") profile, _ := cmd.Flags().GetBool("profile")
autologinOnly, _ := cmd.Flags().GetBool("autologin")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
if term { if term {
if err := syncInTerminal(yes, auth, local, profile); err != nil { if err := syncInTerminal(yes, auth, local, profile, autologinOnly); err != nil {
log.Fatalf("Error launching sync in terminal: %v", err) log.Fatalf("Error launching sync in terminal: %v", err)
} }
return return
} }
if autologinOnly {
if err := syncGreeterAutoLoginOnly(yes); err != nil {
log.Fatalf("Error syncing greeter auto-login: %v", err)
}
return
}
if err := syncGreeter(yes, auth, local, profile); err != nil { if err := syncGreeter(yes, auth, local, profile); err != nil {
log.Fatalf("Error syncing greeter: %v", err) log.Fatalf("Error syncing greeter: %v", err)
} }
@@ -93,6 +101,7 @@ func init() {
greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles") greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles")
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path") greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)") greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
greeterSyncCmd.Flags().Bool("autologin", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
} }
var greeterEnableCmd = &cobra.Command{ var greeterEnableCmd = &cobra.Command{
@@ -520,8 +529,8 @@ func runCommandInTerminal(shellCmd string) error {
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)") return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
} }
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error { func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool, autologinOnly bool) error {
syncFlags := make([]string, 0, 4) syncFlags := make([]string, 0, 5)
if nonInteractive { if nonInteractive {
syncFlags = append(syncFlags, "--yes") syncFlags = append(syncFlags, "--yes")
} }
@@ -534,11 +543,19 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly
if profileOnly { if profileOnly {
syncFlags = append(syncFlags, "--profile") syncFlags = append(syncFlags, "--profile")
} }
if autologinOnly {
syncFlags = append(syncFlags, "--autologin")
}
shellSyncCmd := "dms greeter sync" shellSyncCmd := "dms greeter sync"
if len(syncFlags) > 0 { if len(syncFlags) > 0 {
shellSyncCmd += " " + strings.Join(syncFlags, " ") shellSyncCmd += " " + strings.Join(syncFlags, " ")
} }
shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` var shellCmd string
if autologinOnly {
shellCmd = shellSyncCmd + `; echo; echo "Auto-login update finished. Closing in 3 seconds..."; sleep 3`
} else {
shellCmd = shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
}
return runCommandInTerminal(shellCmd) return runCommandInTerminal(shellCmd)
} }
@@ -552,6 +569,49 @@ func resolveLocalWrapperShell() (string, error) {
return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper") return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper")
} }
func syncGreeterAutoLoginOnly(nonInteractive bool) error {
logFunc := func(msg string) {
fmt.Println(msg)
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
cacheSettingsPath := filepath.Join(greeter.GreeterCacheDir, "settings.json")
enabled := false
for _, path := range []string{cacheSettingsPath, settingsPath} {
data, readErr := os.ReadFile(path)
if readErr != nil {
continue
}
var cfg struct {
GreeterAutoLogin bool `json:"greeterAutoLogin"`
}
if json.Unmarshal(data, &cfg) == nil {
enabled = cfg.GreeterAutoLogin
break
}
}
fmt.Println("=== Greeter Auto-Login ===")
fmt.Println()
if enabled {
fmt.Println("Enabling auto-login on startup in greetd.")
fmt.Println("After your next reboot, DMS will skip the greeter password until you sign out.")
} else {
fmt.Println("Disabling auto-login on startup in greetd.")
fmt.Println("After your next reboot, you will enter your password at the greeter again.")
}
fmt.Println()
fmt.Println("Administrator (sudo) access is required to update /etc/greetd/config.toml.")
fmt.Println()
return greeter.SyncGreeterAutoLoginOnly(logFunc, "")
}
func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error { func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error {
if profileOnly { if profileOnly {
return syncGreeterProfileOnly(nonInteractive) return syncGreeterProfileOnly(nonInteractive)
+36 -4
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
@@ -37,7 +39,7 @@ Modes:
full - Capture the focused output full - Capture the focused output
all - Capture all outputs combined all - Capture all outputs combined
output - Capture a specific output by name output - Capture a specific output by name
window - Capture the focused window (Hyprland/DWL) window - Capture the focused window (Hyprland/Mango)
last - Capture the last selected region last - Capture the last selected region
Output format (--format): Output format (--format):
@@ -95,7 +97,7 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{ var ssWindowCmd = &cobra.Command{
Use: "window", Use: "window",
Short: "Capture the focused window", Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland and DWL.`, Long: `Capture the currently focused window. Supported on Hyprland and Mango.`,
Run: runScreenshotWindow, Run: runScreenshotWindow,
} }
@@ -179,9 +181,39 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
return config return config
} }
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
func setPopoutScreenshotMode(begin bool) {
fn := "end"
if begin {
fn = "begin"
}
cmdArgs := []string{"ipc"}
if pid, ok := getFirstDMSPID(); ok {
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
} else {
if err := findConfig(nil, nil); err != nil {
return
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
}
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Run()
}
func runScreenshot(config screenshot.Config) { func runScreenshot(config screenshot.Config) {
sc := screenshot.New(config) // Region select needs the keyboard; drop popout grabs for its duration.
result, err := sc.Run() result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
}
return screenshot.New(config).Run()
}()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
+68 -32
View File
@@ -100,56 +100,72 @@ var setupWindowrulesCmd = &cobra.Command{
} }
type dmsConfigSpec struct { type dmsConfigSpec struct {
niriFile string niriFile string
hyprFile string hyprFile string
niriContent func(terminal string) string mangoFile string
hyprContent func(terminal string) string niriContent func(terminal string) string
hyprContent func(terminal string) string
mangoContent func(terminal string) string
} }
var dmsConfigSpecs = map[string]dmsConfigSpec{ var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": { "binds": {
niriFile: "binds.kdl", niriFile: "binds.kdl",
hyprFile: "binds.lua", hyprFile: "binds.lua",
mangoFile: "binds.conf",
niriContent: func(t string) string { niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t) return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
}, },
hyprContent: func(t string) string { hyprContent: func(t string) string {
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t) return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
}, },
mangoContent: func(t string) string {
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
}, },
"layout": { "layout": {
niriFile: "layout.kdl", niriFile: "layout.kdl",
hyprFile: "layout.lua", hyprFile: "layout.lua",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, mangoFile: "layout.conf",
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
hyprFile: "colors.lua", hyprFile: "colors.lua",
niriContent: func(_ string) string { return config.NiriColorsConfig }, mangoFile: "colors.conf",
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return config.MangoColorsConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig }, niriContent: func(_ string) string { return config.NiriAlttabConfig },
}, },
"outputs": { "outputs": {
niriFile: "outputs.kdl", niriFile: "outputs.kdl",
hyprFile: "outputs.lua", hyprFile: "outputs.lua",
niriContent: func(_ string) string { return "" }, mangoFile: "outputs.conf",
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
mangoContent: func(_ string) string { return "" },
}, },
"cursor": { "cursor": {
niriFile: "cursor.kdl", niriFile: "cursor.kdl",
hyprFile: "cursor.lua", hyprFile: "cursor.lua",
niriContent: func(_ string) string { return "" }, mangoFile: "cursor.conf",
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
mangoContent: func(_ string) string { return "" },
}, },
"windowrules": { "windowrules": {
niriFile: "windowrules.kdl", niriFile: "windowrules.kdl",
hyprFile: "windowrules.lua", hyprFile: "windowrules.lua",
niriContent: func(_ string) string { return "" }, mangoFile: "windowrules.conf",
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
mangoContent: func(_ string) string { return "" },
}, },
} }
@@ -192,7 +208,7 @@ func detectCompositorForSetup() (string, error) {
switch len(compositors) { switch len(compositors) {
case 0: case 0:
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)") return "", fmt.Errorf("no supported compositors found (niri, Hyprland, or mango required)")
case 1: case 1:
return strings.ToLower(compositors[0]), nil return strings.ToLower(compositors[0]), nil
} }
@@ -224,6 +240,9 @@ func runSetupDmsConfig(name string) error {
case "hyprland": case "hyprland":
filename = spec.hyprFile filename = spec.hyprFile
contentFn = spec.hyprContent contentFn = spec.hyprContent
case "mango", "mangowc":
filename = spec.mangoFile
contentFn = spec.mangoContent
default: default:
return fmt.Errorf("unsupported compositor: %s", compositor) return fmt.Errorf("unsupported compositor: %s", compositor)
} }
@@ -235,9 +254,11 @@ func runSetupDmsConfig(name string) error {
var dmsDir string var dmsDir string
switch compositor { switch compositor {
case "niri": case "niri":
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms") dmsDir = filepath.Join(utils.XDGConfigHome(), "niri", "dms")
case "hyprland": case "hyprland":
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms") dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc":
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
} }
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -273,7 +294,14 @@ func runSetup() error {
wm, wmSelected := promptCompositor() wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal() terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd() useSystemd := true
if wmSelected {
if wm == deps.WindowManagerMango {
useSystemd = false
} else {
useSystemd = promptSystemd()
}
}
if !wmSelected && !terminalSelected { if !wmSelected && !terminalSelected {
fmt.Println("No configurations selected. Exiting.") fmt.Println("No configurations selected. Exiting.")
@@ -379,10 +407,11 @@ func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:") fmt.Println("Select compositor:")
fmt.Println("1) Niri") fmt.Println("1) Niri")
fmt.Println("2) Hyprland") fmt.Println("2) Hyprland")
fmt.Println("3) None") fmt.Println("3) Mango")
fmt.Println("4) None")
var response string var response string
fmt.Print("\nChoice (1-3): ") fmt.Print("\nChoice (1-4): ")
fmt.Scanln(&response) fmt.Scanln(&response)
response = strings.TrimSpace(response) response = strings.TrimSpace(response)
@@ -391,6 +420,8 @@ func promptCompositor() (deps.WindowManager, bool) {
return deps.WindowManagerNiri, true return deps.WindowManagerNiri, true
case "2": case "2":
return deps.WindowManagerHyprland, true return deps.WindowManagerHyprland, true
case "3":
return deps.WindowManagerMango, true
default: default:
return deps.WindowManagerNiri, false return deps.WindowManagerNiri, false
} }
@@ -447,6 +478,11 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"), filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"), filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
} }
case deps.WindowManagerMango:
configPaths = []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
} }
for _, configPath := range configPaths { for _, configPath := range configPaths {
+49 -22
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -26,7 +27,7 @@ var windowrulesListCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -40,7 +41,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -54,7 +55,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3), Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -68,7 +69,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -82,7 +83,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -120,6 +121,9 @@ func getCompositor(args []string) string {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland" return "hyprland"
} }
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
return "mango"
}
return "" return ""
} }
@@ -139,17 +143,14 @@ func writeRuleSuccess(id, path string) {
func runWindowrulesList(cmd *cobra.Command, args []string) { func runWindowrulesList(cmd *cobra.Command, args []string) {
compositor := getCompositor(args) compositor := getCompositor(args)
if compositor == "" { if compositor == "" {
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri") log.Fatalf("Could not detect compositor. Please specify: hyprland, niri, or mango")
} }
var result WindowRulesListResult var result WindowRulesListResult
switch compositor { switch compositor {
case "niri": case "niri":
configDir, err := utils.ExpandPath("$HOME/.config/niri") configDir := filepath.Join(utils.XDGConfigHome(), "niri")
if err != nil {
log.Fatalf("Failed to expand niri config path: %v", err)
}
parseResult, err := providers.ParseNiriWindowRules(configDir) parseResult, err := providers.ParseNiriWindowRules(configDir)
if err != nil { if err != nil {
@@ -182,10 +183,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus result.DMSStatus = parseResult.DMSStatus
case "hyprland": case "hyprland":
configDir, err := utils.ExpandPath("$HOME/.config/hypr") configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err)
}
parseResult, err := providers.ParseHyprlandWindowRules(configDir) parseResult, err := providers.ParseHyprlandWindowRules(configDir)
if err != nil { if err != nil {
@@ -217,6 +215,38 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.Rules = allRules result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus result.DMSStatus = parseResult.DMSStatus
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
parseResult, err := providers.ParseMangoWindowRules(configDir)
if err != nil {
log.Fatalf("Failed to parse mango window rules: %v", err)
}
allRules := providers.ConvertMangoRulesToWindowRules(parseResult.Rules)
provider := providers.NewMangoWritableProvider(configDir)
dmsRules, _ := provider.LoadDMSRules()
dmsRuleMap := make(map[int]windowrules.WindowRule)
for i, dr := range dmsRules {
dmsRuleMap[i] = dr
}
dmsIdx := 0
for i, r := range allRules {
if r.Source == "dms/windowrules.conf" {
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
allRules[i].ID = dmr.ID
allRules[i].Name = dmr.Name
}
dmsIdx++
}
}
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
default: default:
log.Fatalf("Unknown compositor: %s", compositor) log.Fatalf("Unknown compositor: %s", compositor)
} }
@@ -315,17 +345,14 @@ func runWindowrulesReorder(cmd *cobra.Command, args []string) {
func getWindowRulesProvider(compositor string) windowrules.WritableProvider { func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
switch compositor { switch compositor {
case "niri": case "niri":
configDir, err := utils.ExpandPath("$HOME/.config/niri") configDir := filepath.Join(utils.XDGConfigHome(), "niri")
if err != nil {
return nil
}
return providers.NewNiriWritableProvider(configDir) return providers.NewNiriWritableProvider(configDir)
case "hyprland": case "hyprland":
configDir, err := utils.ExpandPath("$HOME/.config/hypr") configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
if err != nil {
return nil
}
return providers.NewHyprlandWritableProvider(configDir) return providers.NewHyprlandWritableProvider(configDir)
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
return providers.NewMangoWritableProvider(configDir)
default: default:
return nil return nil
} }
+6
View File
@@ -12,6 +12,10 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
) )
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
// overhead in the line-delimited IPC response.
const maxIPCMessageSize = 96 * 1024 * 1024
func sendServerRequest(req models.Request) (*models.Response[any], error) { func sendServerRequest(req models.Request) (*models.Response[any], error) {
socketPath := getServerSocketPath() socketPath := getServerSocketPath()
@@ -22,6 +26,7 @@ func sendServerRequest(req models.Request) (*models.Response[any], error) {
defer conn.Close() defer conn.Close()
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req) reqData, err := json.Marshal(req)
@@ -61,6 +66,7 @@ func sendServerRequestFireAndForget(req models.Request) error {
defer conn.Close() defer conn.Close()
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req) reqData, err := json.Marshal(req)
+196 -33
View File
@@ -2,7 +2,9 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
@@ -192,6 +194,7 @@ func runShellInteractive(session bool) {
} }
}() }()
ensureFontCache()
log.Infof("Spawning quickshell with -p %s", configPath) log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath) cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
@@ -227,8 +230,10 @@ func runShellInteractive(session bool) {
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr tracker := &stderrTracker{parent: os.Stderr}
cmd.Stderr = tracker
startTime := time.Now()
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
log.Fatalf("Error starting quickshell: %v", err) log.Fatalf("Error starting quickshell: %v", err)
} }
@@ -277,7 +282,9 @@ func runShellInteractive(session bool) {
case <-errChan: case <-errChan:
cancel() cancel()
os.Remove(socketPath) os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState)) exitCode := getProcessExitCode(cmd.ProcessState)
logStartupFailure(startTime, exitCode, tracker)
os.Exit(exitCode)
case <-time.After(500 * time.Millisecond): case <-time.After(500 * time.Millisecond):
} }
@@ -294,7 +301,9 @@ func runShellInteractive(session bool) {
cmd.Process.Signal(syscall.SIGTERM) cmd.Process.Signal(syscall.SIGTERM)
} }
os.Remove(socketPath) os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState)) exitCode := getProcessExitCode(cmd.ProcessState)
logStartupFailure(startTime, exitCode, tracker)
os.Exit(exitCode)
} }
} }
} }
@@ -434,6 +443,7 @@ func runShellDaemon(session bool) {
} }
}() }()
ensureFontCache()
log.Infof("Spawning quickshell with -p %s", configPath) log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath) cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
@@ -478,8 +488,10 @@ func runShellDaemon(session bool) {
cmd.Stdin = devNull cmd.Stdin = devNull
cmd.Stdout = devNull cmd.Stdout = devNull
cmd.Stderr = devNull tracker := &stderrTracker{parent: devNull}
cmd.Stderr = tracker
startTime := time.Now()
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
log.Fatalf("Error starting daemon: %v", err) log.Fatalf("Error starting daemon: %v", err)
} }
@@ -528,7 +540,9 @@ func runShellDaemon(session bool) {
case <-errChan: case <-errChan:
cancel() cancel()
os.Remove(socketPath) os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState)) exitCode := getProcessExitCode(cmd.ProcessState)
logStartupFailure(startTime, exitCode, tracker)
os.Exit(exitCode)
case <-time.After(500 * time.Millisecond): case <-time.After(500 * time.Millisecond):
} }
@@ -543,7 +557,9 @@ func runShellDaemon(session bool) {
cmd.Process.Signal(syscall.SIGTERM) cmd.Process.Signal(syscall.SIGTERM)
} }
os.Remove(socketPath) os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState)) exitCode := getProcessExitCode(cmd.ProcessState)
logStartupFailure(startTime, exitCode, tracker)
os.Exit(exitCode)
} }
} }
} }
@@ -585,12 +601,30 @@ func parseTargetsFromIPCShowOutput(output string) ipcTargets {
return targets return targets
} }
func getShellIPCCompletions(args []string, _ string) []string { func buildQsIPCBaseArgs() ([]string, error) {
cmdArgs := []string{"ipc"} cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() { switch pid, ok := getFirstDMSPID(); {
cmdArgs = append(cmdArgs, "--any-display") case ok:
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
default:
if err := findConfig(nil, nil); err != nil {
return nil, err
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
} }
cmdArgs = append(cmdArgs, "-p", configPath, "show") return cmdArgs, nil
}
func getShellIPCCompletions(args []string, _ string) []string {
baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
log.Debugf("Error building IPC args for completions: %v", err)
return nil
}
cmdArgs := append(baseArgs, "show")
cmd := exec.Command("qs", cmdArgs...) cmd := exec.Command("qs", cmdArgs...)
var targets ipcTargets var targets ipcTargets
@@ -607,7 +641,7 @@ func getShellIPCCompletions(args []string, _ string) []string {
if len(args) == 0 { if len(args) == 0 {
targetNames := make([]string, 0) targetNames := make([]string, 0)
targetNames = append(targetNames, "call") targetNames = append(targetNames, "call", "list")
for k := range targets { for k := range targets {
targetNames = append(targetNames, k) targetNames = append(targetNames, k)
} }
@@ -680,23 +714,11 @@ func runShellIPCCommand(args []string) {
args = append([]string{"call"}, args...) args = append([]string{"call"}, args...)
} }
cmdArgs := []string{"ipc"} baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
switch pid, ok := getFirstDMSPID(); { log.Fatalf("Error finding config: %v", err)
case ok:
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
default:
if err := findConfig(nil, nil); err != nil {
log.Fatalf("Error finding config: %v", err)
}
// ! TODO - remove check when QS 0.3 is released
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
} }
cmdArgs := append(baseArgs, args...)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("qs", cmdArgs...) cmd := exec.Command("qs", cmdArgs...)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
@@ -708,19 +730,20 @@ func runShellIPCCommand(args []string) {
} }
func printIPCHelp() { func printIPCHelp() {
fmt.Println("Usage: dms ipc <target> <function> [args...]") fmt.Println("Usage: dms ipc call <target> <function> [args...]")
fmt.Println() fmt.Println()
cmdArgs := []string{"ipc"} baseArgs, err := buildQsIPCBaseArgs()
if qsHasAnyDisplay() { if err != nil {
cmdArgs = append(cmdArgs, "--any-display") printIPCHelpFailure(err)
return
} }
cmdArgs = append(cmdArgs, "-p", configPath, "show") cmdArgs := append(baseArgs, "show")
cmd := exec.Command("qs", cmdArgs...) cmd := exec.Command("qs", cmdArgs...)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
fmt.Println("Could not retrieve available IPC targets (is DMS running?)") printIPCHelpFailure(err)
return return
} }
@@ -748,3 +771,143 @@ func printIPCHelp() {
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", ")) fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
} }
} }
func printIPCHelpFailure(err error) {
fmt.Println("Could not retrieve IPC targets.")
if err != nil {
fmt.Printf(" %v\n", err)
}
fmt.Println()
fmt.Println(" Full docs: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc")
fmt.Println(" Try: dms ipc call <target> <function>")
}
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
func ensureFontCache() {
if _, err := exec.LookPath("fc-list"); err != nil {
return
}
if _, err := exec.LookPath("fc-cache"); err != nil {
return
}
var fontsToCheck []string
if configDir, err := os.UserConfigDir(); err == nil {
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" && settings.FontFamily != "Inter Variable" {
fontsToCheck = append(fontsToCheck, settings.FontFamily)
}
if settings.MonoFontFamily != "" && settings.MonoFontFamily != "Fira Code" {
fontsToCheck = append(fontsToCheck, settings.MonoFontFamily)
}
}
}
}
if len(fontsToCheck) == 0 {
return
}
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil || len(strings.TrimSpace(string(output))) == 0 {
log.Warnf("Font cache appears empty or corrupt, rebuilding...")
rebuildFontCache()
return
}
cacheFonts := strings.ToLower(string(output))
var missing []string
for _, font := range fontsToCheck {
if !fontInCache(strings.ToLower(font), cacheFonts) {
missing = append(missing, font)
}
}
if len(missing) > 0 {
log.Warnf("Font(s) not found in cache: %s — rebuilding...", strings.Join(missing, ", "))
rebuildFontCache()
}
}
func fontInCache(target, cache string) bool {
for _, line := range strings.Split(cache, "\n") {
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
func rebuildFontCache() {
cmd := exec.Command("fc-cache", "-f")
if output, err := cmd.CombinedOutput(); err != nil {
log.Warnf("Failed to rebuild font cache: %v\n%s", err, string(output))
} else {
log.Infof("Font cache rebuilt successfully")
}
}
type stderrTracker struct {
mu sync.Mutex
buf strings.Builder
parent io.Writer
}
func (s *stderrTracker) Write(p []byte) (n int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.buf.Len() < 8192 {
s.buf.Write(p)
}
if s.parent != nil {
return s.parent.Write(p)
}
return len(p), nil
}
func (s *stderrTracker) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.String()
}
// logStartupFailure logs diagnostic advice if qs crashes within 5s of launch.
func logStartupFailure(startTime time.Time, exitCode int, tracker *stderrTracker) {
if time.Since(startTime) >= 5*time.Second || exitCode == 0 || exitCode > 128 {
return
}
if containsFontCrashSignature(tracker.String()) {
log.Errorf("DMS startup failed due to a potential font/rendering crash. Try running 'fc-cache -fv' and restarting DMS.")
} else {
log.Errorf("DMS startup failed (exit code %d). Run 'dms doctor' for more diagnostics.", exitCode)
}
}
func containsFontCrashSignature(logStr string) bool {
logStr = strings.ToLower(logStr)
signatures := []string{
"fontconfig",
"freetype",
"ft_load_glyph",
"ft_face",
"fc-list",
"fc-cache",
"glyph",
"typeface",
}
for _, sig := range signatures {
if strings.Contains(logStr, sig) {
return true
}
}
return false
}
+106
View File
@@ -73,6 +73,10 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"), filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
}, },
"Mango": {
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf"),
},
"Ghostty": { "Ghostty": {
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"), filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
}, },
@@ -126,6 +130,14 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err) return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
} }
} }
case deps.WindowManagerMango:
if shouldReplaceConfig("Mango") {
result, err := cd.deployMangoConfig(terminal, useSystemd)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Mango config: %w", err)
}
}
} }
switch terminal { switch terminal {
@@ -269,6 +281,96 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
return nil return nil
} }
func (cd *ConfigDeployer) deployMangoConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Mango",
Path: filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error
}
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty"
}
// DMS owns config.conf for mango (like niri/hyprland): back up and replace.
if existingData, err := os.ReadFile(result.Path); err == nil {
cd.log("Found existing Mango configuration")
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.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Mango configuration")
return result, nil
}
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string) error {
configs := []struct {
name string
content string
overwrite bool
}{
// binds.conf is DMS-owned (overwrite); the rest are runtime/user-managed.
{"binds.conf", strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand), true},
{"colors.conf", MangoColorsConfig, false},
{"layout.conf", MangoLayoutConfig, false},
{"outputs.conf", "", false},
{"cursor.conf", "", false},
{"windowrules.conf", "", false},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
if !cfg.overwrite {
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
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)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
return nil
}
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) { func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult var results []DeploymentResult
@@ -600,6 +702,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error return result, result.Error
} }
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
cd.log(fmt.Sprintf(format, v...))
})
result.Deployed = true result.Deployed = true
cd.log("Successfully deployed Hyprland configuration") cd.log("Successfully deployed Hyprland configuration")
return result, nil return result, nil
+27 -3
View File
@@ -20,13 +20,17 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir() td := t.TempDir()
t.Setenv("HOME", td) t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr") configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755)) dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf") confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644)) require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil) CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated") assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated")
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName)) assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
}) })
@@ -34,20 +38,25 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir() td := t.TempDir()
t.Setenv("HOME", td) t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr") configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755)) dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua") luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644)) require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf") confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644)) require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil) CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath) assert.NoFileExists(t, confPath)
assert.NoFileExists(t, dmsConfPath)
assert.FileExists(t, luaPath) assert.FileExists(t, luaPath)
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName)) entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
require.NoError(t, err) require.NoError(t, err)
require.Len(t, entries, 1) require.Len(t, entries, 1)
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf")) assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf"))
}) })
} }
@@ -404,6 +413,7 @@ general {
dmsDir := filepath.Join(td, ".config", "hypr", "dms") dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755)) 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, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\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(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(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)) require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
@@ -423,10 +433,12 @@ general {
assert.Contains(t, result.BackupPath, hyprlandBackupDirName) assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
assert.NoFileExists(t, hyprPath) 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", "binds.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.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), "hyprland.conf.backup.old"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.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, "binds.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.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(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old")) assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
@@ -485,7 +497,7 @@ general {
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua")) managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
require.NoError(t, err) 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 + 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 })`) assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })`)
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua")) user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err) require.NoError(t, err)
@@ -508,6 +520,18 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandLuaConfig, "input =") assert.Contains(t, HyprlandLuaConfig, "input =")
} }
func TestMangoConfigStructure(t *testing.T) {
assert.Contains(t, MangoConfig, "exec-once=dms run")
assert.NotContains(t, MangoConfig, "exec_once=dms run")
assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,K,focusdir,up")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,L,focusdir,right")
assert.Contains(t, MangoBindsConfig, "gesturebind=none,right,3,viewtoleft_have_client")
assert.Contains(t, MangoBindsConfig, "gesturebind=none,left,3,viewtoright_have_client")
}
func TestGhosttyConfigStructure(t *testing.T) { func TestGhosttyConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyConfig, "window-decoration = false") assert.Contains(t, GhosttyConfig, "window-decoration = false")
assert.Contains(t, GhosttyConfig, "background-opacity = 1.0") assert.Contains(t, GhosttyConfig, "background-opacity = 1.0")
+10 -6
View File
@@ -11,6 +11,7 @@ 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 + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper")) hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview")) hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle")) hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
-- === Cheat sheet -- === Cheat sheet
@@ -38,7 +39,7 @@ hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increme
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true }) hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
-- === Window Management === -- === Window Management ===
hl.bind("SUPER + Q", hl.dsp.window.kill()) hl.bind("SUPER + Q", hl.dsp.window.close())
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })) 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 + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" })) hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
@@ -112,6 +113,9 @@ 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_down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" })) hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
-- === Touchpad Gestures ===
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
-- === Numbered Workspaces === -- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" })) hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" })) hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
@@ -140,7 +144,7 @@ hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout === -- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit")) hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]])) hl.bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "set" }))
-- === Move/resize windows with mainMod + LMB/RMB and dragging === -- === 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:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
@@ -150,10 +154,10 @@ hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = tr
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" }) hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing === -- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true }) hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true }) hl.bind("SUPER + equal", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true }) hl.bind("SUPER + SHIFT + minus", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true }) hl.bind("SUPER + SHIFT + equal", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true })
-- === Screenshots === -- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot")) hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
@@ -13,6 +13,10 @@ hl.config({
input = { input = {
kb_layout = "us", kb_layout = "us",
numlock_by_default = true, numlock_by_default = true,
touchpad = {
tap_to_click = true,
natural_scroll = true,
},
}, },
general = { general = {
gaps_in = 5, gaps_in = 5,
@@ -0,0 +1,140 @@
# DMS default keybinds (MangoWM) — managed by DMS, regenerated by `dms setup`.
# Format: bind=MODS,key,action[,args]
# Put bind descriptions above bind lines; inline # comments break Mango spawn args.
# === Application Launchers ===
# Open Terminal
bind=SUPER,t,spawn,{{TERMINAL_COMMAND}}
# Open Terminal
bind=SUPER,Return,spawn,{{TERMINAL_COMMAND}}
# Application Launcher
bind=SUPER,space,spawn,dms ipc call spotlight toggle
# Spotlight Bar
bind=ALT,space,spawn,dms ipc call spotlight-bar toggle
# Clipboard Manager
bind=SUPER,v,spawn,dms ipc call clipboard toggle
# Task Manager
bind=SUPER,m,spawn,dms ipc call processlist focusOrToggle
# Settings
bind=SUPER,comma,spawn,dms ipc call settings focusOrToggle
# Notification Center
bind=SUPER,n,spawn,dms ipc call notifications toggle
# Notepad
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
# Browse Wallpapers
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
# Power Menu
bind=SUPER,x,spawn,dms ipc call powermenu toggle
# Cycle Display Profile
bind=SUPER,p,spawn,dms ipc outputs cycleProfile
# === Cheat sheet ===
# Keyboard Shortcuts
bind=SUPER+SHIFT,slash,spawn,dms ipc call keybinds toggle mangowc
# === Security ===
# Lock Screen
bind=SUPER+ALT,l,spawn,dms ipc call lock lock
# Task Manager
bind=CTRL+ALT,Delete,spawn,dms ipc call processlist focusOrToggle
# === Window Rules ===
# Create Window Rule
bind=SUPER+SHIFT,w,spawn,dms ipc call window-rules toggle
# === Screenshots ===
# Screenshot: Interactive
bind=none,Print,spawn,dms screenshot
# Screenshot: Full Screen
bind=CTRL,Print,spawn,dms screenshot full
# Screenshot: Window
bind=ALT,Print,spawn,dms screenshot window
# === Audio Controls ===
bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3
bind=none,XF86AudioLowerVolume,spawn,dms ipc call audio decrement 3
bind=none,XF86AudioMute,spawn,dms ipc call audio mute
bind=none,XF86AudioMicMute,spawn,dms ipc call audio micmute
bind=none,XF86AudioPlay,spawn,dms ipc call mpris playPause
bind=none,XF86AudioPause,spawn,dms ipc call mpris playPause
bind=none,XF86AudioPrev,spawn,dms ipc call mpris previous
bind=none,XF86AudioNext,spawn,dms ipc call mpris next
# === Brightness Controls ===
bind=none,XF86MonBrightnessUp,spawn,dms ipc call brightness increment 5
bind=none,XF86MonBrightnessDown,spawn,dms ipc call brightness decrement 5
# === Window Management ===
# Close Window
bind=SUPER,q,killclient,
bind=SUPER,f,togglefullscreen,
bind=SUPER,a,togglemaximizescreen,
bind=SUPER+SHIFT,space,togglefloating,
bind=SUPER,o,toggleoverview
bind=ALT,Tab,toggleoverview
# Exit Compositor
bind=SUPER+SHIFT,e,quit,
# === Focus Navigation ===
bind=SUPER,Tab,focusstack,next
bind=SUPER+SHIFT,Tab,focusstack,prev
bind=SUPER,Left,focusdir,left
bind=SUPER,H,focusdir,left
bind=SUPER,Right,focusdir,right
bind=SUPER,L,focusdir,right
bind=SUPER,Up,focusdir,up
bind=SUPER,K,focusdir,up
bind=SUPER,Down,focusdir,down
bind=SUPER,J,focusdir,down
# === Window Movement ===
bind=SUPER+SHIFT,Left,exchange_client,left
bind=SUPER+SHIFT,Right,exchange_client,right
bind=SUPER+SHIFT,Up,exchange_client,up
bind=SUPER+SHIFT,Down,exchange_client,down
bind=SUPER+SHIFT,H,exchange_client,left
bind=SUPER+SHIFT,L,exchange_client,right
bind=SUPER+SHIFT,K,exchange_client,up
bind=SUPER+SHIFT,J,exchange_client,down
# === Monitor Navigation ===
bind=SUPER+ALT,Left,focusmon,left
bind=SUPER+ALT,Right,focusmon,right
bind=SUPER+ALT+SHIFT,Left,tagmon,left
bind=SUPER+ALT+SHIFT,Right,tagmon,right
# === Layout ===
# Cycle Layout - Gaps, Floating, Tiling
bind=SUPER+ALT,j,switch_layout
bind=SUPER+SHIFT,equal,incgaps,1
bind=SUPER+SHIFT,minus,incgaps,-1
# === Tags (1-9): view tag ===
bind=SUPER,1,view,1
bind=SUPER,2,view,2
bind=SUPER,3,view,3
bind=SUPER,4,view,4
bind=SUPER,5,view,5
bind=SUPER,6,view,6
bind=SUPER,7,view,7
bind=SUPER,8,view,8
bind=SUPER,9,view,9
# === Tags (1-9): move focused window to tag ===
bind=SUPER+SHIFT,1,tag,1
bind=SUPER+SHIFT,2,tag,2
bind=SUPER+SHIFT,3,tag,3
bind=SUPER+SHIFT,4,tag,4
bind=SUPER+SHIFT,5,tag,5
bind=SUPER+SHIFT,6,tag,6
bind=SUPER+SHIFT,7,tag,7
bind=SUPER+SHIFT,8,tag,8
bind=SUPER+SHIFT,9,tag,9
# === Touchpad Gestures ===
# 3-finger horizontal swipe: switch between occupied workspaces
gesturebind=none,right,3,viewtoleft_have_client
gesturebind=none,left,3,viewtoright_have_client
# 4-finger vertical swipe: toggle the overview
gesturebind=none,up,4,toggleoverview
gesturebind=none,down,4,toggleoverview
@@ -0,0 +1,6 @@
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
# Remove `source=./dms/colors.conf` from config.conf to override manually.
bordercolor = 0x595959ff
focuscolor = 0x8ab4f8ff
urgentcolor = 0xff5555ff
@@ -0,0 +1,8 @@
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
border_radius=12
gappih=5
gappiv=5
gappoh=5
gappov=5
borderpx=2
+18
View File
@@ -0,0 +1,18 @@
# DankMaterialShell — MangoWM configuration (managed by `dms setup`)
# Keybinds, colors, layout, outputs, cursor and window rules are pulled from the
# ./dms fragments below. Add your own binds/rules here; they sit alongside DMS's.
env=XDG_CURRENT_DESKTOP,mango
env=XDG_SESSION_TYPE,wayland
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
# every exec= on each config reload, and DMS reloads the config, which would
# spawn a new shell on every reload.
exec-once=dms run
source=./dms/colors.conf
source=./dms/layout.conf
source=./dms/cursor.conf
source=./dms/outputs.conf
source=./dms/windowrules.conf
source=./dms/binds.conf
+1 -1
View File
@@ -1,6 +1,6 @@
binds { binds {
// === System & Overview === // === System & Overview ===
Mod+D repeat=false { toggle-overview; } Mod+O repeat=false { toggle-overview; }
Mod+Tab repeat=false { toggle-overview; } Mod+Tab repeat=false { toggle-overview; }
Mod+Shift+Slash { show-hotkey-overlay; } Mod+Shift+Slash { show-hotkey-overlay; }
+37 -14
View File
@@ -138,11 +138,9 @@ func readExistingHyprlandConfig(configDir string) (data string, sourcePath strin
return "", "", nil return "", "", nil
} }
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf // CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
// into .dms-backups/<timestamp>/ only when hyprland.lua also exists, which // top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
// proves Lua is the live config and the .conf is an autogen Hyprland 0.55 // when hyprland.lua also exists as the live config.
// 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)) { func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" { if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return return
@@ -156,19 +154,44 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if _, err := os.Stat(luaPath); err != nil { if _, err := os.Stat(luaPath); err != nil {
return return
} }
var strayPaths []string
confPath := filepath.Join(configDir, "hyprland.conf") confPath := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(confPath); err != nil { if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
return strayPaths = append(strayPaths, confPath)
} }
ts := time.Now().Format("2006-01-02_15-04-05") dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf") if err == nil {
if err := moveHyprlandConfigFile(confPath, dst); err != nil { for _, p := range dmsConfPaths {
if logFn != nil { if info, err := os.Lstat(p); err == nil && !info.IsDir() {
logFn("Could not move stray hyprland.conf: %v", err) strayPaths = append(strayPaths, p)
}
} }
}
if len(strayPaths) == 0 {
return return
} }
if logFn != nil {
logFn("Moved stray hyprland.conf to %s", dst) ts := time.Now().Format("2006-01-02_15-04-05")
moved := 0
for _, src := range strayPaths {
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
if logFn != nil {
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
}
continue
}
moved++
if logFn != nil {
logFn("Moved stray Hyprland conf file to %s", dst)
}
}
if moved > 0 && logFn != nil {
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
} }
} }
+15
View File
@@ -0,0 +1,15 @@
package config
import _ "embed"
//go:embed embedded/mango.conf
var MangoConfig string
//go:embed embedded/mango-colors.conf
var MangoColorsConfig string
//go:embed embedded/mango-layout.conf
var MangoLayoutConfig string
//go:embed embedded/mango-binds.conf
var MangoBindsConfig string
+1
View File
@@ -35,6 +35,7 @@ type WindowManager int
const ( const (
WindowManagerHyprland WindowManager = iota WindowManagerHyprland WindowManager = iota
WindowManagerNiri WindowManagerNiri
WindowManagerMango
) )
type Terminal int type Terminal int
+21 -1
View File
@@ -112,6 +112,11 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectXwaylandSatellite()) dependencies = append(dependencies, a.detectXwaylandSatellite())
} }
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
dependencies = append(dependencies, a.detectMatugen()) dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop()) dependencies = append(dependencies, a.detectDgop())
@@ -172,6 +177,11 @@ func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
return exec.Command("pacman", "-Si", pkg).Run() == nil return exec.Command("pacman", "-Si", pkg).Run() == nil
} }
// isSonameProvides reports whether dep is a shared-library soname
func isSonameProvides(dep string) bool {
return strings.HasSuffix(dep, ".so") || strings.Contains(dep, ".so.")
}
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
} }
@@ -199,6 +209,9 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
packages["niri"] = a.getNiriMapping(variants["niri"]) packages["niri"] = a.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
packages["mango"] = a.getMangoMapping(variants["mango"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
} }
return packages return packages
@@ -222,6 +235,13 @@ func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMa
return PackageMapping{Name: "niri", Repository: RepoTypeSystem} return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
} }
func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
if runtime.GOARCH == "arm64" { if runtime.GOARCH == "arm64" {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR} return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
@@ -724,7 +744,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
continue continue
} }
seen[dep] = true seen[dep] = true
if a.isInSystemRepo(dep) { if isSonameProvides(dep) || a.isInSystemRepo(dep) {
systemPkgs = append(systemPkgs, dep) systemPkgs = append(systemPkgs, dep)
} else { } else {
aurPkgs = append(aurPkgs, dep) aurPkgs = append(aurPkgs, dep)
+30
View File
@@ -337,6 +337,36 @@ func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Depen
Variant: variant, Variant: variant,
CanToggle: true, CanToggle: true,
} }
case deps.WindowManagerMango:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("mango") {
status = deps.StatusInstalled
cmd := exec.Command("mango", "-v")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "mango",
Status: status,
Version: version,
Description: "dwl-based dynamic tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
default: default:
return deps.Dependency{ return deps.Dependency{
Name: "unknown-wm", Name: "unknown-wm",
+55 -2
View File
@@ -77,7 +77,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
// Common detections using base methods // Common detections using base methods
dependencies = append(dependencies, f.detectGit()) dependencies = append(dependencies, f.detectGit())
dependencies = append(dependencies, f.detectWindowManager(wm)) wmDep := f.detectWindowManager(wm)
if wm == deps.WindowManagerMango {
wmDep.Description = "MangoWM (Wayland compositor) — the Terra repo will be enabled automatically to install it"
}
dependencies = append(dependencies, wmDep)
dependencies = append(dependencies, f.detectQuickshell()) dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectDMSGreeter()) dependencies = append(dependencies, f.detectDMSGreeter())
dependencies = append(dependencies, f.detectXDGPortal()) dependencies = append(dependencies, f.detectXDGPortal())
@@ -93,6 +97,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectXwaylandSatellite()) dependencies = append(dependencies, f.detectXwaylandSatellite())
} }
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
dependencies = append(dependencies, f.detectMatugen()) dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop()) dependencies = append(dependencies, f.detectDgop())
@@ -139,6 +148,10 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"]) packages["niri"] = f.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
// mangowm resolves via Terra, enabled automatically by enableTerraRepo.
packages["mango"] = PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
} }
return packages return packages
@@ -159,7 +172,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
} }
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"} return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "lionheartp/Hyprland"}
} }
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
@@ -297,6 +310,22 @@ func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies [
} }
} }
// Phase 2b: Enable Terra repo for MangoWM (not in Fedora's repos). Must run
// before the DNF phase so `mangowm` resolves.
if wm == deps.WindowManagerMango {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Enabling Terra repository for MangoWM...",
IsComplete: false,
NeedsSudo: true,
LogOutput: "Setting up the Terra repo (fyralabs) to provide mango",
}
if err := f.enableTerraRepo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
}
// Phase 3: System Packages (DNF) // Phase 3: System Packages (DNF)
if len(dnfPkgs) > 0 { if len(dnfPkgs) > 0 {
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
@@ -423,6 +452,30 @@ func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []st
return names return names
} }
// enableTerraRepo registers the persistent Terra repo (via terra-release) so
// `mangowm` resolves in the DNF phase. $releasever is single-quoted so dnf, not
// the shell, expands it.
func (f *FedoraDistribution) enableTerraRepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
// Skip if Terra is already configured
if exec.CommandContext(ctx, "sh", "-c",
"rpm -q terra-release >/dev/null 2>&1 || test -f /etc/yum.repos.d/terra.repo").Run() == nil {
f.log("Terra repository already configured, skipping enable")
return nil
}
f.log("Enabling Terra repository (fyralabs) for mango...")
cmd := privesc.ExecCommand(ctx, sudoPassword,
`dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release 2>&1`)
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to enable Terra repository", err)
f.log(fmt.Sprintf("Terra enable output: %s", string(output)))
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
f.log(fmt.Sprintf("Terra repository enabled: %s", string(output)))
return nil
}
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool) enabledRepos := make(map[string]bool)
+13
View File
@@ -106,6 +106,11 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, g.detectXwaylandSatellite()) dependencies = append(dependencies, g.detectXwaylandSatellite())
} }
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
dependencies = append(dependencies, g.detectMatugen()) dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop()) dependencies = append(dependencies, g.detectDgop())
@@ -176,6 +181,10 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"]) packages["niri"] = g.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword} packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
case deps.WindowManagerMango:
packages["mango"] = g.getMangoMapping(variants["mango"])
packages["scenefx"] = PackageMapping{Name: "gui-libs/scenefx", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
} }
return packages return packages
@@ -197,6 +206,10 @@ func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMappin
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()} return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
} }
func (g *GentooDistribution) getMangoMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/mangowm", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getPrerequisites() []string { func (g *GentooDistribution) getPrerequisites() []string {
return []string{ return []string{
"app-eselect/eselect-repository", "app-eselect/eselect-repository",
+424 -21
View File
@@ -192,6 +192,421 @@ func upsertDefaultSession(configContent, greeterUser, command string) string {
return strings.Join(out, "\n") return strings.Join(out, "\n")
} }
func removeTomlSection(configContent, sectionName string) string {
lines := strings.Split(configContent, "\n")
var out []string
inSection := false
for _, line := range lines {
if section, ok := parseTomlSection(line); ok {
inSection = section == sectionName
if inSection {
continue
}
out = append(out, line)
continue
}
if inSection {
continue
}
out = append(out, line)
}
result := strings.TrimRight(strings.Join(out, "\n"), "\n")
if result != "" {
result += "\n"
}
return result
}
func stripDesktopExecCodes(execLine string) string {
fields := strings.Fields(execLine)
cleaned := make([]string, 0, len(fields))
for _, field := range fields {
if strings.HasPrefix(field, "%") {
continue
}
cleaned = append(cleaned, field)
}
return strings.Join(cleaned, " ")
}
func formatInitialSessionCommand(sessionExec string) string {
execLine := strings.TrimSpace(stripDesktopExecCodes(sessionExec))
if execLine == "" {
return `command = ""`
}
escaped := strings.ReplaceAll(execLine, `'`, `'\''`)
inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped)
tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`)
tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`)
return fmt.Sprintf(`command = "%s"`, tomlEscaped)
}
func upsertInitialSession(configContent, loginUser, sessionExec string, enabled bool) string {
if !enabled {
return removeTomlSection(configContent, "initial_session")
}
commandLine := formatInitialSessionCommand(sessionExec)
lines := strings.Split(configContent, "\n")
var out []string
inInitialSession := false
foundInitialSession := false
initialSessionUserSet := false
initialSessionCommandSet := false
appendInitialSessionFields := func() {
if !initialSessionUserSet {
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
}
if !initialSessionCommandSet {
out = append(out, commandLine)
}
}
for _, line := range lines {
if section, ok := parseTomlSection(line); ok {
if inInitialSession {
appendInitialSessionFields()
}
inInitialSession = section == "initial_session"
if inInitialSession {
foundInitialSession = true
initialSessionUserSet = false
initialSessionCommandSet = false
}
out = append(out, line)
continue
}
if inInitialSession {
trimmed := stripTomlComment(line)
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
initialSessionUserSet = true
continue
}
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
if !initialSessionCommandSet {
out = append(out, commandLine)
initialSessionCommandSet = true
}
continue
}
}
out = append(out, line)
}
if inInitialSession {
appendInitialSessionFields()
}
if !foundInitialSession {
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
out = append(out, "")
}
out = append(out, "[initial_session]")
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
out = append(out, commandLine)
}
return strings.Join(out, "\n")
}
type greeterAutoLoginConfig struct {
GreeterAutoLogin bool `json:"greeterAutoLogin"`
GreeterRememberLastUser bool `json:"greeterRememberLastUser"`
GreeterRememberLastSession bool `json:"greeterRememberLastSession"`
}
type greeterAutoLoginMemory struct {
LastSuccessfulUser string `json:"lastSuccessfulUser"`
LastSessionID string `json:"lastSessionId"`
LastSessionExec string `json:"lastSessionExec"`
AutoLoginEnabled bool `json:"autoLoginEnabled"`
}
func readGreeterAutoLoginConfig(settingsPath string) (greeterAutoLoginConfig, error) {
cfg := greeterAutoLoginConfig{
GreeterRememberLastUser: true,
GreeterRememberLastSession: true,
}
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
}
return cfg, nil
}
func readGreeterAutoLoginMemory(memoryPath string) (greeterAutoLoginMemory, error) {
var mem greeterAutoLoginMemory
data, err := os.ReadFile(memoryPath)
if err != nil {
if os.IsNotExist(err) {
return mem, nil
}
return mem, err
}
if err := json.Unmarshal(data, &mem); err != nil {
return mem, fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
}
return mem, nil
}
func execFromDesktopFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Exec=") {
return strings.TrimSpace(trimmed[len("Exec="):]), nil
}
}
return "", fmt.Errorf("no Exec= line found in %s", path)
}
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionExec string, err error) {
settingsPath := filepath.Join(cacheDir, "settings.json")
if _, statErr := os.Stat(settingsPath); statErr != nil {
settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
}
cfg, err := readGreeterAutoLoginConfig(settingsPath)
if err != nil {
return false, "", "", err
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
mem, err := readGreeterAutoLoginMemory(memoryPath)
if err != nil {
return false, "", "", err
}
enabled = cfg.GreeterAutoLogin
if !enabled {
return false, "", "", nil
}
if !cfg.GreeterRememberLastUser || !cfg.GreeterRememberLastSession {
return true, "", "", nil
}
loginUser = mem.LastSuccessfulUser
if loginUser == "" {
current, userErr := user.Current()
if userErr != nil {
return true, "", "", userErr
}
loginUser = current.Username
}
sessionExec = mem.LastSessionExec
if sessionExec == "" && mem.LastSessionID != "" {
sessionExec, err = execFromDesktopFile(mem.LastSessionID)
if err != nil {
sessionExec = ""
}
}
return true, loginUser, sessionExec, nil
}
func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error {
if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup greetd config: %w", err)
}
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(content); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
}
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 := 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)
}
if logFunc != nil && successMsg != "" {
logFunc(successMsg)
}
return nil
}
func clearGreeterAutoLoginMemory(memoryPath, sudoPassword string) error {
data, err := readGreeterMemoryFile(memoryPath, sudoPassword)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
}
if _, ok := raw["autoLoginEnabled"]; !ok {
return nil
}
delete(raw, "autoLoginEnabled")
encoded, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
if len(encoded) == 0 || string(encoded) == "null" {
encoded = []byte("{}")
}
encoded = append(encoded, '\n')
if err := os.WriteFile(memoryPath, encoded, 0o644); err == nil {
return nil
} else if !os.IsPermission(err) {
return err
}
tmpFile, err := os.CreateTemp("", "greeter-memory-*.json")
if err != nil {
return fmt.Errorf("failed to create temp greeter memory file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(encoded); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greeter memory file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greeter memory file: %w", err)
}
greeterUser := DetectGreeterUser()
greeterGroup := DetectGreeterGroup()
owner := greeterUser + ":" + greeterGroup
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", greeterUser, "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); err != nil {
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); fallbackErr != nil {
return fmt.Errorf("failed to install greeter memory file (preferred %s: %w; fallback root:%s: %v)", owner, err, greeterGroup, fallbackErr)
}
}
return nil
}
func readGreeterMemoryFile(memoryPath, sudoPassword string) ([]byte, error) {
data, err := os.ReadFile(memoryPath)
if err == nil || !os.IsPermission(err) {
return data, err
}
tmpFile, err := os.CreateTemp("", "greeter-memory-read-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file for greeter memory read: %w", err)
}
tmpPath := tmpFile.Name()
_ = tmpFile.Close()
defer os.Remove(tmpPath)
if err := privesc.Run(context.Background(), sudoPassword, "cp", "-f", memoryPath, tmpPath); err != nil {
return nil, fmt.Errorf("failed to read greeter memory at %s: %w", memoryPath, err)
}
return os.ReadFile(tmpPath)
}
func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error {
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
return err
}
configPath := "/etc/greetd/config.toml"
configContent := ""
if data, readErr := os.ReadFile(configPath); readErr == nil {
configContent = string(data)
} else if !os.IsNotExist(readErr) {
return fmt.Errorf("failed to read greetd config: %w", readErr)
}
if !enabled {
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
}
newConfig := upsertInitialSession(configContent, "", "", false)
if newConfig == configContent {
if logFunc != nil {
logFunc("✓ Greeter auto-login disabled")
}
return nil
}
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login")
}
if loginUser == "" || sessionExec == "" {
if logFunc != nil {
logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.")
}
newConfig := upsertInitialSession(configContent, "", "", false)
if newConfig != configContent {
return writeGreetdConfig(configPath, newConfig, nil, sudoPassword, "")
}
return nil
}
newConfig := upsertInitialSession(configContent, loginUser, sessionExec, true)
if newConfig == configContent {
if logFunc != nil {
logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser))
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
_ = clearGreeterAutoLoginMemory(memoryPath, sudoPassword)
return nil
}
if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Configured greeter auto-login for %s", loginUser)); err != nil {
return err
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
}
return nil
}
func SyncGreeterAutoLoginOnly(logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
return SyncGreetdAutoLogin(GreeterCacheDir, homeDir, logFunc, sudoPassword)
}
func DetectGreeterUser() string { func DetectGreeterUser() string {
passwdData, err := os.ReadFile("/etc/passwd") passwdData, err := os.ReadFile("/etc/passwd")
if err == nil { if err == nil {
@@ -265,6 +680,9 @@ func DetectCompositors() []string {
if utils.CommandExists("Hyprland") { if utils.CommandExists("Hyprland") {
compositors = append(compositors, "Hyprland") compositors = append(compositors, "Hyprland")
} }
if utils.CommandExists("mango") {
compositors = append(compositors, "mango")
}
return compositors return compositors
} }
@@ -1267,6 +1685,10 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
return fmt.Errorf("per-user greeter cache sync failed: %w", err) return fmt.Errorf("per-user greeter cache sync failed: %w", err)
} }
if err := SyncGreetdAutoLogin(cacheDir, homeDir, logFunc, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: greeter auto-login sync failed: %v", err))
}
if strings.ToLower(compositor) != "niri" { if strings.ToLower(compositor) != "niri" {
return nil return nil
} }
@@ -1731,29 +2153,10 @@ vt = 1
commandLine := fmt.Sprintf(`command = "%s"`, commandValue) commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine) newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml") if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)); err != nil {
if err != nil { return err
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(newConfig); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
} }
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 := 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)
}
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue))
return nil return nil
} }
+145
View File
@@ -3,6 +3,7 @@ package greeter
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -96,3 +97,147 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
}) })
} }
} }
func TestUpsertInitialSession(t *testing.T) {
t.Parallel()
baseConfig := `[terminal]
vt = 1
[default_session]
user = "greeter"
command = "/usr/bin/dms-greeter --command niri"
`
t.Run("inserts initial session", func(t *testing.T) {
t.Parallel()
got := upsertInitialSession(baseConfig, "alice", "niri", true)
if !strings.Contains(got, "[initial_session]") {
t.Fatalf("expected [initial_session] section, got:\n%s", got)
}
if !strings.Contains(got, `user = "alice"`) {
t.Fatalf("expected alice user in initial session, got:\n%s", got)
}
if !strings.Contains(got, `env XDG_SESSION_TYPE=wayland sh -c 'exec niri'`) {
t.Fatalf("expected wrapped session command, got:\n%s", got)
}
})
t.Run("updates existing initial session", func(t *testing.T) {
t.Parallel()
existing := baseConfig + `
[initial_session]
user = "bob"
command = "old-command"
`
got := upsertInitialSession(existing, "alice", "Hyprland", true)
if strings.Contains(got, `user = "bob"`) {
t.Fatalf("expected bob to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `exec Hyprland`) {
t.Fatalf("expected Hyprland command, got:\n%s", got)
}
})
t.Run("removes initial session when disabled", func(t *testing.T) {
t.Parallel()
existing := baseConfig + `
[initial_session]
user = "alice"
command = "niri"
`
got := upsertInitialSession(existing, "", "", false)
if strings.Contains(got, "[initial_session]") {
t.Fatalf("expected initial session removed, got:\n%s", got)
}
if !strings.Contains(got, "[default_session]") {
t.Fatalf("expected default session preserved, got:\n%s", got)
}
})
}
func TestStripDesktopExecCodes(t *testing.T) {
t.Parallel()
got := stripDesktopExecCodes("niri --session %f")
want := "niri --session"
if got != want {
t.Fatalf("stripDesktopExecCodes = %q, want %q", got, want)
}
}
func TestResolveGreeterAutoLoginState(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": true,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if !enabled || loginUser != "alice" || sessionExec != "niri" {
t.Fatalf("got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
}
}
func TestResolveGreeterAutoLoginStateIgnoresMemoryFlag(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": false,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"autoLoginEnabled": true,
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if enabled || loginUser != "" || sessionExec != "" {
t.Fatalf("expected disabled with empty user/exec, got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
}
}
func TestClearGreeterAutoLoginMemory(t *testing.T) {
t.Parallel()
memoryPath := filepath.Join(t.TempDir(), "memory.json")
writeTestFile(t, memoryPath, `{
"autoLoginEnabled": true,
"lastSuccessfulUser": "alice"
}`)
if err := clearGreeterAutoLoginMemory(memoryPath, ""); err != nil {
t.Fatalf("clearGreeterAutoLoginMemory returned error: %v", err)
}
data, err := os.ReadFile(memoryPath)
if err != nil {
t.Fatalf("failed to read memory file: %v", err)
}
if strings.Contains(string(data), "autoLoginEnabled") {
t.Fatalf("expected autoLoginEnabled removed, got: %s", string(data))
}
if !strings.Contains(string(data), "lastSuccessfulUser") {
t.Fatalf("expected other memory fields preserved, got: %s", string(data))
}
}
+3 -1
View File
@@ -364,8 +364,10 @@ func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
return deps.WindowManagerNiri, nil return deps.WindowManagerNiri, nil
case "hyprland": case "hyprland":
return deps.WindowManagerHyprland, nil return deps.WindowManagerHyprland, nil
case "mango", "mangowc":
return deps.WindowManagerMango, nil
default: default:
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor) return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri', 'hyprland', or 'mango'", r.cfg.Compositor)
} }
} }
+684 -29
View File
@@ -68,6 +68,8 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
Effective: result.DMSStatus.Effective, Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy, OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage, StatusMessage: result.DMSStatus.StatusMessage,
ConfigFormat: result.DMSStatus.ConfigFormat,
ReadOnly: result.DMSStatus.ReadOnly,
} }
} }
@@ -219,6 +221,9 @@ func (h *HyprlandProvider) validateAction(action string) error {
} }
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error { func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
if err := h.validateAction(action); err != nil { if err := h.validateAction(action); err != nil {
return err return err
} }
@@ -242,9 +247,10 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
} }
} }
normalizedKey := strings.ToLower(key) canonicalKey := canonicalHyprlandOverrideKey(key)
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{ existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: key, Key: canonicalKey,
Action: action, Action: action,
Description: description, Description: description,
Flags: flags, Flags: flags,
@@ -255,21 +261,28 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
} }
func (h *HyprlandProvider) RemoveBind(key string) error { func (h *HyprlandProvider) RemoveBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds() existingBinds, err := h.loadOverrideBinds()
if err != nil { if err != nil {
return nil return nil
} }
normalizedKey := strings.ToLower(key) canonicalKey := canonicalHyprlandOverrideKey(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true} normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: canonicalKey, Unbind: true}
return h.writeOverrideBinds(existingBinds) return h.writeOverrideBinds(existingBinds)
} }
func (h *HyprlandProvider) ResetBind(key string) error { func (h *HyprlandProvider) ResetBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds() existingBinds, err := h.loadOverrideBinds()
if err != nil { if err != nil {
return nil return nil
} }
normalizedKey := strings.ToLower(key) normalizedKey := hyprlandOverrideMapKey(key)
delete(existingBinds, normalizedKey) delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds) return h.writeOverrideBinds(existingBinds)
} }
@@ -284,10 +297,46 @@ type hyprlandOverrideBind struct {
Unbind bool Unbind bool
} }
func (h *HyprlandProvider) ensureWritableConfig() error {
if h.isLegacyConfigReadOnly() {
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing keybinds")
}
return nil
}
func (h *HyprlandProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
expanded = h.configPath
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
return readLuaOrHyprlangOverride(h.GetOverridePath()) return readLuaOrHyprlangOverride(h.GetOverridePath())
} }
func canonicalHyprlandOverrideKey(key string) string {
trimmed := strings.TrimSpace(key)
normalized := luaKeyComboToInternalKey(trimmed)
if normalized == "" {
return trimmed
}
return normalized
}
func hyprlandOverrideMapKey(key string) string {
return strings.ToLower(canonicalHyprlandOverrideKey(key))
}
func (h *HyprlandProvider) getBindSortPriority(action string) int { func (h *HyprlandProvider) getBindSortPriority(action string) int {
switch { switch {
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"): case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
@@ -368,24 +417,629 @@ func normalizeLuaBindKeyPart(part string) string {
return part return part
} }
type luaField struct {
name string
value string
}
func luaDispatcherTableCall(funcName string, fields ...luaField) string {
parts := make([]string, 0, len(fields))
for _, field := range fields {
if field.name == "" || field.value == "" {
continue
}
parts = append(parts, field.name+" = "+field.value)
}
return fmt.Sprintf(`%s({ %s })`, funcName, strings.Join(parts, ", "))
}
func luaStringField(name, value string) luaField {
return luaField{name: name, value: strconv.Quote(strings.TrimSpace(value))}
}
func luaBoolField(name string, value bool) luaField {
if value {
return luaField{name: name, value: "true"}
}
return luaField{name: name, value: "false"}
}
func luaNumberOrStringField(name, value string) luaField {
value = strings.TrimSpace(value)
if isBareLuaNumber(value) {
return luaField{name: name, value: value}
}
return luaStringField(name, value)
}
func isBareLuaNumber(value string) bool {
if value == "" || strings.HasPrefix(value, "+") {
return false
}
if value[0] == '-' {
value = value[1:]
}
if value == "" {
return false
}
digitsBeforeDot := 0
i := 0
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
digitsBeforeDot++
i++
}
digitsAfterDot := 0
if i < len(value) && value[i] == '.' {
i++
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
digitsAfterDot++
i++
}
}
return i == len(value) && (digitsBeforeDot > 0 || digitsAfterDot > 0)
}
func splitHyprlandAction(action string) (dispatcher, params string) {
action = strings.TrimSpace(action)
if action == "" {
return "", ""
}
idx := strings.IndexFunc(action, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
})
if idx < 0 {
return strings.ToLower(action), ""
}
return strings.ToLower(strings.TrimSpace(action[:idx])), strings.TrimSpace(action[idx+1:])
}
func isKnownHyprlandDispatcher(dispatcher string) bool {
switch dispatcher {
case "exec", "execr", "spawn",
"killactive", "forcekillactive", "closewindow", "killwindow",
"signal", "signalwindow", "togglefloating", "setfloating", "settiled",
"workspace", "renameworkspace", "fullscreen", "fullscreenstate", "fakefullscreen",
"movetoworkspace", "movetoworkspacesilent", "pseudo", "movefocus",
"movewindow", "swapwindow", "centerwindow", "togglegroup", "changegroupactive",
"movegroupwindow", "focusmonitor", "movecursortocorner", "movecursor",
"workspaceopt", "exit", "movecurrentworkspacetomonitor", "focusworkspaceoncurrentmonitor",
"moveworkspacetomonitor", "togglespecialworkspace", "forcerendererreload",
"resizeactive", "moveactive", "cyclenext", "focuswindowbyclass", "focuswindow",
"tagwindow", "toggleswallow", "submap", "pass", "sendshortcut", "sendkeystate",
"layoutmsg", "splitratio", "dpms", "movewindowpixel", "resizewindowpixel",
"swapnext", "swapactiveworkspaces", "pin", "mouse", "bringactivetotop",
"alterzorder", "focusurgentorlast", "focuscurrentorlast", "lockgroups",
"lockactivegroup", "moveintogroup", "moveoutofgroup", "movewindoworgroup",
"moveintoorcreategroup", "setignoregrouplock", "denywindowfromgroup", "event",
"global", "setprop", "forceidle":
return true
default:
return false
}
}
func firstParam(params string) (head, rest string) {
params = strings.TrimSpace(params)
if params == "" {
return "", ""
}
fields := strings.Fields(params)
if len(fields) == 0 {
return "", ""
}
head = fields[0]
rest = strings.TrimSpace(strings.TrimPrefix(params, head))
return head, rest
}
func xyParams(params string) (x, y string, relative bool, ok bool) {
fields := strings.Fields(params)
if len(fields) > 0 && strings.EqualFold(fields[0], "exact") {
relative = false
fields = fields[1:]
} else {
relative = true
}
if len(fields) < 2 {
return "", "", relative, false
}
return fields[0], fields[1], relative, true
}
func dispatcherWorkspaceMove(params string, follow *bool) string {
workspace, window := firstParam(params)
if workspace == "" {
return ""
}
fields := []luaField{luaStringField("workspace", workspace)}
if follow != nil {
fields = append(fields, luaBoolField("follow", *follow))
}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.move", fields...)
}
func dispatcherActiveMoveResize(funcName, params string) string {
x, y, relative, ok := xyParams(params)
if !ok {
return ""
}
if !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
return luaDispatcherTableCall(funcName,
luaNumberOrStringField("x", x),
luaNumberOrStringField("y", y),
luaBoolField("relative", relative),
)
}
func dispatcherWindowMoveResize(funcName, params string) string {
geometry, window := splitCommaParams(params)
x, y, relative, ok := xyParams(geometry)
if !ok {
return ""
}
if !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
fields := []luaField{
luaNumberOrStringField("x", x),
luaNumberOrStringField("y", y),
luaBoolField("relative", relative),
}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall(funcName, fields...)
}
func splitCommaParams(params string) (left, right string) {
left = strings.TrimSpace(params)
if idx := strings.Index(left, ","); idx >= 0 {
right = strings.TrimSpace(left[idx+1:])
left = strings.TrimSpace(left[:idx])
}
return left, right
}
func luaHyprctlDispatchFunction(action string) string {
return fmt.Sprintf(`function() hl.exec_cmd(%s) end`, strconv.Quote("hyprctl dispatch "+strings.TrimSpace(action)))
}
func luaToggleActionValue(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "on", "enable", "enabled", "set", "lock":
return "on"
case "off", "disable", "disabled", "unset", "unlock":
return "off"
default:
return "toggle"
}
}
func dispatcherToggleTableCall(funcName, params string) string {
return luaDispatcherTableCall(funcName, luaStringField("action", luaToggleActionValue(params)))
}
func dispatcherCycleNext(params string) string {
params = strings.TrimSpace(strings.ToLower(params))
if params == "" {
return `hl.dsp.window.cycle_next()`
}
fields := []luaField{}
for _, field := range strings.Fields(params) {
switch field {
case "prev", "previous", "b":
fields = append(fields, luaBoolField("next", false))
case "next", "f":
fields = append(fields, luaBoolField("next", true))
case "tiled":
fields = append(fields, luaBoolField("tiled", true))
case "floating":
fields = append(fields, luaBoolField("floating", true))
}
}
if len(fields) == 0 {
return ""
}
return luaDispatcherTableCall("hl.dsp.window.cycle_next", fields...)
}
func dispatcherSwapNext(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "prev", "previous", "b":
return `hl.dsp.window.swap({ prev = true })`
default:
return `hl.dsp.window.swap({ next = true })`
}
}
func dispatcherGroupActive(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "f", "next", "forward":
return `hl.dsp.group.next()`
case "b", "prev", "previous", "backward":
return `hl.dsp.group.prev()`
}
if isBareLuaNumber(params) {
return luaDispatcherTableCall("hl.dsp.group.active", luaNumberOrStringField("index", params))
}
return ""
}
func dispatcherMoveGroupWindow(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "b", "prev", "previous", "backward":
return `hl.dsp.group.move_window({ forward = false })`
default:
return `hl.dsp.group.move_window({ forward = true })`
}
}
func dispatcherCursorMove(params string) string {
x, y, _, ok := xyParams(params)
if !ok || !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
return luaDispatcherTableCall("hl.dsp.cursor.move", luaNumberOrStringField("x", x), luaNumberOrStringField("y", y))
}
func dispatcherSignal(params string) string {
signal, window := firstParam(params)
if signal == "" || !isBareLuaNumber(signal) {
return ""
}
fields := []luaField{luaNumberOrStringField("signal", signal)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.signal", fields...)
}
func dispatcherSignalWindow(params string) string {
window, rest := firstParam(params)
signal, _ := firstParam(rest)
if signal == "" || !isBareLuaNumber(signal) {
return ""
}
fields := []luaField{luaNumberOrStringField("signal", signal)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.signal", fields...)
}
func dispatcherTagWindow(params string) string {
tag, window := firstParam(params)
if tag == "" {
return ""
}
fields := []luaField{luaStringField("tag", tag)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.tag", fields...)
}
func luaActionStringFromKnownHyprlandAction(action string) (string, bool) {
dispatcher, params := splitHyprlandAction(action)
switch dispatcher {
case "spawn", "exec":
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(params)), true
case "execr":
return fmt.Sprintf(`hl.dsp.exec_raw(%s)`, strconv.Quote(params)), true
case "killactive":
return `hl.dsp.window.close()`, true
case "forcekillactive":
return `hl.dsp.window.kill()`, true
case "closewindow":
if params == "" {
return `hl.dsp.window.close()`, true
}
return luaDispatcherTableCall("hl.dsp.window.close", luaStringField("window", params)), true
case "killwindow":
if params == "" {
return `hl.dsp.window.kill()`, true
}
return luaDispatcherTableCall("hl.dsp.window.kill", luaStringField("window", params)), true
case "togglefloating":
return dispatcherToggleTableCall("hl.dsp.window.float", "toggle"), true
case "setfloating":
return dispatcherToggleTableCall("hl.dsp.window.float", "on"), true
case "settiled":
return dispatcherToggleTableCall("hl.dsp.window.float", "off"), true
case "fullscreen":
mode := strings.TrimSpace(params)
switch mode {
case "", "0":
return `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })`, true
case "1":
return `hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, true
}
return luaHyprctlDispatchFunction(action), true
case "fullscreenstate":
internal, rest := firstParam(params)
client, _ := firstParam(rest)
if internal != "" && client != "" {
return luaDispatcherTableCall("hl.dsp.window.fullscreen_state",
luaNumberOrStringField("internal", internal),
luaNumberOrStringField("client", client),
), true
}
case "fakefullscreen":
return luaHyprctlDispatchFunction(action), true
case "pin":
if params == "" {
return `hl.dsp.window.pin()`, true
}
return dispatcherToggleTableCall("hl.dsp.window.pin", params), true
case "pseudo":
return dispatcherToggleTableCall("hl.dsp.window.pseudo", params), true
case "centerwindow":
return `hl.dsp.window.center()`, true
case "resizewindow":
return `hl.dsp.window.resize()`, true
case "movewindow":
if params == "" {
return `hl.dsp.window.drag()`, true
}
if monitor, ok := strings.CutPrefix(params, "mon:"); ok {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("monitor", monitor)), true
}
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params)), true
case "swapwindow":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.window.swap", luaStringField("direction", params)), true
case "swapnext":
return dispatcherSwapNext(params), true
case "resizeactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "moveactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "resizewindowpixel":
if expr := dispatcherWindowMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "movewindowpixel":
if expr := dispatcherWindowMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "workspace":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params)), true
case "focusworkspaceoncurrentmonitor":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params), luaBoolField("on_current_monitor", true)), true
case "movetoworkspace":
if expr := dispatcherWorkspaceMove(params, nil); expr != "" {
return expr, true
}
case "movetoworkspacesilent":
follow := false
if expr := dispatcherWorkspaceMove(params, &follow); expr != "" {
return expr, true
}
case "togglespecialworkspace":
if params == "" {
return `hl.dsp.workspace.toggle_special()`, true
}
return fmt.Sprintf(`hl.dsp.workspace.toggle_special(%s)`, strconv.Quote(params)), true
case "renameworkspace":
workspace, name := firstParam(params)
if workspace != "" {
fields := []luaField{luaStringField("workspace", workspace)}
if name != "" {
fields = append(fields, luaStringField("name", name))
}
return luaDispatcherTableCall("hl.dsp.workspace.rename", fields...), true
}
case "movecurrentworkspacetomonitor":
if params != "" {
return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("monitor", params)), true
}
case "moveworkspacetomonitor":
workspace, monitor := firstParam(params)
if workspace != "" && monitor != "" {
return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("workspace", workspace), luaStringField("monitor", monitor)), true
}
case "workspaceopt":
return luaHyprctlDispatchFunction(action), true
case "swapactiveworkspaces":
monitor1, rest := firstParam(params)
monitor2, _ := firstParam(rest)
if monitor1 != "" && monitor2 != "" {
return luaDispatcherTableCall("hl.dsp.workspace.swap_monitors", luaStringField("monitor1", monitor1), luaStringField("monitor2", monitor2)), true
}
case "movefocus":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("direction", params)), true
}
case "focusmonitor":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("monitor", params)), true
}
case "focuswindow":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", params)), true
}
case "focuswindowbyclass":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", "class:"+params)), true
}
case "focuscurrentorlast":
return `hl.dsp.focus({ last = true })`, true
case "focusurgentorlast":
return `hl.dsp.focus({ urgent_or_last = true })`, true
case "cyclenext":
if expr := dispatcherCycleNext(params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "layoutmsg":
if params != "" {
return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), true
}
case "splitratio":
return luaHyprctlDispatchFunction(action), true
case "alterzorder":
mode, window := firstParam(params)
if mode != "" {
fields := []luaField{luaStringField("mode", mode)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.alter_zorder", fields...), true
}
case "setprop":
window, rest := firstParam(params)
prop, value := firstParam(rest)
if window != "" && prop != "" && value != "" {
return luaDispatcherTableCall("hl.dsp.window.set_prop",
luaStringField("window", window),
luaStringField("prop", prop),
luaStringField("value", value),
), true
}
case "bringactivetotop":
return `hl.dsp.window.bring_to_top()`, true
case "toggleswallow":
return `hl.dsp.window.toggle_swallow()`, true
case "signal":
if expr := dispatcherSignal(params); expr != "" {
return expr, true
}
case "signalwindow":
if expr := dispatcherSignalWindow(params); expr != "" {
return expr, true
}
case "tagwindow":
if expr := dispatcherTagWindow(params); expr != "" {
return expr, true
}
case "dpms":
dpmsAction := strings.TrimSpace(params)
switch dpmsAction {
case "on":
dpmsAction = "enable"
case "off":
dpmsAction = "disable"
}
if dpmsAction == "" {
return `hl.dsp.dpms({})`, true
}
return luaDispatcherTableCall("hl.dsp.dpms", luaStringField("action", dpmsAction)), true
case "exit":
return `hl.dsp.exit()`, true
case "submap":
return fmt.Sprintf(`hl.dsp.submap(%s)`, strconv.Quote(params)), true
case "global":
return fmt.Sprintf(`hl.dsp.global(%s)`, strconv.Quote(params)), true
case "event":
return fmt.Sprintf(`hl.dsp.event(%s)`, strconv.Quote(params)), true
case "pass":
if params == "" {
return `hl.dsp.pass({})`, true
}
return luaDispatcherTableCall("hl.dsp.pass", luaStringField("window", params)), true
case "sendshortcut":
mod, rest := firstParam(params)
key, window := firstParam(rest)
if mod != "" && key != "" {
fields := []luaField{luaStringField("mods", mod), luaStringField("key", key)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.send_shortcut", fields...), true
}
case "sendkeystate":
mod, rest := firstParam(params)
key, rest := firstParam(rest)
state, window := firstParam(rest)
if mod != "" && key != "" && state != "" {
fields := []luaField{luaStringField("mods", mod), luaStringField("key", key), luaStringField("state", state)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.send_key_state", fields...), true
}
case "movecursortocorner":
if params != "" && isBareLuaNumber(params) {
return luaDispatcherTableCall("hl.dsp.cursor.move_to_corner", luaNumberOrStringField("corner", params)), true
}
case "movecursor":
if expr := dispatcherCursorMove(params); expr != "" {
return expr, true
}
case "togglegroup":
return `hl.dsp.group.toggle()`, true
case "changegroupactive":
if expr := dispatcherGroupActive(params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "movegroupwindow":
return dispatcherMoveGroupWindow(params), true
case "moveintogroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_group", params)), true
}
case "moveintoorcreategroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_or_create_group", params)), true
}
case "moveoutofgroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("out_of_group", params)), true
}
return luaDispatcherTableCall("hl.dsp.window.move", luaBoolField("out_of_group", true)), true
case "movewindoworgroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true
}
case "lockgroups":
return dispatcherToggleTableCall("hl.dsp.group.lock", params), true
case "lockactivegroup":
return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true
case "denywindowfromgroup":
return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true
case "setignoregrouplock":
return luaHyprctlDispatchFunction(action), true
case "forcerendererreload":
return `hl.dsp.force_renderer_reload()`, true
case "forceidle":
if params != "" && isBareLuaNumber(params) {
return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true
}
}
if isKnownHyprlandDispatcher(dispatcher) {
return luaHyprctlDispatchFunction(action), true
}
return "", false
}
func luaActionStringFromHyprlangAction(action string) string { func luaActionStringFromHyprlangAction(action string) string {
action = strings.TrimSpace(action) action = strings.TrimSpace(action)
if strings.HasPrefix(action, "spawn ") { if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn ")))) return expr
}
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 fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
} }
return action
} }
func luaExprToInternalAction(expr string) string { func luaExprToInternalAction(expr string) string {
@@ -407,7 +1061,7 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
if strings.Contains(bind.Flags, "e") { if strings.Contains(bind.Flags, "e") {
opts = append(opts, "repeating = true") opts = append(opts, "repeating = true")
} }
if bind.Description != "" && strings.Contains(bind.Flags, "d") { if bind.Description != "" {
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description))) opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
} }
return opts return opts
@@ -427,11 +1081,7 @@ func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
if len(opts) > 0 { if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", ")) fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else { } else {
if bind.Description != "" { fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
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') sb.WriteByte('\n')
} }
@@ -450,6 +1100,9 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
action := luaExprToInternalAction(actionExpr) action := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix) flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix) description := luaBindOptDescription(optSuffix)
if description == "" {
description = luaLineTrailingComment(line)
}
return &hyprlandOverrideBind{ return &hyprlandOverrideBind{
Key: internalKey, Key: internalKey,
Action: action, Action: action,
@@ -498,11 +1151,12 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
continue continue
} }
if key, ok := parseLuaUnbindLine(line); ok { if key, ok := parseLuaUnbindLine(line); ok {
pendingUnbinds[strings.ToLower(key)] = key pendingUnbinds[hyprlandOverrideMapKey(key)] = canonicalHyprlandOverrideKey(key)
continue continue
} }
if kb, ok := parseLuaBindOverrideLine(line); ok { if kb, ok := parseLuaBindOverrideLine(line); ok {
normalizedKey := strings.ToLower(kb.Key) kb.Key = canonicalHyprlandOverrideKey(kb.Key)
normalizedKey := hyprlandOverrideMapKey(kb.Key)
binds[normalizedKey] = kb binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey) delete(pendingUnbinds, normalizedKey)
continue continue
@@ -520,7 +1174,8 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
action = kb.Dispatcher + " " + kb.Params action = kb.Dispatcher + " " + kb.Params
} }
flags := kb.Flags flags := kb.Flags
normalizedKey := strings.ToLower(keyStr) keyStr = canonicalHyprlandOverrideKey(keyStr)
normalizedKey := hyprlandOverrideMapKey(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{ binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr, Key: keyStr,
Action: action, Action: action,
@@ -54,6 +54,8 @@ type HyprlandParser struct {
dmsProcessed bool dmsProcessed bool
removedKeys map[string]bool // bare hl.unbind targets (negative overrides) removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf} defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
configFormat string
readOnly bool
} }
func NewHyprlandParser(configDir string) *HyprlandParser { func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -310,6 +312,8 @@ type HyprlandDMSStatus struct {
Effective bool Effective bool
OverriddenBy int OverriddenBy int
StatusMessage string StatusMessage string
ConfigFormat string
ReadOnly bool
} }
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
@@ -319,6 +323,8 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
IncludePosition: p.dmsIncludePos, IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount, TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS, BindsAfterDMS: p.bindsAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
} }
switch { switch {
@@ -398,6 +404,13 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
} else {
p.configFormat = "hyprlang"
p.readOnly = true
}
section, err := p.parseFileWithSource(mainConfig, "") section, err := p.parseFileWithSource(mainConfig, "")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -869,23 +882,20 @@ func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool)
return "", i, false return "", i, false
} }
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses // parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping at
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings). // the next top-level comma. It handles nested calls/tables and inline functions.
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) { func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
start = skipLuaWS(line, start) start = skipLuaWS(line, start)
if start >= len(line) { if start >= len(line) {
return "", start, false return "", start, false
} }
// Find first '(' of the call (e.g. hl.dsp.exec_cmd(...) i := start
firstParen := strings.IndexByte(line[start:], '(') parenDepth := 0
if firstParen < 0 { braceDepth := 0
return "", start, false bracketDepth := 0
} functionDepth := 0
i := start + firstParen
depth := 0
inStr := byte(0) inStr := byte(0)
esc := false esc := false
exprStart := start
for ; i < len(line); i++ { for ; i < len(line); i++ {
c := line[i] c := line[i]
if inStr != 0 { if inStr != 0 {
@@ -902,19 +912,66 @@ func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok boo
} }
continue continue
} }
if c == '[' && i+1 < len(line) && line[i+1] == '[' {
if end := strings.Index(line[i+2:], "]]"); end >= 0 {
i += end + 3
continue
}
return "", start, false
}
if luaWordAt(line, i, "function") {
functionDepth++
i += len("function") - 1
continue
}
if luaWordAt(line, i, "end") && functionDepth > 0 {
functionDepth--
i += len("end") - 1
continue
}
switch c { switch c {
case '"', '\'': case '"', '\'':
inStr = c inStr = c
case '(': case '(':
depth++ parenDepth++
case ')': case ')':
depth-- if parenDepth > 0 {
if depth == 0 { parenDepth--
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true }
case '{':
braceDepth++
case '}':
if braceDepth > 0 {
braceDepth--
}
case '[':
bracketDepth++
case ']':
if bracketDepth > 0 {
bracketDepth--
}
case ',':
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && functionDepth == 0 {
return strings.TrimSpace(line[start:i]), i, true
} }
} }
} }
return "", start, false expr = strings.TrimSpace(line[start:i])
return expr, i, expr != ""
}
func luaWordAt(line string, idx int, word string) bool {
if idx < 0 || idx+len(word) > len(line) || line[idx:idx+len(word)] != word {
return false
}
before := idx == 0 || !isLuaIdentByte(line[idx-1])
afterIdx := idx + len(word)
after := afterIdx >= len(line) || !isLuaIdentByte(line[afterIdx])
return before && after
}
func isLuaIdentByte(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
} }
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line. // parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
@@ -993,19 +1050,39 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if arg != "" { if arg != "" {
if u, err := strconv.Unquote(arg); err == nil { if u, err := strconv.Unquote(arg); err == nil {
if strings.HasPrefix(u, "hyprctl dispatch ") { if strings.HasPrefix(u, "hyprctl dispatch ") {
rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch ")) return splitDispatchCommand(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", u
} }
} }
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd")) return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
case strings.Contains(expr, "hl.dsp.window.kill()"): case strings.HasPrefix(expr, "hl.dsp.exec_raw("):
return "execr", luaCallStringArgValue(expr, "hl.dsp.exec_raw")
case strings.HasPrefix(expr, "hl.dispatch("):
if arg := luaCallStringArgValue(expr, "hl.dispatch"); arg != "" {
return splitDispatchCommand(arg)
}
return "", ""
case strings.Contains(expr, "hl.exec_cmd("):
if arg := luaEmbeddedCallStringArgValue(expr, "hl.exec_cmd"); strings.HasPrefix(arg, "hyprctl dispatch ") {
return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(arg, "hyprctl dispatch ")))
}
case strings.HasPrefix(expr, "hl.dsp.window.close("):
if window := luaTableStringField(expr, "window"); window != "" {
return "closewindow", window
}
if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" {
return "closewindow", arg
}
return "killactive", "" return "killactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.kill("):
if window := luaTableStringField(expr, "window"); window != "" {
return "killwindow", window
}
if arg := luaCallStringArgValue(expr, "hl.dsp.window.kill"); arg != "" {
return "killwindow", arg
}
return "forcekillactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("): case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
switch luaTableStringField(expr, "mode") { switch luaTableStringField(expr, "mode") {
case "maximized", "maximize": case "maximized", "maximize":
@@ -1014,10 +1091,55 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
return "fullscreen", "0" return "fullscreen", "0"
} }
return "fullscreen", luaTableStringField(expr, "mode") return "fullscreen", luaTableStringField(expr, "mode")
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen_state("):
internal := luaStringValue(luaTableScalarField(expr, "internal"))
client := luaStringValue(luaTableScalarField(expr, "client"))
return joinDispatcherParams("fullscreenstate", internal, client)
case strings.HasPrefix(expr, "hl.dsp.window.float("): case strings.HasPrefix(expr, "hl.dsp.window.float("):
return "togglefloating", "" switch luaToggleActionToLegacy(luaTableStringField(expr, "action")) {
case "on":
return "setfloating", ""
case "off":
return "settiled", ""
default:
return "togglefloating", ""
}
case strings.HasPrefix(expr, "hl.dsp.window.pseudo("):
action := luaToggleActionToLegacy(luaTableStringField(expr, "action"))
if action == "" || action == "toggle" {
return "pseudo", ""
}
return "pseudo", action
case strings.HasPrefix(expr, "hl.dsp.window.pin("):
if action := luaToggleActionToLegacy(luaTableStringField(expr, "action")); action != "" && action != "toggle" {
return "pin", action
}
return "pin", ""
case strings.Contains(expr, "hl.dsp.window.center()"):
return "centerwindow", ""
case strings.Contains(expr, "hl.dsp.window.bring_to_top()"):
return "bringactivetotop", ""
case strings.Contains(expr, "hl.dsp.window.toggle_swallow()"):
return "toggleswallow", ""
case strings.Contains(expr, "hl.dsp.group.toggle()"): case strings.Contains(expr, "hl.dsp.group.toggle()"):
return "togglegroup", "" return "togglegroup", ""
case strings.Contains(expr, "hl.dsp.group.next()"):
return "changegroupactive", "f"
case strings.Contains(expr, "hl.dsp.group.prev()"):
return "changegroupactive", "b"
case strings.HasPrefix(expr, "hl.dsp.group.active("):
return "changegroupactive", luaStringValue(luaTableScalarField(expr, "index"))
case strings.HasPrefix(expr, "hl.dsp.group.move_window("):
if forward, ok := luaTableBoolField(expr, "forward"); ok && !forward {
return "movegroupwindow", "b"
}
return "movegroupwindow", "f"
case strings.HasPrefix(expr, "hl.dsp.group.lock_active("):
return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.group.lock("):
return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("):
return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.focus("): case strings.HasPrefix(expr, "hl.dsp.focus("):
switch { switch {
case luaTableStringField(expr, "direction") != "": case luaTableStringField(expr, "direction") != "":
@@ -1025,18 +1147,58 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
case luaTableStringField(expr, "monitor") != "": case luaTableStringField(expr, "monitor") != "":
return "focusmonitor", luaTableStringField(expr, "monitor") return "focusmonitor", luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "": case luaTableStringField(expr, "workspace") != "":
if luaTableBoolFieldValue(expr, "on_current_monitor") {
return "focusworkspaceoncurrentmonitor", luaTableStringField(expr, "workspace")
}
return "workspace", luaTableStringField(expr, "workspace") return "workspace", luaTableStringField(expr, "workspace")
case luaTableStringField(expr, "window") != "": case luaTableStringField(expr, "window") != "":
return "focuswindow", luaTableStringField(expr, "window") return "focuswindow", luaTableStringField(expr, "window")
case luaTableBoolFieldValue(expr, "urgent_or_last"):
return "focusurgentorlast", ""
case luaTableBoolFieldValue(expr, "last"):
return "focuscurrentorlast", ""
} }
case strings.HasPrefix(expr, "hl.dsp.window.move("): case strings.HasPrefix(expr, "hl.dsp.window.move("):
switch { switch {
case luaTableScalarField(expr, "x") != "" || luaTableScalarField(expr, "y") != "":
x := luaStringValue(luaTableScalarField(expr, "x"))
y := luaStringValue(luaTableScalarField(expr, "y"))
if x == "" {
x = "0"
}
if y == "" {
y = "0"
}
prefix := ""
if raw, ok := luaTableBoolField(expr, "relative"); ok && !raw {
prefix = "exact "
}
params := prefix + x + " " + y
if window := luaTableStringField(expr, "window"); window != "" {
return "movewindowpixel", params + "," + window
}
return "moveactive", params
case luaTableStringField(expr, "into_group") != "":
return "moveintogroup", luaTableStringField(expr, "into_group")
case luaTableStringField(expr, "into_or_create_group") != "":
return "moveintoorcreategroup", luaTableStringField(expr, "into_or_create_group")
case luaTableBoolFieldValue(expr, "out_of_group"):
return "moveoutofgroup", ""
case luaTableStringField(expr, "out_of_group") != "":
return "moveoutofgroup", luaTableStringField(expr, "out_of_group")
case luaTableStringField(expr, "direction") != "": case luaTableStringField(expr, "direction") != "":
if luaTableBoolFieldValue(expr, "group_aware") {
return "movewindoworgroup", luaTableStringField(expr, "direction")
}
return "movewindow", luaTableStringField(expr, "direction") return "movewindow", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "": case luaTableStringField(expr, "monitor") != "":
return "movewindow", "mon:" + luaTableStringField(expr, "monitor") return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "": case luaTableStringField(expr, "workspace") != "":
return "movetoworkspace", luaTableStringField(expr, "workspace") action := "movetoworkspace"
if follow, ok := luaTableBoolField(expr, "follow"); ok && !follow {
action = "movetoworkspacesilent"
}
return joinDispatcherParams(action, luaTableStringField(expr, "workspace"), luaTableStringField(expr, "window"))
} }
case expr == "hl.dsp.window.drag()": case expr == "hl.dsp.window.drag()":
return "movewindow", "" return "movewindow", ""
@@ -1052,25 +1214,184 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if y == "" { if y == "" {
y = "0" y = "0"
} }
return "resizeactive", x + " " + y prefix := ""
} if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative {
case strings.HasPrefix(expr, "hl.dsp.layout("): prefix = "exact "
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
return "layoutmsg", u
} }
params := prefix + x + " " + y
if window := luaTableStringField(expr, "window"); window != "" {
return "resizewindowpixel", params + "," + window
}
return "resizeactive", params
}
case strings.HasPrefix(expr, "hl.dsp.window.swap("):
switch {
case luaTableBoolFieldValue(expr, "next"):
return "swapnext", ""
case luaTableBoolFieldValue(expr, "prev"):
return "swapnext", "prev"
}
return "swapwindow", luaTableStringField(expr, "direction")
case strings.HasPrefix(expr, "hl.dsp.window.cycle_next("):
parts := []string{}
if next, ok := luaTableBoolField(expr, "next"); ok && !next {
parts = append(parts, "prev")
}
if luaTableBoolFieldValue(expr, "tiled") {
parts = append(parts, "tiled")
}
if luaTableBoolFieldValue(expr, "floating") {
parts = append(parts, "floating")
}
return "cyclenext", strings.Join(parts, " ")
case strings.HasPrefix(expr, "hl.dsp.window.signal("):
signal := luaStringValue(luaTableScalarField(expr, "signal"))
window := luaTableStringField(expr, "window")
if window != "" {
return joinDispatcherParams("signalwindow", window, signal)
}
return "signal", signal
case strings.HasPrefix(expr, "hl.dsp.window.tag("):
return joinDispatcherParams("tagwindow", luaTableStringField(expr, "tag"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.window.alter_zorder("):
mode := luaTableStringField(expr, "mode")
if mode == "" {
mode = luaTableStringField(expr, "zheight")
}
return joinDispatcherParams("alterzorder", mode, luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.window.set_prop("):
prop := luaTableStringField(expr, "prop")
if prop == "" {
prop = luaTableStringField(expr, "property")
}
return joinDispatcherParams("setprop", luaTableStringField(expr, "window"), prop, luaTableStringField(expr, "value"))
case strings.HasPrefix(expr, "hl.dsp.workspace.rename("):
return joinDispatcherParams("renameworkspace", luaTableStringField(expr, "workspace"), luaTableStringField(expr, "name"))
case strings.HasPrefix(expr, "hl.dsp.workspace.move("):
workspace := luaTableStringField(expr, "workspace")
monitor := luaTableStringField(expr, "monitor")
if workspace != "" {
return joinDispatcherParams("moveworkspacetomonitor", workspace, monitor)
}
return "movecurrentworkspacetomonitor", monitor
case strings.HasPrefix(expr, "hl.dsp.workspace.swap_monitors("):
return joinDispatcherParams("swapactiveworkspaces", luaTableStringField(expr, "monitor1"), luaTableStringField(expr, "monitor2"))
case strings.HasPrefix(expr, "hl.dsp.workspace.toggle_special("):
return "togglespecialworkspace", luaCallStringArgValue(expr, "hl.dsp.workspace.toggle_special")
case strings.HasPrefix(expr, "hl.dsp.layout("):
if arg := luaCallStringArgValue(expr, "hl.dsp.layout"); arg != "" {
return "layoutmsg", arg
} }
case strings.HasPrefix(expr, "hl.dsp.dpms("): case strings.HasPrefix(expr, "hl.dsp.dpms("):
if action := luaTableStringField(expr, "action"); action != "" { if action := luaTableStringField(expr, "action"); action != "" {
switch action {
case "enable":
return "dpms", "on"
case "disable":
return "dpms", "off"
}
return "dpms", action return "dpms", action
} }
return "dpms", ""
case strings.HasPrefix(expr, "hl.dsp.submap("):
return "submap", luaCallStringArgValue(expr, "hl.dsp.submap")
case strings.HasPrefix(expr, "hl.dsp.global("):
return "global", luaCallStringArgValue(expr, "hl.dsp.global")
case strings.HasPrefix(expr, "hl.dsp.event("):
return "event", luaCallStringArgValue(expr, "hl.dsp.event")
case strings.HasPrefix(expr, "hl.dsp.pass("):
if window := luaTableStringField(expr, "window"); window != "" {
return "pass", window
}
return "pass", luaCallStringArgValue(expr, "hl.dsp.pass")
case strings.HasPrefix(expr, "hl.dsp.send_shortcut("):
return joinDispatcherParams("sendshortcut", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.send_key_state("):
return joinDispatcherParams("sendkeystate", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "state"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.cursor.move_to_corner("):
return "movecursortocorner", luaStringValue(luaTableScalarField(expr, "corner"))
case strings.HasPrefix(expr, "hl.dsp.cursor.move("):
return joinDispatcherParams("movecursor", luaStringValue(luaTableScalarField(expr, "x")), luaStringValue(luaTableScalarField(expr, "y")))
case strings.Contains(expr, "hl.dsp.force_renderer_reload()"):
return "forcerendererreload", ""
case strings.HasPrefix(expr, "hl.dsp.force_idle("):
return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle")
case strings.Contains(expr, "hl.dsp.exit()"): case strings.Contains(expr, "hl.dsp.exit()"):
return "exit", "" return "exit", ""
default: default:
return "exec", "hyprctl dispatch lua:" + expr return expr, ""
}
return expr, ""
}
func splitDispatchCommand(command string) (dispatcher, params string) {
command = strings.TrimSpace(command)
if command == "" {
return "", ""
}
parts := strings.SplitN(command, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.TrimSpace(parts[1])
}
func joinDispatcherParams(dispatcher string, values ...string) (string, string) {
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
parts = append(parts, value)
}
}
return dispatcher, strings.Join(parts, " ")
}
func luaEmbeddedCallStringArgValue(expr, funcName string) string {
idx := strings.Index(expr, funcName+"(")
if idx < 0 {
return ""
}
return luaCallStringArgValue(expr[idx:], funcName)
}
func luaCallScalarArgValue(callExpr, funcName string) string {
callExpr = strings.TrimSpace(callExpr)
prefix := funcName + "("
if !strings.HasPrefix(callExpr, prefix) {
return ""
}
inner := strings.TrimSpace(callExpr[len(prefix):])
if inner == "" {
return ""
}
if s := luaCallStringArgValue(callExpr, funcName); s != "" {
return s
}
re := regexp.MustCompile(`^-?\d+(?:\.\d+)?`)
return re.FindString(inner)
}
func luaToggleActionToLegacy(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "on", "enable", "enabled", "set", "lock":
return "on"
case "off", "disable", "disabled", "unset", "unlock":
return "off"
default:
return "toggle"
}
}
func luaToggleActionToLockArg(action string) string {
switch luaToggleActionToLegacy(action) {
case "on":
return "lock"
case "off":
return "unlock"
default:
return "toggle"
} }
return "exec", "hyprctl dispatch lua:" + expr
} }
func extractLuaCallStringArg(callExpr, funcName string) string { func extractLuaCallStringArg(callExpr, funcName string) string {
@@ -1100,10 +1421,46 @@ func extractLuaCallStringArg(callExpr, funcName string) string {
return "" return ""
} }
func luaCallStringArgValue(callExpr, funcName string) string {
arg := extractLuaCallStringArg(callExpr, funcName)
if arg == "" {
return ""
}
u, err := strconv.Unquote(arg)
if err != nil {
return ""
}
return u
}
func luaTableStringField(expr, field string) string { func luaTableStringField(expr, field string) string {
return luaStringValue(luaTableScalarField(expr, field)) return luaStringValue(luaTableScalarField(expr, field))
} }
func luaTableModsField(expr string) string {
if mods := luaTableStringField(expr, "mods"); mods != "" {
return mods
}
return luaTableStringField(expr, "mod")
}
func luaTableBoolFieldValue(expr, field string) bool {
value, ok := luaTableBoolField(expr, field)
return ok && value
}
func luaTableBoolField(expr, field string) (bool, bool) {
raw := strings.ToLower(luaTableScalarField(expr, field))
switch raw {
case "true":
return true, true
case "false":
return false, true
default:
return false, false
}
}
func luaTableScalarField(expr, field string) string { func luaTableScalarField(expr, field string) string {
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`) re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
m := re.FindStringSubmatch(expr) m := re.FindStringSubmatch(expr)
@@ -1136,8 +1493,38 @@ func luaStringValue(raw string) string {
} }
func luaLineTrailingComment(line string) string { func luaLineTrailingComment(line string) string {
if idx := strings.Index(line, "--"); idx >= 0 { inString := byte(0)
return strings.TrimSpace(line[idx+2:]) escaped := false
for i := 0; i < len(line)-1; i++ {
c := line[i]
if inString != 0 {
if escaped {
escaped = false
continue
}
if c == '\\' && inString == '"' {
escaped = true
continue
}
if c == inString {
inString = 0
}
continue
}
if c == '"' || c == '\'' {
inString = c
continue
}
if c == '[' && line[i+1] == '[' {
if end := strings.Index(line[i+2:], "]]"); end >= 0 {
i += end + 3
continue
}
return ""
}
if c == '-' && line[i+1] == '-' {
return strings.TrimSpace(line[i+2:])
}
} }
return "" return ""
} }
@@ -70,12 +70,37 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
wantParams string wantParams string
}{ }{
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`}, {`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
{`hl.dsp.exec_cmd([[hyprctl dispatch workspace 1]])`, "workspace", "1"},
{`hl.dispatch("workspace 2")`, "workspace", "2"},
{`hl.dispatch([[customdispatcher arg one]])`, "customdispatcher", "arg one"},
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"}, {`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
{`hl.dsp.window.float({ action = "on" })`, "setfloating", ""},
{`hl.dsp.window.close()`, "killactive", ""},
{`hl.dsp.window.kill()`, "forcekillactive", ""},
{`hl.dsp.window.close({ window = "class:^(kitty)$" })`, "closewindow", "class:^(kitty)$"},
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"}, {`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
{`hl.dsp.focus({ workspace = "2", on_current_monitor = true })`, "focusworkspaceoncurrentmonitor", "2"},
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"}, {`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"}, {`hl.dsp.window.move({ direction = "r", group_aware = true })`, "movewindoworgroup", "r"},
{`hl.dsp.window.move({ into_group = "l" })`, "moveintogroup", "l"},
{`hl.dsp.window.move({ out_of_group = true })`, "moveoutofgroup", ""},
{`hl.dsp.window.move({ workspace = "special:magic", follow = false })`, "movetoworkspacesilent", "special:magic"},
{`hl.dsp.window.resize({ x = -100, y = 0, relative = true })`, "resizeactive", "-100 0"},
{`hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`, "resizeactive", "exact 1280 720"},
{`hl.dsp.window.resize({ x = 100, y = 50, relative = true, window = "class:^(app)$" })`, "resizewindowpixel", "100 50,class:^(app)$"},
{`hl.dsp.window.cycle_next({ next = false, tiled = true })`, "cyclenext", "prev tiled"},
{`hl.dsp.group.next()`, "changegroupactive", "f"},
{`hl.dsp.group.prev()`, "changegroupactive", "b"},
{`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"},
{`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"},
{`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"},
{`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"},
{`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"},
{`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"},
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"}, {`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"}, {`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
{`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"},
{`hl.dsp.no_op()`, "hl.dsp.no_op()", ""},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -113,12 +138,132 @@ func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
}) })
want := `hl.unbind("SUPER + N") want := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle` hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { description = "Notepad: Toggle" })`
if got := strings.TrimSpace(sb.String()); got != want { if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want) t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
} }
} }
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: "hl.dsp.no_op()",
Description: "Custom Lua",
})
want := `hl.unbind("SUPER + U")
hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
tests := []struct {
action string
want string
}{
{"killactive", `hl.dsp.window.close()`},
{"forcekillactive", `hl.dsp.window.kill()`},
{"workspace 1", `hl.dsp.focus({ workspace = "1" })`},
{"movetoworkspace 2", `hl.dsp.window.move({ workspace = "2" })`},
{"movetoworkspacesilent special:magic", `hl.dsp.window.move({ workspace = "special:magic", follow = false })`},
{"focusmonitor DP-1", `hl.dsp.focus({ monitor = "DP-1" })`},
{"resizeactive exact 1280 720", `hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`},
{"dpms toggle", `hl.dsp.dpms({ action = "toggle" })`},
{"renameworkspace 1 work", `hl.dsp.workspace.rename({ workspace = "1", name = "work" })`},
{"changegroupactive f", `hl.dsp.group.next()`},
{"changegroupactive b", `hl.dsp.group.prev()`},
{"changegroupactive 2", `hl.dsp.group.active({ index = 2 })`},
{"moveintogroup l", `hl.dsp.window.move({ into_group = "l" })`},
{"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`},
{"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`},
{"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`},
{"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`},
{"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`},
{"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`},
{"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`},
{"setfloating", `hl.dsp.window.float({ action = "on" })`},
{"settiled", `hl.dsp.window.float({ action = "off" })`},
{"bringactivetotop", `hl.dsp.window.bring_to_top()`},
{"toggleswallow", `hl.dsp.window.toggle_swallow()`},
{"forceidle 300", `hl.dsp.force_idle(300)`},
}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
got := luaActionStringFromHyprlangAction(tt.action)
if got != tt.want {
t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, want %q", tt.action, got, tt.want)
}
if strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected native Lua dispatcher, got legacy dispatch wrapper: %q", got)
}
})
}
}
func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) {
got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%")
want := `function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
}
func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
line := `hl.bind("SUPER + R", function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end, { description = "Unsupported Resize" })`
got, ok := parseLuaBindOverrideLine(line)
if !ok {
t.Fatalf("expected line to parse")
}
if got.Action != "resizeactive exact 100% 100%" {
t.Fatalf("Action = %q, want resizeactive exact 100%% 100%%", got.Action)
}
if got.Description != "Unsupported Resize" {
t.Fatalf("Description = %q, want Unsupported Resize", got.Description)
}
}
func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
want := `hl.dsp.no_op()`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
}
}
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
tmpDir := t.TempDir()
overridePath := filepath.Join(tmpDir, "binds-user.lua")
contents := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle
hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help"))
`
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
binds, err := readLuaOrHyprlangOverride(overridePath)
if err != nil {
t.Fatal(err)
}
got := binds["super+n"]
if got == nil {
t.Fatalf("expected SUPER+N override, got %#v", binds)
}
if got.Description != "Notepad: Toggle" {
t.Fatalf("expected trailing comment to be preserved as description, got %q", got.Description)
}
if got := binds["super+h"]; got == nil || got.Description != "" {
t.Fatalf("expected -- inside a Lua string to stay out of the description, got %#v", got)
}
}
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) { func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
@@ -283,6 +428,64 @@ func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
} }
} }
func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("bind = SUPER, T, exec, kitty\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
err := provider.SetBind("SUPER+N", "workspace 1", "Workspace 1", nil)
if err == nil {
t.Fatal("expected SetBind to reject conf-only Hyprland config")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected read-only error, got %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err)
}
}
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `-- DMS user keybind overrides
hl.unbind("SUPER + SHIFT + S")
hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.SetBind("SUPER + 1", "workspace 1", "", nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, `hl.unbind("SUPER + 1")`) != 1 {
t.Fatalf("expected one SUPER+1 unbind, got:\n%s", got)
}
if strings.Count(got, `hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))`) != 1 {
t.Fatalf("expected one native SUPER+1 bind, got:\n%s", got)
}
if strings.Contains(got, "hyprctl dispatch workspace 1") {
t.Fatalf("expected old hyprctl workspace dispatcher to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `hl.unbind("SUPER + SHIFT + S")`) {
t.Fatalf("expected unrelated override to be preserved, got:\n%s", got)
}
}
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) { func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
+280 -60
View File
@@ -7,6 +7,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
@@ -228,11 +229,20 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
} }
normalizedKey := strings.ToLower(key) normalizedKey := strings.ToLower(key)
prefix := "bind"
if existing, ok := existingBinds[normalizedKey]; ok && existing.Prefix != "" {
prefix = existing.Prefix
}
if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" {
prefix = optionPrefix
}
existingBinds[normalizedKey] = &mangowcOverrideBind{ existingBinds[normalizedKey] = &mangowcOverrideBind{
Key: key, Key: key,
Action: action, Action: action,
Description: description, Description: description,
Options: options, Options: options,
Prefix: prefix,
} }
return m.writeOverrideBinds(existingBinds) return m.writeOverrideBinds(existingBinds)
@@ -246,7 +256,7 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
normalizedKey := strings.ToLower(key) normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey) delete(existingBinds, normalizedKey)
return m.writeOverrideBinds(existingBinds) return m.writeOverrideBindsWithRemoved(existingBinds, map[string]bool{normalizedKey: true})
} }
func (m *MangoWCProvider) ResetBind(key string) error { func (m *MangoWCProvider) ResetBind(key string) error {
@@ -258,6 +268,7 @@ type mangowcOverrideBind struct {
Action string Action string
Description string Description string
Options map[string]any Options map[string]any
Prefix string
} }
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) { func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
@@ -272,62 +283,99 @@ func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind,
return nil, err return nil, err
} }
lines := strings.Split(string(data), "\n") var pendingComment string
for _, line := range lines { for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue continue
} }
if !strings.HasPrefix(line, "bind") { bind, ok := m.parseOverrideBindLine(line, pendingComment)
pendingComment = ""
if !ok || bind == nil {
continue continue
} }
parts := strings.SplitN(line, "=", 2) binds[strings.ToLower(bind.Key)] = bind
if len(parts) < 2 {
continue
}
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])
}
fields := strings.SplitN(bindContent, ",", 4)
if len(fields) < 3 {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
command := strings.TrimSpace(fields[2])
var params string
if len(fields) > 3 {
params = strings.TrimSpace(fields[3])
}
keyStr := m.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := command
if params != "" {
action = command + " " + params
}
binds[normalizedKey] = &mangowcOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
}
} }
return binds, nil return binds, nil
} }
func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (*mangowcOverrideBind, bool) {
trimmed := strings.TrimSpace(line)
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
return nil, false
}
prefix := strings.TrimSpace(parts[0])
if !m.isBindPrefix(prefix) {
return nil, false
}
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
description := strings.TrimSpace(precedingComment)
if isMangoWCSectionComment(description) {
description = ""
}
if len(commentParts) > 1 {
description = strings.TrimSpace(commentParts[1])
}
if strings.HasPrefix(description, MangoWCHideComment) {
return nil, true
}
fields := strings.SplitN(bindContent, ",", 4)
if len(fields) < 3 {
return nil, false
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
command := strings.TrimSpace(fields[2])
var params string
if len(fields) > 3 {
params = strings.TrimSpace(fields[3])
}
action := command
if params != "" {
action = command + " " + params
}
return &mangowcOverrideBind{
Key: m.buildKeyString(mods, keyName),
Action: action,
Description: description,
Prefix: prefix,
}, true
}
func (m *MangoWCProvider) isBindPrefix(prefix string) bool {
if !strings.HasPrefix(prefix, "bind") {
return false
}
for _, ch := range strings.TrimPrefix(prefix, "bind") {
if !strings.ContainsRune("lsrp", ch) {
return false
}
}
return true
}
func (m *MangoWCProvider) buildKeyString(mods, key string) string { func (m *MangoWCProvider) buildKeyString(mods, key string) string {
if mods == "" || strings.EqualFold(mods, "none") { if mods == "" || strings.EqualFold(mods, "none") {
return key return key
@@ -362,21 +410,113 @@ func (m *MangoWCProvider) getBindSortPriority(action string) int {
} }
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error { func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
return m.writeOverrideBindsWithRemoved(binds, nil)
}
func (m *MangoWCProvider) writeOverrideBindsWithRemoved(binds map[string]*mangowcOverrideBind, removed map[string]bool) error {
overridePath := m.GetOverridePath() overridePath := m.GetOverridePath()
content := m.generateBindsContent(binds) existingContent := ""
if data, err := os.ReadFile(overridePath); err == nil {
existingContent = string(data)
}
content := m.generatePreservedBindsContent(existingContent, binds, removed)
return os.WriteFile(overridePath, []byte(content), 0o644) return os.WriteFile(overridePath, []byte(content), 0o644)
} }
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string { func (m *MangoWCProvider) generatePreservedBindsContent(existingContent string, binds map[string]*mangowcOverrideBind, removed map[string]bool) string {
if len(binds) == 0 { useStockScaffold := m.shouldUseStockScaffold(existingContent)
return "" source := existingContent
if useStockScaffold {
source = m.stockBindsScaffold(binds)
} }
remaining := make(map[string]*mangowcOverrideBind, len(binds))
for key, bind := range binds {
remaining[key] = bind
}
if useStockScaffold {
m.dropReplacedStockBinds(remaining)
}
var lines []string
for _, line := range strings.Split(source, "\n") {
templateBind, ok := m.parseOverrideBindLine(line, m.previousComment(lines))
if !ok || templateBind == nil {
lines = append(lines, line)
continue
}
normalizedKey := strings.ToLower(templateBind.Key)
m.dropPreviousDescriptionComment(&lines)
if bind, exists := remaining[normalizedKey]; exists {
if useStockScaffold && bind.Description == "" {
bind = m.copyBindWithDescription(bind, templateBind.Description)
}
m.writeBindLineToLines(&lines, bind)
delete(remaining, normalizedKey)
continue
}
if useStockScaffold && !removed[normalizedKey] {
m.writeBindLineToLines(&lines, templateBind)
}
}
if len(remaining) > 0 {
m.trimTrailingEmptyLines(&lines)
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, "# === Custom Keybinds ===")
for _, bind := range m.sortedBinds(remaining) {
m.writeBindLineToLines(&lines, bind)
}
}
m.trimTrailingEmptyLines(&lines)
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n") + "\n"
}
func (m *MangoWCProvider) shouldUseStockScaffold(content string) bool {
if strings.TrimSpace(content) == "" {
return true
}
if strings.Contains(content, "gesturebind=") && strings.Contains(content, "# ===") {
return false
}
return !strings.Contains(content, "gesturebind=") && (strings.Count(content, "\nbind=")+strings.Count(content, "\nbindl=")+strings.Count(content, "\nbinds=")+strings.Count(content, "\nbindr=")+strings.Count(content, "\nbindp=") >= 10 || strings.Contains(content, "dms ipc call"))
}
func (m *MangoWCProvider) stockBindsScaffold(binds map[string]*mangowcOverrideBind) string {
terminalCommand := "ghostty"
for _, key := range []string{"super+t", "super+return"} {
if bind, ok := binds[key]; ok {
command, params := m.parseAction(bind.Action)
if command == "spawn" && strings.TrimSpace(params) != "" && !strings.Contains(params, "dms ") {
terminalCommand = params
break
}
}
}
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
}
func (m *MangoWCProvider) dropReplacedStockBinds(binds map[string]*mangowcOverrideBind) {
if bind, ok := binds["super+j"]; ok && bind.Action == "switch_layout" {
delete(binds, "super+j")
}
}
func (m *MangoWCProvider) sortedBinds(binds map[string]*mangowcOverrideBind) []*mangowcOverrideBind {
bindList := make([]*mangowcOverrideBind, 0, len(binds)) bindList := make([]*mangowcOverrideBind, 0, len(binds))
for _, bind := range binds { for _, bind := range binds {
bindList = append(bindList, bind) bindList = append(bindList, bind)
} }
sort.Slice(bindList, func(i, j int) bool { sort.Slice(bindList, func(i, j int) bool {
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action) pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
if pi != pj { if pi != pj {
@@ -384,20 +524,75 @@ func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverride
} }
return bindList[i].Key < bindList[j].Key return bindList[i].Key < bindList[j].Key
}) })
return bindList
}
func (m *MangoWCProvider) writeBindLineToLines(lines *[]string, bind *mangowcOverrideBind) {
var sb strings.Builder var sb strings.Builder
for _, bind := range bindList { m.writeBindLine(&sb, bind)
m.writeBindLine(&sb, bind) text := strings.TrimSuffix(sb.String(), "\n")
if text == "" {
return
} }
*lines = append(*lines, strings.Split(text, "\n")...)
}
return sb.String() func (m *MangoWCProvider) previousComment(lines []string) string {
if len(lines) == 0 {
return ""
}
trimmed := strings.TrimSpace(lines[len(lines)-1])
if !strings.HasPrefix(trimmed, "#") {
return ""
}
comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(comment) {
return ""
}
return comment
}
func (m *MangoWCProvider) dropPreviousDescriptionComment(lines *[]string) {
if len(*lines) == 0 {
return
}
trimmed := strings.TrimSpace((*lines)[len(*lines)-1])
if !strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "# ===") {
return
}
*lines = (*lines)[:len(*lines)-1]
}
func (m *MangoWCProvider) trimTrailingEmptyLines(lines *[]string) {
for len(*lines) > 0 && strings.TrimSpace((*lines)[len(*lines)-1]) == "" {
*lines = (*lines)[:len(*lines)-1]
}
}
func (m *MangoWCProvider) copyBindWithDescription(bind *mangowcOverrideBind, description string) *mangowcOverrideBind {
copy := *bind
copy.Description = description
return &copy
} }
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) { func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
mods, key := m.parseKeyString(bind.Key) mods, key := m.parseKeyString(bind.Key)
command, params := m.parseAction(bind.Action) command, params := m.parseAction(bind.Action)
sb.WriteString("bind=") // Description goes on the line ABOVE the bind: mango doesn't strip inline `#`
// comments from a value, so a trailing comment would break spawn (extra argv).
if bind.Description != "" {
sb.WriteString("# ")
sb.WriteString(bind.Description)
sb.WriteString("\n")
}
prefix := bind.Prefix
if prefix == "" {
prefix = "bind"
}
sb.WriteString(prefix)
sb.WriteString("=")
if mods == "" { if mods == "" {
sb.WriteString("none") sb.WriteString("none")
} else { } else {
@@ -413,14 +608,39 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
sb.WriteString(params) sb.WriteString(params)
} }
if bind.Description != "" {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n") sb.WriteString("\n")
} }
func (m *MangoWCProvider) bindPrefixFromOptions(options map[string]any) string {
if options == nil {
return ""
}
value, ok := options["flags"]
if !ok {
return ""
}
flags := ""
switch v := value.(type) {
case string:
flags = v
case fmt.Stringer:
flags = v.String()
default:
return ""
}
flags = strings.TrimSpace(flags)
if flags == "" {
return "bind"
}
var clean strings.Builder
for _, ch := range flags {
if strings.ContainsRune("lsrp", ch) && !strings.ContainsRune(clean.String(), ch) {
clean.WriteRune(ch)
}
}
return "bind" + clean.String()
}
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) { func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+") parts := strings.Split(keyStr, "+")
switch len(parts) { switch len(parts) {
@@ -15,6 +15,10 @@ const (
var MangoWCModSeparators = []rune{'+', ' '} var MangoWCModSeparators = []rune{'+', ' '}
func isMangoWCSectionComment(comment string) bool {
return strings.HasPrefix(strings.TrimSpace(comment), "===")
}
type MangoWCKeyBinding struct { type MangoWCKeyBinding struct {
Mods []string `json:"mods"` Mods []string `json:"mods"`
Key string `json:"key"` Key string `json:"key"`
@@ -216,101 +220,40 @@ func mangowcAutogenerateComment(command, params string) string {
} }
} }
func (p *MangoWCParser) getKeybindAtLine(lineNumber int) *MangoWCKeyBinding { func (p *MangoWCParser) getKeybindAtLine(lineNumber int, precedingComment string) *MangoWCKeyBinding {
if lineNumber >= len(p.contentLines) { if lineNumber >= len(p.contentLines) {
return nil return nil
} }
return p.getKeybindAtLineContent(p.contentLines[lineNumber], precedingComment)
line := p.contentLines[lineNumber]
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
bindType := matches[1]
content := matches[2]
parts := strings.SplitN(content, "#", 2)
keys := parts[0]
var comment string
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(comment, MangoWCHideComment) {
return nil
}
keyFields := strings.SplitN(keys, ",", 4)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
}
if comment == "" {
comment = mangowcAutogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(MangoWCModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range MangoWCModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
_ = bindType
return &MangoWCKeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
} }
func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding { func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
var keybinds []MangoWCKeyBinding var keybinds []MangoWCKeyBinding
var pendingComment string
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ { for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
line := p.contentLines[lineNumber] trimmed := strings.TrimSpace(p.contentLines[lineNumber])
if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue
}
if !strings.HasPrefix(trimmed, "bind") {
pendingComment = ""
continue continue
} }
if !strings.HasPrefix(strings.TrimSpace(line), "bind") { keybind := p.getKeybindAtLine(lineNumber, pendingComment)
continue
}
keybind := p.getKeybindAtLine(lineNumber)
if keybind != nil { if keybind != nil {
keybinds = append(keybinds, *keybind) keybinds = append(keybinds, *keybind)
} }
pendingComment = ""
} }
return keybinds return keybinds
@@ -459,21 +402,38 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
p.currentSource = absPath p.currentSource = absPath
var keybinds []MangoWCKeyBinding var keybinds []MangoWCKeyBinding
var pendingComment string
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
for lineNum, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "source") { if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds) p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue continue
} }
if !strings.HasPrefix(trimmed, "bind") { if !strings.HasPrefix(trimmed, "bind") {
pendingComment = ""
continue continue
} }
kb := p.getKeybindAtLineContent(line, lineNum) kb := p.getKeybindAtLineContent(line, pendingComment)
pendingComment = ""
if kb == nil { if kb == nil {
continue continue
} }
@@ -529,8 +489,11 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
return keybinds return keybinds
} }
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding { // getKeybindAtLineContent parses one `bind=` line. precedingComment (a `# ...`
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`) // line directly above) is the description: mango feeds inline comments to spawn
// as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback.
func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding {
bindMatch := regexp.MustCompile(`^(bind[lsrp]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line) matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 { if len(matches) < 3 {
return nil return nil
@@ -544,6 +507,12 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyB
if len(parts) > 1 { if len(parts) > 1 {
comment = strings.TrimSpace(parts[1]) comment = strings.TrimSpace(parts[1])
} }
if comment == "" {
comment = strings.TrimSpace(precedingComment)
if isMangoWCSectionComment(comment) {
comment = ""
}
}
if strings.HasPrefix(comment, MangoWCHideComment) { if strings.HasPrefix(comment, MangoWCHideComment) {
return nil return nil
@@ -71,9 +71,10 @@ func TestMangoWCAutogenerateComment(t *testing.T) {
func TestMangoWCGetKeybindAtLine(t *testing.T) { func TestMangoWCGetKeybindAtLine(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
line string line string
expected *MangoWCKeyBinding precedingComment string
expected *MangoWCKeyBinding
}{ }{
{ {
name: "basic_keybind", name: "basic_keybind",
@@ -157,6 +158,41 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
Comment: "dms ipc call lock lock", Comment: "dms ipc call lock lock",
}, },
}, },
{
name: "bindp_flag",
line: "bindp=SUPER,p,spawn,pass-through",
expected: &MangoWCKeyBinding{
Mods: []string{"SUPER"},
Key: "p",
Command: "spawn",
Params: "pass-through",
Comment: "pass-through",
},
},
{
name: "preceding_comment",
line: "bind=SUPER+SHIFT,S,spawn,dms screenshot",
precedingComment: "Screenshot: Interactive",
expected: &MangoWCKeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "S",
Command: "spawn",
Params: "dms screenshot",
Comment: "Screenshot: Interactive",
},
},
{
name: "section_header_not_description",
line: "bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3",
precedingComment: "=== Audio Controls ===",
expected: &MangoWCKeyBinding{
Mods: []string{},
Key: "XF86AudioRaiseVolume",
Command: "spawn",
Params: "dms ipc call audio increment 3",
Comment: "dms ipc call audio increment 3",
},
},
{ {
name: "keybind_with_spaces", name: "keybind_with_spaces",
line: "bind = SUPER, r, reload_config", line: "bind = SUPER, r, reload_config",
@@ -174,7 +210,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("") parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0, tt.precedingComment)
if tt.expected == nil { if tt.expected == nil {
if result != nil { if result != nil {
@@ -421,7 +457,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("") parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0, "")
if result != nil { if result != nil {
t.Errorf("expected nil for invalid line, got %+v", result) t.Errorf("expected nil for invalid line, got %+v", result)
@@ -3,7 +3,10 @@ package providers
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
) )
func TestMangoWCProviderName(t *testing.T) { func TestMangoWCProviderName(t *testing.T) {
@@ -318,3 +321,138 @@ bind=Ctrl,1,view,1,0
t.Error("Did not find terminal keybind with correct key and description") t.Error("Did not find terminal keybind with correct key and description")
} }
} }
func TestMangoWCSetBindPreservesStockCommentsAndGestures(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
t.Fatalf("failed to write stock binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
t.Fatalf("SetBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
for _, want := range []string{
"# === Application Launchers ===",
"# === Touchpad Gestures ===",
"gesturebind=none,right,3,viewtoleft_have_client",
"gesturebind=none,left,3,viewtoright_have_client",
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected saved binds to contain %q\ncontent:\n%s", want, content)
}
}
if strings.Contains(content, "# === Audio Controls ===\n# === Audio Controls ===") {
t.Fatalf("section header should not be duplicated as a bind description\ncontent:\n%s", content)
}
}
func TestMangoWCSetBindRestoresScaffoldForStrippedFile(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stripped := `bind=SUPER,t,spawn,ghostty
bind=SUPER,Return,spawn,ghostty
bind=SUPER,space,spawn,dms ipc call spotlight toggle
bind=SUPER,v,spawn,dms ipc call clipboard toggle
bind=SUPER,q,killclient
bind=SUPER,Left,focusdir,left
bind=SUPER,Right,focusdir,right
bind=SUPER,Up,focusdir,up
bind=SUPER,Down,focusdir,down
bind=SUPER,1,view,1
bind=SUPER,2,view,2
bind=SUPER,3,view,3
`
if err := os.WriteFile(bindsPath, []byte(stripped), 0o644); err != nil {
t.Fatalf("failed to write stripped binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
t.Fatalf("SetBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
for _, want := range []string{
"# DMS default keybinds (MangoWM)",
"# === Touchpad Gestures ===",
"gesturebind=none,right,3,viewtoleft_have_client",
"bind=SUPER,H,focusdir,left",
"bind=SUPER,J,focusdir,down",
"bind=SUPER,K,focusdir,up",
"bind=SUPER,L,focusdir,right",
"# === Custom Keybinds ===",
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
"bind=SUPER,t,spawn,ghostty",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected restored binds to contain %q\ncontent:\n%s", want, content)
}
}
if strings.Contains(content, "{{TERMINAL_COMMAND}}") {
t.Fatalf("terminal placeholder should have been resolved\ncontent:\n%s", content)
}
}
func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
t.Fatalf("failed to write stock binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.RemoveBind("SUPER+Tab"); err != nil {
t.Fatalf("RemoveBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
if strings.Contains(content, "bind=SUPER,Tab,focusstack,next") {
t.Fatalf("removed bind should be absent\ncontent:\n%s", content)
}
if strings.Contains(content, "# Focus Next Window") {
t.Fatalf("removed bind description should be absent\ncontent:\n%s", content)
}
for _, want := range []string{
"# === Focus Navigation ===",
"# === Touchpad Gestures ===",
"gesturebind=none,down,4,toggleoverview",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected non-bind line %q to be preserved\ncontent:\n%s", want, content)
}
}
}
@@ -51,7 +51,7 @@ type NiriParser struct {
} }
func parseKDL(data []byte) (*document.Document, error) { func parseKDL(data []byte) (*document.Document, error) {
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data)))) return kdl.Parse(strings.NewReader(normalizeKDLBraces(quoteLeadingUnderscoreIdents(string(data)))))
} }
func normalizeKDLBraces(input string) string { func normalizeKDLBraces(input string) string {
@@ -94,6 +94,93 @@ func normalizeKDLBraces(input string) string {
return sb.String() return sb.String()
} }
// quoteLeadingUnderscoreIdents wraps bare KDL identifiers that begin with '_'
// in double quotes. kdl-go rejects '_' as the first character of a bare
// identifier (e.g. the common `_JAVA_AWT_WM_NONREPARENTING "1"` environment
// node), even though niri's own parser and the KDL spec accept it — so without
// this the whole config fails to parse and no keybinds load. Quoting lets
// kdl-go parse it; this is safe because the niri parser only dispatches on
// fixed node/section names (binds, recent-windows, include, ...) that never
// start with '_', so re-quoting such a name cannot change what DMS reads.
// Underscores elsewhere in an identifier (XDG_CURRENT_DESKTOP) are left
// untouched, and underscores inside strings or comments are skipped. Only a
// leading '_' is handled; other start characters kdl-go over-rejects (e.g. '.'
// or '?') do not occur in niri configs.
func quoteLeadingUnderscoreIdents(input string) string {
var sb strings.Builder
sb.Grow(len(input))
var prev byte
n := len(input)
for i := 0; i < n; {
c := input[i]
switch {
case c == '"':
end := findStringEnd(input, i)
sb.WriteString(input[i:end])
prev = '"'
i = end
case c == '/' && i+1 < n && input[i+1] == '/':
end := findLineCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = '\n'
i = end
case c == '/' && i+1 < n && input[i+1] == '*':
end := findBlockCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = ' '
i = end
case c == '/' && i+1 < n && input[i+1] == '-':
// KDL slashdash: /- comments out the next node/value. Keep the
// marker but treat what follows as a fresh token start, so a
// slashdashed leading-underscore node (e.g. `/-_FOO "1"`) still
// gets quoted instead of crashing kdl-go.
sb.WriteByte('/')
sb.WriteByte('-')
prev = ' '
i += 2
case c == '_' && isIdentBoundary(prev):
end := scanBareIdent(input, i)
sb.WriteByte('"')
sb.WriteString(input[i:end])
sb.WriteByte('"')
prev = '"'
i = end
default:
sb.WriteByte(c)
prev = c
i++
}
}
return sb.String()
}
// isIdentBoundary reports whether the previously emitted byte ends a token, so
// that a following '_' starts a fresh bare identifier rather than sitting in
// the middle of one.
func isIdentBoundary(prev byte) bool {
switch prev {
case 0, ' ', '\t', '\n', '\r', '{', '}', ';', '=', '(', ')', ',':
return true
}
return false
}
// scanBareIdent returns the index just past the bare identifier starting at
// start, stopping at whitespace or any KDL delimiter.
func scanBareIdent(s string, start int) int {
n := len(s)
for i := start; i < n; i++ {
switch s[i] {
case ' ', '\t', '\n', '\r', '"', '{', '}', '(', ')', ';', '=', ',', '/', '\\', '<', '>', '[', ']':
return i
}
}
return n
}
func findStringEnd(s string, start int) int { func findStringEnd(s string, start int) int {
n := len(s) n := len(s)
for i := start + 1; i < n; { for i := start + 1; i < n; {
@@ -71,6 +71,101 @@ func TestNormalizeKDLBraces(t *testing.T) {
} }
} }
func TestQuoteLeadingUnderscoreIdents(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{"leading underscore node", `_JAVA_AWT_WM_NONREPARENTING "1"`, `"_JAVA_AWT_WM_NONREPARENTING" "1"`},
{"mid underscore untouched", `XDG_CURRENT_DESKTOP "niri"`, `XDG_CURRENT_DESKTOP "niri"`},
{"indented node", "environment {\n _FOO \"1\"\n}", "environment {\n \"_FOO\" \"1\"\n}"},
{"underscore in string", `spawn "_not_a_node"`, `spawn "_not_a_node"`},
{"underscore in line comment", "// _comment\n_FOO \"1\"", "// _comment\n\"_FOO\" \"1\""},
{"underscore in block comment", "/* _x */ _FOO \"1\"", "/* _x */ \"_FOO\" \"1\""},
{"block comment abuts node", `/* x */_FOO "1"`, `/* x */"_FOO" "1"`},
{"slashdash before node", `/-_FOO "1"`, `/-"_FOO" "1"`},
{"node after closing paren", "node (u8)_v", `node (u8)"_v"`},
{"node before brace without space", "_FOO{ }", `"_FOO"{ }`},
{"lone underscore", `_ "x"`, `"_" "x"`},
{"property value", "node key=_val", `node key="_val"`},
{"no underscores", "node child", "node child"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := quoteLeadingUnderscoreIdents(tc.in)
if got != tc.out {
t.Errorf("quoteLeadingUnderscoreIdents(%q) = %q, want %q", tc.in, got, tc.out)
}
})
}
}
func TestNiriParseLeadingUnderscoreEnvironment(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
// A leading-underscore environment node (a common Java/tiling-WM fix) must
// not abort parsing of the rest of the config — keybinds still have to load.
content := `environment {
XDG_CURRENT_DESKTOP "niri"
_JAVA_AWT_WM_NONREPARENTING "1"
}
binds {
Mod+Q { close-window; }
Mod+KP_Home { focus-workspace 1; }
}
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed on config with leading-underscore env node: %v", err)
}
if len(result.Section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds, got %d", len(result.Section.Keybinds))
}
foundClose := false
for _, kb := range result.Section.Keybinds {
if kb.Action == "close-window" {
foundClose = true
}
}
if !foundClose {
t.Error("close-window keybind not found — leading-underscore env node broke parsing")
}
}
func TestNiriParseSlashdashLeadingUnderscore(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
// A slashdashed leading-underscore node must not abort parsing either.
content := `environment {
/-_JAVA_AWT_WM_NONREPARENTING "1"
}
binds {
Mod+Q { close-window; }
}
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed on config with slashdashed leading-underscore node: %v", err)
}
if len(result.Section.Keybinds) != 1 {
t.Errorf("Expected 1 keybind, got %d", len(result.Section.Keybinds))
}
}
func TestNiriParseKeyCombo(t *testing.T) { func TestNiriParseKeyCombo(t *testing.T) {
tests := []struct { tests := []struct {
combo string combo string
+2
View File
@@ -25,6 +25,8 @@ type DMSBindsStatus struct {
Effective bool `json:"effective"` Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"` OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"` StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
type CheatSheet struct { type CheatSheet struct {
-791
View File
@@ -1,791 +0,0 @@
// Generated by go-wayland-scanner
// https://github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/cmd/go-wayland-scanner
// XML file : internal/proto/xml/dwl-ipc-unstable-v2.xml
//
// dwl_ipc_unstable_v2 Protocol Copyright:
package dwl_ipc
import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
// ZdwlIpcManagerV2InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZdwlIpcManagerV2InterfaceName = "zdwl_ipc_manager_v2"
// ZdwlIpcManagerV2 : manage dwl state
//
// This interface is exposed as a global in wl_registry.
//
// Clients can use this interface to get a dwl_ipc_output.
// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
type ZdwlIpcManagerV2 struct {
client.BaseProxy
tagsHandler ZdwlIpcManagerV2TagsHandlerFunc
layoutHandler ZdwlIpcManagerV2LayoutHandlerFunc
}
// NewZdwlIpcManagerV2 : manage dwl state
//
// This interface is exposed as a global in wl_registry.
//
// Clients can use this interface to get a dwl_ipc_output.
// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
func NewZdwlIpcManagerV2(ctx *client.Context) *ZdwlIpcManagerV2 {
zdwlIpcManagerV2 := &ZdwlIpcManagerV2{}
ctx.Register(zdwlIpcManagerV2)
return zdwlIpcManagerV2
}
// Release : release dwl_ipc_manager
//
// Indicates that the client will not the dwl_ipc_manager object anymore.
// Objects created through this instance are not affected.
func (i *ZdwlIpcManagerV2) Release() error {
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// GetOutput : get a dwl_ipc_outout for a wl_output
//
// Get a dwl_ipc_outout for the specified wl_output.
func (i *ZdwlIpcManagerV2) GetOutput(output *client.Output) (*ZdwlIpcOutputV2, error) {
id := NewZdwlIpcOutputV2(i.Context())
const opcode = 1
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], id.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], output.ID())
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return id, err
}
// ZdwlIpcManagerV2TagsEvent : Announces tag amount
//
// This event is sent after binding.
// A roundtrip after binding guarantees the client recieved all tags.
type ZdwlIpcManagerV2TagsEvent struct {
Amount uint32
}
type ZdwlIpcManagerV2TagsHandlerFunc func(ZdwlIpcManagerV2TagsEvent)
// SetTagsHandler : sets handler for ZdwlIpcManagerV2TagsEvent
func (i *ZdwlIpcManagerV2) SetTagsHandler(f ZdwlIpcManagerV2TagsHandlerFunc) {
i.tagsHandler = f
}
// ZdwlIpcManagerV2LayoutEvent : Announces a layout
//
// This event is sent after binding.
// A roundtrip after binding guarantees the client recieved all layouts.
type ZdwlIpcManagerV2LayoutEvent struct {
Name string
}
type ZdwlIpcManagerV2LayoutHandlerFunc func(ZdwlIpcManagerV2LayoutEvent)
// SetLayoutHandler : sets handler for ZdwlIpcManagerV2LayoutEvent
func (i *ZdwlIpcManagerV2) SetLayoutHandler(f ZdwlIpcManagerV2LayoutHandlerFunc) {
i.layoutHandler = f
}
func (i *ZdwlIpcManagerV2) Dispatch(opcode uint32, fd int, data []byte) {
switch opcode {
case 0:
if i.tagsHandler == nil {
return
}
var e ZdwlIpcManagerV2TagsEvent
l := 0
e.Amount = client.Uint32(data[l : l+4])
l += 4
i.tagsHandler(e)
case 1:
if i.layoutHandler == nil {
return
}
var e ZdwlIpcManagerV2LayoutEvent
l := 0
nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Name = client.String(data[l : l+nameLen])
l += nameLen
i.layoutHandler(e)
}
}
// ZdwlIpcOutputV2InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZdwlIpcOutputV2InterfaceName = "zdwl_ipc_output_v2"
// ZdwlIpcOutputV2 : control dwl output
//
// Observe and control a dwl output.
//
// Events are double-buffered:
// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
//
// Request are not double-buffered:
// The compositor will update immediately upon request.
type ZdwlIpcOutputV2 struct {
client.BaseProxy
toggleVisibilityHandler ZdwlIpcOutputV2ToggleVisibilityHandlerFunc
activeHandler ZdwlIpcOutputV2ActiveHandlerFunc
tagHandler ZdwlIpcOutputV2TagHandlerFunc
layoutHandler ZdwlIpcOutputV2LayoutHandlerFunc
titleHandler ZdwlIpcOutputV2TitleHandlerFunc
appidHandler ZdwlIpcOutputV2AppidHandlerFunc
layoutSymbolHandler ZdwlIpcOutputV2LayoutSymbolHandlerFunc
frameHandler ZdwlIpcOutputV2FrameHandlerFunc
fullscreenHandler ZdwlIpcOutputV2FullscreenHandlerFunc
floatingHandler ZdwlIpcOutputV2FloatingHandlerFunc
xHandler ZdwlIpcOutputV2XHandlerFunc
yHandler ZdwlIpcOutputV2YHandlerFunc
widthHandler ZdwlIpcOutputV2WidthHandlerFunc
heightHandler ZdwlIpcOutputV2HeightHandlerFunc
lastLayerHandler ZdwlIpcOutputV2LastLayerHandlerFunc
kbLayoutHandler ZdwlIpcOutputV2KbLayoutHandlerFunc
keymodeHandler ZdwlIpcOutputV2KeymodeHandlerFunc
scalefactorHandler ZdwlIpcOutputV2ScalefactorHandlerFunc
}
// NewZdwlIpcOutputV2 : control dwl output
//
// Observe and control a dwl output.
//
// Events are double-buffered:
// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
//
// Request are not double-buffered:
// The compositor will update immediately upon request.
func NewZdwlIpcOutputV2(ctx *client.Context) *ZdwlIpcOutputV2 {
zdwlIpcOutputV2 := &ZdwlIpcOutputV2{}
ctx.Register(zdwlIpcOutputV2)
return zdwlIpcOutputV2
}
// Release : release dwl_ipc_outout
//
// Indicates to that the client no longer needs this dwl_ipc_output.
func (i *ZdwlIpcOutputV2) Release() error {
defer i.MarkZombie()
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// SetTags : Set the active tags of this output
//
// tagmask: bitmask of the tags that should be set.
// toggleTagset: toggle the selected tagset, zero for invalid, nonzero for valid.
func (i *ZdwlIpcOutputV2) SetTags(tagmask, toggleTagset uint32) error {
const opcode = 1
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(tagmask))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(toggleTagset))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// SetClientTags : Set the tags of the focused client.
//
// The tags are updated as follows:
// new_tags = (current_tags AND and_tags) XOR xor_tags
func (i *ZdwlIpcOutputV2) SetClientTags(andTags, xorTags uint32) error {
const opcode = 2
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(andTags))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(xorTags))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// SetLayout : Set the layout of this output
//
// index: index of a layout recieved by dwl_ipc_manager.layout
func (i *ZdwlIpcOutputV2) SetLayout(index uint32) error {
const opcode = 3
const _reqBufLen = 8 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(index))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// Quit : Quit mango
// This request allows clients to instruct the compositor to quit mango.
func (i *ZdwlIpcOutputV2) Quit() error {
const opcode = 4
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// SendDispatch : Set the active tags of this output
//
// dispatch: dispatch name.
// arg1: arg1.
// arg2: arg2.
// arg3: arg3.
// arg4: arg4.
// arg5: arg5.
func (i *ZdwlIpcOutputV2) SendDispatch(dispatch, arg1, arg2, arg3, arg4, arg5 string) error {
const opcode = 5
dispatchLen := client.PaddedLen(len(dispatch) + 1)
arg1Len := client.PaddedLen(len(arg1) + 1)
arg2Len := client.PaddedLen(len(arg2) + 1)
arg3Len := client.PaddedLen(len(arg3) + 1)
arg4Len := client.PaddedLen(len(arg4) + 1)
arg5Len := client.PaddedLen(len(arg5) + 1)
_reqBufLen := 8 + (4 + dispatchLen) + (4 + arg1Len) + (4 + arg2Len) + (4 + arg3Len) + (4 + arg4Len) + (4 + arg5Len)
_reqBuf := make([]byte, _reqBufLen)
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutString(_reqBuf[l:l+(4+dispatchLen)], dispatch)
l += (4 + dispatchLen)
client.PutString(_reqBuf[l:l+(4+arg1Len)], arg1)
l += (4 + arg1Len)
client.PutString(_reqBuf[l:l+(4+arg2Len)], arg2)
l += (4 + arg2Len)
client.PutString(_reqBuf[l:l+(4+arg3Len)], arg3)
l += (4 + arg3Len)
client.PutString(_reqBuf[l:l+(4+arg4Len)], arg4)
l += (4 + arg4Len)
client.PutString(_reqBuf[l:l+(4+arg5Len)], arg5)
l += (4 + arg5Len)
err := i.Context().WriteMsg(_reqBuf, nil)
return err
}
type ZdwlIpcOutputV2TagState uint32
// ZdwlIpcOutputV2TagState :
const (
// ZdwlIpcOutputV2TagStateNone : no state
ZdwlIpcOutputV2TagStateNone ZdwlIpcOutputV2TagState = 0
// ZdwlIpcOutputV2TagStateActive : tag is active
ZdwlIpcOutputV2TagStateActive ZdwlIpcOutputV2TagState = 1
// ZdwlIpcOutputV2TagStateUrgent : tag has at least one urgent client
ZdwlIpcOutputV2TagStateUrgent ZdwlIpcOutputV2TagState = 2
)
func (e ZdwlIpcOutputV2TagState) Name() string {
switch e {
case ZdwlIpcOutputV2TagStateNone:
return "none"
case ZdwlIpcOutputV2TagStateActive:
return "active"
case ZdwlIpcOutputV2TagStateUrgent:
return "urgent"
default:
return ""
}
}
func (e ZdwlIpcOutputV2TagState) Value() string {
switch e {
case ZdwlIpcOutputV2TagStateNone:
return "0"
case ZdwlIpcOutputV2TagStateActive:
return "1"
case ZdwlIpcOutputV2TagStateUrgent:
return "2"
default:
return ""
}
}
func (e ZdwlIpcOutputV2TagState) String() string {
return e.Name() + "=" + e.Value()
}
// ZdwlIpcOutputV2ToggleVisibilityEvent : Toggle client visibilty
//
// Indicates the client should hide or show themselves.
// If the client is visible then hide, if hidden then show.
type ZdwlIpcOutputV2ToggleVisibilityEvent struct{}
type ZdwlIpcOutputV2ToggleVisibilityHandlerFunc func(ZdwlIpcOutputV2ToggleVisibilityEvent)
// SetToggleVisibilityHandler : sets handler for ZdwlIpcOutputV2ToggleVisibilityEvent
func (i *ZdwlIpcOutputV2) SetToggleVisibilityHandler(f ZdwlIpcOutputV2ToggleVisibilityHandlerFunc) {
i.toggleVisibilityHandler = f
}
// ZdwlIpcOutputV2ActiveEvent : Update the selected output.
//
// Indicates if the output is active. Zero is invalid, nonzero is valid.
type ZdwlIpcOutputV2ActiveEvent struct {
Active uint32
}
type ZdwlIpcOutputV2ActiveHandlerFunc func(ZdwlIpcOutputV2ActiveEvent)
// SetActiveHandler : sets handler for ZdwlIpcOutputV2ActiveEvent
func (i *ZdwlIpcOutputV2) SetActiveHandler(f ZdwlIpcOutputV2ActiveHandlerFunc) {
i.activeHandler = f
}
// ZdwlIpcOutputV2TagEvent : Update the state of a tag.
//
// Indicates that a tag has been updated.
type ZdwlIpcOutputV2TagEvent struct {
Tag uint32
State uint32
Clients uint32
Focused uint32
}
type ZdwlIpcOutputV2TagHandlerFunc func(ZdwlIpcOutputV2TagEvent)
// SetTagHandler : sets handler for ZdwlIpcOutputV2TagEvent
func (i *ZdwlIpcOutputV2) SetTagHandler(f ZdwlIpcOutputV2TagHandlerFunc) {
i.tagHandler = f
}
// ZdwlIpcOutputV2LayoutEvent : Update the layout.
//
// Indicates a new layout is selected.
type ZdwlIpcOutputV2LayoutEvent struct {
Layout uint32
}
type ZdwlIpcOutputV2LayoutHandlerFunc func(ZdwlIpcOutputV2LayoutEvent)
// SetLayoutHandler : sets handler for ZdwlIpcOutputV2LayoutEvent
func (i *ZdwlIpcOutputV2) SetLayoutHandler(f ZdwlIpcOutputV2LayoutHandlerFunc) {
i.layoutHandler = f
}
// ZdwlIpcOutputV2TitleEvent : Update the title.
//
// Indicates the title has changed.
type ZdwlIpcOutputV2TitleEvent struct {
Title string
}
type ZdwlIpcOutputV2TitleHandlerFunc func(ZdwlIpcOutputV2TitleEvent)
// SetTitleHandler : sets handler for ZdwlIpcOutputV2TitleEvent
func (i *ZdwlIpcOutputV2) SetTitleHandler(f ZdwlIpcOutputV2TitleHandlerFunc) {
i.titleHandler = f
}
// ZdwlIpcOutputV2AppidEvent : Update the appid.
//
// Indicates the appid has changed.
type ZdwlIpcOutputV2AppidEvent struct {
Appid string
}
type ZdwlIpcOutputV2AppidHandlerFunc func(ZdwlIpcOutputV2AppidEvent)
// SetAppidHandler : sets handler for ZdwlIpcOutputV2AppidEvent
func (i *ZdwlIpcOutputV2) SetAppidHandler(f ZdwlIpcOutputV2AppidHandlerFunc) {
i.appidHandler = f
}
// ZdwlIpcOutputV2LayoutSymbolEvent : Update the current layout symbol
//
// Indicates the layout has changed. Since layout symbols are dynamic.
// As opposed to the zdwl_ipc_manager.layout event, this should take precendence when displaying.
// You can ignore the zdwl_ipc_output.layout event.
type ZdwlIpcOutputV2LayoutSymbolEvent struct {
Layout string
}
type ZdwlIpcOutputV2LayoutSymbolHandlerFunc func(ZdwlIpcOutputV2LayoutSymbolEvent)
// SetLayoutSymbolHandler : sets handler for ZdwlIpcOutputV2LayoutSymbolEvent
func (i *ZdwlIpcOutputV2) SetLayoutSymbolHandler(f ZdwlIpcOutputV2LayoutSymbolHandlerFunc) {
i.layoutSymbolHandler = f
}
// ZdwlIpcOutputV2FrameEvent : The update sequence is done.
//
// Indicates that a sequence of status updates have finished and the client should redraw.
type ZdwlIpcOutputV2FrameEvent struct{}
type ZdwlIpcOutputV2FrameHandlerFunc func(ZdwlIpcOutputV2FrameEvent)
// SetFrameHandler : sets handler for ZdwlIpcOutputV2FrameEvent
func (i *ZdwlIpcOutputV2) SetFrameHandler(f ZdwlIpcOutputV2FrameHandlerFunc) {
i.frameHandler = f
}
// ZdwlIpcOutputV2FullscreenEvent : Update fullscreen status
//
// Indicates if the selected client on this output is fullscreen.
type ZdwlIpcOutputV2FullscreenEvent struct {
IsFullscreen uint32
}
type ZdwlIpcOutputV2FullscreenHandlerFunc func(ZdwlIpcOutputV2FullscreenEvent)
// SetFullscreenHandler : sets handler for ZdwlIpcOutputV2FullscreenEvent
func (i *ZdwlIpcOutputV2) SetFullscreenHandler(f ZdwlIpcOutputV2FullscreenHandlerFunc) {
i.fullscreenHandler = f
}
// ZdwlIpcOutputV2FloatingEvent : Update the floating status
//
// Indicates if the selected client on this output is floating.
type ZdwlIpcOutputV2FloatingEvent struct {
IsFloating uint32
}
type ZdwlIpcOutputV2FloatingHandlerFunc func(ZdwlIpcOutputV2FloatingEvent)
// SetFloatingHandler : sets handler for ZdwlIpcOutputV2FloatingEvent
func (i *ZdwlIpcOutputV2) SetFloatingHandler(f ZdwlIpcOutputV2FloatingHandlerFunc) {
i.floatingHandler = f
}
// ZdwlIpcOutputV2XEvent : Update the x coordinates
//
// Indicates if x coordinates of the selected client.
type ZdwlIpcOutputV2XEvent struct {
X int32
}
type ZdwlIpcOutputV2XHandlerFunc func(ZdwlIpcOutputV2XEvent)
// SetXHandler : sets handler for ZdwlIpcOutputV2XEvent
func (i *ZdwlIpcOutputV2) SetXHandler(f ZdwlIpcOutputV2XHandlerFunc) {
i.xHandler = f
}
// ZdwlIpcOutputV2YEvent : Update the y coordinates
//
// Indicates if y coordinates of the selected client.
type ZdwlIpcOutputV2YEvent struct {
Y int32
}
type ZdwlIpcOutputV2YHandlerFunc func(ZdwlIpcOutputV2YEvent)
// SetYHandler : sets handler for ZdwlIpcOutputV2YEvent
func (i *ZdwlIpcOutputV2) SetYHandler(f ZdwlIpcOutputV2YHandlerFunc) {
i.yHandler = f
}
// ZdwlIpcOutputV2WidthEvent : Update the width
//
// Indicates if width of the selected client.
type ZdwlIpcOutputV2WidthEvent struct {
Width int32
}
type ZdwlIpcOutputV2WidthHandlerFunc func(ZdwlIpcOutputV2WidthEvent)
// SetWidthHandler : sets handler for ZdwlIpcOutputV2WidthEvent
func (i *ZdwlIpcOutputV2) SetWidthHandler(f ZdwlIpcOutputV2WidthHandlerFunc) {
i.widthHandler = f
}
// ZdwlIpcOutputV2HeightEvent : Update the height
//
// Indicates if height of the selected client.
type ZdwlIpcOutputV2HeightEvent struct {
Height int32
}
type ZdwlIpcOutputV2HeightHandlerFunc func(ZdwlIpcOutputV2HeightEvent)
// SetHeightHandler : sets handler for ZdwlIpcOutputV2HeightEvent
func (i *ZdwlIpcOutputV2) SetHeightHandler(f ZdwlIpcOutputV2HeightHandlerFunc) {
i.heightHandler = f
}
// ZdwlIpcOutputV2LastLayerEvent : last map layer.
//
// last map layer.
type ZdwlIpcOutputV2LastLayerEvent struct {
LastLayer string
}
type ZdwlIpcOutputV2LastLayerHandlerFunc func(ZdwlIpcOutputV2LastLayerEvent)
// SetLastLayerHandler : sets handler for ZdwlIpcOutputV2LastLayerEvent
func (i *ZdwlIpcOutputV2) SetLastLayerHandler(f ZdwlIpcOutputV2LastLayerHandlerFunc) {
i.lastLayerHandler = f
}
// ZdwlIpcOutputV2KbLayoutEvent : current keyboard layout.
//
// current keyboard layout.
type ZdwlIpcOutputV2KbLayoutEvent struct {
KbLayout string
}
type ZdwlIpcOutputV2KbLayoutHandlerFunc func(ZdwlIpcOutputV2KbLayoutEvent)
// SetKbLayoutHandler : sets handler for ZdwlIpcOutputV2KbLayoutEvent
func (i *ZdwlIpcOutputV2) SetKbLayoutHandler(f ZdwlIpcOutputV2KbLayoutHandlerFunc) {
i.kbLayoutHandler = f
}
// ZdwlIpcOutputV2KeymodeEvent : current keybind mode.
//
// current keybind mode.
type ZdwlIpcOutputV2KeymodeEvent struct {
Keymode string
}
type ZdwlIpcOutputV2KeymodeHandlerFunc func(ZdwlIpcOutputV2KeymodeEvent)
// SetKeymodeHandler : sets handler for ZdwlIpcOutputV2KeymodeEvent
func (i *ZdwlIpcOutputV2) SetKeymodeHandler(f ZdwlIpcOutputV2KeymodeHandlerFunc) {
i.keymodeHandler = f
}
// ZdwlIpcOutputV2ScalefactorEvent : scale factor of monitor.
//
// scale factor of monitor.
type ZdwlIpcOutputV2ScalefactorEvent struct {
Scalefactor uint32
}
type ZdwlIpcOutputV2ScalefactorHandlerFunc func(ZdwlIpcOutputV2ScalefactorEvent)
// SetScalefactorHandler : sets handler for ZdwlIpcOutputV2ScalefactorEvent
func (i *ZdwlIpcOutputV2) SetScalefactorHandler(f ZdwlIpcOutputV2ScalefactorHandlerFunc) {
i.scalefactorHandler = f
}
func (i *ZdwlIpcOutputV2) Dispatch(opcode uint32, fd int, data []byte) {
switch opcode {
case 0:
if i.toggleVisibilityHandler == nil {
return
}
var e ZdwlIpcOutputV2ToggleVisibilityEvent
i.toggleVisibilityHandler(e)
case 1:
if i.activeHandler == nil {
return
}
var e ZdwlIpcOutputV2ActiveEvent
l := 0
e.Active = client.Uint32(data[l : l+4])
l += 4
i.activeHandler(e)
case 2:
if i.tagHandler == nil {
return
}
var e ZdwlIpcOutputV2TagEvent
l := 0
e.Tag = client.Uint32(data[l : l+4])
l += 4
e.State = client.Uint32(data[l : l+4])
l += 4
e.Clients = client.Uint32(data[l : l+4])
l += 4
e.Focused = client.Uint32(data[l : l+4])
l += 4
i.tagHandler(e)
case 3:
if i.layoutHandler == nil {
return
}
var e ZdwlIpcOutputV2LayoutEvent
l := 0
e.Layout = client.Uint32(data[l : l+4])
l += 4
i.layoutHandler(e)
case 4:
if i.titleHandler == nil {
return
}
var e ZdwlIpcOutputV2TitleEvent
l := 0
titleLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Title = client.String(data[l : l+titleLen])
l += titleLen
i.titleHandler(e)
case 5:
if i.appidHandler == nil {
return
}
var e ZdwlIpcOutputV2AppidEvent
l := 0
appidLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Appid = client.String(data[l : l+appidLen])
l += appidLen
i.appidHandler(e)
case 6:
if i.layoutSymbolHandler == nil {
return
}
var e ZdwlIpcOutputV2LayoutSymbolEvent
l := 0
layoutLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Layout = client.String(data[l : l+layoutLen])
l += layoutLen
i.layoutSymbolHandler(e)
case 7:
if i.frameHandler == nil {
return
}
var e ZdwlIpcOutputV2FrameEvent
i.frameHandler(e)
case 8:
if i.fullscreenHandler == nil {
return
}
var e ZdwlIpcOutputV2FullscreenEvent
l := 0
e.IsFullscreen = client.Uint32(data[l : l+4])
l += 4
i.fullscreenHandler(e)
case 9:
if i.floatingHandler == nil {
return
}
var e ZdwlIpcOutputV2FloatingEvent
l := 0
e.IsFloating = client.Uint32(data[l : l+4])
l += 4
i.floatingHandler(e)
case 10:
if i.xHandler == nil {
return
}
var e ZdwlIpcOutputV2XEvent
l := 0
e.X = int32(client.Uint32(data[l : l+4]))
l += 4
i.xHandler(e)
case 11:
if i.yHandler == nil {
return
}
var e ZdwlIpcOutputV2YEvent
l := 0
e.Y = int32(client.Uint32(data[l : l+4]))
l += 4
i.yHandler(e)
case 12:
if i.widthHandler == nil {
return
}
var e ZdwlIpcOutputV2WidthEvent
l := 0
e.Width = int32(client.Uint32(data[l : l+4]))
l += 4
i.widthHandler(e)
case 13:
if i.heightHandler == nil {
return
}
var e ZdwlIpcOutputV2HeightEvent
l := 0
e.Height = int32(client.Uint32(data[l : l+4]))
l += 4
i.heightHandler(e)
case 14:
if i.lastLayerHandler == nil {
return
}
var e ZdwlIpcOutputV2LastLayerEvent
l := 0
lastLayerLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.LastLayer = client.String(data[l : l+lastLayerLen])
l += lastLayerLen
i.lastLayerHandler(e)
case 15:
if i.kbLayoutHandler == nil {
return
}
var e ZdwlIpcOutputV2KbLayoutEvent
l := 0
kbLayoutLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.KbLayout = client.String(data[l : l+kbLayoutLen])
l += kbLayoutLen
i.kbLayoutHandler(e)
case 16:
if i.keymodeHandler == nil {
return
}
var e ZdwlIpcOutputV2KeymodeEvent
l := 0
keymodeLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Keymode = client.String(data[l : l+keymodeLen])
l += keymodeLen
i.keymodeHandler(e)
case 17:
if i.scalefactorHandler == nil {
return
}
var e ZdwlIpcOutputV2ScalefactorEvent
l := 0
e.Scalefactor = client.Uint32(data[l : l+4])
l += 4
i.scalefactorHandler(e)
}
}
@@ -0,0 +1,25 @@
package qmlchecks
import (
"os"
"regexp"
"strings"
"testing"
)
func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Lock/LockScreenContent.qml")
if err != nil {
t.Fatalf("read lock screen QML: %v", err)
}
content := string(data)
textInputPasswordField := regexp.MustCompile(`(?s)TextInput\s*\{[^{}]*id:\s*passwordField`)
if textInputPasswordField.MatchString(content) {
t.Fatalf("passwordField must not be a TextInput because TextInput can route physical keyboard input through IME")
}
if !strings.Contains(content, "Keys.onPressed") || !strings.Contains(content, "event.text") {
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
}
}
+107 -325
View File
@@ -6,7 +6,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
@@ -19,9 +18,9 @@ const (
CompositorHyprland CompositorHyprland
CompositorSway CompositorSway
CompositorNiri CompositorNiri
CompositorDWL
CompositorScroll CompositorScroll
CompositorMiracle CompositorMiracle
CompositorMango
) )
var detectedCompositor Compositor = -1 var detectedCompositor Compositor = -1
@@ -36,8 +35,14 @@ func DetectCompositor() Compositor {
swaySocket := os.Getenv("SWAYSOCK") swaySocket := os.Getenv("SWAYSOCK")
scrollSocket := os.Getenv("SCROLLSOCK") scrollSocket := os.Getenv("SCROLLSOCK")
miracleSocket := os.Getenv("MIRACLESOCK") miracleSocket := os.Getenv("MIRACLESOCK")
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE")
switch { switch {
case mangoSocket != "":
if _, err := os.Stat(mangoSocket); err == nil {
detectedCompositor = CompositorMango
return detectedCompositor
}
case niriSocket != "": case niriSocket != "":
if _, err := os.Stat(niriSocket); err == nil { if _, err := os.Stat(niriSocket); err == nil {
detectedCompositor = CompositorNiri detectedCompositor = CompositorNiri
@@ -63,66 +68,29 @@ func DetectCompositor() Compositor {
return detectedCompositor return detectedCompositor
} }
if detectDWLProtocol() {
detectedCompositor = CompositorDWL
return detectedCompositor
}
detectedCompositor = CompositorUnknown detectedCompositor = CompositorUnknown
return detectedCompositor return detectedCompositor
} }
func detectDWLProtocol() bool {
display, err := client.Connect("")
if err != nil {
return false
}
ctx := display.Context()
defer ctx.Close()
registry, err := display.GetRegistry()
if err != nil {
return false
}
found := false
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
if e.Interface == dwl_ipc.ZdwlIpcManagerV2InterfaceName {
found = true
}
})
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return false
}
return found
}
func SetCompositorDWL() {
detectedCompositor = CompositorDWL
}
type WindowGeometry struct { type WindowGeometry struct {
X int32 X int32
Y int32 Y int32
Width int32 Width int32
Height int32 Height int32
Output string Output string
Scale float64 Scale float64
OutputX int32 OutputX int32
OutputY int32 OutputY int32
OutputTransform int32
} }
func GetActiveWindow() (*WindowGeometry, error) { func GetActiveWindow() (*WindowGeometry, error) {
switch DetectCompositor() { switch DetectCompositor() {
case CompositorHyprland: case CompositorHyprland:
return getHyprlandActiveWindow() return getHyprlandActiveWindow()
case CompositorDWL: case CompositorMango:
return getDWLActiveWindow() return getMangoActiveWindow()
default: default:
return nil, fmt.Errorf("window capture requires Hyprland or DWL") return nil, fmt.Errorf("window capture requires Hyprland or Mango")
} }
} }
@@ -285,6 +253,93 @@ func getMiracleFocusedMonitor() string {
return "" return ""
} }
type mangoMonitor struct {
Name string `json:"name"`
Active bool `json:"active"`
X int32 `json:"x"`
Y int32 `json:"y"`
Scale float64 `json:"scale"`
}
func getMangoMonitors() []mangoMonitor {
output, err := exec.Command("mmsg", "get", "all-monitors").Output()
if err != nil {
return nil
}
var data struct {
Monitors []mangoMonitor `json:"monitors"`
}
if err := json.Unmarshal(output, &data); err != nil {
return nil
}
return data.Monitors
}
func getMangoFocusedMonitor() string {
for _, m := range getMangoMonitors() {
if m.Active {
return m.Name
}
}
return ""
}
type mangoClient struct {
Monitor string `json:"monitor"`
IsFocused bool `json:"is_focused"`
X int32 `json:"x"`
Y int32 `json:"y"`
Width int32 `json:"width"`
Height int32 `json:"height"`
}
func getMangoActiveWindow() (*WindowGeometry, error) {
output, err := exec.Command("mmsg", "get", "all-clients").Output()
if err != nil {
return nil, fmt.Errorf("mmsg get all-clients: %w", err)
}
var data struct {
Clients []mangoClient `json:"clients"`
}
if err := json.Unmarshal(output, &data); err != nil {
return nil, fmt.Errorf("parse all-clients: %w", err)
}
for _, c := range data.Clients {
if !c.IsFocused {
continue
}
if c.Width <= 0 || c.Height <= 0 {
return nil, fmt.Errorf("no active window")
}
geom := &WindowGeometry{
X: c.X,
Y: c.Y,
Width: c.Width,
Height: c.Height,
Output: c.Monitor,
Scale: 1.0,
}
for _, m := range getMangoMonitors() {
if m.Name != c.Monitor {
continue
}
geom.OutputX = m.X
geom.OutputY = m.Y
if m.Scale > 0 {
geom.Scale = m.Scale
}
break
}
return geom, nil
}
return nil, fmt.Errorf("no focused window")
}
type niriWorkspace struct { type niriWorkspace struct {
Output string `json:"output"` Output string `json:"output"`
IsFocused bool `json:"is_focused"` IsFocused bool `json:"is_focused"`
@@ -309,121 +364,6 @@ func getNiriFocusedMonitor() string {
return "" return ""
} }
var dwlActiveOutput string
func SetDWLActiveOutput(name string) {
dwlActiveOutput = name
}
func getDWLFocusedMonitor() string {
if dwlActiveOutput != "" {
return dwlActiveOutput
}
return queryDWLActiveOutput()
}
func queryDWLActiveOutput() string {
display, err := client.Connect("")
if err != nil {
return ""
}
ctx := display.Context()
defer ctx.Close()
registry, err := display.GetRegistry()
if err != nil {
return ""
}
var dwlManager *dwl_ipc.ZdwlIpcManagerV2
outputs := make(map[uint32]*client.Output)
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
switch e.Interface {
case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
mgr := dwl_ipc.NewZdwlIpcManagerV2(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil {
dwlManager = mgr
}
case client.OutputInterfaceName:
out := client.NewOutput(ctx)
version := e.Version
if version > 4 {
version = 4
}
if err := registry.Bind(e.Name, e.Interface, version, out); err == nil {
outputs[e.Name] = out
}
}
})
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return ""
}
if dwlManager == nil || len(outputs) == 0 {
return ""
}
outputNames := make(map[uint32]string)
for name, out := range outputs {
n := name
out.SetNameHandler(func(e client.OutputNameEvent) {
outputNames[n] = e.Name
})
}
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return ""
}
type outputState struct {
name string
active bool
gotFrame bool
}
states := make(map[uint32]*outputState)
for name, out := range outputs {
dwlOut, err := dwlManager.GetOutput(out)
if err != nil {
continue
}
state := &outputState{name: outputNames[name]}
states[name] = state
dwlOut.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
state.active = e.Active != 0
})
dwlOut.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
state.gotFrame = true
})
}
allFramesReceived := func() bool {
for _, s := range states {
if !s.gotFrame {
return false
}
}
return true
}
for !allFramesReceived() {
if err := ctx.Dispatch(); err != nil {
return ""
}
}
for _, state := range states {
if state.active {
return state.name
}
}
return ""
}
func GetFocusedMonitor() string { func GetFocusedMonitor() string {
switch DetectCompositor() { switch DetectCompositor() {
case CompositorHyprland: case CompositorHyprland:
@@ -436,8 +376,8 @@ func GetFocusedMonitor() string {
return getMiracleFocusedMonitor() return getMiracleFocusedMonitor()
case CompositorNiri: case CompositorNiri:
return getNiriFocusedMonitor() return getNiriFocusedMonitor()
case CompositorDWL: case CompositorMango:
return getDWLFocusedMonitor() return getMangoFocusedMonitor()
} }
return "" return ""
} }
@@ -534,161 +474,3 @@ func getAllOutputInfos() map[string]*outputInfo {
} }
return result return result
} }
func getOutputInfo(outputName string) (*outputInfo, bool) {
infos := getAllOutputInfos()
if infos == nil {
return nil, false
}
info, ok := infos[outputName]
return info, ok
}
func getDWLActiveWindow() (*WindowGeometry, error) {
display, err := client.Connect("")
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
ctx := display.Context()
defer ctx.Close()
registry, err := display.GetRegistry()
if err != nil {
return nil, fmt.Errorf("get registry: %w", err)
}
var dwlManager *dwl_ipc.ZdwlIpcManagerV2
outputs := make(map[uint32]*client.Output)
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
switch e.Interface {
case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
mgr := dwl_ipc.NewZdwlIpcManagerV2(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil {
dwlManager = mgr
}
case client.OutputInterfaceName:
out := client.NewOutput(ctx)
version := e.Version
if version > 4 {
version = 4
}
if err := registry.Bind(e.Name, e.Interface, version, out); err == nil {
outputs[e.Name] = out
}
}
})
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return nil, fmt.Errorf("roundtrip: %w", err)
}
if dwlManager == nil {
return nil, fmt.Errorf("dwl_ipc_manager not available")
}
if len(outputs) == 0 {
return nil, fmt.Errorf("no outputs found")
}
outputNames := make(map[uint32]string)
for name, out := range outputs {
n := name
out.SetNameHandler(func(e client.OutputNameEvent) {
outputNames[n] = e.Name
})
}
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return nil, fmt.Errorf("roundtrip: %w", err)
}
type dwlOutputState struct {
output *dwl_ipc.ZdwlIpcOutputV2
name string
active bool
x, y int32
w, h int32
scalefactor uint32
gotFrame bool
}
dwlOutputs := make(map[uint32]*dwlOutputState)
for name, out := range outputs {
dwlOut, err := dwlManager.GetOutput(out)
if err != nil {
continue
}
state := &dwlOutputState{output: dwlOut, name: outputNames[name]}
dwlOutputs[name] = state
dwlOut.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
state.active = e.Active != 0
})
dwlOut.SetXHandler(func(e dwl_ipc.ZdwlIpcOutputV2XEvent) {
state.x = e.X
})
dwlOut.SetYHandler(func(e dwl_ipc.ZdwlIpcOutputV2YEvent) {
state.y = e.Y
})
dwlOut.SetWidthHandler(func(e dwl_ipc.ZdwlIpcOutputV2WidthEvent) {
state.w = e.Width
})
dwlOut.SetHeightHandler(func(e dwl_ipc.ZdwlIpcOutputV2HeightEvent) {
state.h = e.Height
})
dwlOut.SetScalefactorHandler(func(e dwl_ipc.ZdwlIpcOutputV2ScalefactorEvent) {
state.scalefactor = e.Scalefactor
})
dwlOut.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
state.gotFrame = true
})
}
allFramesReceived := func() bool {
for _, s := range dwlOutputs {
if !s.gotFrame {
return false
}
}
return true
}
for !allFramesReceived() {
if err := ctx.Dispatch(); err != nil {
return nil, fmt.Errorf("dispatch: %w", err)
}
}
for _, state := range dwlOutputs {
if !state.active {
continue
}
if state.w <= 0 || state.h <= 0 {
return nil, fmt.Errorf("no active window")
}
scale := float64(state.scalefactor) / 100.0
if scale <= 0 {
scale = 1.0
}
geom := &WindowGeometry{
X: state.x,
Y: state.y,
Width: state.w,
Height: state.h,
Output: state.name,
Scale: scale,
}
if info, ok := getOutputInfo(state.name); ok {
geom.OutputX = info.x
geom.OutputY = info.y
geom.OutputTransform = info.transform
}
return geom, nil
}
return nil, fmt.Errorf("no active output found")
}
+4 -4
View File
@@ -156,14 +156,14 @@ func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
switch DetectCompositor() { switch DetectCompositor() {
case CompositorHyprland: case CompositorHyprland:
return s.captureAndCrop(output, region) return s.captureAndCrop(output, region)
case CompositorDWL: case CompositorMango:
return s.captureDWLWindow(output, region, geom) return s.captureMangoWindow(output, region, geom)
default: default:
return s.captureRegionOnOutput(output, region) return s.captureRegionOnOutput(output, region)
} }
} }
func (s *Screenshoter) captureDWLWindow(output *WaylandOutput, region Region, geom *WindowGeometry) (*CaptureResult, error) { func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region, geom *WindowGeometry) (*CaptureResult, error) {
result, err := s.captureWholeOutput(output) result, err := s.captureWholeOutput(output)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -628,7 +628,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
w := int32(float64(region.Width) * scale) w := int32(float64(region.Width) * scale)
h := int32(float64(region.Height) * scale) h := int32(float64(region.Height) * scale)
if DetectCompositor() == CompositorDWL { if DetectCompositor() == CompositorMango {
scaledOutW := int32(float64(output.width) * scale) scaledOutW := int32(float64(output.width) * scale)
scaledOutH := int32(float64(output.height) * scale) scaledOutH := int32(float64(output.height) * scale)
if localX >= scaledOutW { if localX >= scaledOutW {
@@ -2,6 +2,7 @@ package clipboard
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net" "net"
@@ -73,6 +74,10 @@ func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
entry, err := m.GetEntry(uint64(id)) entry, err := m.GetEntry(uint64(id))
if err != nil { if err != nil {
if errors.Is(err, errEntryNotFound) {
models.Respond[any](conn, req.ID, nil)
return
}
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
return return
} }
+57 -37
View File
@@ -3,6 +3,7 @@ package clipboard
import ( import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"errors"
"fmt" "fmt"
"image" "image"
_ "image/gif" _ "image/gif"
@@ -34,6 +35,8 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
var errEntryNotFound = errors.New("entry not found")
// These mime types won't be stored in history // These mime types won't be stored in history
var sensitiveMimeTypes = []string{ var sensitiveMimeTypes = []string{
"x-kde-passwordManagerHint", "x-kde-passwordManagerHint",
@@ -572,16 +575,16 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
func (m *Manager) selectMimeType(mimes []string) string { func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{ preferredTypes := []string{
"text/uri-list", "text/uri-list",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
"image/png", "image/png",
"image/jpeg", "image/jpeg",
"image/gif", "image/gif",
"image/bmp", "image/bmp",
"image/tiff", "image/tiff",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
} }
for _, pref := range preferredTypes { for _, pref := range preferredTypes {
@@ -764,9 +767,25 @@ func stateEqual(a, b *State) bool {
if len(a.History) != len(b.History) { if len(a.History) != len(b.History) {
return false return false
} }
for i := range a.History {
if !entryStateEqual(a.History[i], b.History[i]) {
return false
}
}
return true return true
} }
func entryStateEqual(a, b Entry) bool {
return a.ID == b.ID &&
a.Hash == b.Hash &&
a.Pinned == b.Pinned &&
a.IsImage == b.IsImage &&
a.MimeType == b.MimeType &&
a.Preview == b.Preview &&
a.Size == b.Size &&
a.Timestamp.Equal(b.Timestamp)
}
func (m *Manager) GetHistory() []Entry { func (m *Manager) GetHistory() []Entry {
if m.db == nil { if m.db == nil {
return nil return nil
@@ -854,7 +873,7 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) {
return nil, err return nil, err
} }
if !found { if !found {
return nil, fmt.Errorf("entry not found") return nil, errEntryNotFound
} }
return &entry, nil return &entry, nil
@@ -916,7 +935,7 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
Pinned: false, Pinned: false,
} }
if err := m.storeEntryWithoutDedup(newEntry); err != nil { if err := m.storeEntry(newEntry); err != nil {
return err return err
} }
@@ -926,36 +945,6 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
return nil return nil
} }
func (m *Manager) storeEntryWithoutDedup(entry Entry) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
entry.Hash = computeHash(entry.Data)
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
id, err := b.NextSequence()
if err != nil {
return err
}
entry.ID = id
encoded, err := encodeEntry(entry)
if err != nil {
return err
}
if err := b.Put(itob(id), encoded); err != nil {
return err
}
return m.trimLengthInTx(b)
})
}
func (m *Manager) ClearHistory() { func (m *Manager) ClearHistory() {
if m.db == nil { if m.db == nil {
return return
@@ -1634,6 +1623,37 @@ func (m *Manager) UnpinEntry(id uint64) error {
return err return err
} }
if entry.Pinned {
currentKey := itob(id)
var keepKey []byte
var deleteKeys [][]byte
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
if bytes.Equal(k, currentKey) || extractHash(v) != entry.Hash {
continue
}
duplicate, err := decodeEntryMeta(v)
if err == nil && !duplicate.Pinned {
key := append([]byte(nil), k...)
if keepKey == nil {
keepKey = key
} else {
deleteKeys = append(deleteKeys, key)
}
}
}
if keepKey != nil {
for _, key := range deleteKeys {
if err := b.Delete(key); err != nil {
return err
}
}
return b.Delete(currentKey)
}
}
entry.Pinned = false entry.Pinned = false
encoded, err := encodeEntry(entry) encoded, err := encodeEntry(entry)
if err != nil { if err != nil {
+246 -2
View File
@@ -1,17 +1,53 @@
package clipboard package clipboard
import ( import (
"bytes"
"encoding/json"
"net"
"path/filepath"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
bolt "go.etcd.io/bbolt"
mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext" mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
) )
type clipboardTestConn struct {
net.Conn
writeBuf *bytes.Buffer
}
func newClipboardTestConn() *clipboardTestConn {
return &clipboardTestConn{writeBuf: &bytes.Buffer{}}
}
func (c *clipboardTestConn) Write(b []byte) (int, error) {
return c.writeBuf.Write(b)
}
func newTestManagerWithDB(t *testing.T) *Manager {
t.Helper()
db, err := openDB(filepath.Join(t.TempDir(), "clipboard.db"))
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
return &Manager{
config: DefaultConfig(),
db: db,
}
}
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) { func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
original := Entry{ original := Entry{
ID: 12345, ID: 12345,
@@ -131,11 +167,217 @@ func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
} }
func TestStateEqual_BothEqual(t *testing.T) { func TestStateEqual_BothEqual(t *testing.T) {
a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}} ts := time.Now().Truncate(time.Second)
b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}} entry := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: true,
}
a := &State{Enabled: true, History: []Entry{entry}}
b := &State{Enabled: true, History: []Entry{entry}}
assert.True(t, stateEqual(a, b)) assert.True(t, stateEqual(a, b))
} }
func TestStateEqual_SameLengthDifferentIDs(t *testing.T) {
ts := time.Now().Truncate(time.Second)
a := &State{Enabled: true, History: []Entry{{ID: 1, Hash: 100, Timestamp: ts}}}
b := &State{Enabled: true, History: []Entry{{ID: 2, Hash: 100, Timestamp: ts}}}
assert.False(t, stateEqual(a, b))
}
func TestStateEqual_MetadataDiffers(t *testing.T) {
ts := time.Now().Truncate(time.Second)
base := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: false,
}
tests := []struct {
name string
mutate func(*Entry)
}{
{name: "hash", mutate: func(e *Entry) { e.Hash = 101 }},
{name: "pinned", mutate: func(e *Entry) { e.Pinned = true }},
{name: "is image", mutate: func(e *Entry) { e.IsImage = false }},
{name: "mime type", mutate: func(e *Entry) { e.MimeType = "image/jpeg" }},
{name: "preview", mutate: func(e *Entry) { e.Preview = "[[ image 2 KiB jpeg 64x64 ]]" }},
{name: "size", mutate: func(e *Entry) { e.Size = 2048 }},
{name: "timestamp", mutate: func(e *Entry) { e.Timestamp = ts.Add(time.Second) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
changed := base
tt.mutate(&changed)
a := &State{Enabled: true, History: []Entry{base}}
b := &State{Enabled: true, History: []Entry{changed}}
assert.False(t, stateEqual(a, b))
})
}
}
func TestHandleGetEntry_ReturnsExistingEntry(t *testing.T) {
m := newTestManagerWithDB(t)
err := m.storeEntry(Entry{
Data: []byte("hello world"),
MimeType: "text/plain;charset=utf-8",
Preview: "hello world",
Size: len("hello world"),
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
})
require.NoError(t, err)
history := m.GetHistory()
require.Len(t, history, 1)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(history[0].ID)},
}, m)
var resp models.Response[Entry]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.Equal(t, history[0].ID, resp.Result.ID)
assert.Equal(t, []byte("hello world"), resp.Result.Data)
}
func TestHandleGetEntry_MissingIDReturnsNullResult(t *testing.T) {
m := newTestManagerWithDB(t)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(999)},
}, m)
var resp models.Response[any]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
assert.Nil(t, resp.Result)
}
func TestUnpinEntry_KeepsTopUnpinnedDuplicate(t *testing.T) {
m := newTestManagerWithDB(t)
require.NoError(t, m.storeEntry(Entry{
Data: []byte("saved content"),
MimeType: "text/plain;charset=utf-8",
Preview: "saved content",
Size: len("saved content"),
Timestamp: time.Now().Add(-time.Minute).Truncate(time.Second),
IsImage: false,
}))
history := m.GetHistory()
require.Len(t, history, 1)
pinnedID := history[0].ID
require.NoError(t, m.PinEntry(pinnedID))
pinnedEntry, err := m.GetEntry(pinnedID)
require.NoError(t, err)
require.True(t, pinnedEntry.Pinned)
// Bypass storeEntry to simulate legacy duplicate ordinary history entries.
insertLegacyUnpinnedDuplicate := func(timestamp time.Time) Entry {
duplicate := Entry{
Data: pinnedEntry.Data,
MimeType: pinnedEntry.MimeType,
Preview: pinnedEntry.Preview,
Size: pinnedEntry.Size,
Timestamp: timestamp,
IsImage: pinnedEntry.IsImage,
Pinned: false,
}
duplicate.Hash = computeHash(duplicate.Data)
require.NoError(t, m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
id, err := b.NextSequence()
if err != nil {
return err
}
duplicate.ID = id
encoded, err := encodeEntry(duplicate)
if err != nil {
return err
}
return b.Put(itob(id), encoded)
}))
return duplicate
}
olderHistoryDuplicate := insertLegacyUnpinnedDuplicate(time.Now().Add(time.Hour))
topHistoryDuplicate := insertLegacyUnpinnedDuplicate(time.Now().Add(-time.Hour))
require.Greater(t, topHistoryDuplicate.ID, olderHistoryDuplicate.ID)
require.True(t, olderHistoryDuplicate.Timestamp.After(topHistoryDuplicate.Timestamp))
history = m.GetHistory()
require.Len(t, history, 3)
require.Equal(t, topHistoryDuplicate.ID, history[0].ID)
require.NoError(t, m.UnpinEntry(pinnedID))
history = m.GetHistory()
require.Len(t, history, 1)
assert.False(t, history[0].Pinned)
assert.Equal(t, pinnedEntry.Hash, history[0].Hash)
assert.Equal(t, topHistoryDuplicate.ID, history[0].ID)
}
func TestCreateHistoryEntryFromPinned_KeepsLatestUnpinnedDuplicate(t *testing.T) {
m := newTestManagerWithDB(t)
require.NoError(t, m.storeEntry(Entry{
Data: []byte("saved content"),
MimeType: "text/plain;charset=utf-8",
Preview: "saved content",
Size: len("saved content"),
Timestamp: time.Now().Add(-time.Minute).Truncate(time.Second),
IsImage: false,
}))
history := m.GetHistory()
require.Len(t, history, 1)
pinnedID := history[0].ID
require.NoError(t, m.PinEntry(pinnedID))
pinnedEntry, err := m.GetEntry(pinnedID)
require.NoError(t, err)
require.True(t, pinnedEntry.Pinned)
require.NoError(t, m.CreateHistoryEntryFromPinned(pinnedEntry))
firstDuplicate := m.GetHistory()[0]
require.NotEqual(t, pinnedID, firstDuplicate.ID)
require.NoError(t, m.CreateHistoryEntryFromPinned(pinnedEntry))
latestDuplicate := m.GetHistory()[0]
history = m.GetHistory()
require.Len(t, history, 2)
assert.Equal(t, latestDuplicate.ID, history[0].ID)
assert.False(t, history[0].Pinned)
assert.Equal(t, pinnedID, history[1].ID)
assert.True(t, history[1].Pinned)
assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID)
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) { func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{ m := &Manager{
subscribers: make(map[string]chan State), subscribers: make(map[string]chan State),
@@ -410,6 +652,8 @@ func TestSelectMimeType(t *testing.T) {
{[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"}, {[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
{[]string{"text/html", "text/plain"}, "text/plain"}, {[]string{"text/html", "text/plain"}, "text/plain"},
{[]string{"text/html", "image/png"}, "image/png"}, {[]string{"text/html", "image/png"}, "image/png"},
{[]string{"image/png", "text/plain"}, "image/png"},
{[]string{"text/plain", "image/png"}, "image/png"},
{[]string{"image/png", "image/jpeg"}, "image/png"}, {[]string{"image/png", "image/jpeg"}, "image/png"},
{[]string{"image/png"}, "image/png"}, {[]string{"image/png"}, "image/png"},
{[]string{"application/octet-stream"}, "application/octet-stream"}, {[]string{"application/octet-stream"}, "application/octet-stream"},
-138
View File
@@ -1,138 +0,0 @@
package dwl
import (
"encoding/json"
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
type SuccessResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
if manager == nil {
models.RespondError(conn, req.ID, "dwl manager not initialized")
return
}
switch req.Method {
case "dwl.getState":
handleGetState(conn, req, manager)
case "dwl.setTags":
handleSetTags(conn, req, manager)
case "dwl.setClientTags":
handleSetClientTags(conn, req, manager)
case "dwl.setLayout":
handleSetLayout(conn, req, manager)
case "dwl.subscribe":
handleSubscribe(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
state := manager.GetState()
models.Respond(conn, req.ID, state)
}
func handleSetTags(conn net.Conn, req models.Request, manager *Manager) {
output, ok := models.Get[string](req, "output")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return
}
tagmask, ok := models.Get[float64](req, "tagmask")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'tagmask' parameter")
return
}
toggleTagset, ok := models.Get[float64](req, "toggleTagset")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'toggleTagset' parameter")
return
}
if err := manager.SetTags(output, uint32(tagmask), uint32(toggleTagset)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "tags set"})
}
func handleSetClientTags(conn net.Conn, req models.Request, manager *Manager) {
output, ok := models.Get[string](req, "output")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return
}
andTags, ok := models.Get[float64](req, "andTags")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'andTags' parameter")
return
}
xorTags, ok := models.Get[float64](req, "xorTags")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'xorTags' parameter")
return
}
if err := manager.SetClientTags(output, uint32(andTags), uint32(xorTags)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "client tags set"})
}
func handleSetLayout(conn net.Conn, req models.Request, manager *Manager) {
output, ok := models.Get[string](req, "output")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return
}
index, ok := models.Get[float64](req, "index")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'index' parameter")
return
}
if err := manager.SetLayout(output, uint32(index)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "layout set"})
}
func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
clientID := fmt.Sprintf("client-%p", conn)
stateChan := manager.Subscribe(clientID)
defer manager.Unsubscribe(clientID)
initialState := manager.GetState()
if err := json.NewEncoder(conn).Encode(models.Response[State]{
ID: req.ID,
Result: &initialState,
}); err != nil {
return
}
for state := range stateChan {
if err := json.NewEncoder(conn).Encode(models.Response[State]{
Result: &state,
}); err != nil {
return
}
}
}
-522
View File
@@ -1,522 +0,0 @@
package dwl
import (
"fmt"
"time"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc"
)
func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
m := &Manager{
display: display,
ctx: display.Context(),
cmdq: make(chan cmd, 128),
outputSetupReq: make(chan uint32, 16),
stopChan: make(chan struct{}),
dirty: make(chan struct{}, 1),
layouts: make([]string, 0),
}
if err := m.setupRegistry(); err != nil {
return nil, err
}
m.updateState()
m.notifierWg.Add(1)
go m.notifier()
m.wg.Add(1)
go m.waylandActor()
return m, nil
}
func (m *Manager) post(fn func()) {
select {
case m.cmdq <- cmd{fn: fn}:
default:
log.Warn("DWL actor command queue full, dropping command")
}
}
func (m *Manager) waylandActor() {
defer m.wg.Done()
for {
select {
case <-m.stopChan:
return
case c := <-m.cmdq:
c.fn()
case outputID := <-m.outputSetupReq:
out, exists := m.outputs.Load(outputID)
if !exists {
log.Warnf("DWL: Output %d no longer exists, skipping setup", outputID)
continue
}
if out.ipcOutput != nil {
continue
}
mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2)
if !ok || mgr == nil {
log.Errorf("DWL: Manager not available for output %d setup", outputID)
continue
}
log.Infof("DWL: Setting up ipcOutput for dynamically added output %d", outputID)
if err := m.setupOutput(mgr, out.output); err != nil {
log.Errorf("DWL: Failed to setup output %d: %v", outputID, err)
} else {
m.updateState()
}
}
}
}
func (m *Manager) setupRegistry() error {
log.Info("DWL: starting registry setup")
registry, err := m.display.GetRegistry()
if err != nil {
return fmt.Errorf("failed to get registry: %w", err)
}
m.registry = registry
outputs := make([]*wlclient.Output, 0)
outputRegNames := make(map[uint32]uint32)
var dwlMgr *dwl_ipc.ZdwlIpcManagerV2
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
log.Infof("DWL: found %s", dwl_ipc.ZdwlIpcManagerV2InterfaceName)
manager := dwl_ipc.NewZdwlIpcManagerV2(m.ctx)
version := e.Version
if version > 2 {
version = 2
}
if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
dwlMgr = manager
log.Info("DWL: manager bound successfully")
// Set handlers immediately after binding, before roundtrips
manager.SetTagsHandler(func(e dwl_ipc.ZdwlIpcManagerV2TagsEvent) {
log.Infof("DWL: Tags count: %d", e.Amount)
m.tagCount = e.Amount
m.updateState()
})
manager.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcManagerV2LayoutEvent) {
log.Infof("DWL: Layout: %s", e.Name)
m.layouts = append(m.layouts, e.Name)
m.updateState()
})
} else {
log.Errorf("DWL: failed to bind manager: %v", err)
}
case "wl_output":
log.Debugf("DWL: found wl_output (name=%d)", e.Name)
output := wlclient.NewOutput(m.ctx)
outState := &outputState{
registryName: e.Name,
output: output,
tags: make([]TagState, 0),
}
output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
log.Debugf("DWL: Output name: %s (registry=%d)", ev.Name, e.Name)
outState.name = ev.Name
})
output.SetDescriptionHandler(func(ev wlclient.OutputDescriptionEvent) {
log.Debugf("DWL: Output description: %s", ev.Description)
})
version := e.Version
if version > 4 {
version = 4
}
if err := registry.Bind(e.Name, e.Interface, version, output); err == nil {
outputID := output.ID()
outState.id = outputID
log.Infof("DWL: Bound wl_output id=%d registry_name=%d", outputID, e.Name)
outputs = append(outputs, output)
outputRegNames[outputID] = e.Name
m.outputs.Store(outputID, outState)
if m.manager != nil {
select {
case m.outputSetupReq <- outputID:
log.Debugf("DWL: Queued setup for output %d", outputID)
default:
log.Warnf("DWL: Setup queue full, output %d will not be initialized", outputID)
}
}
} else {
log.Errorf("DWL: Failed to bind wl_output: %v", err)
}
}
})
registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
m.post(func() {
var outToRelease *outputState
m.outputs.Range(func(id uint32, out *outputState) bool {
if out.registryName == e.Name {
log.Infof("DWL: Output %d removed", id)
outToRelease = out
m.outputs.Delete(id)
return false
}
return true
})
if outToRelease != nil {
if ipcOut, ok := outToRelease.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok && ipcOut != nil {
m.wlMutex.Lock()
ipcOut.Release()
m.wlMutex.Unlock()
log.Debugf("DWL: Released ipcOutput for removed output %d", outToRelease.id)
}
m.updateState()
}
})
})
if err := m.display.Roundtrip(); err != nil {
return fmt.Errorf("first roundtrip failed: %w", err)
}
if err := m.display.Roundtrip(); err != nil {
return fmt.Errorf("second roundtrip failed: %w", err)
}
if dwlMgr == nil {
log.Info("DWL: manager not found in registry")
return fmt.Errorf("dwl_ipc_manager_v2 not available")
}
m.manager = dwlMgr
for _, output := range outputs {
if err := m.setupOutput(dwlMgr, output); err != nil {
log.Warnf("DWL: Failed to setup output %d: %v", output.ID(), err)
}
}
if err := m.display.Roundtrip(); err != nil {
return fmt.Errorf("final roundtrip failed: %w", err)
}
log.Info("DWL: registry setup complete")
return nil
}
func (m *Manager) setupOutput(manager *dwl_ipc.ZdwlIpcManagerV2, output *wlclient.Output) error {
m.wlMutex.Lock()
ipcOutput, err := manager.GetOutput(output)
m.wlMutex.Unlock()
if err != nil {
return fmt.Errorf("failed to get dwl output: %w", err)
}
outState, exists := m.outputs.Load(output.ID())
if !exists {
return fmt.Errorf("output state not found for id %d", output.ID())
}
outState.ipcOutput = ipcOutput
ipcOutput.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
outState.active = e.Active
})
ipcOutput.SetTagHandler(func(e dwl_ipc.ZdwlIpcOutputV2TagEvent) {
updated := false
for i, tag := range outState.tags {
if tag.Tag == e.Tag {
outState.tags[i] = TagState{
Tag: e.Tag,
State: e.State,
Clients: e.Clients,
Focused: e.Focused,
}
updated = true
break
}
}
if !updated {
outState.tags = append(outState.tags, TagState{
Tag: e.Tag,
State: e.State,
Clients: e.Clients,
Focused: e.Focused,
})
}
m.updateState()
})
ipcOutput.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutEvent) {
outState.layout = e.Layout
})
ipcOutput.SetTitleHandler(func(e dwl_ipc.ZdwlIpcOutputV2TitleEvent) {
outState.title = e.Title
})
ipcOutput.SetAppidHandler(func(e dwl_ipc.ZdwlIpcOutputV2AppidEvent) {
outState.appID = e.Appid
})
ipcOutput.SetLayoutSymbolHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutSymbolEvent) {
outState.layoutSymbol = e.Layout
})
ipcOutput.SetKbLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2KbLayoutEvent) {
outState.kbLayout = e.KbLayout
})
ipcOutput.SetKeymodeHandler(func(e dwl_ipc.ZdwlIpcOutputV2KeymodeEvent) {
outState.keymode = e.Keymode
})
ipcOutput.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
m.updateState()
})
return nil
}
func (m *Manager) updateState() {
outputs := make(map[string]*OutputState)
activeOutput := ""
m.outputs.Range(func(key uint32, out *outputState) bool {
name := out.name
if name == "" {
name = fmt.Sprintf("output-%d", out.id)
}
tagsCopy := make([]TagState, len(out.tags))
copy(tagsCopy, out.tags)
outputs[name] = &OutputState{
Name: name,
Active: out.active,
Tags: tagsCopy,
Layout: out.layout,
LayoutSymbol: out.layoutSymbol,
Title: out.title,
AppID: out.appID,
KbLayout: out.kbLayout,
Keymode: out.keymode,
}
if out.active != 0 {
activeOutput = name
}
return true
})
newState := State{
Outputs: outputs,
TagCount: m.tagCount,
Layouts: m.layouts,
ActiveOutput: activeOutput,
}
m.stateMutex.Lock()
m.state = &newState
m.stateMutex.Unlock()
m.notifySubscribers()
}
func (m *Manager) notifier() {
defer m.notifierWg.Done()
const minGap = 100 * time.Millisecond
timer := time.NewTimer(minGap)
timer.Stop()
var pending bool
for {
select {
case <-m.stopChan:
timer.Stop()
return
case <-m.dirty:
if pending {
continue
}
pending = true
timer.Reset(minGap)
case <-timer.C:
if !pending {
continue
}
currentState := m.GetState()
if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
pending = false
continue
}
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- currentState:
default:
log.Warn("DWL: subscriber channel full, dropping update")
}
return true
})
stateCopy := currentState
m.lastNotified = &stateCopy
pending = false
}
}
}
func (m *Manager) ensureOutputSetup(out *outputState) error {
if out.ipcOutput != nil {
return nil
}
return fmt.Errorf("output not yet initialized - setup in progress, retry in a moment")
}
func (m *Manager) SetTags(outputName string, tagmask uint32, toggleTagset uint32) error {
availableOutputs := make([]string, 0)
var targetOut *outputState
m.outputs.Range(func(key uint32, out *outputState) bool {
name := out.name
if name == "" {
name = fmt.Sprintf("output-%d", out.id)
}
availableOutputs = append(availableOutputs, name)
if name == outputName {
targetOut = out
return false
}
return true
})
if targetOut == nil {
return fmt.Errorf("output not found: %s (available: %v)", outputName, availableOutputs)
}
if err := m.ensureOutputSetup(targetOut); err != nil {
return fmt.Errorf("failed to setup output %s: %w", outputName, err)
}
ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
if !ok {
return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
}
m.wlMutex.Lock()
err := ipcOut.SetTags(tagmask, toggleTagset)
m.wlMutex.Unlock()
return err
}
func (m *Manager) SetClientTags(outputName string, andTags uint32, xorTags uint32) error {
var targetOut *outputState
m.outputs.Range(func(key uint32, out *outputState) bool {
name := out.name
if name == "" {
name = fmt.Sprintf("output-%d", out.id)
}
if name == outputName {
targetOut = out
return false
}
return true
})
if targetOut == nil {
return fmt.Errorf("output not found: %s", outputName)
}
if err := m.ensureOutputSetup(targetOut); err != nil {
return fmt.Errorf("failed to setup output %s: %w", outputName, err)
}
ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
if !ok {
return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
}
m.wlMutex.Lock()
err := ipcOut.SetClientTags(andTags, xorTags)
m.wlMutex.Unlock()
return err
}
func (m *Manager) SetLayout(outputName string, index uint32) error {
var targetOut *outputState
m.outputs.Range(func(key uint32, out *outputState) bool {
name := out.name
if name == "" {
name = fmt.Sprintf("output-%d", out.id)
}
if name == outputName {
targetOut = out
return false
}
return true
})
if targetOut == nil {
return fmt.Errorf("output not found: %s", outputName)
}
if err := m.ensureOutputSetup(targetOut); err != nil {
return fmt.Errorf("failed to setup output %s: %w", outputName, err)
}
ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
if !ok {
return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
}
m.wlMutex.Lock()
err := ipcOut.SetLayout(index)
m.wlMutex.Unlock()
return err
}
func (m *Manager) Close() {
close(m.stopChan)
m.wg.Wait()
m.notifierWg.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
m.outputs.Range(func(key uint32, out *outputState) bool {
if ipcOut, ok := out.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok {
ipcOut.Release()
}
m.outputs.Delete(key)
return true
})
if mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2); ok {
mgr.Release()
}
}
-366
View File
@@ -1,366 +0,0 @@
package dwl
import (
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
)
func TestStateChanged_BothNil(t *testing.T) {
assert.True(t, stateChanged(nil, nil))
}
func TestStateChanged_OneNil(t *testing.T) {
s := &State{TagCount: 9}
assert.True(t, stateChanged(s, nil))
assert.True(t, stateChanged(nil, s))
}
func TestStateChanged_TagCountDiffers(t *testing.T) {
a := &State{TagCount: 9, Outputs: make(map[string]*OutputState), Layouts: []string{}}
b := &State{TagCount: 10, Outputs: make(map[string]*OutputState), Layouts: []string{}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_LayoutLengthDiffers(t *testing.T) {
a := &State{TagCount: 9, Layouts: []string{"tile"}, Outputs: make(map[string]*OutputState)}
b := &State{TagCount: 9, Layouts: []string{"tile", "monocle"}, Outputs: make(map[string]*OutputState)}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_ActiveOutputDiffers(t *testing.T) {
a := &State{TagCount: 9, ActiveOutput: "eDP-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
b := &State{TagCount: 9, ActiveOutput: "HDMI-A-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputCountDiffers(t *testing.T) {
a := &State{
TagCount: 9,
Outputs: map[string]*OutputState{"eDP-1": {}},
Layouts: []string{},
}
b := &State{
TagCount: 9,
Outputs: map[string]*OutputState{},
Layouts: []string{},
}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputFieldsDiffer(t *testing.T) {
a := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Active: 1, Layout: 0, Title: "Firefox"},
},
}
b := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Active: 0, Layout: 0, Title: "Firefox"},
},
}
assert.True(t, stateChanged(a, b))
b.Outputs["eDP-1"].Active = 1
b.Outputs["eDP-1"].Layout = 1
assert.True(t, stateChanged(a, b))
b.Outputs["eDP-1"].Layout = 0
b.Outputs["eDP-1"].Title = "Code"
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_TagsDiffer(t *testing.T) {
a := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}},
},
}
b := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Tags: []TagState{{Tag: 1, State: 2, Clients: 2, Focused: 1}}},
},
}
assert.True(t, stateChanged(a, b))
b.Outputs["eDP-1"].Tags[0].State = 1
b.Outputs["eDP-1"].Tags[0].Clients = 3
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_Equal(t *testing.T) {
a := &State{
TagCount: 9,
ActiveOutput: "eDP-1",
Layouts: []string{"tile", "monocle"},
Outputs: map[string]*OutputState{
"eDP-1": {
Name: "eDP-1",
Active: 1,
Layout: 0,
LayoutSymbol: "[]=",
Title: "Firefox",
AppID: "firefox",
KbLayout: "us",
Keymode: "",
Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
},
},
}
b := &State{
TagCount: 9,
ActiveOutput: "eDP-1",
Layouts: []string{"tile", "monocle"},
Outputs: map[string]*OutputState{
"eDP-1": {
Name: "eDP-1",
Active: 1,
Layout: 0,
LayoutSymbol: "[]=",
Title: "Firefox",
AppID: "firefox",
KbLayout: "us",
Keymode: "",
Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
},
},
}
assert.False(t, stateChanged(a, b))
}
func TestManager_ConcurrentGetState(t *testing.T) {
m := &Manager{
state: &State{
TagCount: 9,
Layouts: []string{"tile"},
Outputs: map[string]*OutputState{"eDP-1": {Name: "eDP-1"}},
},
}
var wg sync.WaitGroup
const goroutines = 50
const iterations = 100
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
s := m.GetState()
_ = s.TagCount
_ = s.Outputs
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.stateMutex.Lock()
m.state = &State{
TagCount: uint32(j % 10),
Layouts: []string{"tile", "monocle"},
Outputs: map[string]*OutputState{"eDP-1": {Active: uint32(j % 2)}},
}
m.stateMutex.Unlock()
}
}(i)
}
wg.Wait()
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{
stopChan: make(chan struct{}),
dirty: make(chan struct{}, 1),
}
var wg sync.WaitGroup
const goroutines = 20
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
subID := string(rune('a' + id))
ch := m.Subscribe(subID)
assert.NotNil(t, ch)
time.Sleep(time.Millisecond)
m.Unsubscribe(subID)
}(i)
}
wg.Wait()
}
func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
m := &Manager{}
var wg sync.WaitGroup
const goroutines = 30
const iterations = 50
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
key := uint32(id)
for j := 0; j < iterations; j++ {
state := &outputState{
id: key,
name: "test-output",
active: uint32(j % 2),
tags: []TagState{{Tag: uint32(j), State: 1}},
}
m.outputs.Store(key, state)
if loaded, ok := m.outputs.Load(key); ok {
assert.Equal(t, key, loaded.id)
}
m.outputs.Range(func(k uint32, v *outputState) bool {
_ = v.name
_ = v.active
return true
})
}
m.outputs.Delete(key)
}(i)
}
wg.Wait()
}
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
m := &Manager{
dirty: make(chan struct{}, 1),
}
for i := 0; i < 10; i++ {
m.notifySubscribers()
}
assert.Len(t, m.dirty, 1)
}
func TestManager_PostQueueFull(t *testing.T) {
m := &Manager{
cmdq: make(chan cmd, 2),
stopChan: make(chan struct{}),
}
m.post(func() {})
m.post(func() {})
m.post(func() {})
m.post(func() {})
assert.Len(t, m.cmdq, 2)
}
func TestManager_GetStateNilState(t *testing.T) {
m := &Manager{}
s := m.GetState()
assert.NotNil(t, s.Outputs)
assert.NotNil(t, s.Layouts)
assert.Equal(t, uint32(0), s.TagCount)
}
func TestTagState_Fields(t *testing.T) {
tag := TagState{
Tag: 1,
State: 2,
Clients: 3,
Focused: 1,
}
assert.Equal(t, uint32(1), tag.Tag)
assert.Equal(t, uint32(2), tag.State)
assert.Equal(t, uint32(3), tag.Clients)
assert.Equal(t, uint32(1), tag.Focused)
}
func TestOutputState_Fields(t *testing.T) {
out := OutputState{
Name: "eDP-1",
Active: 1,
Tags: []TagState{{Tag: 1}},
Layout: 0,
LayoutSymbol: "[]=",
Title: "Firefox",
AppID: "firefox",
KbLayout: "us",
Keymode: "",
}
assert.Equal(t, "eDP-1", out.Name)
assert.Equal(t, uint32(1), out.Active)
assert.Len(t, out.Tags, 1)
assert.Equal(t, "[]=", out.LayoutSymbol)
}
func TestStateChanged_NewOutputAppears(t *testing.T) {
a := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Name: "eDP-1"},
},
}
b := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Name: "eDP-1"},
"HDMI-A-1": {Name: "HDMI-A-1"},
},
}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_TagsLengthDiffers(t *testing.T) {
a := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Tags: []TagState{{Tag: 1}}},
},
}
b := &State{
TagCount: 9,
Layouts: []string{},
Outputs: map[string]*OutputState{
"eDP-1": {Tags: []TagState{{Tag: 1}, {Tag: 2}}},
},
}
assert.True(t, stateChanged(a, b))
}
func TestNewManager_GetRegistryError(t *testing.T) {
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
mockDisplay.EXPECT().Context().Return(nil)
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
_, err := NewManager(mockDisplay)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get registry")
}
-176
View File
@@ -1,176 +0,0 @@
package dwl
import (
"sync"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
type TagState struct {
Tag uint32 `json:"tag"`
State uint32 `json:"state"`
Clients uint32 `json:"clients"`
Focused uint32 `json:"focused"`
}
type OutputState struct {
Name string `json:"name"`
Active uint32 `json:"active"`
Tags []TagState `json:"tags"`
Layout uint32 `json:"layout"`
LayoutSymbol string `json:"layoutSymbol"`
Title string `json:"title"`
AppID string `json:"appId"`
KbLayout string `json:"kbLayout"`
Keymode string `json:"keymode"`
}
type State struct {
Outputs map[string]*OutputState `json:"outputs"`
TagCount uint32 `json:"tagCount"`
Layouts []string `json:"layouts"`
ActiveOutput string `json:"activeOutput"`
}
type cmd struct {
fn func()
}
type Manager struct {
display wlclient.WaylandDisplay
ctx *wlclient.Context
registry *wlclient.Registry
manager any
outputs syncmap.Map[uint32, *outputState]
tagCount uint32
layouts []string
wlMutex sync.Mutex
cmdq chan cmd
outputSetupReq chan uint32
stopChan chan struct{}
wg sync.WaitGroup
subscribers syncmap.Map[string, chan State]
dirty chan struct{}
notifierWg sync.WaitGroup
lastNotified *State
stateMutex sync.RWMutex
state *State
}
type outputState struct {
id uint32
registryName uint32
output *wlclient.Output
ipcOutput any
name string
active uint32
tags []TagState
layout uint32
layoutSymbol string
title string
appID string
kbLayout string
keymode string
}
func (m *Manager) GetState() State {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
return State{
Outputs: make(map[string]*OutputState),
Layouts: []string{},
TagCount: 0,
}
}
stateCopy := *m.state
return stateCopy
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 64)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) notifySubscribers() {
select {
case m.dirty <- struct{}{}:
default:
}
}
func stateChanged(old, new *State) bool {
if old == nil || new == nil {
return true
}
if old.TagCount != new.TagCount {
return true
}
if len(old.Layouts) != len(new.Layouts) {
return true
}
if old.ActiveOutput != new.ActiveOutput {
return true
}
if len(old.Outputs) != len(new.Outputs) {
return true
}
for name, newOut := range new.Outputs {
oldOut, exists := old.Outputs[name]
if !exists {
return true
}
if oldOut.Active != newOut.Active {
return true
}
if oldOut.Layout != newOut.Layout {
return true
}
if oldOut.LayoutSymbol != newOut.LayoutSymbol {
return true
}
if oldOut.Title != newOut.Title {
return true
}
if oldOut.AppID != newOut.AppID {
return true
}
if oldOut.KbLayout != newOut.KbLayout {
return true
}
if oldOut.Keymode != newOut.Keymode {
return true
}
if len(oldOut.Tags) != len(newOut.Tags) {
return true
}
for i, newTag := range newOut.Tags {
if i >= len(oldOut.Tags) {
return true
}
oldTag := oldOut.Tags[i]
if oldTag.Tag != newTag.Tag || oldTag.State != newTag.State ||
oldTag.Clients != newTag.Clients || oldTag.Focused != newTag.Focused {
return true
}
}
}
return false
}
+2
View File
@@ -125,6 +125,8 @@ State updates are sent whenever network configuration changes:
- `wifiConnected`: Whether associated with an access point - `wifiConnected`: Whether associated with an access point
- `wifiSSID`: Currently connected network name - `wifiSSID`: Currently connected network name
- `wifiIP`: Assigned IP address (empty until DHCP completes) - `wifiIP`: Assigned IP address (empty until DHCP completes)
- `savedWifiNetworks` (API v26+): Saved WiFi profiles exposed at SSID granularity. If a backend has multiple profiles for the same SSID, DMS merges them into one SSID-level entry. Clients talking to older servers should derive saved visible networks from `wifiNetworks` entries where `saved` is true.
- `savedWifiNetworks[].outOfRange` (API v26+): Whether the saved profile is not currently visible in scan results. Fallback entries derived from `wifiNetworks` should be treated as visible (`outOfRange: false`).
- `lastError`: Error message from last failed connection attempt - `lastError`: Error message from last failed connection attempt
### network.credentials Service Events ### network.credentials Service Events
+1
View File
@@ -67,6 +67,7 @@ type BackendState struct {
WiFiBSSID string WiFiBSSID string
WiFiSignal uint8 WiFiSignal uint8
WiFiNetworks []WiFiNetwork WiFiNetworks []WiFiNetwork
SavedWiFiNetworks []WiFiNetwork
WiFiDevices []WiFiDevice WiFiDevices []WiFiDevice
WiredConnections []WiredConnection WiredConnections []WiredConnection
VPNProfiles []VPNProfile VPNProfiles []VPNProfile
@@ -27,6 +27,19 @@ func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) {
wifi.state.WiFiBSSID = "00:11:22:33:44:55" wifi.state.WiFiBSSID = "00:11:22:33:44:55"
wifi.state.WiFiSignal = 75 wifi.state.WiFiSignal = 75
wifi.state.WiFiDevice = "wlan0" wifi.state.WiFiDevice = "wlan0"
wifi.state.SavedWiFiNetworks = []WiFiNetwork{
{
SSID: "TestNetwork",
Saved: true,
Autoconnect: true,
Connected: true,
},
{
SSID: "AwayNetwork",
Saved: true,
OutOfRange: true,
},
}
l3.state.WiFiIP = "192.168.1.100" l3.state.WiFiIP = "192.168.1.100"
l3.state.EthernetConnected = false l3.state.EthernetConnected = false
@@ -42,6 +55,9 @@ func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) {
assert.True(t, state.WiFiConnected) assert.True(t, state.WiFiConnected)
assert.False(t, state.EthernetConnected) assert.False(t, state.EthernetConnected)
assert.Equal(t, StatusWiFi, state.NetworkStatus) assert.Equal(t, StatusWiFi, state.NetworkStatus)
assert.Len(t, state.SavedWiFiNetworks, 2)
assert.Equal(t, "TestNetwork", state.SavedWiFiNetworks[0].SSID)
assert.True(t, state.SavedWiFiNetworks[1].OutOfRange)
} }
func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) { func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) {
@@ -80,6 +80,10 @@ func (b *IWDBackend) Initialize() error {
return fmt.Errorf("failed to discover iwd devices: %w", err) return fmt.Errorf("failed to discover iwd devices: %w", err)
} }
if err := b.updateSavedWiFiNetworks(); err != nil {
log.Warnf("Failed to get initial saved WiFi networks: %v", err)
}
if err := b.updateState(); err != nil { if err := b.updateState(); err != nil {
conn.Close() conn.Close()
return fmt.Errorf("failed to get initial state: %w", err) return fmt.Errorf("failed to get initial state: %w", err)
@@ -145,6 +149,7 @@ func (b *IWDBackend) GetCurrentState() (*BackendState, error) {
state := *b.state state := *b.state
state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...) state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...)
state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
state.WiFiDevices = b.getWiFiDevicesLocked() state.WiFiDevices = b.getWiFiDevicesLocked()
@@ -45,12 +45,42 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
} }
} }
if err := b.conn.AddMatchSignal(
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
dbus.WithMatchArg(0, iwdKnownNetworkInterface),
); err != nil {
return fmt.Errorf("failed to add known network signal match: %w", err)
}
if err := b.conn.AddMatchSignal(
dbus.WithMatchInterface(dbusObjectManager),
dbus.WithMatchMember("InterfacesAdded"),
); err != nil {
return fmt.Errorf("failed to add iwd interfaces-added signal match: %w", err)
}
if err := b.conn.AddMatchSignal(
dbus.WithMatchInterface(dbusObjectManager),
dbus.WithMatchMember("InterfacesRemoved"),
); err != nil {
return fmt.Errorf("failed to add iwd interfaces-removed signal match: %w", err)
}
b.sigWG.Add(1) b.sigWG.Add(1)
go b.signalHandler(sigChan) go b.signalHandler(sigChan)
return nil return nil
} }
func (b *IWDBackend) refreshWiFiNetworkState() bool {
_, err := b.updateWiFiNetworks()
if err == nil {
return true
}
return b.updateSavedWiFiNetworks() == nil
}
func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
defer b.sigWG.Done() defer b.sigWG.Done()
@@ -66,11 +96,36 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
return return
} }
if sig.Name != dbusPropertiesInterface+".PropertiesChanged" { if sig.Name == dbusObjectManager+".InterfacesAdded" {
if len(sig.Body) >= 2 {
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
if _, ok := interfaces[iwdKnownNetworkInterface]; ok {
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange()
}
}
}
}
continue continue
} }
if len(sig.Body) < 2 { if sig.Name == dbusObjectManager+".InterfacesRemoved" {
if len(sig.Body) >= 2 {
if interfaces, ok := sig.Body[1].([]string); ok {
for _, iface := range interfaces {
if iface == iwdKnownNetworkInterface {
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange()
}
break
}
}
}
}
continue
}
if sig.Name != dbusPropertiesInterface+".PropertiesChanged" || len(sig.Body) < 2 {
continue continue
} }
@@ -87,6 +142,9 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
stateChanged := false stateChanged := false
switch iface { switch iface {
case iwdKnownNetworkInterface:
stateChanged = b.refreshWiFiNetworkState()
case iwdDeviceInterface: case iwdDeviceInterface:
if sig.Path == b.devicePath { if sig.Path == b.devicePath {
if poweredVar, ok := changed["Powered"]; ok { if poweredVar, ok := changed["Powered"]; ok {
@@ -105,13 +163,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
if sig.Path == b.stationPath { if sig.Path == b.stationPath {
if scanningVar, ok := changed["Scanning"]; ok { if scanningVar, ok := changed["Scanning"]; ok {
if scanning, ok := scanningVar.Value().(bool); ok && !scanning { if scanning, ok := scanningVar.Value().(bool); ok && !scanning {
networks, err := b.updateWiFiNetworks() stateChanged = b.refreshWiFiNetworkState() || stateChanged
if err == nil {
b.stateMutex.Lock()
b.state.WiFiNetworks = networks
b.stateMutex.Unlock()
stateChanged = true
}
b.stateMutex.RLock() b.stateMutex.RLock()
wifiConnected := b.state.WiFiConnected wifiConnected := b.state.WiFiConnected
@@ -236,6 +288,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
} }
} }
b.refreshWiFiNetworkState()
stateChanged = true stateChanged = true
if att != nil && isTarget { if att != nil && isTarget {
@@ -282,6 +335,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
b.state.NetworkStatus = StatusDisconnected b.state.NetworkStatus = StatusDisconnected
} }
b.stateMutex.Unlock() b.stateMutex.Unlock()
b.refreshWiFiNetworkState()
stateChanged = true stateChanged = true
} }
} }
@@ -342,6 +396,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
stateChanged = true stateChanged = true
} }
b.stateMutex.Unlock() b.stateMutex.Unlock()
b.refreshWiFiNetworkState()
} }
} }
} }
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/godbus/dbus/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -168,6 +169,92 @@ func TestIWDBackend_MapIwdDBusError(t *testing.T) {
} }
} }
func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) {
objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/known_network/1": {
iwdKnownNetworkInterface: {
"Name": dbus.MakeVariant("Home"),
"AutoConnect": dbus.MakeVariant(false),
"Hidden": dbus.MakeVariant(true),
"Type": dbus.MakeVariant("psk"),
},
},
"/net/connman/iwd/known_network/2": {
iwdKnownNetworkInterface: {
"Name": dbus.MakeVariant("Office"),
"Type": dbus.MakeVariant("8021x"),
},
},
"/net/connman/iwd/known_network/3": {
iwdKnownNetworkInterface: {
"Name": dbus.MakeVariant("Cafe"),
"Type": dbus.MakeVariant("open"),
},
},
"/net/connman/iwd/network/1": {
iwdNetworkInterface: {
"Name": dbus.MakeVariant("VisibleOnly"),
},
},
}
profiles := iwdSavedWiFiProfilesFromManagedObjects(objects)
assert.Len(t, profiles, 3)
assert.False(t, profiles["Home"].Autoconnect)
assert.True(t, profiles["Home"].Hidden)
assert.True(t, profiles["Home"].Secured)
assert.False(t, profiles["Home"].Enterprise)
assert.True(t, profiles["Office"].Autoconnect)
assert.True(t, profiles["Office"].Secured)
assert.True(t, profiles["Office"].Enterprise)
assert.True(t, profiles["Cafe"].Autoconnect)
assert.False(t, profiles["Cafe"].Secured)
assert.False(t, profiles["Cafe"].Enterprise)
}
func TestIWDWiFiNetworksFromVisibleIncludesConnectedHiddenFallback(t *testing.T) {
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Secured: true,
Hidden: true,
Mode: "infrastructure",
},
}
visible := []WiFiNetwork{
{
SSID: "Cafe",
Signal: 42,
Secured: false,
},
}
networks := iwdWiFiNetworksFromVisible(visible, profiles, "Home", true, 68)
savedNetworks := savedWiFiNetworksFromProfiles(profiles, map[string]WiFiNetwork{
networks[0].SSID: networks[0],
networks[1].SSID: networks[1],
}, "Home", true)
assert.Len(t, networks, 2)
assert.Equal(t, "Cafe", networks[0].SSID)
assert.False(t, networks[0].Connected)
assert.Equal(t, "Home", networks[1].SSID)
assert.True(t, networks[1].Connected)
assert.True(t, networks[1].Hidden)
assert.True(t, networks[1].Saved)
assert.True(t, networks[1].Autoconnect)
assert.Equal(t, uint8(68), networks[1].Signal)
assert.Len(t, savedNetworks, 1)
assert.Equal(t, "Home", savedNetworks[0].SSID)
assert.True(t, savedNetworks[0].Connected)
assert.False(t, savedNetworks[0].OutOfRange)
}
func TestConnectAttempt_Finalization(t *testing.T) { func TestConnectAttempt_Finalization(t *testing.T) {
backend, _ := NewIWDBackend() backend, _ := NewIWDBackend()
backend.state = &BackendState{} backend.state = &BackendState{}
+138 -55
View File
@@ -164,22 +164,18 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
return nil, fmt.Errorf("failed to get networks: %w", err) return nil, fmt.Errorf("failed to get networks: %w", err)
} }
knownNetworks, err := b.getKnownNetworks() savedProfiles, err := b.getIWDSavedWiFiProfiles()
if err != nil { if err != nil {
knownNetworks = make(map[string]bool) savedProfiles = make(map[string]savedWiFiProfile)
}
autoconnectMap, err := b.getAutoconnectSettings()
if err != nil {
autoconnectMap = make(map[string]bool)
} }
b.stateMutex.RLock() b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID currentSSID := b.state.WiFiSSID
wifiConnected := b.state.WiFiConnected wifiConnected := b.state.WiFiConnected
wifiSignal := b.state.WiFiSignal
b.stateMutex.RUnlock() b.stateMutex.RUnlock()
networks := make([]WiFiNetwork, 0, len(orderedNetworks)) visibleNetworks := make([]WiFiNetwork, 0, len(orderedNetworks))
for _, netData := range orderedNetworks { for _, netData := range orderedNetworks {
if len(netData) < 2 { if len(netData) < 2 {
continue continue
@@ -225,23 +221,26 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
secured := netType != "open" secured := netType != "open"
network := WiFiNetwork{ visibleNetworks = append(visibleNetworks, WiFiNetwork{
SSID: name, SSID: name,
Signal: signal, Signal: signal,
Secured: secured, Secured: secured,
Connected: wifiConnected && name == currentSSID, Enterprise: netType == "8021x",
Saved: knownNetworks[name], })
Autoconnect: autoconnectMap[name],
Enterprise: netType == "8021x",
}
networks = append(networks, network)
} }
networks := iwdWiFiNetworksFromVisible(visibleNetworks, savedProfiles, currentSSID, wifiConnected, wifiSignal)
visibleNetworkMap := make(map[string]WiFiNetwork, len(networks))
for _, network := range networks {
visibleNetworkMap[network.SSID] = network
}
savedNetworks := savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworkMap, currentSSID, wifiConnected)
sortWiFiNetworks(networks) sortWiFiNetworks(networks)
b.stateMutex.Lock() b.stateMutex.Lock()
b.state.WiFiNetworks = networks b.state.WiFiNetworks = networks
b.state.SavedWiFiNetworks = savedNetworks
b.stateMutex.Unlock() b.stateMutex.Unlock()
now := time.Now() now := time.Now()
@@ -254,30 +253,129 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
return networks, nil return networks, nil
} }
func (b *IWDBackend) getKnownNetworks() (map[string]bool, error) { func (b *IWDBackend) updateSavedWiFiNetworks() error {
obj := b.conn.Object(iwdBusName, iwdObjectPath) savedProfiles, err := b.getIWDSavedWiFiProfiles()
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
if err != nil { if err != nil {
return nil, err return err
} }
known := make(map[string]bool) b.stateMutex.RLock()
for _, interfaces := range objects { currentSSID := b.state.WiFiSSID
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok { wifiConnected := b.state.WiFiConnected
if nameVar, ok := knownProps["Name"]; ok { wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
if name, ok := nameVar.Value().(string); ok { b.stateMutex.RUnlock()
known[name] = true
}
}
}
}
return known, nil wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, savedProfiles, currentSSID, wifiConnected)
b.stateMutex.Lock()
b.state.WiFiNetworks = wifiNetworks
b.state.SavedWiFiNetworks = savedNetworks
b.stateMutex.Unlock()
return nil
} }
func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) { func iwdWiFiNetworksFromVisible(visibleNetworks []WiFiNetwork, savedProfiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool, wifiSignal uint8) []WiFiNetwork {
networks := make([]WiFiNetwork, 0, len(visibleNetworks)+1)
seenSSIDs := make(map[string]struct{}, len(visibleNetworks)+1)
for _, network := range visibleNetworks {
profile, saved := savedProfiles[network.SSID]
network.Connected = wifiConnected && network.SSID == currentSSID
network.Saved = saved
network.Autoconnect = profile.Autoconnect
network.Hidden = network.Hidden || profile.Hidden
network.Secured = network.Secured || profile.Secured
network.Enterprise = network.Enterprise || profile.Enterprise
if network.Mode == "" {
network.Mode = profile.Mode
}
networks = append(networks, network)
seenSSIDs[network.SSID] = struct{}{}
}
if wifiConnected && currentSSID != "" {
if _, exists := seenSSIDs[currentSSID]; !exists {
profile, saved := savedProfiles[currentSSID]
secured := profile.Secured
if !saved {
secured = true
}
mode := profile.Mode
if mode == "" {
mode = "infrastructure"
}
networks = append(networks, WiFiNetwork{
SSID: currentSSID,
Signal: wifiSignal,
Secured: secured,
Enterprise: profile.Enterprise,
Connected: true,
Saved: saved,
Autoconnect: profile.Autoconnect,
Hidden: true,
Mode: mode,
})
}
}
return networks
}
func iwdSavedWiFiProfilesFromManagedObjects(objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant) map[string]savedWiFiProfile {
profiles := make(map[string]savedWiFiProfile)
for _, interfaces := range objects {
knownProps, ok := interfaces[iwdKnownNetworkInterface]
if !ok {
continue
}
nameVar, ok := knownProps["Name"]
if !ok {
continue
}
name, ok := nameVar.Value().(string)
if !ok || name == "" {
continue
}
profile := savedWiFiProfile{
Autoconnect: true,
Mode: "infrastructure",
}
if acVar, ok := knownProps["AutoConnect"]; ok {
if autoconnect, ok := acVar.Value().(bool); ok {
profile.Autoconnect = autoconnect
}
}
if hiddenVar, ok := knownProps["Hidden"]; ok {
if hidden, ok := hiddenVar.Value().(bool); ok {
profile.Hidden = hidden
}
}
if typeVar, ok := knownProps["Type"]; ok {
if networkType, ok := typeVar.Value().(string); ok {
profile.Secured = networkType != "" && networkType != "open"
profile.Enterprise = networkType == "8021x"
}
}
if existing, ok := profiles[name]; ok {
profile.Autoconnect = profile.Autoconnect || existing.Autoconnect
profile.Hidden = profile.Hidden || existing.Hidden
profile.Secured = profile.Secured || existing.Secured
profile.Enterprise = profile.Enterprise || existing.Enterprise
}
profiles[name] = profile
}
return profiles
}
func (b *IWDBackend) getIWDSavedWiFiProfiles() (map[string]savedWiFiProfile, error) {
obj := b.conn.Object(iwdBusName, iwdObjectPath) obj := b.conn.Object(iwdBusName, iwdObjectPath)
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
@@ -286,24 +384,7 @@ func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) {
return nil, err return nil, err
} }
autoconnectMap := make(map[string]bool) return iwdSavedWiFiProfilesFromManagedObjects(objects), nil
for _, interfaces := range objects {
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
if nameVar, ok := knownProps["Name"]; ok {
if name, ok := nameVar.Value().(string); ok {
autoconnect := true
if acVar, ok := knownProps["AutoConnect"]; ok {
if ac, ok := acVar.Value().(bool); ok {
autoconnect = ac
}
}
autoconnectMap[name] = autoconnect
}
}
}
}
return autoconnectMap, nil
} }
func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) { func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
@@ -614,6 +695,8 @@ func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
b.stateMutex.Unlock() b.stateMutex.Unlock()
} }
_, _ = b.updateWiFiNetworks()
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
} }
@@ -27,16 +27,19 @@ type linkInfo struct {
} }
func (l *linkInfo) isWired() bool { func (l *linkInfo) isWired() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType != "" { if l.linkType != "" {
return l.linkType == "ether" return l.linkType == "ether"
} }
if looksVirtual(l.name) || strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp") { return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
return false
}
return true
} }
func (l *linkInfo) isWireless() bool { func (l *linkInfo) isWireless() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType != "" { if l.linkType != "" {
return l.linkType == "wlan" return l.linkType == "wlan"
} }
@@ -45,7 +48,7 @@ func (l *linkInfo) isWireless() bool {
func looksVirtual(name string) bool { func looksVirtual(name string) bool {
virtualPrefixes := []string{ virtualPrefixes := []string{
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap", "lo", "docker", "podman", "veth", "virbr", "br-", "vnet", "tun", "tap",
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali", "vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
} }
for _, prefix := range virtualPrefixes { for _, prefix := range virtualPrefixes {
@@ -110,6 +113,12 @@ func (b *SystemdNetworkdBackend) Close() {
} }
} }
type enumeratedLink struct {
ifindex int32
name string
path dbus.ObjectPath
}
func (b *SystemdNetworkdBackend) enumerateLinks() error { func (b *SystemdNetworkdBackend) enumerateLinks() error {
obj := b.conn.Object(networkdBusName, b.managerPath) obj := b.conn.Object(networkdBusName, b.managerPath)
@@ -123,25 +132,48 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error {
return fmt.Errorf("ListLinks: %w", err) return fmt.Errorf("ListLinks: %w", err)
} }
fresh := make([]enumeratedLink, len(links))
for i, l := range links {
fresh[i] = enumeratedLink{ifindex: l.Ifindex, name: l.Name, path: l.Path}
}
b.linksMutex.Lock() b.linksMutex.Lock()
defer b.linksMutex.Unlock() defer b.linksMutex.Unlock()
b.syncLinks(fresh)
for _, l := range links { return nil
if existing, ok := b.links[l.Name]; ok && existing.path == l.Path { }
existing.ifindex = l.Ifindex
// syncLinks reconciles the cached link map against the freshly enumerated set:
// it adds links not seen before (querying their Type once), refreshes the
// ifindex of survivors, and prunes links that no longer appear. Pruning is what
// keeps torn-down container interfaces (podman bridges, veth pairs) from
// lingering as routable and being mistaken for the wired uplink.
// Callers must hold linksMutex.
func (b *SystemdNetworkdBackend) syncLinks(fresh []enumeratedLink) {
present := make(map[string]bool, len(fresh))
for _, l := range fresh {
present[l.name] = true
if existing, ok := b.links[l.name]; ok && existing.path == l.path {
existing.ifindex = l.ifindex
continue continue
} }
info := &linkInfo{ info := &linkInfo{
ifindex: l.Ifindex, ifindex: l.ifindex,
name: l.Name, name: l.name,
path: l.Path, path: l.path,
linkType: b.fetchLinkType(l.Path), linkType: b.fetchLinkType(l.path),
} }
b.links[l.Name] = info b.links[l.name] = info
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.Name, l.Ifindex, l.Path, info.linkType) log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.name, l.ifindex, l.path, info.linkType)
} }
return nil for name := range b.links {
if !present[name] {
log.Debugf("networkd: pruned stale link %s", name)
delete(b.links, name)
}
}
} }
// fetchLinkType queries networkd's Describe method and extracts the link Type // fetchLinkType queries networkd's Describe method and extracts the link Type
@@ -160,6 +160,12 @@ func TestLinkInfo_Classify(t *testing.T) {
{"loopback type", "lo", "loopback", false, false}, {"loopback type", "lo", "loopback", false, false},
{"none type (tun overlay)", "nebula.homelab", "none", false, false}, {"none type (tun overlay)", "nebula.homelab", "none", false, false},
{"none type (wireguard)", "wg0", "none", false, false}, {"none type (wireguard)", "wg0", "none", false, false},
// Virtual interfaces report Type=ether but must never be mistaken for
// the wired uplink — stale podman/veth links would otherwise poison
// ethernet detection.
{"veth ether excluded", "veth1234", "ether", false, false},
{"podman bridge ether excluded", "podman3", "ether", false, false},
{"docker bridge ether excluded", "docker0", "ether", false, false},
// Fallback path: linkType unavailable, name-prefix heuristic applies. // Fallback path: linkType unavailable, name-prefix heuristic applies.
{"fallback enp wired", "enp141s0", "", true, false}, {"fallback enp wired", "enp141s0", "", true, false},
{"fallback wlan wireless", "wlan0", "", false, true}, {"fallback wlan wireless", "wlan0", "", false, true},
@@ -205,8 +211,46 @@ func TestParseDescribeType(t *testing.T) {
} }
} }
func TestSyncLinks_PrunesRemovedLinks(t *testing.T) {
// Stale container interfaces (torn-down podman bridges, veth pairs) must
// not linger in the link map after they disappear from ListLinks — kept as
// routable, they stole the wired-uplink slot from the real ethernet NIC.
backend, _ := NewSystemdNetworkdBackend()
backend.links = map[string]*linkInfo{
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether", opState: "routable"},
"podman3": {ifindex: 9, name: "podman3", path: "/org/freedesktop/network1/link/_39", linkType: "ether", opState: "routable"},
"veth0": {ifindex: 10, name: "veth0", path: "/org/freedesktop/network1/link/_310", linkType: "ether", opState: "routable"},
}
backend.syncLinks([]enumeratedLink{
{ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
})
assert.Len(t, backend.links, 1)
assert.Contains(t, backend.links, "eno1")
assert.NotContains(t, backend.links, "podman3")
assert.NotContains(t, backend.links, "veth0")
}
func TestSyncLinks_RefreshesSurvivingLink(t *testing.T) {
// A link that survives keeps its cached Type — Describe is only queried for
// newly seen links — while picking up a refreshed ifindex.
backend, _ := NewSystemdNetworkdBackend()
backend.links = map[string]*linkInfo{
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether"},
}
backend.syncLinks([]enumeratedLink{
{ifindex: 7, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
})
assert.Len(t, backend.links, 1)
assert.Equal(t, int32(7), backend.links["eno1"].ifindex)
assert.Equal(t, "ether", backend.links["eno1"].linkType)
}
func TestLooksVirtual(t *testing.T) { func TestLooksVirtual(t *testing.T) {
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc"} virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc", "podman0", "podman3"}
for _, n := range virtual { for _, n := range virtual {
assert.True(t, looksVirtual(n), "%s should look virtual", n) assert.True(t, looksVirtual(n), "%s should look virtual", n)
} }
@@ -222,6 +222,10 @@ func (b *NetworkManagerBackend) Initialize() error {
log.Warnf("Failed to update WiFi state: %v", err) log.Warnf("Failed to update WiFi state: %v", err)
} }
if err := b.updateSavedWiFiNetworks(); err != nil {
log.Warnf("Failed to get initial saved WiFi networks: %v", err)
}
if wifiEnabled { if wifiEnabled {
if _, err := b.updateWiFiNetworks(); err != nil { if _, err := b.updateWiFiNetworks(); err != nil {
log.Warnf("Failed to get initial networks: %v", err) log.Warnf("Failed to get initial networks: %v", err)
@@ -261,6 +265,7 @@ func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) {
state := *b.state state := *b.state
state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...) state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...)
state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...) state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...)
state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...) state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...)
@@ -5,6 +5,12 @@ import (
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
const (
dbusNMSettingsPath = "/org/freedesktop/NetworkManager/Settings"
dbusNMSettingsInterface = "org.freedesktop.NetworkManager.Settings"
dbusNMSettingsConnectionInterface = "org.freedesktop.NetworkManager.Settings.Connection"
)
func (b *NetworkManagerBackend) startSignalPump() error { func (b *NetworkManagerBackend) startSignalPump() error {
conn, err := dbus.ConnectSystemBus() conn, err := dbus.ConnectSystemBus()
if err != nil { if err != nil {
@@ -27,8 +33,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
} }
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("NewConnection"), dbus.WithMatchMember("NewConnection"),
); err != nil { ); err != nil {
conn.RemoveMatchSignal( conn.RemoveMatchSignal(
@@ -42,8 +48,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
} }
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("ConnectionRemoved"), dbus.WithMatchMember("ConnectionRemoved"),
); err != nil { ); err != nil {
conn.RemoveMatchSignal( conn.RemoveMatchSignal(
@@ -52,8 +58,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
conn.RemoveMatchSignal( conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("NewConnection"), dbus.WithMatchMember("NewConnection"),
) )
conn.RemoveSignal(signals) conn.RemoveSignal(signals)
@@ -61,6 +67,31 @@ func (b *NetworkManagerBackend) startSignalPump() error {
return err return err
} }
if err := conn.AddMatchSignal(
dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsConnectionInterface),
dbus.WithMatchMember("Updated"),
); err != nil {
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("NewConnection"),
)
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("ConnectionRemoved"),
)
conn.RemoveSignal(signals)
conn.Close()
return err
}
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusNMInterface), dbus.WithMatchInterface(dbusNMInterface),
@@ -137,6 +168,32 @@ func (b *NetworkManagerBackend) stopSignalPump() {
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("NewConnection"),
)
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("ConnectionRemoved"),
)
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsConnectionInterface),
dbus.WithMatchMember("Updated"),
)
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusNMInterface),
dbus.WithMatchMember("DeviceAdded"),
)
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusNMInterface),
dbus.WithMatchMember("DeviceRemoved"),
)
for _, info := range b.wifiDevices { for _, info := range b.wifiDevices {
b.dbusConn.RemoveMatchSignal( b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
@@ -164,9 +221,13 @@ func (b *NetworkManagerBackend) stopSignalPump() {
} }
func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) { func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
if sig.Name == "org.freedesktop.NetworkManager.Settings.NewConnection" || if sig.Name == dbusNMSettingsInterface+".NewConnection" ||
sig.Name == "org.freedesktop.NetworkManager.Settings.ConnectionRemoved" { sig.Name == dbusNMSettingsInterface+".ConnectionRemoved" ||
sig.Name == dbusNMSettingsConnectionInterface+".Updated" {
b.ListVPNProfiles() b.ListVPNProfiles()
if err := b.updateSavedWiFiNetworks(); err != nil {
b.updateWiFiNetworks()
}
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
} }
@@ -225,24 +225,14 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error
return "", fmt.Errorf("failed to identify security type of network `%s`", ssid) return "", fmt.Errorf("failed to identify security type of network `%s`", ssid)
} }
var securityType string
switch keyMgmt { switch keyMgmt {
case "none": case "none":
authAlg, _ := secSettings["auth-alg"].(string) return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` is open or WEP", ssid)
switch authAlg {
case "open":
securityType = "nopass"
default:
securityType = "WEP"
}
case "ieee8021x": case "ieee8021x":
securityType = "WEP" return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` is enterprise", ssid)
case "wpa-psk", "sae", "wpa-psk-sae":
default: default:
securityType = "WPA" return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` uses %s", ssid, keyMgmt)
}
if securityType != "WPA" {
return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType)
} }
var psk string var psk string
@@ -276,7 +266,7 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid) return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
} }
return FormatWiFiQRString(securityType, ssid, psk), nil return FormatWiFiQRString("WPA", ssid, psk), nil
} }
func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error { func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
@@ -405,6 +395,74 @@ func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
return nil return nil
} }
func getSavedWiFiProfiles(connections []gonetworkmanager.Connection) map[string]savedWiFiProfile {
profiles := make(map[string]savedWiFiProfile)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := connSettings["connection"]
if !ok {
continue
}
connType, ok := connMeta["type"].(string)
if !ok || connType != "802-11-wireless" {
continue
}
wifiSettings, ok := connSettings["802-11-wireless"]
if !ok {
continue
}
ssidBytes, ok := wifiSettings["ssid"].([]byte)
if !ok || len(ssidBytes) == 0 {
continue
}
ssid := string(ssidBytes)
profile := savedWiFiProfile{
Autoconnect: true,
Mode: "infrastructure",
}
if ac, ok := connMeta["autoconnect"].(bool); ok {
profile.Autoconnect = ac
}
if hidden, ok := wifiSettings["hidden"].(bool); ok {
profile.Hidden = hidden
}
if mode, ok := wifiSettings["mode"].(string); ok && mode != "" {
profile.Mode = mode
}
if _, ok := connSettings["802-11-wireless-security"]; ok {
profile.Secured = true
}
if _, ok := connSettings["802-1x"]; ok {
profile.Enterprise = true
profile.Secured = true
}
if existing, ok := profiles[ssid]; ok {
profile.Autoconnect = profile.Autoconnect || existing.Autoconnect
profile.Hidden = profile.Hidden || existing.Hidden
profile.Secured = profile.Secured || existing.Secured
profile.Enterprise = profile.Enterprise || existing.Enterprise
if profile.Mode == "" {
profile.Mode = existing.Mode
}
}
profiles[ssid] = profile
}
return profiles
}
func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool { func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool {
b.stateMutex.RLock() b.stateMutex.RLock()
defer b.stateMutex.RUnlock() defer b.stateMutex.RUnlock()
@@ -442,47 +500,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
return nil, fmt.Errorf("failed to get connections: %w", err) return nil, fmt.Errorf("failed to get connections: %w", err)
} }
savedSSIDs := make(map[string]bool) savedProfiles := getSavedWiFiProfiles(connections)
autoconnectMap := make(map[string]bool)
hiddenSSIDs := make(map[string]bool)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := connSettings["connection"]
if !ok {
continue
}
connType, ok := connMeta["type"].(string)
if !ok || connType != "802-11-wireless" {
continue
}
wifiSettings, ok := connSettings["802-11-wireless"]
if !ok {
continue
}
ssidBytes, ok := wifiSettings["ssid"].([]byte)
if !ok {
continue
}
ssid := string(ssidBytes)
savedSSIDs[ssid] = true
autoconnect := true
if ac, ok := connMeta["autoconnect"].(bool); ok {
autoconnect = ac
}
autoconnectMap[ssid] = autoconnect
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
hiddenSSIDs[ssid] = true
}
}
b.stateMutex.RLock() b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID currentSSID := b.state.WiFiSSID
@@ -491,8 +509,8 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
wifiBSSID := b.state.WiFiBSSID wifiBSSID := b.state.WiFiBSSID
b.stateMutex.RUnlock() b.stateMutex.RUnlock()
seenSSIDs := make(map[string]*WiFiNetwork) seenSSIDs := make(map[string]int)
networks := []WiFiNetwork{} networks := make([]WiFiNetwork, 0, len(apPaths)+1)
for _, ap := range apPaths { for _, ap := range apPaths {
ssid, err := ap.GetPropertySSID() ssid, err := ap.GetPropertySSID()
@@ -500,7 +518,8 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
continue continue
} }
if existing, exists := seenSSIDs[ssid]; exists { if existingIndex, exists := seenSSIDs[ssid]; exists {
existing := &networks[existingIndex]
strength, _ := ap.GetPropertyStrength() strength, _ := ap.GetPropertyStrength()
if strength > existing.Signal { if strength > existing.Signal {
existing.Signal = strength existing.Signal = strength
@@ -550,6 +569,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
} }
} }
profile, saved := savedProfiles[ssid]
network := WiFiNetwork{ network := WiFiNetwork{
SSID: ssid, SSID: ssid,
BSSID: bssid, BSSID: bssid,
@@ -557,45 +577,86 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: isConnected, Connected: isConnected,
Saved: savedSSIDs[ssid], Saved: saved,
Autoconnect: autoconnectMap[ssid], Autoconnect: profile.Autoconnect,
Hidden: hiddenSSIDs[ssid], Hidden: profile.Hidden,
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: rate, Rate: rate,
Channel: channel, Channel: channel,
} }
seenSSIDs[ssid] = &network
networks = append(networks, network) networks = append(networks, network)
seenSSIDs[ssid] = len(networks) - 1
} }
if wifiConnected && currentSSID != "" { if wifiConnected && currentSSID != "" {
if _, exists := seenSSIDs[currentSSID]; !exists { if _, exists := seenSSIDs[currentSSID]; !exists {
profile, saved := savedProfiles[currentSSID]
hiddenNetwork := WiFiNetwork{ hiddenNetwork := WiFiNetwork{
SSID: currentSSID, SSID: currentSSID,
BSSID: wifiBSSID, BSSID: wifiBSSID,
Signal: wifiSignal, Signal: wifiSignal,
Secured: true, Secured: true,
Connected: true, Connected: true,
Saved: savedSSIDs[currentSSID], Saved: saved,
Autoconnect: autoconnectMap[currentSSID], Autoconnect: profile.Autoconnect,
Hidden: true, Hidden: true,
Mode: "infrastructure", Mode: "infrastructure",
} }
networks = append(networks, hiddenNetwork) networks = append(networks, hiddenNetwork)
seenSSIDs[currentSSID] = len(networks) - 1
} }
} }
visibleNetworks := wiFiNetworksBySSID(networks, true)
savedNetworks := savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworks, currentSSID, wifiConnected)
sortWiFiNetworks(networks) sortWiFiNetworks(networks)
b.stateMutex.Lock() b.stateMutex.Lock()
b.state.WiFiNetworks = networks b.state.WiFiNetworks = networks
b.state.SavedWiFiNetworks = savedNetworks
b.stateMutex.Unlock() b.stateMutex.Unlock()
return networks, nil return networks, nil
} }
func (b *NetworkManagerBackend) updateSavedWiFiNetworks() error {
s := b.settings
if s == nil {
var err error
s, err = gonetworkmanager.NewSettings()
if err != nil {
return fmt.Errorf("failed to get settings: %w", err)
}
b.settings = s
}
settingsMgr := s.(gonetworkmanager.Settings)
connections, err := settingsMgr.ListConnections()
if err != nil {
return fmt.Errorf("failed to get connections: %w", err)
}
savedProfiles := getSavedWiFiProfiles(connections)
b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID
wifiConnected := b.state.WiFiConnected
wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
b.stateMutex.RUnlock()
wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, savedProfiles, currentSSID, wifiConnected)
b.stateMutex.Lock()
b.state.WiFiNetworks = wifiNetworks
b.state.SavedWiFiNetworks = savedNetworks
b.stateMutex.Unlock()
return nil
}
func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) { func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) {
s := b.settings s := b.settings
if s == nil { if s == nil {
@@ -975,49 +1036,14 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
return return
} }
savedSSIDs := make(map[string]bool) savedProfiles := getSavedWiFiProfiles(connections)
autoconnectMap := make(map[string]bool)
hiddenSSIDs := make(map[string]bool)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := connSettings["connection"]
if !ok {
continue
}
connType, ok := connMeta["type"].(string)
if !ok || connType != "802-11-wireless" {
continue
}
wifiSettings, ok := connSettings["802-11-wireless"]
if !ok {
continue
}
ssidBytes, ok := wifiSettings["ssid"].([]byte)
if !ok {
continue
}
ssid := string(ssidBytes)
savedSSIDs[ssid] = true
autoconnect := true
if ac, ok := connMeta["autoconnect"].(bool); ok {
autoconnect = ac
}
autoconnectMap[ssid] = autoconnect
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
hiddenSSIDs[ssid] = true
}
}
var devices []WiFiDevice var devices []WiFiDevice
visibleNetworks := make(map[string]WiFiNetwork)
b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID
wifiConnected := b.state.WiFiConnected
b.stateMutex.RUnlock()
for name, devInfo := range b.wifiDevices { for name, devInfo := range b.wifiDevices {
state, _ := devInfo.device.GetPropertyState() state, _ := devInfo.device.GetPropertyState()
@@ -1050,14 +1076,16 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
apPaths, err := devInfo.wireless.GetAccessPoints() apPaths, err := devInfo.wireless.GetAccessPoints()
var networks []WiFiNetwork var networks []WiFiNetwork
if err == nil { if err == nil {
seenSSIDs := make(map[string]*WiFiNetwork) seenSSIDs := make(map[string]int)
networks = make([]WiFiNetwork, 0, len(apPaths)+1)
for _, ap := range apPaths { for _, ap := range apPaths {
apSSID, err := ap.GetPropertySSID() apSSID, err := ap.GetPropertySSID()
if err != nil || apSSID == "" { if err != nil || apSSID == "" {
continue continue
} }
if existing, exists := seenSSIDs[apSSID]; exists { if existingIndex, exists := seenSSIDs[apSSID]; exists {
existing := &networks[existingIndex]
strength, _ := ap.GetPropertyStrength() strength, _ := ap.GetPropertyStrength()
if strength > existing.Signal { if strength > existing.Signal {
existing.Signal = strength existing.Signal = strength
@@ -1107,6 +1135,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
} }
} }
profile, saved := savedProfiles[apSSID]
network := WiFiNetwork{ network := WiFiNetwork{
SSID: apSSID, SSID: apSSID,
BSSID: apBSSID, BSSID: apBSSID,
@@ -1114,9 +1143,9 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: isConnected, Connected: isConnected,
Saved: savedSSIDs[apSSID], Saved: saved,
Autoconnect: autoconnectMap[apSSID], Autoconnect: profile.Autoconnect,
Hidden: hiddenSSIDs[apSSID], Hidden: profile.Hidden,
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: rate, Rate: rate,
@@ -1124,25 +1153,31 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
Device: name, Device: name,
} }
seenSSIDs[apSSID] = &network
networks = append(networks, network) networks = append(networks, network)
seenSSIDs[apSSID] = len(networks) - 1
if existing, ok := visibleNetworks[apSSID]; !ok || network.Signal > existing.Signal {
visibleNetworks[apSSID] = network
}
} }
if connected && ssid != "" { if connected && ssid != "" {
if _, exists := seenSSIDs[ssid]; !exists { if _, exists := seenSSIDs[ssid]; !exists {
profile, saved := savedProfiles[ssid]
hiddenNetwork := WiFiNetwork{ hiddenNetwork := WiFiNetwork{
SSID: ssid, SSID: ssid,
BSSID: bssid, BSSID: bssid,
Signal: signal, Signal: signal,
Secured: true, Secured: true,
Connected: true, Connected: true,
Saved: savedSSIDs[ssid], Saved: saved,
Autoconnect: autoconnectMap[ssid], Autoconnect: profile.Autoconnect,
Hidden: true, Hidden: true,
Mode: "infrastructure", Mode: "infrastructure",
Device: name, Device: name,
} }
networks = append(networks, hiddenNetwork) networks = append(networks, hiddenNetwork)
seenSSIDs[ssid] = len(networks) - 1
visibleNetworks[ssid] = hiddenNetwork
} }
} }
@@ -1168,6 +1203,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
b.stateMutex.Lock() b.stateMutex.Lock()
b.state.WiFiDevices = devices b.state.WiFiDevices = devices
b.state.SavedWiFiNetworks = savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworks, currentSSID, wifiConnected)
b.stateMutex.Unlock() b.stateMutex.Unlock()
} }
@@ -4,6 +4,7 @@ import (
"testing" "testing"
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
"github.com/Wifx/gonetworkmanager/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -176,6 +177,54 @@ func TestNetworkManagerBackend_UpdateWiFiNetworks_NoDevice(t *testing.T) {
assert.Contains(t, err.Error(), "no WiFi device available") assert.Contains(t, err.Error(), "no WiFi device available")
} }
func TestNetworkManagerBackend_UpdateSavedWiFiNetworksPreservesVisibleSavedNetworks(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
mockConn := mock_gonetworkmanager.NewMockConnection(t)
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
backend.settings = mockSettings
backend.stateMutex.Lock()
backend.state.WiFiNetworks = []WiFiNetwork{
{
SSID: "Home",
Signal: 76,
},
}
backend.stateMutex.Unlock()
settings := gonetworkmanager.ConnectionSettings{
"connection": {
"type": "802-11-wireless",
"autoconnect": true,
},
"802-11-wireless": {
"ssid": []byte("Home"),
},
"802-11-wireless-security": {},
}
mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil)
mockConn.EXPECT().GetSettings().Return(settings, nil)
err = backend.updateSavedWiFiNetworks()
assert.NoError(t, err)
backend.stateMutex.RLock()
savedNetworks := append([]WiFiNetwork(nil), backend.state.SavedWiFiNetworks...)
wifiNetworks := append([]WiFiNetwork(nil), backend.state.WiFiNetworks...)
backend.stateMutex.RUnlock()
assert.Len(t, wifiNetworks, 1)
assert.True(t, wifiNetworks[0].Saved)
assert.Len(t, savedNetworks, 1)
assert.Equal(t, "Home", savedNetworks[0].SSID)
assert.True(t, savedNetworks[0].Saved)
assert.False(t, savedNetworks[0].OutOfRange)
assert.Equal(t, uint8(76), savedNetworks[0].Signal)
}
func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) { func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+26 -3
View File
@@ -64,9 +64,10 @@ func NewManager() (*Manager, error) {
m := &Manager{ m := &Manager{
backend: backend, backend: backend,
state: &NetworkState{ state: &NetworkState{
NetworkStatus: StatusDisconnected, NetworkStatus: StatusDisconnected,
Preference: PreferenceAuto, Preference: PreferenceAuto,
WiFiNetworks: []WiFiNetwork{}, WiFiNetworks: []WiFiNetwork{},
SavedWiFiNetworks: []WiFiNetwork{},
}, },
stateMutex: sync.RWMutex{}, stateMutex: sync.RWMutex{},
@@ -120,6 +121,7 @@ func (m *Manager) syncStateFromBackend() error {
m.state.WiFiBSSID = backendState.WiFiBSSID m.state.WiFiBSSID = backendState.WiFiBSSID
m.state.WiFiSignal = backendState.WiFiSignal m.state.WiFiSignal = backendState.WiFiSignal
m.state.WiFiNetworks = backendState.WiFiNetworks m.state.WiFiNetworks = backendState.WiFiNetworks
m.state.SavedWiFiNetworks = backendState.SavedWiFiNetworks
m.state.WiFiDevices = backendState.WiFiDevices m.state.WiFiDevices = backendState.WiFiDevices
m.state.WiredConnections = backendState.WiredConnections m.state.WiredConnections = backendState.WiredConnections
m.state.VPNProfiles = backendState.VPNProfiles m.state.VPNProfiles = backendState.VPNProfiles
@@ -156,6 +158,7 @@ func (m *Manager) snapshotState() NetworkState {
defer m.stateMutex.RUnlock() defer m.stateMutex.RUnlock()
s := *m.state s := *m.state
s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...) s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...)
s.SavedWiFiNetworks = append([]WiFiNetwork(nil), m.state.SavedWiFiNetworks...)
s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...) s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...)
s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...) s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...)
s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...) s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...)
@@ -211,6 +214,9 @@ func stateChangedMeaningfully(old, new *NetworkState) bool {
if len(old.WiFiNetworks) != len(new.WiFiNetworks) { if len(old.WiFiNetworks) != len(new.WiFiNetworks) {
return true return true
} }
if len(old.SavedWiFiNetworks) != len(new.SavedWiFiNetworks) {
return true
}
if len(old.WiFiDevices) != len(new.WiFiDevices) { if len(old.WiFiDevices) != len(new.WiFiDevices) {
return true return true
} }
@@ -238,6 +244,23 @@ func stateChangedMeaningfully(old, new *NetworkState) bool {
} }
} }
for i := range old.SavedWiFiNetworks {
oldNet := &old.SavedWiFiNetworks[i]
newNet := &new.SavedWiFiNetworks[i]
if oldNet.SSID != newNet.SSID {
return true
}
if oldNet.Connected != newNet.Connected {
return true
}
if oldNet.Autoconnect != newNet.Autoconnect {
return true
}
if oldNet.OutOfRange != newNet.OutOfRange {
return true
}
}
for i := range old.WiredConnections { for i := range old.WiredConnections {
oldNet := &old.WiredConnections[i] oldNet := &old.WiredConnections[i]
newNet := &new.WiredConnections[i] newNet := &new.WiredConnections[i]
+2
View File
@@ -34,6 +34,7 @@ type WiFiNetwork struct {
Saved bool `json:"saved"` Saved bool `json:"saved"`
Autoconnect bool `json:"autoconnect"` Autoconnect bool `json:"autoconnect"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
OutOfRange bool `json:"outOfRange"`
Frequency uint32 `json:"frequency"` Frequency uint32 `json:"frequency"`
Mode string `json:"mode"` Mode string `json:"mode"`
Rate uint32 `json:"rate"` Rate uint32 `json:"rate"`
@@ -111,6 +112,7 @@ type NetworkState struct {
WiFiBSSID string `json:"wifiBSSID"` WiFiBSSID string `json:"wifiBSSID"`
WiFiSignal uint8 `json:"wifiSignal"` WiFiSignal uint8 `json:"wifiSignal"`
WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` WiFiNetworks []WiFiNetwork `json:"wifiNetworks"`
SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"`
WiFiDevices []WiFiDevice `json:"wifiDevices"` WiFiDevices []WiFiDevice `json:"wifiDevices"`
WiredConnections []WiredConnection `json:"wiredConnections"` WiredConnections []WiredConnection `json:"wiredConnections"`
VPNProfiles []VPNProfile `json:"vpnProfiles"` VPNProfiles []VPNProfile `json:"vpnProfiles"`
+103
View File
@@ -0,0 +1,103 @@
package network
import "sort"
type savedWiFiProfile struct {
Autoconnect bool
Hidden bool
Secured bool
Enterprise bool
Mode string
}
// Saved WiFi state is keyed by SSID because the UI/API accepts SSID actions.
// Multiple backend profiles for the same SSID are intentionally collapsed here.
func mergeSavedProfilesIntoWiFiNetworks(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool) []WiFiNetwork {
merged := make([]WiFiNetwork, len(networks))
for i, network := range networks {
profile, saved := profiles[network.SSID]
network.Connected = wifiConnected && network.SSID == currentSSID
network.Saved = saved
if saved {
network.Autoconnect = profile.Autoconnect
network.Hidden = network.Hidden || profile.Hidden
network.Secured = network.Secured || profile.Secured
network.Enterprise = network.Enterprise || profile.Enterprise
if network.Mode == "" {
network.Mode = profile.Mode
}
} else {
network.Autoconnect = false
}
merged[i] = network
}
return merged
}
func wiFiNetworksBySSID(networks []WiFiNetwork, visibleOnly bool) map[string]WiFiNetwork {
visible := make(map[string]WiFiNetwork, len(networks))
for _, network := range networks {
if visibleOnly && network.OutOfRange {
continue
}
visible[network.SSID] = network
}
return visible
}
func refreshSavedWiFiState(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool) ([]WiFiNetwork, []WiFiNetwork) {
mergedNetworks := mergeSavedProfilesIntoWiFiNetworks(networks, profiles, currentSSID, wifiConnected)
visibleNetworks := wiFiNetworksBySSID(mergedNetworks, true)
savedNetworks := savedWiFiNetworksFromProfiles(profiles, visibleNetworks, currentSSID, wifiConnected)
return mergedNetworks, savedNetworks
}
func savedWiFiNetworksFromProfiles(profiles map[string]savedWiFiProfile, visible map[string]WiFiNetwork, currentSSID string, wifiConnected bool) []WiFiNetwork {
networks := make([]WiFiNetwork, 0, len(profiles))
for ssid, profile := range profiles {
if network, ok := visible[ssid]; ok {
network.Saved = true
network.Autoconnect = profile.Autoconnect
network.Hidden = network.Hidden || profile.Hidden
network.Secured = network.Secured || profile.Secured
network.Enterprise = network.Enterprise || profile.Enterprise
network.OutOfRange = false
if network.Mode == "" {
network.Mode = profile.Mode
}
networks = append(networks, network)
continue
}
isConnected := wifiConnected && ssid == currentSSID
networks = append(networks, WiFiNetwork{
SSID: ssid,
Secured: profile.Secured,
Enterprise: profile.Enterprise,
Connected: isConnected,
Saved: true,
Autoconnect: profile.Autoconnect,
Hidden: profile.Hidden,
OutOfRange: !isConnected,
Mode: profile.Mode,
})
}
sort.Slice(networks, func(i, j int) bool {
if networks[i].Connected && !networks[j].Connected {
return true
}
if !networks[i].Connected && networks[j].Connected {
return false
}
if networks[i].OutOfRange != networks[j].OutOfRange {
return !networks[i].OutOfRange
}
if networks[i].Signal != networks[j].Signal {
return networks[i].Signal > networks[j].Signal
}
return networks[i].SSID < networks[j].SSID
})
return networks
}
@@ -0,0 +1,170 @@
package network
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMergeSavedProfilesIntoWiFiNetworks(t *testing.T) {
networks := []WiFiNetwork{
{
SSID: "Home",
Signal: 80,
Secured: false,
Autoconnect: false,
},
{
SSID: "Cafe",
Signal: 50,
Secured: false,
Autoconnect: true,
},
}
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Hidden: true,
Secured: true,
Mode: "infrastructure",
},
}
merged := mergeSavedProfilesIntoWiFiNetworks(networks, profiles, "Home", true)
assert.Len(t, merged, 2)
assert.Equal(t, "Home", merged[0].SSID)
assert.True(t, merged[0].Connected)
assert.True(t, merged[0].Saved)
assert.True(t, merged[0].Autoconnect)
assert.True(t, merged[0].Hidden)
assert.True(t, merged[0].Secured)
assert.Equal(t, "infrastructure", merged[0].Mode)
assert.Equal(t, "Cafe", merged[1].SSID)
assert.False(t, merged[1].Saved)
assert.False(t, merged[1].Autoconnect)
}
func TestSavedWiFiNetworksFromProfilesOutOfRangeWithoutVisibleNetworks(t *testing.T) {
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Secured: true,
Mode: "infrastructure",
},
}
networks := savedWiFiNetworksFromProfiles(profiles, nil, "", false)
assert.Len(t, networks, 1)
assert.Equal(t, "Home", networks[0].SSID)
assert.True(t, networks[0].Saved)
assert.True(t, networks[0].OutOfRange)
assert.Equal(t, uint8(0), networks[0].Signal)
}
func TestSavedWiFiNetworksFromProfilesKeepsConnectedCurrentNetworkInRange(t *testing.T) {
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Secured: true,
},
}
networks := savedWiFiNetworksFromProfiles(profiles, nil, "Home", true)
assert.Len(t, networks, 1)
assert.Equal(t, "Home", networks[0].SSID)
assert.True(t, networks[0].Connected)
assert.False(t, networks[0].OutOfRange)
}
func TestSavedWiFiNetworksFromProfilesIncludesOutOfRange(t *testing.T) {
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Hidden: true,
Secured: true,
Mode: "infrastructure",
},
"Office": {
Autoconnect: false,
Secured: true,
Enterprise: true,
Mode: "infrastructure",
},
}
visible := map[string]WiFiNetwork{
"Home": {
SSID: "Home",
Signal: 72,
Secured: true,
Connected: true,
},
}
networks := savedWiFiNetworksFromProfiles(profiles, visible, "Home", true)
assert.Len(t, networks, 2)
assert.Equal(t, "Home", networks[0].SSID)
assert.True(t, networks[0].Saved)
assert.True(t, networks[0].Connected)
assert.False(t, networks[0].OutOfRange)
assert.True(t, networks[0].Hidden)
assert.Equal(t, uint8(72), networks[0].Signal)
assert.Equal(t, "Office", networks[1].SSID)
assert.True(t, networks[1].Saved)
assert.False(t, networks[1].Autoconnect)
assert.True(t, networks[1].Enterprise)
assert.True(t, networks[1].OutOfRange)
}
func TestWiFiNetworksBySSIDVisibleOnlySkipsOutOfRange(t *testing.T) {
visible := wiFiNetworksBySSID([]WiFiNetwork{
{SSID: "Home", Signal: 70},
{SSID: "Office", Signal: 0, OutOfRange: true},
}, true)
assert.Contains(t, visible, "Home")
assert.NotContains(t, visible, "Office")
}
func TestRefreshSavedWiFiStatePreservesVisibleSavedNetworks(t *testing.T) {
networks := []WiFiNetwork{
{
SSID: "Home",
Signal: 82,
},
}
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Secured: true,
Mode: "infrastructure",
},
"Office": {
Autoconnect: false,
Secured: true,
Mode: "infrastructure",
},
}
mergedNetworks, savedNetworks := refreshSavedWiFiState(networks, profiles, "", false)
assert.Len(t, mergedNetworks, 1)
assert.Equal(t, "Home", mergedNetworks[0].SSID)
assert.True(t, mergedNetworks[0].Saved)
assert.True(t, mergedNetworks[0].Autoconnect)
assert.Len(t, savedNetworks, 2)
assert.Equal(t, "Home", savedNetworks[0].SSID)
assert.True(t, savedNetworks[0].Saved)
assert.False(t, savedNetworks[0].OutOfRange)
assert.Equal(t, uint8(82), savedNetworks[0].Signal)
assert.Equal(t, "Office", savedNetworks[1].SSID)
assert.True(t, savedNetworks[1].Saved)
assert.True(t, savedNetworks[1].OutOfRange)
}
-10
View File
@@ -11,7 +11,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus" serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
@@ -125,15 +124,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "dwl.") {
if dwlManager == nil {
models.RespondError(conn, req.ID, "dwl manager not initialized")
return
}
dwl.HandleRequest(conn, req, dwlManager)
return
}
if strings.HasPrefix(req.Method, "brightness.") { if strings.HasPrefix(req.Method, "brightness.") {
if brightnessManager == nil { if brightnessManager == nil {
models.RespondError(conn, req.ID, "brightness manager not initialized") models.RespondError(conn, req.ID, "brightness manager not initialized")
+1 -87
View File
@@ -22,7 +22,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus" serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
@@ -39,7 +38,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
) )
const APIVersion = 24 const APIVersion = 26
var CLIVersion = "dev" var CLIVersion = "dev"
@@ -66,7 +65,6 @@ var bluezManager *bluez.Manager
var appPickerManager *apppicker.Manager var appPickerManager *apppicker.Manager
var cupsManager *cups.Manager var cupsManager *cups.Manager
var tailscaleManager *tailscale.Manager var tailscaleManager *tailscale.Manager
var dwlManager *dwl.Manager
var brightnessManager *brightness.Manager var brightnessManager *brightness.Manager
var wlrOutputManager *wlroutput.Manager var wlrOutputManager *wlroutput.Manager
var evdevManager *evdev.Manager var evdevManager *evdev.Manager
@@ -252,30 +250,6 @@ func InitializeCupsManager() error {
return nil return nil
} }
func InitializeDwlManager() error {
log.Info("Attempting to initialize DWL IPC...")
if wlContext == nil {
ctx, err := wlcontext.New()
if err != nil {
log.Errorf("Failed to create shared Wayland context: %v", err)
return err
}
wlContext = ctx
}
manager, err := dwl.NewManager(wlContext.Display())
if err != nil {
log.Debug("Failed to initialize dwl manager: %v", err)
return err
}
dwlManager = manager
log.Info("DWL IPC initialized successfully")
return nil
}
func InitializeBrightnessManager() error { func InitializeBrightnessManager() error {
manager, err := brightness.NewManager() manager, err := brightness.NewManager()
if err != nil { if err != nil {
@@ -468,10 +442,6 @@ func getCapabilities() Capabilities {
caps = append(caps, "tailscale") caps = append(caps, "tailscale")
} }
if dwlManager != nil {
caps = append(caps, "dwl")
}
if brightnessManager != nil { if brightnessManager != nil {
caps = append(caps, "brightness") caps = append(caps, "brightness")
} }
@@ -538,10 +508,6 @@ func getServerInfo() ServerInfo {
caps = append(caps, "tailscale") caps = append(caps, "tailscale")
} }
if dwlManager != nil {
caps = append(caps, "dwl")
}
if brightnessManager != nil { if brightnessManager != nil {
caps = append(caps, "brightness") caps = append(caps, "brightness")
} }
@@ -1046,38 +1012,6 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}() }()
} }
if shouldSubscribe("dwl") && dwlManager != nil {
wg.Add(1)
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
go func() {
defer wg.Done()
defer dwlManager.Unsubscribe(clientID + "-dwl")
initialState := dwlManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "dwl", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-dwlChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "dwl", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("brightness") && brightnessManager != nil { if shouldSubscribe("brightness") && brightnessManager != nil {
wg.Add(2) wg.Add(2)
brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state") brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
@@ -1333,9 +1267,6 @@ func cleanupManagers() {
if cupsManager != nil { if cupsManager != nil {
cupsManager.Close() cupsManager.Close()
} }
if dwlManager != nil {
dwlManager.Close()
}
if brightnessManager != nil { if brightnessManager != nil {
brightnessManager.Close() brightnessManager.Close()
} }
@@ -1502,19 +1433,6 @@ func Start(printDocs bool) error {
log.Info(" cups.resumePrinter - Resume printer (params: printerName)") log.Info(" cups.resumePrinter - Resume printer (params: printerName)")
log.Info(" cups.cancelJob - Cancel job (params: printerName, jobID)") log.Info(" cups.cancelJob - Cancel job (params: printerName, jobID)")
log.Info(" cups.purgeJobs - Cancel all jobs (params: printerName)") log.Info(" cups.purgeJobs - Cancel all jobs (params: printerName)")
log.Info("DWL:")
log.Info(" dwl.getState - Get current dwl state (tags, windows, layouts, keyboard)")
log.Info(" dwl.setTags - Set active tags (params: output, tagmask, toggleTagset)")
log.Info(" dwl.setClientTags - Set focused client tags (params: output, andTags, xorTags)")
log.Info(" dwl.setLayout - Set layout (params: output, index)")
log.Info(" dwl.subscribe - Subscribe to dwl state changes (streaming)")
log.Info(" Output state includes:")
log.Info(" - tags : Tag states (active, clients, focused)")
log.Info(" - layoutSymbol : Current layout name")
log.Info(" - title : Focused window title")
log.Info(" - appId : Focused window app ID")
log.Info(" - kbLayout : Current keyboard layout")
log.Info(" - keymode : Current keybind mode")
log.Info("Brightness:") log.Info("Brightness:")
log.Info(" brightness.getState - Get current brightness state for all devices") log.Info(" brightness.getState - Get current brightness state for all devices")
log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)") log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
@@ -1691,10 +1609,6 @@ func Start(printDocs bool) error {
log.Debugf("AppPicker manager unavailable: %v", err) log.Debugf("AppPicker manager unavailable: %v", err)
} }
if err := InitializeDwlManager(); err != nil {
log.Debugf("DWL manager unavailable: %v", err)
}
if err := InitializeWlrOutputManager(); err != nil { if err := InitializeWlrOutputManager(); err != nil {
log.Debugf("WlrOutput manager unavailable: %v", err) log.Debugf("WlrOutput manager unavailable: %v", err)
} }
+11 -10
View File
@@ -66,16 +66,17 @@ func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg
} }
peer := Peer{ peer := Peer{
ID: string(ps.ID), ID: string(ps.ID),
Hostname: hostname, Hostname: hostname,
DNSName: dnsName, DNSName: dnsName,
OS: ps.OS, OS: ps.OS,
Online: ps.Online, Online: ps.Online,
Active: ps.Active, Active: ps.Active,
ExitNode: ps.ExitNode, ExitNode: ps.ExitNode,
Relay: ps.Relay, ExitNodeOption: ps.ExitNodeOption,
RxBytes: ps.RxBytes, Relay: ps.Relay,
TxBytes: ps.TxBytes, RxBytes: ps.RxBytes,
TxBytes: ps.TxBytes,
} }
for _, ip := range ps.TailscaleIPs { for _, ip := range ps.TailscaleIPs {
@@ -14,6 +14,14 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleGetStatus(conn, req, manager) handleGetStatus(conn, req, manager)
case "tailscale.refresh": case "tailscale.refresh":
handleRefresh(conn, req, manager) handleRefresh(conn, req, manager)
case "tailscale.connect":
handleConnect(conn, req, manager)
case "tailscale.disconnect":
handleDisconnect(conn, req, manager)
case "tailscale.setExitNode":
handleSetExitNode(conn, req, manager)
case "tailscale.setAllowLanAccess":
handleSetAllowLanAccess(conn, req, manager)
default: default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
} }
@@ -28,3 +36,37 @@ func handleRefresh(conn net.Conn, req models.Request, manager *Manager) {
manager.RefreshState() manager.RefreshState()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
} }
func handleConnect(conn net.Conn, req models.Request, manager *Manager) {
if err := manager.Connect(); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connected"})
}
func handleDisconnect(conn net.Conn, req models.Request, manager *Manager) {
if err := manager.Disconnect(); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"})
}
func handleSetExitNode(conn net.Conn, req models.Request, manager *Manager) {
id := models.GetOr(req, "id", "")
if err := manager.SetExitNode(id); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "exit node updated"})
}
func handleSetAllowLanAccess(conn net.Conn, req models.Request, manager *Manager) {
enabled := models.GetOr(req, "enabled", false)
if err := manager.SetAllowLANAccess(enabled); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "lan access updated"})
}
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net" "net"
"testing" "testing"
"time" "time"
@@ -78,6 +79,63 @@ func TestHandleRefresh(t *testing.T) {
assert.True(t, resp.Result.Success) assert.True(t, resp.Result.Success)
} }
func TestHandleActions(t *testing.T) {
cases := []struct {
name string
method string
params map[string]any
}{
{"connect", "tailscale.connect", nil},
{"disconnect", "tailscale.disconnect", nil},
{"setExitNode", "tailscale.setExitNode", map[string]any{"id": "nABC123"}},
{"clearExitNode", "tailscale.setExitNode", map[string]any{"id": ""}},
{"setAllowLanAccess", "tailscale.setAllowLanAccess", map[string]any{"enabled": true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := handlerTestManager()
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: tc.method, Params: tc.params}
HandleRequest(conn, req, m)
var resp models.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(buf).Decode(&resp))
assert.Equal(t, 1, resp.ID)
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.True(t, resp.Result.Success)
})
}
}
func TestHandleAction_BackendError(t *testing.T) {
client := &mockClient{
watchFn: blockingWatch,
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
return nil, fmt.Errorf("backend rejected edit")
},
}
m := newManager(client)
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: "tailscale.connect"}
HandleRequest(conn, req, m)
var resp models.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(buf).Decode(&resp))
assert.Nil(t, resp.Result)
assert.Contains(t, resp.Error, "backend rejected edit")
}
func TestHandleRequest_UnknownMethod(t *testing.T) { func TestHandleRequest_UnknownMethod(t *testing.T) {
m := handlerTestManager() m := handlerTestManager()
defer m.Close() defer m.Close()
+85 -4
View File
@@ -11,6 +11,7 @@ import (
"tailscale.com/client/local" "tailscale.com/client/local"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
) )
const ( const (
@@ -22,6 +23,8 @@ const (
type tailscaleClient interface { type tailscaleClient interface {
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
Status(ctx context.Context) (*ipnstate.Status, error) Status(ctx context.Context) (*ipnstate.Status, error)
GetPrefs(ctx context.Context) (*ipn.Prefs, error)
EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error)
} }
// ipnBusWatcher abstracts the IPN bus watcher for testing. // ipnBusWatcher abstracts the IPN bus watcher for testing.
@@ -43,6 +46,14 @@ func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, erro
return w.client.Status(ctx) return w.client.Status(ctx)
} }
func (w *localClientWrapper) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
return w.client.GetPrefs(ctx)
}
func (w *localClientWrapper) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
return w.client.EditPrefs(ctx, mp)
}
// Manager manages Tailscale state via IPN bus events and subscriber notifications. // Manager manages Tailscale state via IPN bus events and subscriber notifications.
type Manager struct { type Manager struct {
state *TailscaleState state *TailscaleState
@@ -169,16 +180,36 @@ func (m *Manager) fetchAndBroadcast(ctx context.Context) {
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout) statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel() defer cancel()
status, err := m.client.Status(statusCtx) state, err := m.fetchState(statusCtx)
if err != nil { if err != nil {
log.Warnf("[Tailscale] Failed to fetch status: %v", err) log.Warnf("[Tailscale] Failed to fetch status: %v", err)
return return
} }
state := convertStatus(status)
m.updateState(state) m.updateState(state)
} }
// fetchState fetches the current status and merges in pref-derived fields
// (e.g. exit-node LAN access) that are not present in the IPN status itself.
func (m *Manager) fetchState(ctx context.Context) (*TailscaleState, error) {
status, err := m.client.Status(ctx)
if err != nil {
return nil, err
}
state := convertStatus(status)
// Prefs carry the exit-node LAN-access toggle, which the status does not
// expose. Treat a prefs failure as non-fatal so status still updates.
if prefs, err := m.client.GetPrefs(ctx); err != nil {
log.Warnf("[Tailscale] Failed to fetch prefs: %v", err)
} else if prefs != nil {
state.ExitNodeAllowLANAccess = prefs.ExitNodeAllowLANAccess
}
return state, nil
}
func (m *Manager) updateState(state *TailscaleState) { func (m *Manager) updateState(state *TailscaleState) {
m.stateMutex.Lock() m.stateMutex.Lock()
m.state = state m.state = state
@@ -266,12 +297,62 @@ func (m *Manager) RefreshState() {
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout) ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
defer cancel() defer cancel()
status, err := m.client.Status(ctx) state, err := m.fetchState(ctx)
if err != nil { if err != nil {
log.Warnf("[Tailscale] Failed to refresh state: %v", err) log.Warnf("[Tailscale] Failed to refresh state: %v", err)
return return
} }
state := convertStatus(status)
m.updateState(state) m.updateState(state)
} }
// Connect brings the Tailscale backend up (WantRunning = true).
func (m *Manager) Connect() error {
return m.editPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: true},
WantRunningSet: true,
})
}
// Disconnect brings the Tailscale backend down (WantRunning = false).
func (m *Manager) Disconnect() error {
return m.editPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: false},
WantRunningSet: true,
})
}
// SetExitNode selects the exit node identified by its stable node ID. An empty
// id clears the current exit node. Mirrors `tailscale set --exit-node=<id>`,
// which also clears any legacy IP-based exit node so a stale ExitNodeIP cannot
// silently take precedence over the now-empty ID.
func (m *Manager) SetExitNode(id string) error {
return m.editPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{ExitNodeID: tailcfg.StableNodeID(id)},
ExitNodeIDSet: true,
ExitNodeIPSet: true,
})
}
// SetAllowLANAccess toggles whether locally accessible subnets remain
// reachable while an exit node is in use.
func (m *Manager) SetAllowLANAccess(enabled bool) error {
return m.editPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{ExitNodeAllowLANAccess: enabled},
ExitNodeAllowLANAccessSet: true,
})
}
// editPrefs applies a masked prefs edit and refreshes state so subscribers see
// the result immediately, in addition to the IPN bus notification it triggers.
func (m *Manager) editPrefs(mp *ipn.MaskedPrefs) error {
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
defer cancel()
if _, err := m.client.EditPrefs(ctx, mp); err != nil {
return err
}
m.RefreshState()
return nil
}
+101 -2
View File
@@ -12,8 +12,16 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
) )
// blockingWatch is a watchFn that blocks until the context is cancelled, used
// by tests that exercise direct manager calls rather than the watch loop.
func blockingWatch(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
<-ctx.Done()
return nil, ctx.Err()
}
// mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel. // mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel.
type mockWatcher struct { type mockWatcher struct {
events []ipn.Notify events []ipn.Notify
@@ -68,8 +76,10 @@ func (w *mockWatcher) Close() error {
// mockClient implements tailscaleClient for testing. // mockClient implements tailscaleClient for testing.
type mockClient struct { type mockClient struct {
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
statusFn func(ctx context.Context) (*ipnstate.Status, error) statusFn func(ctx context.Context) (*ipnstate.Status, error)
getPrefsFn func(ctx context.Context) (*ipn.Prefs, error)
editPrefsFn func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error)
} }
func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
@@ -80,6 +90,20 @@ func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) {
return c.statusFn(ctx) return c.statusFn(ctx)
} }
func (c *mockClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
if c.getPrefsFn != nil {
return c.getPrefsFn(ctx)
}
return &ipn.Prefs{}, nil
}
func (c *mockClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
if c.editPrefsFn != nil {
return c.editPrefsFn(ctx, mp)
}
return &ipn.Prefs{}, nil
}
func runningStatus() *ipnstate.Status { func runningStatus() *ipnstate.Status {
return &ipnstate.Status{ return &ipnstate.Status{
Version: "1.94.2", Version: "1.94.2",
@@ -296,3 +320,78 @@ func TestManager_RefreshState(t *testing.T) {
assert.True(t, state.Connected) assert.True(t, state.Connected)
assert.Equal(t, "cachyos", state.Self.Hostname) assert.Equal(t, "cachyos", state.Self.Hostname)
} }
func TestManager_RefreshState_MergesPrefs(t *testing.T) {
client := &mockClient{
watchFn: blockingWatch,
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
getPrefsFn: func(ctx context.Context) (*ipn.Prefs, error) {
return &ipn.Prefs{ExitNodeAllowLANAccess: true}, nil
},
}
m := newManager(client)
defer m.Close()
m.RefreshState()
assert.True(t, m.GetState().ExitNodeAllowLANAccess)
}
func TestManager_Actions_EditPrefs(t *testing.T) {
var captured *ipn.MaskedPrefs
client := &mockClient{
watchFn: blockingWatch,
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
captured = mp
return &ipn.Prefs{}, nil
},
}
m := newManager(client)
defer m.Close()
require.NoError(t, m.Connect())
require.NotNil(t, captured)
assert.True(t, captured.WantRunningSet)
assert.True(t, captured.WantRunning)
require.NoError(t, m.Disconnect())
assert.True(t, captured.WantRunningSet)
assert.False(t, captured.WantRunning)
require.NoError(t, m.SetExitNode("nABC123"))
assert.True(t, captured.ExitNodeIDSet)
assert.Equal(t, tailcfg.StableNodeID("nABC123"), captured.ExitNodeID)
// ExitNodeIPSet must also be set so a stale legacy ExitNodeIP cannot
// override the ID-based selection (mirrors `tailscale set --exit-node`).
assert.True(t, captured.ExitNodeIPSet)
require.NoError(t, m.SetExitNode(""))
assert.True(t, captured.ExitNodeIDSet)
assert.Equal(t, tailcfg.StableNodeID(""), captured.ExitNodeID)
// Clearing must zero both the ID and any legacy IP-based exit node.
assert.True(t, captured.ExitNodeIPSet)
require.NoError(t, m.SetAllowLANAccess(true))
assert.True(t, captured.ExitNodeAllowLANAccessSet)
assert.True(t, captured.ExitNodeAllowLANAccess)
}
func TestManager_Actions_PropagateError(t *testing.T) {
client := &mockClient{
watchFn: blockingWatch,
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
return nil, fmt.Errorf("backend rejected edit")
},
}
m := newManager(client)
defer m.Close()
assert.Error(t, m.Connect())
assert.Error(t, m.SetExitNode("nABC123"))
assert.Error(t, m.SetAllowLANAccess(true))
}
+24 -22
View File
@@ -2,30 +2,32 @@ package tailscale
// TailscaleState represents the current state of the Tailscale daemon. // TailscaleState represents the current state of the Tailscale daemon.
type TailscaleState struct { type TailscaleState struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
Version string `json:"version"` Version string `json:"version"`
BackendState string `json:"backendState"` BackendState string `json:"backendState"`
MagicDNSSuffix string `json:"magicDnsSuffix"` MagicDNSSuffix string `json:"magicDnsSuffix"`
TailnetName string `json:"tailnetName"` TailnetName string `json:"tailnetName"`
Self Peer `json:"self"` ExitNodeAllowLANAccess bool `json:"exitNodeAllowLanAccess"`
Peers []Peer `json:"peers"` Self Peer `json:"self"`
Peers []Peer `json:"peers"`
} }
// Peer represents a single node in the Tailscale network. // Peer represents a single node in the Tailscale network.
type Peer struct { type Peer struct {
ID string `json:"id"` ID string `json:"id"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
DNSName string `json:"dnsName"` DNSName string `json:"dnsName"`
TailscaleIP string `json:"tailscaleIp"` TailscaleIP string `json:"tailscaleIp"`
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"` TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
OS string `json:"os"` OS string `json:"os"`
Online bool `json:"online"` Online bool `json:"online"`
LastSeen string `json:"lastSeen,omitempty"` LastSeen string `json:"lastSeen,omitempty"`
ExitNode bool `json:"exitNode"` ExitNode bool `json:"exitNode"`
Tags []string `json:"tags,omitempty"` ExitNodeOption bool `json:"exitNodeOption"`
Owner string `json:"owner"` Tags []string `json:"tags,omitempty"`
Relay string `json:"relay,omitempty"` Owner string `json:"owner"`
Active bool `json:"active"` Relay string `json:"relay,omitempty"`
RxBytes int64 `json:"rxBytes"` Active bool `json:"active"`
TxBytes int64 `json:"txBytes"` RxBytes int64 `json:"rxBytes"`
TxBytes int64 `json:"txBytes"`
} }
+20 -11
View File
@@ -103,15 +103,7 @@ func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) deployConfigurations() tea.Cmd { func (m Model) deployConfigurations() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Determine the selected window manager // Determine the selected window manager
var wm deps.WindowManager wm := m.selectedWindowManager()
switch m.selectedWM {
case 0:
wm = deps.WindowManagerNiri
case 1:
wm = deps.WindowManagerHyprland
default:
wm = deps.WindowManagerNiri
}
// Determine the selected terminal // Determine the selected terminal
var terminal deps.Terminal var terminal deps.Terminal
@@ -288,7 +280,8 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
var configs []ExistingConfigInfo var configs []ExistingConfigInfo
if m.selectedWM == 0 { switch m.selectedWindowManager() {
case deps.WindowManagerNiri:
niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl") niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl")
niriExists := false niriExists := false
if _, err := os.Stat(niriPath); err == nil { if _, err := os.Stat(niriPath); err == nil {
@@ -299,7 +292,23 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
Path: niriPath, Path: niriPath,
Exists: niriExists, Exists: niriExists,
}) })
} else { case deps.WindowManagerMango:
mangoConfPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf")
mangoMainPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf")
mangoPath := mangoConfPath
mangoExists := false
if _, err := os.Stat(mangoConfPath); err == nil {
mangoExists = true
} else if _, err := os.Stat(mangoMainPath); err == nil {
mangoPath = mangoMainPath
mangoExists = true
}
configs = append(configs, ExistingConfigInfo{
ConfigType: "Mango",
Path: mangoPath,
Exists: mangoExists,
})
default:
hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua") hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua")
hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf") hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
hyprlandPath := hyprlandLuaPath hyprlandPath := hyprlandLuaPath
+5 -7
View File
@@ -209,12 +209,7 @@ func (m Model) installPackages() tea.Cmd {
} }
// Convert TUI selection to deps enum // Convert TUI selection to deps enum
var wm deps.WindowManager wm := m.selectedWindowManager()
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri
} else {
wm = deps.WindowManagerHyprland
}
installerProgressChan := make(chan distros.InstallProgressMsg, 100) installerProgressChan := make(chan distros.InstallProgressMsg, 100)
@@ -245,8 +240,11 @@ func (m Model) installPackages() tea.Cmd {
} }
if greeterSelected { if greeterSelected {
compositorName := "niri" compositorName := "niri"
if m.selectedWM == 1 { switch m.selectedWindowManager() {
case deps.WindowManagerHyprland:
compositorName = "Hyprland" compositorName = "Hyprland"
case deps.WindowManagerMango:
compositorName = "mango"
} }
m.packageProgressChan <- packageInstallProgressMsg{ m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.92, progress: 0.92,
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -65,7 +66,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
m.skipGentooUseFlags = !m.skipGentooUseFlags m.skipGentooUseFlags = !m.skipGentooUseFlags
return m, nil return m, nil
case "enter": case "enter":
if m.selectedWM == 1 { if m.selectedWindowManager() == deps.WindowManagerHyprland {
return m, m.checkGCCVersion() return m, m.checkGCCVersion()
} }
return m.enterAuthPhase() return m.enterAuthPhase()
+21 -3
View File
@@ -199,8 +199,21 @@ func (m Model) viewInstallComplete() string {
b.WriteString("\n") b.WriteString("\n")
} }
wm := m.selectedWindowManager()
// mango launches DMS via `exec-once=dms run` (not a systemd session target)
loginHint := "If you do not have a greeter, login with \"niri-session\" or \"Hyprland\""
switch wm {
case deps.WindowManagerNiri:
loginHint = "If you do not have a greeter, login with \"niri-session\""
case deps.WindowManagerHyprland:
loginHint = "If you do not have a greeter, login with \"Hyprland\""
case deps.WindowManagerMango:
loginHint = "If you do not have a greeter, login with \"mango\""
}
b.WriteString("\n") b.WriteString("\n")
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\"") info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\n" + loginHint)
b.WriteString(info) b.WriteString(info)
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -209,8 +222,13 @@ func (m Model) viewInstallComplete() string {
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle)) labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle))
b.WriteString(labelStyle.Render("Troubleshooting:") + "\n") b.WriteString(labelStyle.Render("Troubleshooting:") + "\n")
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n") if wm == deps.WindowManagerMango {
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n") b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec-once=dms run' from ~/.config/mango/config.conf") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("qs -p ~/.config/quickshell/dms log") + "\n")
} else {
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n")
}
if m.osInfo != nil { if m.osInfo != nil {
if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" { if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" {
+27 -10
View File
@@ -10,6 +10,26 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// windowManagerOptions returns the WM enums in selection-list order (debian omits
// Hyprland). selectedWM indexes into this, so all index->WM conversions use it.
func (m Model) windowManagerOptions() []deps.WindowManager {
opts := []deps.WindowManager{deps.WindowManagerNiri}
if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
opts = append(opts, deps.WindowManagerHyprland)
}
opts = append(opts, deps.WindowManagerMango)
return opts
}
// selectedWindowManager maps the current selectedWM index to its WM enum.
func (m Model) selectedWindowManager() deps.WindowManager {
opts := m.windowManagerOptions()
if m.selectedWM >= 0 && m.selectedWM < len(opts) {
return opts[m.selectedWM]
}
return deps.WindowManagerNiri
}
func (m Model) viewSelectWindowManager() string { func (m Model) viewSelectWindowManager() string {
var b strings.Builder var b strings.Builder
@@ -34,6 +54,11 @@ func (m Model) viewSelectWindowManager() string {
}{"Hyprland", "Dynamic tiling Wayland compositor."}) }{"Hyprland", "Dynamic tiling Wayland compositor."})
} }
options = append(options, struct {
name string
description string
}{"mango", "dwl-based dynamic tiling Wayland compositor."})
for i, option := range options { for i, option := range options {
if i == m.selectedWM { if i == m.selectedWM {
selected := m.styles.SelectedOption.Render("▶ " + option.name) selected := m.styles.SelectedOption.Render("▶ " + option.name)
@@ -152,10 +177,7 @@ func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
maxWMIndex := 1 maxWMIndex := len(m.windowManagerOptions()) - 1
if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" {
maxWMIndex = 0
}
switch keyMsg.String() { switch keyMsg.String() {
case "up": case "up":
@@ -190,12 +212,7 @@ func (m Model) detectDependencies() tea.Cmd {
} }
// Convert TUI selection to deps enum // Convert TUI selection to deps enum
var wm deps.WindowManager wm := m.selectedWindowManager()
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri // First option is Niri
} else {
wm = deps.WindowManagerHyprland // Second option is Hyprland
}
var terminal deps.Terminal var terminal deps.Terminal
if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
@@ -44,6 +44,8 @@ type HyprlandRulesParser struct {
dmsIncludePos int dmsIncludePos int
rulesAfterDMS int rulesAfterDMS int
dmsProcessed bool dmsProcessed bool
configFormat string
readOnly bool
requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1 requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1
primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config
@@ -82,10 +84,15 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) {
} }
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") { if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
p.probeRequireWindowrulesLine(mainConfig) p.probeRequireWindowrulesLine(mainConfig)
if ap, err := filepath.Abs(mainConfig); err == nil { if ap, err := filepath.Abs(mainConfig); err == nil {
p.primaryHyprLua = ap p.primaryHyprLua = ap
} }
} else {
p.configFormat = "hyprlang"
p.readOnly = true
} }
if err := p.parseFile(mainConfig); err != nil { if err := p.parseFile(mainConfig); err != nil {
@@ -300,6 +307,8 @@ func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
IncludePosition: p.dmsIncludePos, IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount, TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS, RulesAfterDMS: p.rulesAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
} }
switch { switch {
@@ -451,6 +460,9 @@ func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
} }
func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error { func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
rules = []windowrules.WindowRule{} rules = []windowrules.WindowRule{}
@@ -472,6 +484,9 @@ func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
} }
func (p *HyprlandWritableProvider) RemoveRule(id string) error { func (p *HyprlandWritableProvider) RemoveRule(id string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
return err return err
@@ -488,6 +503,9 @@ func (p *HyprlandWritableProvider) RemoveRule(id string) error {
} }
func (p *HyprlandWritableProvider) ReorderRules(ids []string) error { func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
return err return err
@@ -513,6 +531,29 @@ func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
return p.writeDMSRules(newRules) return p.writeDMSRules(newRules)
} }
func (p *HyprlandWritableProvider) ensureWritableConfig() error {
if p.isLegacyConfigReadOnly() {
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing window rules")
}
return nil
}
func (p *HyprlandWritableProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(p.configDir)
if err != nil {
expanded = p.configDir
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
}
var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
@@ -758,11 +799,7 @@ func (p *HyprlandWritableProvider) loadDMSRulesFromLua(data []byte, rulesPath st
Actions: *acts, Actions: *acts,
} }
if wr.ID == "" { if wr.ID == "" {
if wr.MatchCriteria.AppID != "" { wr.ID = fmt.Sprintf("dms_rule_%d", len(rules))
wr.ID = wr.MatchCriteria.AppID
} else {
wr.ID = wr.MatchCriteria.Title
}
} }
rules = append(rules, wr) rules = append(rules, wr)
} }
@@ -188,6 +188,27 @@ func TestHyprlandSetAndLoadDMSRules(t *testing.T) {
} }
} }
func TestHyprlandSetRuleLeavesConfOnlyInstallReadOnly(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("windowrulev2 = float, class:^(kitty)$\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandWritableProvider(tmpDir)
rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$")
rule.Actions.OpenFloating = boolPtr(true)
err := provider.SetRule(rule)
if err == nil {
t.Fatal("expected SetRule to reject conf-only Hyprland config")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected read-only error, got %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "windowrules.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua windowrules file to be created for conf-only config, stat err=%v", err)
}
}
func TestHyprlandRemoveRule(t *testing.T) { func TestHyprlandRemoveRule(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir) provider := NewHyprlandWritableProvider(tmpDir)
@@ -0,0 +1,378 @@
package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
// Mango window rules are flat `windowrule=key:value,...` lines. DMS-managed rules
// live in dms/windowrules.conf (sourced from config.conf), each preceded by an
// `# @id=<id> @name=<name>` comment so they round-trip.
type MangoWindowRule struct {
Source string
Fields map[string]string
}
var mangoWindowRuleRegex = regexp.MustCompile(`^windowrule\s*=\s*(.+)$`)
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
func parseMangoWindowRuleLine(value string) map[string]string {
fields := map[string]string{}
for _, pair := range strings.Split(value, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
colon := strings.Index(pair, ":")
if colon < 0 {
continue
}
key := strings.TrimSpace(pair[:colon])
val := strings.TrimSpace(pair[colon+1:])
if key != "" {
fields[key] = val
}
}
return fields
}
// mangoConfigPath returns the main mango config (config.conf or mango.conf).
func mangoConfigPath(configDir string) string {
candidates := []string{
filepath.Join(configDir, "config.conf"),
filepath.Join(configDir, "mango.conf"),
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return candidates[0]
}
func mangoOverridePath(configDir string) string {
return filepath.Join(configDir, "dms", "windowrules.conf")
}
// parseMangoRulesFile reads a config file and returns its windowrule= lines.
func parseMangoRulesFile(path, source string) []MangoWindowRule {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var rules []MangoWindowRule
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
rules = append(rules, MangoWindowRule{Source: source, Fields: parseMangoWindowRuleLine(m[1])})
}
}
return rules
}
type MangoRulesParseResult struct {
Rules []MangoWindowRule
DMSRulesIncluded bool
DMSStatus *windowrules.DMSRulesStatus
}
func ParseMangoWindowRules(configDir string) (*MangoRulesParseResult, error) {
mainPath := mangoConfigPath(configDir)
overridePath := mangoOverridePath(configDir)
var rules []MangoWindowRule
rules = append(rules, parseMangoRulesFile(mainPath, "config.conf")...)
rules = append(rules, parseMangoRulesFile(overridePath, "dms/windowrules.conf")...)
included := mangoDMSRulesIncluded(mainPath)
return &MangoRulesParseResult{
Rules: rules,
DMSRulesIncluded: included,
DMSStatus: &windowrules.DMSRulesStatus{
Exists: fileExists(overridePath),
Included: included,
Effective: included,
ConfigFormat: "conf",
StatusMessage: mangoIncludeMessage(included),
},
}, nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func mangoDMSRulesIncluded(mainPath string) bool {
data, err := os.ReadFile(mainPath)
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") && strings.Contains(trimmed, "dms/windowrules.conf") {
return true
}
}
return false
}
func mangoIncludeMessage(included bool) string {
if included {
return "DMS window rules are sourced from config.conf"
}
return "Add `source=./dms/windowrules.conf` to config.conf to apply DMS window rules"
}
func mangoBoolField(fields map[string]string, key string) *bool {
v, ok := fields[key]
if !ok {
return nil
}
b := v == "1" || strings.EqualFold(v, "true")
return &b
}
func mangoBoolStr(b *bool) string {
if b != nil && *b {
return "1"
}
return "0"
}
func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(mangoRules))
for i, mr := range mangoRules {
f := mr.Fields
actions := windowrules.Actions{
OpenFloating: mangoBoolField(f, "isfloating"),
OpenFullscreen: mangoBoolField(f, "isfullscreen"),
NoBlur: mangoBoolField(f, "noblur"),
NoBorder: mangoBoolField(f, "isnoborder"),
NoShadow: mangoBoolField(f, "isnoshadow"),
NoRounding: mangoBoolField(f, "isnoradius"),
NoAnim: mangoBoolField(f, "isnoanimation"),
}
if tags, ok := f["tags"]; ok {
actions.Workspace = tags
}
if mon, ok := f["monitor"]; ok {
actions.Monitor = mon
}
if w, ok := f["width"]; ok {
if h, ok2 := f["height"]; ok2 {
actions.Size = w + "x" + h
}
}
result = append(result, windowrules.WindowRule{
ID: fmt.Sprintf("rule_%d", i),
Enabled: true,
Source: mr.Source,
MatchCriteria: windowrules.MatchCriteria{
AppID: f["appid"],
Title: f["title"],
},
Actions: actions,
})
}
return result
}
// formatMangoRule serializes a shared WindowRule into a mango windowrule= line.
func formatMangoRule(rule windowrules.WindowRule) string {
var parts []string
add := func(k, v string) {
if v != "" {
parts = append(parts, k+":"+v)
}
}
add("appid", rule.MatchCriteria.AppID)
add("title", rule.MatchCriteria.Title)
add("tags", rule.Actions.Workspace)
add("monitor", rule.Actions.Monitor)
if rule.Actions.Size != "" {
if w, h, ok := splitSize(rule.Actions.Size); ok {
add("width", w)
add("height", h)
}
}
addBool := func(k string, b *bool) {
if b != nil {
parts = append(parts, k+":"+mangoBoolStr(b))
}
}
addBool("isfloating", rule.Actions.OpenFloating)
addBool("isfullscreen", rule.Actions.OpenFullscreen)
addBool("noblur", rule.Actions.NoBlur)
addBool("isnoborder", rule.Actions.NoBorder)
addBool("isnoshadow", rule.Actions.NoShadow)
addBool("isnoradius", rule.Actions.NoRounding)
addBool("isnoanimation", rule.Actions.NoAnim)
return "windowrule=" + strings.Join(parts, ",")
}
func splitSize(size string) (w, h string, ok bool) {
for _, sep := range []string{"x", "X", " "} {
if parts := strings.Split(size, sep); len(parts) == 2 {
w = strings.TrimSpace(parts[0])
h = strings.TrimSpace(parts[1])
if _, err := strconv.ParseFloat(w, 64); err == nil {
return w, h, true
}
}
}
return "", "", false
}
type MangoWritableProvider struct {
configDir string
}
func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
return &MangoWritableProvider{configDir: configDir}
}
func (p *MangoWritableProvider) Name() string { return "mango" }
func (p *MangoWritableProvider) GetOverridePath() string {
return mangoOverridePath(p.configDir)
}
func (p *MangoWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
result, err := ParseMangoWindowRules(p.configDir)
if err != nil {
return nil, err
}
return &windowrules.RuleSet{
Title: "Mango Window Rules",
Provider: "mango",
Rules: ConvertMangoRulesToWindowRules(result.Rules),
DMSRulesIncluded: result.DMSRulesIncluded,
DMSStatus: result.DMSStatus,
}, nil
}
func (p *MangoWritableProvider) SetRule(rule windowrules.WindowRule) error {
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
}
found := false
for i, r := range rules {
if r.ID == rule.ID {
rules[i] = rule
found = true
break
}
}
if !found {
rules = append(rules, rule)
}
return p.writeDMSRules(rules)
}
func (p *MangoWritableProvider) RemoveRule(id string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
newRules := make([]windowrules.WindowRule, 0, len(rules))
for _, r := range rules {
if r.ID != id {
newRules = append(newRules, r)
}
}
return p.writeDMSRules(newRules)
}
func (p *MangoWritableProvider) ReorderRules(ids []string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
ruleMap := make(map[string]windowrules.WindowRule, len(rules))
for _, r := range rules {
ruleMap[r.ID] = r
}
newRules := make([]windowrules.WindowRule, 0, len(ids))
for _, id := range ids {
if r, ok := ruleMap[id]; ok {
newRules = append(newRules, r)
delete(ruleMap, id)
}
}
for _, r := range ruleMap {
newRules = append(newRules, r)
}
return p.writeDMSRules(newRules)
}
// LoadDMSRules parses only the DMS override file, preserving @id/@name metadata.
func (p *MangoWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
data, err := os.ReadFile(p.GetOverridePath())
if err != nil {
if os.IsNotExist(err) {
return []windowrules.WindowRule{}, nil
}
return nil, err
}
var rules []windowrules.WindowRule
var curID, curName string
idx := 0
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if m := mangoMetaCommentRegex.FindStringSubmatch(trimmed); m != nil {
curID = m[1]
curName = strings.TrimSpace(m[2])
continue
}
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
converted := ConvertMangoRulesToWindowRules([]MangoWindowRule{{Source: "dms/windowrules.conf", Fields: parseMangoWindowRuleLine(m[1])}})
wr := converted[0]
if curID != "" {
wr.ID = curID
} else {
wr.ID = fmt.Sprintf("rule_%d", idx)
}
wr.Name = curName
rules = append(rules, wr)
curID, curName = "", ""
idx++
}
}
return rules, nil
}
func (p *MangoWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
overridePath := p.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return err
}
var sb strings.Builder
sb.WriteString("# Auto-generated by DMS - DMS-managed mango window rules\n\n")
for i, r := range rules {
id := r.ID
if id == "" {
id = fmt.Sprintf("rule_%d", i)
}
fmt.Fprintf(&sb, "# @id=%s @name=%s\n", id, r.Name)
sb.WriteString(formatMangoRule(r))
sb.WriteString("\n\n")
}
return os.WriteFile(overridePath, []byte(sb.String()), 0o644)
}
@@ -0,0 +1,116 @@
package providers
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
func TestParseMangoWindowRuleLine(t *testing.T) {
fields := parseMangoWindowRuleLine("appid:firefox,title:Gmail,isfloating:1,tags:2,monitor:HDMI-A-1")
if fields["appid"] != "firefox" {
t.Errorf("appid = %q, want firefox", fields["appid"])
}
if fields["title"] != "Gmail" {
t.Errorf("title = %q, want Gmail", fields["title"])
}
if fields["isfloating"] != "1" {
t.Errorf("isfloating = %q, want 1", fields["isfloating"])
}
if fields["tags"] != "2" {
t.Errorf("tags = %q, want 2", fields["tags"])
}
if fields["monitor"] != "HDMI-A-1" {
t.Errorf("monitor = %q, want HDMI-A-1", fields["monitor"])
}
}
func TestConvertMangoRulesToWindowRules(t *testing.T) {
mangoRules := []MangoWindowRule{
{Source: "config.conf", Fields: parseMangoWindowRuleLine("appid:discord,tags:9,isfloating:1,noblur:1")},
}
rules := ConvertMangoRulesToWindowRules(mangoRules)
if len(rules) != 1 {
t.Fatalf("got %d rules, want 1", len(rules))
}
r := rules[0]
if r.MatchCriteria.AppID != "discord" {
t.Errorf("AppID = %q, want discord", r.MatchCriteria.AppID)
}
if r.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", r.Actions.Workspace)
}
if r.Actions.OpenFloating == nil || !*r.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", r.Actions.OpenFloating)
}
if r.Actions.NoBlur == nil || !*r.Actions.NoBlur {
t.Errorf("NoBlur = %v, want true", r.Actions.NoBlur)
}
}
func TestMangoSetAndLoadRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
provider := NewMangoWritableProvider(tmpDir)
floating := true
rule := windowrules.WindowRule{
ID: "rule_test",
Name: "Float Discord",
Enabled: true,
MatchCriteria: windowrules.MatchCriteria{
AppID: "discord",
},
Actions: windowrules.Actions{
OpenFloating: &floating,
Workspace: "9",
Size: "1000x900",
},
}
if err := provider.SetRule(rule); err != nil {
t.Fatalf("SetRule: %v", err)
}
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
if _, err := os.Stat(expectedPath); err != nil {
t.Fatalf("override file not written: %v", err)
}
loaded, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules: %v", err)
}
if len(loaded) != 1 {
t.Fatalf("got %d rules, want 1", len(loaded))
}
got := loaded[0]
if got.ID != "rule_test" {
t.Errorf("ID = %q, want rule_test", got.ID)
}
if got.Name != "Float Discord" {
t.Errorf("Name = %q, want 'Float Discord'", got.Name)
}
if got.MatchCriteria.AppID != "discord" {
t.Errorf("AppID = %q, want discord", got.MatchCriteria.AppID)
}
if got.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", got.Actions.Workspace)
}
if got.Actions.Size != "1000x900" {
t.Errorf("Size = %q, want 1000x900", got.Actions.Size)
}
if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating)
}
// Remove and confirm empty.
if err := provider.RemoveRule("rule_test"); err != nil {
t.Fatalf("RemoveRule: %v", err)
}
loaded, _ = provider.LoadDMSRules()
if len(loaded) != 0 {
t.Errorf("after remove got %d rules, want 0", len(loaded))
}
}
@@ -14,6 +14,18 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
) )
type NiriMatch struct {
AppID string
Title string
IsFloating *bool
IsActive *bool
IsFocused *bool
IsActiveInColumn *bool
IsWindowCastTarget *bool
IsUrgent *bool
AtStartup *bool
}
type NiriWindowRule struct { type NiriWindowRule struct {
MatchAppID string MatchAppID string
MatchTitle string MatchTitle string
@@ -24,6 +36,7 @@ type NiriWindowRule struct {
MatchIsWindowCastTarget *bool MatchIsWindowCastTarget *bool
MatchIsUrgent *bool MatchIsUrgent *bool
MatchAtStartup *bool MatchAtStartup *bool
Matches []NiriMatch
Opacity *float64 Opacity *float64
OpenFloating *bool OpenFloating *bool
OpenMaximized *bool OpenMaximized *bool
@@ -50,6 +63,13 @@ type NiriWindowRule struct {
FocusRingOff *bool FocusRingOff *bool
BorderOff *bool BorderOff *bool
DrawBorderWithBg *bool DrawBorderWithBg *bool
BgBlur *bool
BgXray *bool
BgNoise *float64
BgSaturation *float64
DefaultFloatingX *int
DefaultFloatingY *int
DefaultFloatingRelative string
Source string Source string
} }
@@ -191,7 +211,7 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
switch childName { switch childName {
case "match": case "match":
p.parseMatchNode(child, &rule) rule.Matches = append(rule.Matches, p.parseMatchNode(child))
case "opacity": case "opacity":
if len(child.Arguments) > 0 { if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue() val := child.Arguments[0].ResolvedValue()
@@ -297,9 +317,26 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
case "draw-border-with-background": case "draw-border-with-background":
b := p.parseBoolArg(child) b := p.parseBoolArg(child)
rule.DrawBorderWithBg = &b rule.DrawBorderWithBg = &b
case "background-effect":
p.parseBackgroundEffectNode(child, &rule)
case "default-floating-position":
p.parseFloatingPositionNode(child, &rule)
} }
} }
if len(rule.Matches) > 0 {
first := rule.Matches[0]
rule.MatchAppID = first.AppID
rule.MatchTitle = first.Title
rule.MatchIsFloating = first.IsFloating
rule.MatchIsActive = first.IsActive
rule.MatchIsFocused = first.IsFocused
rule.MatchIsActiveInColumn = first.IsActiveInColumn
rule.MatchIsWindowCastTarget = first.IsWindowCastTarget
rule.MatchIsUrgent = first.IsUrgent
rule.MatchAtStartup = first.AtStartup
}
p.rules = append(p.rules, rule) p.rules = append(p.rules, rule)
} }
@@ -326,45 +363,47 @@ func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
return "" return ""
} }
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) { func (p *NiriRulesParser) parseMatchNode(node *document.Node) NiriMatch {
m := NiriMatch{}
if node.Properties == nil { if node.Properties == nil {
return return m
} }
if val, ok := node.Properties.Get("app-id"); ok { if val, ok := node.Properties.Get("app-id"); ok {
rule.MatchAppID = val.ValueString() m.AppID = val.ValueString()
} }
if val, ok := node.Properties.Get("title"); ok { if val, ok := node.Properties.Get("title"); ok {
rule.MatchTitle = val.ValueString() m.Title = val.ValueString()
} }
if val, ok := node.Properties.Get("is-floating"); ok { if val, ok := node.Properties.Get("is-floating"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsFloating = &b m.IsFloating = &b
} }
if val, ok := node.Properties.Get("is-active"); ok { if val, ok := node.Properties.Get("is-active"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsActive = &b m.IsActive = &b
} }
if val, ok := node.Properties.Get("is-focused"); ok { if val, ok := node.Properties.Get("is-focused"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsFocused = &b m.IsFocused = &b
} }
if val, ok := node.Properties.Get("is-active-in-column"); ok { if val, ok := node.Properties.Get("is-active-in-column"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsActiveInColumn = &b m.IsActiveInColumn = &b
} }
if val, ok := node.Properties.Get("is-window-cast-target"); ok { if val, ok := node.Properties.Get("is-window-cast-target"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsWindowCastTarget = &b m.IsWindowCastTarget = &b
} }
if val, ok := node.Properties.Get("is-urgent"); ok { if val, ok := node.Properties.Get("is-urgent"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchIsUrgent = &b m.IsUrgent = &b
} }
if val, ok := node.Properties.Get("at-startup"); ok { if val, ok := node.Properties.Get("at-startup"); ok {
b := val.ValueString() == "true" b := val.ValueString() == "true"
rule.MatchAtStartup = &b m.AtStartup = &b
} }
return m
} }
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) { func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
@@ -385,6 +424,64 @@ func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowR
} }
} }
func (p *NiriRulesParser) parseBackgroundEffectNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
}
for _, child := range node.Children {
switch child.Name.String() {
case "blur":
b := p.parseBoolArg(child)
rule.BgBlur = &b
case "xray":
b := p.parseBoolArg(child)
rule.BgXray = &b
case "noise":
if f, ok := p.parseFloatArg(child); ok {
rule.BgNoise = &f
}
case "saturation":
if f, ok := p.parseFloatArg(child); ok {
rule.BgSaturation = &f
}
}
}
}
func (p *NiriRulesParser) parseFloatingPositionNode(node *document.Node, rule *NiriWindowRule) {
if node.Properties == nil {
return
}
if val, ok := node.Properties.Get("x"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(val.ValueString())); err == nil {
rule.DefaultFloatingX = &n
}
}
if val, ok := node.Properties.Get("y"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(val.ValueString())); err == nil {
rule.DefaultFloatingY = &n
}
}
if val, ok := node.Properties.Get("relative-to"); ok {
rule.DefaultFloatingRelative = val.ValueString()
}
}
func (p *NiriRulesParser) parseFloatArg(node *document.Node) (float64, bool) {
if len(node.Arguments) == 0 {
return 0, false
}
val := node.Arguments[0].ResolvedValue()
switch v := val.(type) {
case float64:
return v, true
case int64:
return float64(v), true
}
return 0, false
}
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) { func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil { if node.Children == nil {
return return
@@ -461,6 +558,27 @@ func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
}, nil }, nil
} }
func convertNiriMatches(matches []NiriMatch) []windowrules.MatchCriteria {
if len(matches) == 0 {
return nil
}
result := make([]windowrules.MatchCriteria, 0, len(matches))
for _, m := range matches {
result = append(result, windowrules.MatchCriteria{
AppID: m.AppID,
Title: m.Title,
IsFloating: m.IsFloating,
IsActive: m.IsActive,
IsFocused: m.IsFocused,
IsActiveInColumn: m.IsActiveInColumn,
IsWindowCastTarget: m.IsWindowCastTarget,
IsUrgent: m.IsUrgent,
AtStartup: m.AtStartup,
})
}
return result
}
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule { func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(niriRules)) result := make([]windowrules.WindowRule, 0, len(niriRules))
for i, nr := range niriRules { for i, nr := range niriRules {
@@ -479,33 +597,41 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
IsUrgent: nr.MatchIsUrgent, IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup, AtStartup: nr.MatchAtStartup,
}, },
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{ Actions: windowrules.Actions{
Opacity: nr.Opacity, Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating, OpenFloating: nr.OpenFloating,
OpenMaximized: nr.OpenMaximized, OpenMaximized: nr.OpenMaximized,
OpenMaximizedToEdges: nr.OpenMaximizedToEdges, OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
OpenFullscreen: nr.OpenFullscreen, OpenFullscreen: nr.OpenFullscreen,
OpenFocused: nr.OpenFocused, OpenFocused: nr.OpenFocused,
OpenOnOutput: nr.OpenOnOutput, OpenOnOutput: nr.OpenOnOutput,
OpenOnWorkspace: nr.OpenOnWorkspace, OpenOnWorkspace: nr.OpenOnWorkspace,
DefaultColumnWidth: nr.DefaultColumnWidth, DefaultColumnWidth: nr.DefaultColumnWidth,
DefaultWindowHeight: nr.DefaultWindowHeight, DefaultWindowHeight: nr.DefaultWindowHeight,
VariableRefreshRate: nr.VariableRefreshRate, VariableRefreshRate: nr.VariableRefreshRate,
BlockOutFrom: nr.BlockOutFrom, BlockOutFrom: nr.BlockOutFrom,
DefaultColumnDisplay: nr.DefaultColumnDisplay, DefaultColumnDisplay: nr.DefaultColumnDisplay,
ScrollFactor: nr.ScrollFactor, ScrollFactor: nr.ScrollFactor,
CornerRadius: nr.CornerRadius, CornerRadius: nr.CornerRadius,
ClipToGeometry: nr.ClipToGeometry, ClipToGeometry: nr.ClipToGeometry,
TiledState: nr.TiledState, TiledState: nr.TiledState,
MinWidth: nr.MinWidth, MinWidth: nr.MinWidth,
MaxWidth: nr.MaxWidth, MaxWidth: nr.MaxWidth,
MinHeight: nr.MinHeight, MinHeight: nr.MinHeight,
MaxHeight: nr.MaxHeight, MaxHeight: nr.MaxHeight,
BorderColor: nr.BorderColor, BorderColor: nr.BorderColor,
FocusRingColor: nr.FocusRingColor, FocusRingColor: nr.FocusRingColor,
FocusRingOff: nr.FocusRingOff, FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff, BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg, DrawBorderWithBg: nr.DrawBorderWithBg,
BackgroundBlur: nr.BgBlur,
BackgroundXray: nr.BgXray,
BackgroundNoise: nr.BgNoise,
BackgroundSaturation: nr.BgSaturation,
DefaultFloatingX: nr.DefaultFloatingX,
DefaultFloatingY: nr.DefaultFloatingY,
DefaultFloatingRelativeTo: nr.DefaultFloatingRelative,
}, },
} }
result = append(result, wr) result = append(result, wr)
@@ -684,33 +810,41 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
IsUrgent: nr.MatchIsUrgent, IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup, AtStartup: nr.MatchAtStartup,
}, },
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{ Actions: windowrules.Actions{
Opacity: nr.Opacity, Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating, OpenFloating: nr.OpenFloating,
OpenMaximized: nr.OpenMaximized, OpenMaximized: nr.OpenMaximized,
OpenMaximizedToEdges: nr.OpenMaximizedToEdges, OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
OpenFullscreen: nr.OpenFullscreen, OpenFullscreen: nr.OpenFullscreen,
OpenFocused: nr.OpenFocused, OpenFocused: nr.OpenFocused,
OpenOnOutput: nr.OpenOnOutput, OpenOnOutput: nr.OpenOnOutput,
OpenOnWorkspace: nr.OpenOnWorkspace, OpenOnWorkspace: nr.OpenOnWorkspace,
DefaultColumnWidth: nr.DefaultColumnWidth, DefaultColumnWidth: nr.DefaultColumnWidth,
DefaultWindowHeight: nr.DefaultWindowHeight, DefaultWindowHeight: nr.DefaultWindowHeight,
VariableRefreshRate: nr.VariableRefreshRate, VariableRefreshRate: nr.VariableRefreshRate,
BlockOutFrom: nr.BlockOutFrom, BlockOutFrom: nr.BlockOutFrom,
DefaultColumnDisplay: nr.DefaultColumnDisplay, DefaultColumnDisplay: nr.DefaultColumnDisplay,
ScrollFactor: nr.ScrollFactor, ScrollFactor: nr.ScrollFactor,
CornerRadius: nr.CornerRadius, CornerRadius: nr.CornerRadius,
ClipToGeometry: nr.ClipToGeometry, ClipToGeometry: nr.ClipToGeometry,
TiledState: nr.TiledState, TiledState: nr.TiledState,
MinWidth: nr.MinWidth, MinWidth: nr.MinWidth,
MaxWidth: nr.MaxWidth, MaxWidth: nr.MaxWidth,
MinHeight: nr.MinHeight, MinHeight: nr.MinHeight,
MaxHeight: nr.MaxHeight, MaxHeight: nr.MaxHeight,
BorderColor: nr.BorderColor, BorderColor: nr.BorderColor,
FocusRingColor: nr.FocusRingColor, FocusRingColor: nr.FocusRingColor,
FocusRingOff: nr.FocusRingOff, FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff, BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg, DrawBorderWithBg: nr.DrawBorderWithBg,
BackgroundBlur: nr.BgBlur,
BackgroundXray: nr.BgXray,
BackgroundNoise: nr.BgNoise,
BackgroundSaturation: nr.BgSaturation,
DefaultFloatingX: nr.DefaultFloatingX,
DefaultFloatingY: nr.DefaultFloatingY,
DefaultFloatingRelativeTo: nr.DefaultFloatingRelative,
}, },
} }
@@ -740,44 +874,54 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644) return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
} }
func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
var matchProps []string
if m.AppID != "" {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
}
if m.Title != "" {
matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
}
if m.IsFloating != nil {
matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
}
if m.IsActive != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
}
if m.IsFocused != nil {
matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
}
if m.IsActiveInColumn != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
}
if m.IsWindowCastTarget != nil {
matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
}
if m.IsUrgent != nil {
matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
}
if m.AtStartup != nil {
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
}
if len(matchProps) == 0 {
return "", false
}
return " match " + strings.Join(matchProps, " "), true
}
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string { func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
var lines []string var lines []string
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name)) lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
lines = append(lines, "window-rule {") lines = append(lines, "window-rule {")
m := rule.MatchCriteria matches := rule.Matches
if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil || if len(matches) == 0 {
m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil || matches = []windowrules.MatchCriteria{rule.MatchCriteria}
m.IsUrgent != nil || m.AtStartup != nil { }
var matchProps []string for _, m := range matches {
if m.AppID != "" { if line, ok := formatNiriMatchLine(m); ok {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID)) lines = append(lines, line)
} }
if m.Title != "" {
matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
}
if m.IsFloating != nil {
matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
}
if m.IsActive != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
}
if m.IsFocused != nil {
matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
}
if m.IsActiveInColumn != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
}
if m.IsWindowCastTarget != nil {
matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
}
if m.IsUrgent != nil {
matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
}
if m.AtStartup != nil {
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
}
lines = append(lines, " match "+strings.Join(matchProps, " "))
} }
a := rule.Actions a := rule.Actions
@@ -858,10 +1002,39 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg)) lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
} }
if a.BackgroundBlur != nil || a.BackgroundXray != nil || a.BackgroundNoise != nil || a.BackgroundSaturation != nil {
lines = append(lines, " background-effect {")
if a.BackgroundBlur != nil {
lines = append(lines, fmt.Sprintf(" blur %t", *a.BackgroundBlur))
}
if a.BackgroundXray != nil {
lines = append(lines, fmt.Sprintf(" xray %t", *a.BackgroundXray))
}
if a.BackgroundNoise != nil {
lines = append(lines, fmt.Sprintf(" noise %s", formatFloat(*a.BackgroundNoise)))
}
if a.BackgroundSaturation != nil {
lines = append(lines, fmt.Sprintf(" saturation %s", formatFloat(*a.BackgroundSaturation)))
}
lines = append(lines, " }")
}
if a.DefaultFloatingX != nil && a.DefaultFloatingY != nil {
line := fmt.Sprintf(" default-floating-position x=%d y=%d", *a.DefaultFloatingX, *a.DefaultFloatingY)
if a.DefaultFloatingRelativeTo != "" {
line += fmt.Sprintf(" relative-to=%q", a.DefaultFloatingRelativeTo)
}
lines = append(lines, line)
}
lines = append(lines, "}") lines = append(lines, "}")
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
func formatSizeProperty(name, value string) string { func formatSizeProperty(name, value string) string {
parts := strings.SplitN(value, " ", 2) parts := strings.SplitN(value, " ", 2)
if len(parts) == 2 { if len(parts) == 2 {
+33 -22
View File
@@ -43,31 +43,40 @@ type Actions struct {
FocusRingOff *bool `json:"focusRingOff,omitempty"` FocusRingOff *bool `json:"focusRingOff,omitempty"`
BorderOff *bool `json:"borderOff,omitempty"` BorderOff *bool `json:"borderOff,omitempty"`
DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"` DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
Size string `json:"size,omitempty"` BackgroundBlur *bool `json:"backgroundBlur,omitempty"`
Move string `json:"move,omitempty"` BackgroundXray *bool `json:"backgroundXray,omitempty"`
Monitor string `json:"monitor,omitempty"` BackgroundNoise *float64 `json:"backgroundNoise,omitempty"`
Workspace string `json:"workspace,omitempty"` BackgroundSaturation *float64 `json:"backgroundSaturation,omitempty"`
Tile *bool `json:"tile,omitempty"`
NoFocus *bool `json:"nofocus,omitempty"` DefaultFloatingX *int `json:"defaultFloatingX,omitempty"`
NoBorder *bool `json:"noborder,omitempty"` DefaultFloatingY *int `json:"defaultFloatingY,omitempty"`
NoShadow *bool `json:"noshadow,omitempty"` DefaultFloatingRelativeTo string `json:"defaultFloatingRelativeTo,omitempty"`
NoDim *bool `json:"nodim,omitempty"` Size string `json:"size,omitempty"`
NoBlur *bool `json:"noblur,omitempty"` Move string `json:"move,omitempty"`
NoAnim *bool `json:"noanim,omitempty"` Monitor string `json:"monitor,omitempty"`
NoRounding *bool `json:"norounding,omitempty"` Workspace string `json:"workspace,omitempty"`
Pin *bool `json:"pin,omitempty"` Tile *bool `json:"tile,omitempty"`
Opaque *bool `json:"opaque,omitempty"` NoFocus *bool `json:"nofocus,omitempty"`
ForcergbX *bool `json:"forcergbx,omitempty"` NoBorder *bool `json:"noborder,omitempty"`
Idleinhibit string `json:"idleinhibit,omitempty"` NoShadow *bool `json:"noshadow,omitempty"`
NoDim *bool `json:"nodim,omitempty"`
NoBlur *bool `json:"noblur,omitempty"`
NoAnim *bool `json:"noanim,omitempty"`
NoRounding *bool `json:"norounding,omitempty"`
Pin *bool `json:"pin,omitempty"`
Opaque *bool `json:"opaque,omitempty"`
ForcergbX *bool `json:"forcergbx,omitempty"`
Idleinhibit string `json:"idleinhibit,omitempty"`
} }
type WindowRule struct { type WindowRule struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
MatchCriteria MatchCriteria `json:"matchCriteria"` MatchCriteria MatchCriteria `json:"matchCriteria"`
Actions Actions `json:"actions"` Matches []MatchCriteria `json:"matches,omitempty"`
Source string `json:"source,omitempty"` Actions Actions `json:"actions"`
Source string `json:"source,omitempty"`
} }
type DMSRulesStatus struct { type DMSRulesStatus struct {
@@ -79,6 +88,8 @@ type DMSRulesStatus struct {
Effective bool `json:"effective"` Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"` OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"` StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
type RuleSet struct { type RuleSet struct {
+7 -7
View File
@@ -3,10 +3,10 @@
# Usage: ./create-source.sh <package-dir> [ubuntu-series] # Usage: ./create-source.sh <package-dir> [ubuntu-series]
# #
# Example: # Example:
# ./create-source.sh ../dms questing # Ubuntu 25.10 (default series in ppa-upload) # ./create-source.sh ../dms resolute # Ubuntu 26.04 LTS (default series in ppa-upload)
# ./create-source.sh ../dms resolute # Ubuntu 26.04 LTS # ./create-source.sh ../dms stonking # Ubuntu 26.10
# ./create-source.sh ../dms-git questing
# ./create-source.sh ../dms-git resolute # ./create-source.sh ../dms-git resolute
# ./create-source.sh ../dms-git stonking
set -e set -e
@@ -27,13 +27,13 @@ if [ $# -lt 1 ]; then
echo "Arguments:" echo "Arguments:"
echo " package-dir : Path to package directory (e.g., ../dms)" echo " package-dir : Path to package directory (e.g., ../dms)"
echo " ubuntu-series : Ubuntu series (optional, default: noble)" echo " ubuntu-series : Ubuntu series (optional, default: noble)"
echo " Options: noble, jammy, oracular, mantic, questing, resolute" echo " Options: noble, jammy, oracular, mantic, resolute, stonking"
echo echo
echo "Examples:" echo "Examples:"
echo " $0 ../dms questing"
echo " $0 ../dms resolute" echo " $0 ../dms resolute"
echo " $0 ../dms-git questing" echo " $0 ../dms stonking"
echo " $0 ../dms-git resolute" echo " $0 ../dms-git resolute"
echo " $0 ../dms-git stonking"
exit 1 exit 1
fi fi
@@ -135,7 +135,7 @@ check_ppa_version_exists() {
local CHECK_MODE="${4:-commit}" local CHECK_MODE="${4:-commit}"
local DISTRO_SERIES="${5:-}" local DISTRO_SERIES="${5:-}"
# Query Launchpad API (optionally scoped to one Ubuntu series so the same version can ship to questing and resolute) # Query Launchpad API (optionally scoped to one Ubuntu series so the same version can ship to resolute and stonking)
local API_URL="https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$SOURCE_NAME&status=Published" local API_URL="https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$SOURCE_NAME&status=Published"
if [[ -n "$DISTRO_SERIES" ]]; then if [[ -n "$DISTRO_SERIES" ]]; then
API_URL+="&distro_series=https://api.launchpad.net/1.0/ubuntu/${DISTRO_SERIES}" API_URL+="&distro_series=https://api.launchpad.net/1.0/ubuntu/${DISTRO_SERIES}"
+2 -2
View File
@@ -10,8 +10,8 @@
PPA_OWNER="avengemedia" PPA_OWNER="avengemedia"
LAUNCHPAD_API="https://api.launchpad.net/1.0" LAUNCHPAD_API="https://api.launchpad.net/1.0"
# Supported Ubuntu series for PPA builds (25.10 questing + 26.04 LTS resolute) # Supported Ubuntu series for PPA builds (26.04 LTS resolute + 26.10 stonking)
DISTRO_SERIES_LIST=(questing resolute) DISTRO_SERIES_LIST=(resolute stonking)
# Define packages (sync with ppa-upload.sh) # Define packages (sync with ppa-upload.sh)
ALL_PACKAGES=(dms dms-git dms-greeter) ALL_PACKAGES=(dms dms-git dms-greeter)
+3 -3
View File
@@ -5,7 +5,7 @@ set -euo pipefail
PPA_OWNER="avengemedia" PPA_OWNER="avengemedia"
LAUNCHPAD_API="https://api.launchpad.net/1.0" LAUNCHPAD_API="https://api.launchpad.net/1.0"
SERIES_LIST=(questing resolute) SERIES_LIST=(resolute stonking)
PACKAGE_FILTER="dms-git" PACKAGE_FILTER="dms-git"
REBUILD_RELEASE="" REBUILD_RELEASE=""
JSON=false JSON=false
@@ -72,12 +72,12 @@ embedded_commit() {
target_ppa() { target_ppa() {
local series="$1" local series="$1"
if [[ -n "$REBUILD_RELEASE" ]]; then if [[ -n "$REBUILD_RELEASE" ]]; then
if [[ "$series" == "resolute" ]]; then if [[ "$series" == "stonking" ]]; then
echo $((REBUILD_RELEASE + 1)) echo $((REBUILD_RELEASE + 1))
else else
echo "$REBUILD_RELEASE" echo "$REBUILD_RELEASE"
fi fi
elif [[ "$series" == "resolute" ]]; then elif [[ "$series" == "stonking" ]]; then
echo "2" echo "2"
else else
echo "1" echo "1"
+8 -8
View File
@@ -3,13 +3,13 @@
# Usage: ./ppa-upload.sh [package-name] [ppa-name] [ubuntu-series] [rebuild-number] [--keep-builds] [--rebuild=N] # Usage: ./ppa-upload.sh [package-name] [ppa-name] [ubuntu-series] [rebuild-number] [--keep-builds] [--rebuild=N]
# #
# Examples: # Examples:
# ./ppa-upload.sh dms # Upload to questing + resolute (default) # ./ppa-upload.sh dms # Upload to resolute + stonking (default)
# ./ppa-upload.sh dms 2 # Native: questing ppa2, resolute ppa3 (auto +1 on second series) # ./ppa-upload.sh dms 2 # Native: resolute ppa2, stonking ppa3 (auto +1 on second series)
# ./ppa-upload.sh dms --rebuild=2 # Rebuild with ppa2 (flag syntax) # ./ppa-upload.sh dms --rebuild=2 # Rebuild with ppa2 (flag syntax)
# ./ppa-upload.sh dms-git # Single package (both series) # ./ppa-upload.sh dms-git # Single package (both series)
# ./ppa-upload.sh all # All packages (each to both series) # ./ppa-upload.sh all # All packages (each to both series)
# ./ppa-upload.sh dms resolute # 26.04 LTS only (same as "dms dms resolute") # ./ppa-upload.sh dms resolute # 26.04 LTS only (same as "dms dms resolute")
# ./ppa-upload.sh dms questing # 25.10 only # ./ppa-upload.sh dms stonking # 26.10 only
# ./ppa-upload.sh dms dms resolute # Explicit PPA name + one series (optional form) # ./ppa-upload.sh dms dms resolute # Explicit PPA name + one series (optional form)
# ./ppa-upload.sh dms dms resolute 2 # One series + rebuild number # ./ppa-upload.sh dms dms resolute 2 # One series + rebuild number
# ./ppa-upload.sh distro/ubuntu/dms dms # Path-style (backward compatible) # ./ppa-upload.sh distro/ubuntu/dms dms # Path-style (backward compatible)
@@ -70,8 +70,8 @@ if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then
fi fi
fi fi
# Shorthand: "dms resolute" / "dms questing" (package + series; PPA inferred — no need for "dms dms resolute") # Shorthand: "dms resolute" / "dms stonking" (package + series; PPA inferred — no need for "dms dms resolute")
if [[ ${#POSITIONAL_ARGS[@]} -eq 2 ]] && [[ "${POSITIONAL_ARGS[1]}" == "questing" || "${POSITIONAL_ARGS[1]}" == "resolute" ]]; then if [[ ${#POSITIONAL_ARGS[@]} -eq 2 ]] && [[ "${POSITIONAL_ARGS[1]}" == "resolute" || "${POSITIONAL_ARGS[1]}" == "stonking" ]]; then
PACKAGE_INPUT="${POSITIONAL_ARGS[0]}" PACKAGE_INPUT="${POSITIONAL_ARGS[0]}"
PPA_NAME_INPUT="" PPA_NAME_INPUT=""
UBUNTU_SERIES_RAW="${POSITIONAL_ARGS[1]}" UBUNTU_SERIES_RAW="${POSITIONAL_ARGS[1]}"
@@ -79,11 +79,11 @@ fi
SERIES_LIST=() SERIES_LIST=()
if [[ -z "$UBUNTU_SERIES_RAW" ]]; then if [[ -z "$UBUNTU_SERIES_RAW" ]]; then
SERIES_LIST=(questing resolute) SERIES_LIST=(resolute stonking)
elif [[ "$UBUNTU_SERIES_RAW" == "questing" || "$UBUNTU_SERIES_RAW" == "resolute" ]]; then elif [[ "$UBUNTU_SERIES_RAW" == "resolute" || "$UBUNTU_SERIES_RAW" == "stonking" ]]; then
SERIES_LIST=("$UBUNTU_SERIES_RAW") SERIES_LIST=("$UBUNTU_SERIES_RAW")
else else
error "Invalid Ubuntu series: $UBUNTU_SERIES_RAW (use questing, resolute, or omit for both)" error "Invalid Ubuntu series: $UBUNTU_SERIES_RAW (use resolute, stonking, or omit for both)"
exit 1 exit 1
fi fi
+11 -4
View File
@@ -40,10 +40,17 @@ override_dh_auto_install:
install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE
install -Dpm0644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf \ # Install systemd tmpfiles/sysusers fragments only when present in the fetched source.
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf # sysusers-dms-greeter.conf landed upstream after v1.4.6; guarding both lets older
install -Dm644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf \ # release tarballs build, while future tags that ship the files install them automatically.
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf if [ -f DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf ]; then \
install -Dpm0644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf \
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf; \
fi
if [ -f DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf ]; then \
install -Dm644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf \
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf; \
fi
# Create cache directory structure (will be created by postinst) # Create cache directory structure (will be created by postinst)
mkdir -p debian/dms-greeter/var/cache/dms-greeter mkdir -p debian/dms-greeter/var/cache/dms-greeter
+7
View File
@@ -48,6 +48,13 @@ fragments.
keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in
DMS Settings. DMS Settings.
Stock configs include a 3-finger horizontal touchpad gesture for workspace
switching (`hl.gesture` in `dms/binds.lua`) and basic touchpad settings
(`tap_to_click`, `natural_scroll` in `hyprland.lua`). To customize or disable
gestures, add your own `hl.gesture(...)` lines to `dms/binds-user.lua`, or unset
a stock gesture with `action = "unset"` matching the original fingers,
direction, and modifiers.
Most other existing non-empty Lua fragments are preserved. Most other existing non-empty Lua fragments are preserved.
## Legacy Config Migration ## Legacy Config Migration
+132 -1
View File
@@ -6,6 +6,18 @@ DankMaterialShell provides comprehensive IPC (Inter-Process Communication) funct
dms ipc call <target> <function> [parameters...] dms ipc call <target> <function> [parameters...]
``` ```
## Discovering IPC commands
List all available targets and functions while DMS is running:
```bash
dms ipc list
dms ipc # same
dms ipc --help # same, plus usage text
```
Live listing requires DMS to be running. If listing fails, use this document or the [Keybinds & IPC docs](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc) as an offline reference.
## Target: `audio` ## Target: `audio`
Audio system control and information. Audio system control and information.
@@ -282,6 +294,53 @@ dms ipc call inhibit toggle
dms ipc call inhibit enable dms ipc call inhibit enable
``` ```
## Target: `powerprofile`
Power profile control via `power-profiles-daemon`. Changes stay in sync with DMS UI and trigger the power profile OSD when enabled.
Requires `power-profiles-daemon` to be installed and running. Works on all compositors.
### Functions
**`open`**
- Show the power profile picker modal
- Returns: Success confirmation or error if daemon unavailable
**`close`**
- Close the power profile picker modal
- Returns: Success confirmation
**`toggle`**
- Toggle power profile picker modal visibility
- Returns: Success confirmation or error if daemon unavailable
**`list`**
- List available profile slugs, one per line
- Returns: `power-saver`, `balanced`, and `performance` when supported
**`status`**
- Get the currently active profile slug
- Returns: `power-saver`, `balanced`, `performance`, or error if daemon unavailable
**`set <profile>`**
- Set the active power profile
- Parameters: Profile slug or alias — `power-saver` (`powersaver`, `saver`, `0`), `balanced` (`1`), `performance` (`2`)
- Returns: Success confirmation or error if profile unknown, unsupported, or write failed
**`cycle`**
- Cycle to the next available profile in order: power-saver → balanced → performance → power-saver
- Returns: Success confirmation or error if daemon unavailable or write failed
### Examples
```bash
dms ipc call powerprofile status
dms ipc call powerprofile list
dms ipc call powerprofile cycle
dms ipc call powerprofile set balanced
dms ipc call powerprofile set performance
dms ipc call powerprofile toggle
```
## Target: `wallpaper` ## Target: `wallpaper`
Wallpaper management and retrieval with support for per-monitor configurations. Wallpaper management and retrieval with support for per-monitor configurations.
@@ -485,6 +544,54 @@ dms ipc call systemupdater close
dms ipc call systemupdater updatestatus dms ipc call systemupdater updatestatus
``` ```
## Target: `defaultApp`
Launch applications configured in Settings > Default Apps.
### Functions
**`browser`**
- Launch the configured default web browser
- Returns: Launch request confirmation
**`fileManager`**
- Launch the configured default file manager
- Returns: Launch request confirmation
**`textEditor`**
- Launch the configured default text editor
- Returns: Launch request confirmation
**`pdfReader`**
- Launch the configured default PDF reader
- Returns: Launch request confirmation
**`imageViewer`**
- Launch the configured default image viewer
- Returns: Launch request confirmation
**`videoPlayer`**
- Launch the configured default video player
- Returns: Launch request confirmation
**`musicPlayer`**
- Launch the configured default music player
- Returns: Launch request confirmation
**`mail`**
- Launch the configured default mail client
- Returns: Launch request confirmation
**`calendar`**
- Launch the configured default calendar application
- Returns: Launch request confirmation
### Examples
```bash
dms ipc call defaultApp browser
dms ipc call defaultApp fileManager
```
## Modal Controls ## Modal Controls
These targets control various modal windows and overlays. These targets control various modal windows and overlays.
@@ -543,6 +650,18 @@ Power menu modal control for system power actions.
- `close` - Hide power menu modal - `close` - Hide power menu modal
- `toggle` - Toggle power menu modal visibility - `toggle` - Toggle power menu modal visibility
### Target: `powerprofile`
Power profile picker modal and profile control via `power-profiles-daemon`.
**Functions:**
- `open` - Show power profile picker modal
- `close` - Hide power profile picker modal
- `toggle` - Toggle power profile picker modal visibility
- `list` - List available profile slugs
- `status` - Get current profile slug
- `set <profile>` - Set profile by slug or alias (`power-saver`, `balanced`, `performance`)
- `cycle` - Cycle to the next available profile
### Target: `control-center` ### Target: `control-center`
Control Center popout containing network, bluetooth, audio, power, and other quick settings. Control Center popout containing network, bluetooth, audio, power, and other quick settings.
@@ -600,7 +719,7 @@ File browser controls for selecting wallpapers and profile images.
- Both browsers support common image formats (jpg, jpeg, png, bmp, gif, webp) - Both browsers support common image formats (jpg, jpeg, png, bmp, gif, webp)
### Target: `color-picker` ### Target: `color-picker`
Color picker modal control. In-shell color picker modal for theme and settings color selection.
**Functions:** **Functions:**
- `open` - Show color picker modal - `open` - Show color picker modal
@@ -611,6 +730,14 @@ Color picker modal control.
- `toggle` - Toggle color picker modal visibility - `toggle` - Toggle color picker modal visibility
- `toggleInstant` - Toggle color picker modal visibility without animation on hide - `toggleInstant` - Toggle color picker modal visibility without animation on hide
**Note:** This controls the in-shell modal. To pick a pixel from the screen via CLI, use `dms color pick` instead (see [Color Picker CLI](https://danklinux.com/docs/dankmaterialshell/cli-color-picker)).
**Examples:**
```bash
dms ipc call color-picker toggle
dms ipc call color-picker openColor "#3f51b5"
```
### Target: `hypr` ### Target: `hypr`
Hyprland-specific controls including keybinds cheatsheet and workspace overview (Hyprland only). Hyprland-specific controls including keybinds cheatsheet and workspace overview (Hyprland only).
@@ -673,6 +800,10 @@ dms ipc call processlist toggle
# Show power menu # Show power menu
dms ipc call powermenu toggle dms ipc call powermenu toggle
# Cycle or set power profile (requires power-profiles-daemon)
dms ipc call powerprofile cycle
dms ipc call powerprofile toggle
# Open notepad # Open notepad
dms ipc call notepad toggle dms ipc call notepad toggle
+4
View File
@@ -11,6 +11,10 @@ Singleton {
readonly property int durMed: 450 readonly property int durMed: 450
readonly property int durLong: 600 readonly property int durLong: 600
// Navigation feedback stays responsive even when ambient shell motion is slow.
readonly property int settingsNavigationStateDuration: 180
readonly property int settingsNavigationRippleDuration: 200
readonly property int slidePx: 80 readonly property int slidePx: 80
readonly property var emphasized: [0.05, 0.00, 0.133333, 0.06, 0.166667, 0.40, 0.208333, 0.82, 0.25, 1.00, 1.00, 1.00] readonly property var emphasized: [0.05, 0.00, 0.133333, 0.06, 0.166667, 0.40, 0.208333, 0.82, 0.25, 1.00, 1.00, 1.00]
+111
View File
@@ -0,0 +1,111 @@
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
required property var modalHandle
required property string claimPrefix
property string surfaceKind: "modal"
property string screenName: ""
property bool enabled: false
property bool active: false
property bool presented: false
property bool dockBlocked: false
property string dockSide: ""
property alias claimId: lease.claimId
property alias claimedScreenName: lease.claimedScreenName
signal recoveryRequested
visible: false
function _isCurrentModal(name) {
return !!name && ModalManager.isCurrentModal(modalHandle, name);
}
ConnectedSurfaceLease {
id: lease
claimPrefix: root.claimPrefix
screenName: root.screenName
enabled: root.enabled
active: root.active
presented: root.presented
dockBlocked: root.dockBlocked
dockSide: root.dockSide
isCurrentOwner: function(name) {
return root._isCurrentModal(name);
}
hasOwner: function(name, ownerId) {
return ConnectedModeState.hasModalOwner(name, ownerId);
}
statePresent: function(name, ownerId) {
return ConnectedModeState.hasModalOwner(name, ownerId) && ConnectedModeState.hasSurfaceDescriptor(name, root.surfaceKind, ownerId);
}
claimState: function(name, state, ownerId) {
return ConnectedModeState.claimModalState(name, state, ownerId);
}
ensureState: function(name, state, ownerId) {
return ConnectedModeState.ensureModalState(name, state, ownerId);
}
releaseState: function(name, ownerId) {
return ConnectedModeState.clearModalState(name, ownerId);
}
updateAnimationState: function(name, ownerId, animX, animY) {
return ConnectedModeState.setModalAnim(name, animX, animY, ownerId);
}
updateBodyState: function(name, ownerId, bodyX, bodyY, bodyW, bodyH) {
return ConnectedModeState.setModalBody(name, bodyX, bodyY, bodyW, bodyH, ownerId);
}
requestDockRetract: function(ownerId, name, side) {
return ConnectedModeState.requestDockRetract(ownerId, name, side);
}
releaseDockRetract: function(ownerId) {
return ConnectedModeState.releaseDockRetract(ownerId);
}
onRecoveryRequested: root.recoveryRequested()
}
function publish(state) {
return lease.publish(Object.assign({}, state, {
"kind": root.surfaceKind,
"screenName": root.screenName,
"presented": root.presented,
"dockRetractSide": root.dockBlocked ? root.dockSide : ""
}), false);
}
function updateAnim(animX, animY) {
return lease.updateAnim(animX, animY);
}
function updateBody(bodyX, bodyY, bodyW, bodyH) {
return lease.updateBody(bodyX, bodyY, bodyW, bodyH);
}
function release() {
return lease.release();
}
Connections {
target: ModalManager
function onModalChanged() {
lease.requestRecovery();
}
}
Connections {
target: ConnectedModeState
function onModalOwnersChanged() {
lease.checkOwnershipRecovery();
}
function onModalStatesChanged() {
lease.checkStateRecovery();
}
function onSurfaceDescriptorsChanged() {
lease.checkStateRecovery();
}
}
}

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