1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-03 02:52:07 -04:00

Compare commits

..

218 Commits

Author SHA1 Message Date
bbedward
056576fbe7 core: execute quickshell IPC with pid 2026-03-24 10:45:53 -04:00
bbedward
d28a5bdf7f widgets: convert DankButtonGroup to Row instead of Flow 2026-03-24 10:45:49 -04:00
bbedward
f62ea119f7 popout: avoid calling close on bad reference 2026-03-24 10:45:44 -04:00
bbedward
a1f9b98727 wallpaper: updatesEnabled set on screen changes 2026-03-24 10:45:37 -04:00
bbedward
b9c8914d46 cli/notify: append file:// prefix for --file arguments
fixes #1962
2026-03-24 10:45:28 -04:00
İlkecan Bozdoğan
8697840d46 nix: add package option for dms-shell (#1864)
... to make it configurable.
2026-03-24 10:44:51 -04:00
purian23
1f64bb8031 notifications(Settings): Update notifs popout settings overflow 2026-03-20 19:59:45 -04:00
purian23
eea7d12c0b dankinstall(Arch): improve AUR package installation logic 2026-03-20 17:50:24 -04:00
Linken Quy Dinh
85173126f4 fix: multi-monitor wallpaper cycling not working (#2042)
Fixed a QML property binding timing issue where dynamically created timers
and processes for per-monitor wallpaper cycling were being assigned to
properties and then immediately read back, which could return undefined
or stale values.

The fix stores the created object in a local variable before assigning
to the property map, ensuring a valid reference is always used.

Affected functions:
- startMonitorCycling() - timer creation
- cycleToNextWallpaper() - process creation
- cycleToPrevWallpaper() - process creation
2026-03-20 17:40:52 -04:00
bbedward
222187d8a6 niri: set com.danklinux.dms window rule for future compat 2026-03-20 10:05:29 -04:00
bbedward
bef3f65f63 popout: avoid calling functions on stale references 2026-03-20 09:36:38 -04:00
Dimariqe
bff83fe563 fix: redraw wallpaper after DMS lock screen is dismissed (#2037)
After unlocking the screen (startup lock or wake from sleep), the desktop
showed Hyprland's background color instead of the wallpaper.

WallpaperBackground disables QML updates via updatesEnabled after a 1-second
settle timer. While WlSessionLock is active, Hyprland does not composite the
background layer, so when the lock is released it needs a fresh Wayland buffer
— but none is committed because the render loop is already paused.

The previous attempt used SessionService.sessionUnlocked, which is unreliable
for the startup lock case: DMSService is not yet connected when lock() is
called at startup, so notifyLoginctl is a no-op and the loginctl state never
transitions, meaning sessionUnlocked never fires.

Fix by tracking the shell lock state directly from Lock.qml's shouldLock via
a new IdleService.isShellLocked property. WallpaperBackground watches this and
re-enables rendering for 1 second on unlock, ensuring a fresh buffer is
committed to Wayland before the compositor resumes displaying the layer.
2026-03-20 09:36:31 -04:00
bbedward
cbf00d133a wallpaper: tweak binding again for updatesEnabled 2026-03-20 09:25:04 -04:00
purian23
347f06b758 refactor(Notepad): Streamline hide behavior & auto-save function 2026-03-19 21:42:19 -04:00
bbedward
9070903512 cleanup settings tabs 2026-03-19 20:02:46 -04:00
purian23
e9d030f6d8 (greeter): Revise dir perms and add validations 2026-03-19 19:56:18 -04:00
bbedward
fbf9e6d1b9 greeter: remove variable assignments 2026-03-19 19:55:47 -04:00
purian23
e803812344 theme(greeter): fix auto theme accent variants & update selections 2026-03-19 19:55:43 -04:00
nick-linux8
9a64f2acf0 Fix(Greeter): Fixes #1992 Changed Greetd logic to include registryThemeVariants to pull in accent color (#2000) 2026-03-19 19:55:40 -04:00
zion
c647eafadc fix(nix/greeter): skip invalid customThemeFile in preStart (#1997)
* fix(nix/greeter): skip invalid customThemeFile in preStart

Avoid attempting to copy a null/empty/missing customThemeFile path by validating the jq result and file existence before cp.

Update distro/nix/greeter.nix

Co-authored-by: Lucas <43530291+LuckShiba@users.noreply.github.com>

* nix/greeter: update customTheme verification

---------

Co-authored-by: Lucas <43530291+LuckShiba@users.noreply.github.com>
Co-authored-by: LuckShiba <luckshiba@protonmail.com>
2026-03-19 19:55:35 -04:00
purian23
720ec07d13 (greeter): Trial fix for 30s auth delay & wireplumber state dir 2026-03-19 19:55:31 -04:00
purian23
4b4334e611 dms(policy): Restore dms greeter sync in immutable distros 2026-03-19 19:55:27 -04:00
purian23
b69a96e80b fix(greeter): add wireplumber state directory & update U2F env variables 2026-03-19 19:55:23 -04:00
purian23
1e6a73fd60 greeter(auth): Enhance fingerprint/U2F auth support w/Quickshell PAM
- Split auth capability state by lock screen and greeter
- Share detection between settings UI and lock runtime
- Broaden greeter PAM include detection across supported distros
2026-03-19 19:55:18 -04:00
purian23
60b6280750 greeter(distros): Move comps to Suggests on Debian/OpenSUSE 2026-03-19 19:53:58 -04:00
purian23
9e079f8a4b fix(greeter): Dup crash handlers 2026-03-19 19:53:54 -04:00
purian23
62c2e858ef (settings): Enhance authentication checks in Greeter & LockScreen tabs 2026-03-19 19:53:49 -04:00
purian23
78357d45bb fix(greeter): Allow empty password submits to reach PAM 2026-03-19 19:53:12 -04:00
purian23
3ff9564c9b (greeter): PAM auth improvements and defaults update 2026-03-19 19:53:09 -04:00
purian23
b0989cecad fix(Greeter): Multi-distro reliability updates
- Merge duplicate niri input/output KDL nodes instead of appending. Allows more overrides
- Guard AppArmor install/uninstall behind IsAppArmorEnabled() check
2026-03-19 19:53:04 -04:00
purian23
47be6a1033 fix(Greeter): Don't stop greeter immediately upon uninstallation 2026-03-19 19:53:01 -04:00
purian23
31b415b086 feat(Greeter): Add install/uninstall/activate cli commands & new UI opts
- AppArmor profile management
- Introduced `dms greeter uninstall` command to remove DMS greeter configuration and restore previous display manager.
- Implemented AppArmor profile installation and uninstallation for enhanced security.
2026-03-19 19:52:57 -04:00
purian23
7156e1e299 feat: Implement immutable DMS command policy
- Added pre-run checks for greeter and setup commands to enforce policy restrictions
- Created cli-policy.default.json to define blocked commands and user messages for immutable environments.
2026-03-19 19:52:52 -04:00
purian23
c72c9bfb08 greeter: New Greeter Settings UI & Sync fixes
- Add PAM Auth via GUI
- Added new sync flags
- Refactored cache directory management & many others
- Fix for wireplumber permissions
- Fix for polkit auth w/icon
- Add pam_fprintd timeout=5 to prevent 30s auth blocks when using password
2026-03-19 19:52:48 -04:00
purian23
73c75fcc2c refactor(greeter): Update auth flows and add configurable opts
- Finally fix debug info logs before dms greeter loads
- prevent greeter/lockscreen auth stalls with timeout recovery and unlock-state sync
2026-03-19 19:50:58 -04:00
bbedward
2ff42eba41 greeter: sync power menu options 2026-03-19 19:49:26 -04:00
purian23
9f13465cd7 feat: Add independent power action confirmation settings for dms greeter 2026-03-19 19:49:23 -04:00
purian23
366a98e0cc dms-greeter: Enhance DMS Greeter dankinstall & packaging across distros
- Added support for Debian, Ubuntu, Fedora, Arch, and OpenSUSE on dankinstall / dms greeter install
2026-03-19 19:48:20 -04:00
bbedward
31aeb8dc4b wallpaper: fixes for updatesEnable handling 2026-03-19 14:24:01 -04:00
bbedward
c4e7f3d62f workspaces: ignore X scroll events
fixes #2029
2026-03-19 13:22:07 -04:00
purian23
a1d13f276a dankinstall(debian): Minor update to ARM64 support 2026-03-18 09:27:52 -04:00
bbedward
dbf132d633 launcher v2: simplify screen change bindings 2026-03-18 09:27:47 -04:00
bbedward
59451890f1 popout: fix focusing of password prompts when popout is open
undesired effect of closing the popout but its probably the best solution
2026-03-16 11:37:21 -04:00
bbedward
e633c9e039 focused app: fallback to app name if no title in compact mode fixes #2005 2026-03-16 11:37:15 -04:00
bbedward
6c1fff2df1 cc: fix invalid number displays on percentages fixes #2010 2026-03-16 11:37:10 -04:00
bbedward
3891d125d1 dankbar: guard against nil screen names 2026-03-16 11:35:11 -04:00
bbedward
997011e008 fix: missing import in Hyprland service 2026-03-13 13:26:02 -04:00
dms-ci[bot]
2504396435 nix: update vendorHash for go.mod changes 2026-03-13 16:24:54 +00:00
bbedward
d206723b36 ci: fix hardcoded branch in vendor workflow 2026-03-13 12:22:46 -04:00
bbedward
a0ec3d59b8 nix: update flake 2026-03-13 12:18:38 -04:00
bbedward
17ef08aa58 nix: fix go regex matching 2026-03-13 12:18:38 -04:00
bbedward
57279d1c53 nix: dynamically resolve go version in flake 2026-03-13 12:18:38 -04:00
bbedward
8b003ac9cd ci: reveal errors in nix vendor hash update 2026-03-13 12:17:38 -04:00
Nek
0ea10b0ad2 fix(wallpaper): preserve per-monitor cycling when changing interval (#1981)
(#1816)
2026-03-13 11:46:14 -04:00
nick-linux8
2db4c9daa0 Added Better Handling In Event Dispatcher Function (#1980) 2026-03-13 11:45:02 -04:00
bbedward
363964e90b fix(udev): avoid event loop termination core: bump go to 1.26 2026-03-13 11:45:02 -04:00
Nek
a7b49eba70 fix(matugen): detect Zed Linux binary aliases (#1982) 2026-03-13 11:44:10 -04:00
bbedward
4ae334f60f settings: allow custom json to render all theme options 2026-03-13 11:44:05 -04:00
bbedward
86c0064ff9 fix(settings): fix animation speed binding in notifications tab fixes #1974 2026-03-12 11:45:36 -04:00
Adarsh219
5a6b52f07f fix(matugen): use single quotes for zed template paths (#1972) 2026-03-12 11:45:36 -04:00
Adarsh219
5aaa56853f feat: Add Zed editor theming support (#1954)
* feat: Add Zed editor theming support

* fix formatting and switch to CONFIG_DIR
2026-03-12 11:45:31 -04:00
bbedward
35913c22f5 fix(idle): ensure timeouts can never be 0 2026-03-11 18:55:44 -04:00
purian23
d7b560573c fix(settings): Improve error handling for plugin settings loading 2026-03-11 18:55:44 -04:00
bbedward
02a274ebe2 fix(launcher): select first file search result by default fixes #1967 2026-03-11 12:47:39 -04:00
nick-linux8
fc7b61c20b Issue:(Settings)Switched Neovim Mutagen Theme To Default False (#1964)
* Issue:(Settings)Switched Neovim Mutagen Theme To Default False

* also set to false in settingsData
- this is the case when file fails to parse

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-03-11 12:44:14 -04:00
bbedward
5880043f56 fix: dsearch references 2026-03-11 12:08:24 -04:00
Triệu Kha
fee3b7f2a7 fix(appdrawer): launcher launched via appdrawer doesnt respect size (#1960)
setting
2026-03-11 10:56:21 -04:00
bbedward
c0b0339fca plugins: fix list delegates 2026-03-11 09:58:24 -04:00
bbedward
26c1e62204 fix(dankbar): use ID as tie breaker 2026-03-10 11:48:33 -04:00
purian23
7b2d4dbe30 dankinstall: Update Arch/Quickshell installation 2026-03-10 11:05:25 -04:00
CaptainSpof
78c5d46c6b fix(wallpaper): follow symlinks when scanning wallpaper directory (#1947) 2026-03-10 11:05:25 -04:00
purian23
3fb85df504 fix(Clipboard) remove unused copyServe logic 2026-03-10 11:05:00 -04:00
micko
227dd24726 update deprecated syntax (#1928) 2026-03-10 11:05:00 -04:00
purian23
ae6a656899 fix(Clipboard): Epic RAM Growth - Closes #1920 2026-03-10 11:05:00 -04:00
Connor Welsh
a4055e0f01 fix(Calendar): add missing qs.Common import (#1926)
fixes calendar events getting dropped
2026-03-10 11:05:00 -04:00
Lucas
6d98c229ef flake: allow extra QT packages in dms-shell package (#1903) 2026-03-10 11:04:01 -04:00
Michael Erdely
71d93ad85e Not everyone uses paru or yay on Arch: Support pacman command (#1900)
* Not everyone uses paru or yay on Arch: Support pacman command
* Handle sudo properly when using pacman
* Move pacman to bottom per Purian23
* Remote duplicate which -- thanks Purian23!
2026-03-10 11:04:01 -04:00
Triệu Kha
4ec21fcd3d fix(dock): Dock flickering when having cursor floating by the side (#1897) 2026-03-10 11:04:01 -04:00
Lucas
0a2fe03fee ipc: update DankBar selection (#1894)
* ipc: update DankBar selection

* ipc: use getPreferredBar in dash open

* ipc: don't toggle dash on dash open
2026-03-10 11:04:01 -04:00
Triệu Kha
4f4745609b fix(osd): play/pause icon flipped in MediaPlaybackOSD (#1889) 2026-03-10 11:03:23 -04:00
purian23
a69cd515fb fix(dbar): Fixes autohide + click through edge case 2026-03-10 11:03:23 -04:00
purian23
06c4b97a6b fix(notifications): Allow duplicate history entry management w/unique IDs & source tracking 2026-03-10 11:03:23 -04:00
purian23
a6cf71a190 fix(notifications): Apply appIdSubs to iconFrImage fallback path - Consistent with the appIcon PR changes in #1880. 2026-03-10 11:01:34 -04:00
odt
21750156dc refactor(icons): centralize icon resolution into Paths.resolveIconPath/resolveIconUrl (#1880)
Supersedes #1878. Rather than duplicating the moddedAppId + file path
substitution pattern inline across 8 files, this introduces two
centralized functions in Paths.qml:

- resolveIconPath(iconName): for Quickshell.iconPath() callsites,
  with DesktopService.resolveIconPath() fallback
- resolveIconUrl(iconName): for image://icon/ URL callsites

All consumer files now use one-line calls. When no substitutions are
configured, moddedAppId() returns the original name unchanged (zero
cost), so this has no impact on users who don't use the feature.

Affected components:
- AppIconRenderer (8 lines → 1)
- NotificationCard, NotificationPopup, HistoryNotificationCard
- DockContextMenu, AppsDockContextMenu
- LauncherContent, LauncherTab (×3)

Co-authored-by: odtgit <odtgit@taliops.com>
2026-03-10 11:00:57 -04:00
supposede
f9b737f543 Update toolbar button styles with primary color (#1879) 2026-03-10 10:55:58 -04:00
odt
246b59f3b9 fix(icons): apply file path substitutions in launcher icon resolution (#1877)
Follow-up to #1867. The launcher's AppIconRenderer used its own
Quickshell.iconPath() call without going through appIdSubstitutions,
so PWA icons configured via regex file path rules were not resolved
in the app launcher.

Co-authored-by: odtgit <odtgit@taliops.com>
2026-03-10 10:55:53 -04:00
bbedward
dcda81ea64 wallpaper: bump render settle timer 2026-03-01 10:27:10 -05:00
bbedward
9909b665cd blurred wallpaper: defer update disabling much longer 2026-02-28 15:40:18 -05:00
bbedward
4bcd786be3 wallpaper: defer updatesEnabled binding 2026-02-28 01:10:26 -05:00
bbedward
64c9222000 loginctl: add fallbacks for session discovery 2026-02-27 10:12:25 -05:00
Iris
12acf2dd51 Change IsPluggedIn logic (#1859)
Co-authored-by: Iris <iris@raidev.eu>
2026-02-27 10:12:22 -05:00
Jan Greimann
fea97b4aad Adjust SystemUpdate process (#1845)
This fixes the problem that the system update terminal closes when the package manager encounters a problem (exit code != 0), allowing the user to understand the problem.

Signed-off-by: Jan Phillip Greimann <jan.greimann@ionos.com>
2026-02-27 10:12:19 -05:00
Kangheng Liu
c6d398eeac Systray: call context menu fallback for legacy protocol (#1839)
* systray: add call contextmenu fallback

directly call dbus contextmenu method. needs refactoring to be more
robust.

* add TODO

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-02-27 10:12:16 -05:00
bbedward
7a74be83d7 greeter: sync power menu options 2026-02-25 14:50:47 -05:00
bbedward
67a6427418 dankdash: fix menu overlays 2026-02-25 14:50:47 -05:00
purian23
18b20d3225 feat: Add independent power action confirmation settings for dms greeter 2026-02-25 14:50:47 -05:00
bbedward
8a76885fb6 desktop widgets: fix deactive loaders when widgets disabled fixes #1813 2026-02-25 12:34:47 -05:00
bbedward
69b1e61ab7 stage 1.4.3 2026-02-25 12:34:43 -05:00
bbedward
3f24cf37ca settings: make horizontal change more smart 2026-02-24 20:49:02 -05:00
bbedward
01218f34cb settings: restore notifyHorizontalBarChanged 2026-02-24 19:43:01 -05:00
purian23
9da58d8296 fix: Update HTML rendering injections 2026-02-24 19:43:01 -05:00
purian23
af0038e634 dbar: Refactor to memoize dbar & widget state via json 2026-02-24 19:20:30 -05:00
purian23
05c312b9eb cpu widget: Fix monitor binding 2026-02-24 19:20:30 -05:00
bbedward
89d5c958c4 settings: use Image in theme colors tab wp preview 2026-02-24 15:23:07 -05:00
bbedward
e4d86ad595 popout: fully unload popout layers on close 2026-02-24 15:20:00 -05:00
bbedward
532b54a028 wallpaper: handle initial load better, add dms randr command for quick physical scale retrieval 2026-02-24 15:20:00 -05:00
bbedward
504d027c3f privacy indicator: fix width when not active 2026-02-24 13:59:58 -05:00
bbedward
e8f95f4533 settings: use Image for per-mode previews 2026-02-24 13:37:34 -05:00
bbedward
b83256c83a matugen: skip theme refreshes if no colors changed 2026-02-24 13:37:34 -05:00
bbedward
8e2cd21be8 dock: fix tooltip positioning 2026-02-24 13:37:34 -05:00
bbedward
c5413608da dankbar: fix some defaults in reset 2026-02-24 13:37:34 -05:00
bbedward
586bcad442 widgets: set updatesEnabled false on background layers, if qs supports it 2026-02-24 13:37:34 -05:00
bbedward
3b3d10f730 widgets: fix moddedAppID consistency 2026-02-24 13:37:34 -05:00
purian23
4834891b36 settings: Re-adjust dbar layout 2026-02-24 13:37:34 -05:00
purian23
f60e65aecb settings: Dankbar layout updates 2026-02-24 13:37:34 -05:00
purian23
01387b0123 fix: Clipboard button widget alignment 2026-02-24 13:37:34 -05:00
bbedward
1476658c23 dankbar: fix syncing settings to new bars 2026-02-24 13:37:34 -05:00
bbedward
7861c6e316 i18n: term sync 2026-02-24 10:52:35 -05:00
bbedward
d2247d7b24 dankbar: restore horizontal change debounce 2026-02-24 10:52:35 -05:00
bbedward
2ff78d4a02 dpms: disable fade overlay in onRequestMonitorOn 2026-02-24 10:52:35 -05:00
bbedward
785243ce5f dankbar: optimize bindings in bar window 2026-02-24 10:52:35 -05:00
bbedward
0e1b868384 widgets: fix undefined icon warnings 2026-02-24 10:52:35 -05:00
null
2b08e800e8 feat: improve icon resolution and align switcher fallback styling (#1823)
- Implement deep search icon resolution in DesktopService with runtime caching.
- Update Paths.getAppIcon to utilize enhanced resolution for mismatched app IDs.
- Align Workspace Switcher fallback icons with AppsDock visual style.
- Synchronize fallback text logic between Switcher and Dock using app names.
2026-02-24 10:52:35 -05:00
purian23
74e4f8ea1e display: Fix output config on delete & popup height 2026-02-24 10:52:35 -05:00
purian23
9c58569b4c template: Refine bug report tracker 2026-02-24 10:52:35 -05:00
purian23
29de677e00 feat: Refactor DankBar w/New granular options - New background toggles - New maxIcon & maxText widget sizes (global) - Dedicated M3 padding slider - New independent icon scale options - Updated logic to improve performance on single & dual bar modes 2026-02-24 10:52:35 -05:00
purian23
fae4944845 fix: Animated Image warnings 2026-02-24 10:52:34 -05:00
Lucas
07a0ac4b7d doctor: fix imageformats detection (#1811) 2026-02-23 19:45:10 -05:00
bbedward
b2d8f4d73b keybinds: preserve scroll position of expanded item on list change fixes #1766 2026-02-23 19:33:29 -05:00
bbedward
fe58c45233 widgets: fallback when AnimatedImage probe fails to static Image 2026-02-23 19:03:48 -05:00
bbedward
3ea4e389eb thememode: connect to loginctl PrepareForSleep event 2026-02-23 19:03:48 -05:00
purian23
7276f295fc dms-greeter: Update dankinstall greeter automation w/distro packages 2026-02-23 18:53:29 -05:00
bbedward
93ed96a789 launcher: don't tie unload to visibility 2026-02-23 18:53:29 -05:00
purian23
bea325e94c audio: Sync audio hide opts w/dash Output devices 2026-02-23 18:53:29 -05:00
bbedward
2f8f1c30ad audio: fix cycle output, improve icon resolution for sink fixes #1808 2026-02-23 18:53:29 -05:00
Lucas
f859a14173 nix: update flake.lock (#1809) 2026-02-23 18:53:29 -05:00
bbedward
153f39da48 audio: disable effects when mpris player is playing 2026-02-23 18:53:29 -05:00
bbedward
e4accdd1c7 launcher: implement memory for selected tab fixes #1806 2026-02-23 10:20:48 -05:00
dms-ci[bot]
a2c89e0a8c nix: update vendorHash for go.mod changes 2026-02-23 10:20:48 -05:00
bbedward
e282831c2e widgets: make AnimatedImage conditional in DankCircularImage - Cut potential overhead of always using AnimatedImage 2026-02-23 10:20:48 -05:00
bbedward
5c5ff6195a osd: disable media playback OSD by default 2026-02-23 10:20:48 -05:00
Triệu Kha
c4bbf54679 clipboard: fix html elements get parsed in clipboard entry (#1798)
* clipboard: fix html elements get parsed in clipboard entry

* Revert "clipboard: fix html elements get parsed in clipboard entry"

This reverts commit 52b11eeb98.

* clipboard: fix html elements get parsed in clipboard entry
2026-02-23 10:20:48 -05:00
Jonas Bloch
98acafb4b8 fix(notepad): decode path URI when saving/creating a file (#1805) 2026-02-23 10:20:48 -05:00
Jonas Bloch
da20681fc0 feat: add support for animated gifs as profile pictures (#1804) 2026-02-23 10:20:48 -05:00
purian23
b38cb961b2 dms-greeter: Enable greetd via dms greeter install all-in-one cmd 2026-02-23 10:20:48 -05:00
bbedward
7a0bb07518 matugen: unconditionally run portal sync even if matugen errors 2026-02-22 23:09:18 -05:00
purian23
403e3e90a2 dms-greeter: Enhance DMS Greeter dankinstall & packaging across distros - Added support for Debian, Ubuntu, Fedora, Arch, and OpenSUSE on dankinstall / dms greeter install 2026-02-22 23:09:18 -05:00
bbedward
50b91f14b6 launcher: fix frecency ranking in search results fixes #1799 2026-02-22 23:09:18 -05:00
bbedward
b3df47fce0 scripts: fix shellcheck 2026-02-22 23:09:18 -05:00
bbedward
09bd65d746 bluetooth: expose trust/untrust on devices 2026-02-22 23:09:18 -05:00
长夜月玩Fedora
020d56ab7f Add support for 'evernight' distribution in Fedora (#1786) 2026-02-22 23:09:18 -05:00
Triệu Kha
f3bee65da9 Fix dock visible when theres no app (#1797)
* clipboard: improve image thumbnail
- thumbnail image is now bigger
- circular mask has been replaced with rounded rectangular mask

* dock: fix dock still visible when there's no app
2026-02-22 23:09:18 -05:00
purian23
b14b0946e2 feat: DMS Greeter packaging for Debian/OpenSUSE on OBS 2026-02-22 23:09:18 -05:00
Lucas
ca44205f1c zen: add more commands to detection (#1792) 2026-02-22 23:09:18 -05:00
purian23
2d39e8fd2a ipc: Fix DankDash Wallpaper call 2026-02-22 23:09:18 -05:00
purian23
6d4df6e927 theme: Fix Light/Dark mode portal sync 2026-02-22 23:09:18 -05:00
Connor Welsh
b8ab86e6c0 distro: add cups-pk-helper as suggested dependency (#1670) 2026-02-22 23:09:18 -05:00
bbedward
837329a6d8 window rules: default to fixed for width/height part of #1774 2026-02-22 23:09:18 -05:00
purian23
8c6c2ffd23 ubuntu: Fix dms-git Go versioning to restore builds 2026-02-22 23:09:18 -05:00
bbedward
ad3c8b6755 v1.4.3 version file 2026-02-22 23:07:18 -05:00
bbedward
03a8e1e0d5 clipboard: fix memory leak from unbounded offer maps and unguarded file reads 2026-02-20 11:42:14 -05:00
bbedward
4d4d3c20a1 keybinds/niri: fix quote preservation 2026-02-20 11:42:14 -05:00
bbedward
cef16d6bc9 dankdash: fix widgets across different bar section fixes #1764s 2026-02-20 11:42:14 -05:00
bbedward
aafaad1791 core/screenshot: light cleanups 2026-02-20 11:42:14 -05:00
Patrick Fischer
7906fdc2b0 screensaver: emit ActiveChanged on lock/unlock (#1761) 2026-02-20 11:42:14 -05:00
Triệu Kha
397650ca52 clipboard: improve image thumbnail (#1759)
- thumbnail image is now bigger
- circular mask has been replaced with rounded rectangular mask
2026-02-20 11:42:14 -05:00
purian23
826207006a template: Default install method 2026-02-20 11:42:14 -05:00
purian23
58c2fcd31c issues: Template fix 2026-02-20 11:42:14 -05:00
purian23
b2a2b425ec templates: Fix GitHub issue labels 2026-02-20 11:42:14 -05:00
shorinkiwata
942c9c9609 feat(distros): allow CatOS to run DMS installer (#1768)
- This PR adds support for **CatOS**
- CatOS is fully compatible with Arch Linux
2026-02-20 11:42:14 -05:00
purian23
46d6e1cff3 templates: Update DMS issue formats 2026-02-20 11:42:14 -05:00
bbedward
a4137c57c1 running apps: fix ordering on niri 2026-02-19 20:46:26 -05:00
bbedward
1ad8b627f1 launcher: fix premature exit of file search fixes #1749 2026-02-19 16:47:34 -05:00
Jonas Bloch
58a02ce290 Search keybinds fixes (#1748)
* fix: close keybind cheatsheet on escape press

* feat: match all space separated words in keybind cheatsheet search
2026-02-19 16:27:14 -05:00
bbedward
8e1ad1a2be audio: fix hide device not working 2026-02-19 16:24:48 -05:00
bbedward
68cd7ab32c i18n: term sync 2026-02-19 14:11:21 -05:00
Youseffo13
f649ce9a8e Added missing i18n strings and changed reset button (#1746)
* Update it.json

* Enhance SettingsSliderRow: add resetText property and update reset button styling

* added i18n strings

* adjust reset button width to be dynamic based on content size

* added i18n strings

* Update template.json

* reverted changes

* Update it.json

* Update template.json
2026-02-19 14:11:21 -05:00
bbedward
c4df242f07 dankbar: remove behaviors from monitoring widgets 2026-02-19 14:11:21 -05:00
bbedward
26846c8d55 dgop: round computed values to match display format 2026-02-19 14:11:21 -05:00
bbedward
31b44a667c flake: fix dev flake for go 1.25 and ashellchheck 2026-02-19 14:11:21 -05:00
bbedward
4f3b73ee21 hyprland: add serial to output model generator 2026-02-19 09:22:56 -05:00
bbedward
4cfae91f02 dock: fix context menu styling fixes #1742 2026-02-19 09:22:56 -05:00
bbedward
8d947a6e95 dock: fix transparency setting fixes #1739 2026-02-19 09:22:56 -05:00
bbedward
1e84d4252c launcher: improve perf of settings search 2026-02-19 09:22:56 -05:00
bbedward
76072e1d4c launcher: always heuristic lookup cached entries 2026-02-19 09:22:56 -05:00
bbedward
6408dce4a9 launcher v2: always heuristicLookup tab actions 2026-02-18 19:07:30 -05:00
bbedward
0b2e1cca38 i18n: term updates 2026-02-18 18:35:29 -05:00
bbedward
c1bfd8c0b7 system tray: fix to take up 0 space when empty 2026-02-18 18:35:29 -05:00
Youseffo13
90ffa5833b Added Missing i18n strings (#1729)
* inverted dock visibility and position option

* added missing I18n strings

* added missing i18n strings

* added i18n strings

* Added missing i18n strings

* updated translations

* Update it.json
2026-02-18 18:35:29 -05:00
bbedward
169c669286 widgets: add openWith/toggleWith modes for dankbar widgets 2026-02-18 16:24:07 -05:00
bbedward
f8350deafc keybinds: fix escape in keybinds modal 2026-02-18 14:57:53 -05:00
bbedward
0286a1b80b launcher v2: remove calc cc: enhancements for plugins to size details 2026-02-18 14:48:44 -05:00
beluch-dev
7c3e6c1f02 fix: correct parameter name in Hyprland windowrule (no_initial_focus) (#1726)
##Description
This PR corrects the parameter name to match new Hyprland standard.

## Changes
-Before: 'noinitialfocus'
-After: 'no_initial_focus'
2026-02-18 14:48:40 -05:00
bbedward
d2d72db3c9 plugins: fix settings focus loss 2026-02-18 13:36:51 -05:00
Evgeny Zemtsov
f81f861408 handle recycled server object IDs for workspace/group handles (#1725)
When switching tabs rapidly or closing multiple tabs, the taskbar shows
"ghost" workspaces — entries with no name, no coordinates, and no active
state. The ghosts appear at positions where workspaces were removed and
then recreated by the compositor.

When a compositor removes a workspace (sends `removed` event) and the
client calls Destroy(), the proxy is marked as zombie but stays in the
Context.objects map. For server-created objects (IDs >= 0xFF000000), the
server never sends `delete_id`, so the zombie proxy persists indefinitely.

When the compositor later creates a new workspace that gets a recycled
server object ID, GetProxy() returns the old zombie proxy. The dispatch
loop in GetDispatch() checks IsZombie() and silently drops ALL events
for zombie proxies — including property events (name, id, coordinates,
state, capabilities) intended for the new workspace. This causes the
ghost workspaces with empty properties in the UI.

Fix: check IsZombie() when handling `workspace` and `workspace_group`
events that carry a `new_id` argument. If the existing proxy is a
zombie, treat it as absent and create a fresh proxy via
registerServerProxy(), which replaces the zombie in the map. Subsequent
property events are then dispatched to the live proxy.
2026-02-18 13:36:51 -05:00
bbedward
af494543f5 1.4.2: staging ground 2026-02-18 13:36:43 -05:00
bbedward
db4de55338 popout: decouple shadow from content layer 2026-02-18 10:46:01 -05:00
bbedward
37ecbbbbde popout: disable layer after animation 2026-02-18 10:34:21 -05:00
purian23
d6a6d2a438 notifications: Maintain shadow during expansion 2026-02-18 10:34:21 -05:00
purian23
bf1c6eec74 notifications: Update initial popup height surfaces 2026-02-18 10:34:21 -05:00
bbedward
0ddae80584 running apps: fix scroll events being propagated fixes #1724 2026-02-18 10:34:21 -05:00
bbedward
5c96c03bfa matugen: make v4 detection more resilient 2026-02-18 09:57:35 -05:00
bbedward
dfe36e47d8 process list: fix scaling with fonts fixes #1721 2026-02-18 09:57:35 -05:00
purian23
63e1b75e57 dankinstall: Fix Debian ARM64 detection 2026-02-18 09:57:35 -05:00
bbedward
29efdd8598 matugen: detect emacs directory fixes #1720 2026-02-18 09:57:35 -05:00
bbedward
34d03cf11b osd: optimize bindings 2026-02-18 09:57:35 -05:00
bbedward
c339389d44 screenshot: adjust cursor CLI option to be more explicit 2026-02-17 22:28:46 -05:00
bbedward
af5f6eb656 settings: workaround crash 2026-02-17 22:20:19 -05:00
purian23
a6d28e2553 notifications: Tweak animation scale & settings 2026-02-17 22:07:36 -05:00
bbedward
6213267908 settings: guard internal writes from watcher 2026-02-17 22:03:57 -05:00
bbedward
d084114149 cc: fix plugin reloading in bar position changes 2026-02-17 17:25:19 -05:00
bbedward
f6d99eca0d popout: anchor height changing popout surfaces to top and bottom 2026-02-17 17:25:19 -05:00
bbedward
722eb3289e workspaces: fix named workspace icons 2026-02-17 17:25:19 -05:00
bbedward
b7f2bdcb2d dankinstall: no_anim on dms layers 2026-02-17 17:25:19 -05:00
bbedward
11c20db6e6 1.4.1 2026-02-17 14:08:15 -05:00
bbedward
8a4e3f8bb1 system updater: fix hide no update option 2026-02-17 14:08:04 -05:00
bbedward
bc8fe97c13 launcher: fix kb navigation not always showing last delegate in view 2026-02-17 14:08:04 -05:00
bbedward
47262155aa doctor: add qt6-imageformats check 2026-02-17 14:08:04 -05:00
492 changed files with 28221 additions and 91483 deletions

View File

@@ -20,7 +20,7 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}

View File

@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
@@ -38,7 +38,7 @@ jobs:
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version-file: ./core/go.mod

View File

@@ -1,4 +1,4 @@
name: Nix flake and NixOS tests
name: Check nix flake
on:
pull_request:
@@ -9,35 +9,15 @@ on:
jobs:
check-flake:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
enable_kvm: true
extra_nix_config: |
system-features = nixos-test benchmark big-parallel kvm
- name: Check the flake
run: nix flake check -L
- name: Run NixOS module test
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
- name: Run NixOS service start test
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
- name: Run greeter niri test
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
- name: Run home-manager module test
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
- name: Run niri home-manager module test
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
run: nix flake check

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
@@ -21,7 +21,7 @@ jobs:
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version-file: core/go.mod

View File

@@ -32,13 +32,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version-file: ./core/go.mod
@@ -106,7 +106,7 @@ jobs:
- name: Upload artifacts (${{ matrix.arch }})
if: matrix.arch == 'arm64'
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: core-assets-${{ matrix.arch }}
path: |
@@ -120,7 +120,7 @@ jobs:
- name: Upload artifacts with completions
if: matrix.arch == 'amd64'
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: core-assets-${{ matrix.arch }}
path: |
@@ -147,7 +147,7 @@ jobs:
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
# - name: Checkout
# uses: actions/checkout@v6
# uses: actions/checkout@v4
# with:
# token: ${{ steps.app_token.outputs.token }}
# fetch-depth: 0
@@ -181,7 +181,7 @@ jobs:
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
@@ -192,12 +192,12 @@ jobs:
git checkout ${TAG}
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version-file: ./core/go.mod
- name: Download core artifacts
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
pattern: core-assets-*
merge-multiple: true

View File

@@ -46,7 +46,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Determine version
id: version
@@ -134,7 +134,7 @@ jobs:
rpm -qpi "$SRPM"
- name: Upload SRPM artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }}
path: ${{ steps.build.outputs.srpm_path }}

View File

@@ -9,8 +9,8 @@ on:
type: choice
options:
- dms
- dms-greeter
- dms-git
- dms-greeter
- all
default: "dms"
rebuild_release:
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -119,8 +119,9 @@ jobs:
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
elif [[ "$PKG" == "all" ]]; then
# Check each stable package and build list of those needing updates
# Check each package and build list of those needing updates
PACKAGES_TO_UPDATE=()
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
if check_dms_stable; then
PACKAGES_TO_UPDATE+=("dms")
if [[ -n "$LATEST_TAG" ]]; then
@@ -139,7 +140,7 @@ jobs:
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
echo "✓ Both packages up to date"
echo "✓ All packages up to date"
fi
elif [[ "$PKG" == "dms-git" ]]; then
@@ -195,13 +196,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Wait before OBS upload
run: sleep 3
- name: Determine packages to update
id: packages
run: |
@@ -247,7 +245,7 @@ jobs:
fi
- name: Update dms-git spec version
if: contains(steps.packages.outputs.packages, 'dms-git')
if: contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all'
run: |
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
COMMIT_COUNT=$(git rev-list --count HEAD)
@@ -268,7 +266,7 @@ jobs:
} > distro/opensuse/dms-git.spec
- name: Update Debian dms-git changelog version
if: contains(steps.packages.outputs.packages, 'dms-git')
if: contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all'
run: |
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
COMMIT_COUNT=$(git rev-list --count HEAD)
@@ -347,7 +345,7 @@ jobs:
done
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version-file: ./core/go.mod
@@ -391,7 +389,7 @@ jobs:
UPLOADED_PACKAGES=()
SKIPPED_PACKAGES=()
# PACKAGES can be space-separated list (e.g., "dms dms-greeter" from "all" check)
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
# Loop through each package and upload
for PKG in $PACKAGES; do
echo ""

View File

@@ -4,15 +4,9 @@ on:
workflow_dispatch:
inputs:
package:
description: "Package to upload"
required: true
type: choice
options:
- dms
- dms-greeter
- dms-git
- all
default: "dms"
description: "Package to upload (dms, dms-git, dms-greeter, or all)"
required: false
default: "dms-git"
rebuild_release:
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
required: false
@@ -31,7 +25,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -145,7 +139,7 @@ jobs:
fi
else
# Fallback
echo "packages=dms" >> $GITHUB_OUTPUT
echo "packages=dms-git" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
fi
@@ -157,12 +151,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version-file: ./core/go.mod
cache: false
@@ -215,7 +209,7 @@ jobs:
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
fi
# PACKAGES can be space-separated list (e.g., "dms-git dms dms-greeter" from "all" check)
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
# Loop through each package and upload
for PKG in $PACKAGES; do
# Map package to PPA name
@@ -242,11 +236,7 @@ jobs:
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ppa-upload.sh uploads to questing + resolute when series is omitted
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" "" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
echo "::error::Upload failed for $PKG"
exit 1
fi
bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" questing ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}
done
- name: Summary

View File

@@ -24,7 +24,7 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}

View File

@@ -20,11 +20,3 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
pass_filenames: false
- repo: local
hooks:
- id: no-console-in-qml
name: no console.* in QML (use Log service)
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
language: system
files: ^quickshell/.*\.qml$
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)

View File

@@ -1,13 +1,5 @@
This file is more of a quick reference so I know what to account for before next releases.
# 1.5.0
- Overhauled shadows
- App ID changed to com.danklinux.dms - breaking for window rules
- Greeter stuff
- Terminal mux
- Locale overrides
- new neovim theming
# 1.4.0
- Overhauled system monitor, graphs, styling

View File

@@ -86,9 +86,7 @@ touch .qmlls.ini
4. Restart dms to generate the `.qmlls.ini` file
5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`.
6. Make your changes, test, and open a pull request.
5. Make your changes, test, and open a pull request.
### I18n/Localization

View File

@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications
.PHONY: all build clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
.PHONY: all build clean install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
all: build
@@ -32,9 +32,6 @@ clean:
@$(MAKE) -C $(CORE_DIR) clean
@echo "Clean complete"
lint-qml:
@./quickshell/scripts/qmllint-entrypoints.sh
# Installation targets
install-bin:
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@@ -79,7 +76,7 @@ install-desktop:
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entry installed"
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
install: build install-bin install-shell install-completions install-systemd install-icon install-desktop
@echo ""
@echo "Installation complete!"
@echo ""
@@ -133,7 +130,6 @@ help:
@echo " all (default) - Build the DMS binary"
@echo " build - Same as 'all'"
@echo " clean - Clean build artifacts"
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
@echo ""
@echo "Install:"
@echo " install - Build and install everything (requires sudo)"

View File

@@ -13,7 +13,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases)
[![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/)
[![AUR version](https://img.shields.io/aur/version/dms-shell-bin?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://aur.archlinux.org/packages/dms-shell-bin)
[![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fdanklinux)](https://ko-fi.com/danklinux)

View File

@@ -28,12 +28,6 @@ packages:
outpkg: mocks_brightness
interfaces:
DBusConn:
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config:
dir: "internal/mocks/geolocation"
outpkg: mocks_geolocation
interfaces:
Client:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config:
dir: "internal/mocks/network"

View File

@@ -1,26 +1,13 @@
repos:
- repo: local
- repo: https://github.com/golangci/golangci-lint
rev: v2.9.0
hooks:
- id: golangci-lint-fmt
name: golangci-lint-fmt
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt
language: system
require_serial: true
types: [go]
pass_filenames: false
- id: golangci-lint-full
name: golangci-lint-full
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix
language: system
require_serial: true
types: [go]
pass_filenames: false
- id: golangci-lint-config-verify
name: golangci-lint-config-verify
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 config verify
language: system
files: \.golangci\.(?:yml|yaml|toml|json)
pass_filenames: false
- repo: local
hooks:
- id: go-test
name: go test
entry: go test ./...

View File

@@ -63,19 +63,19 @@ endif
build-all: build dankinstall
install:
install: build
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Installation complete"
install-all:
install-all: build-all
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Installation complete"
install-dankinstall:
install-dankinstall: dankinstall
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Installation complete"

View File

@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
Command-line interface and daemon for shell management and system control.
**dankinstall**
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
## System Integration
@@ -147,50 +147,10 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
## Installation via dankinstall
**Interactive (TUI):**
```bash
curl -fsSL https://install.danklinux.com | sh
```
**Headless (unattended):**
Headless mode requires cached sudo credentials. Run `sudo -v` first:
```bash
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y
```
| Flag | Short | Description |
|------|-------|-------------|
| `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
| `--exclude-deps <name,...>` | | Skip specific dependencies |
| `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) |
| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) |
| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts |
Headless mode requires `--yes` to proceed; without it, the installer exits with an error.
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
When no flags are provided, `dankinstall` launches the interactive TUI.
### Headless mode validation rules
Headless mode activates when `--compositor` or `--term` is provided.
- Both `--compositor` and `--term` are required; providing only one results in an error.
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
- Positional arguments are not accepted.
### Log file location
`dankinstall` writes logs to `/tmp` by default.
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory.
## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)

View File

@@ -3,152 +3,20 @@ package main
import (
"fmt"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
var Version = "dev"
// Flag variables bound via pflag
var (
compositor string
term string
includeDeps []string
excludeDeps []string
replaceConfigs []string
replaceConfigsAll bool
yes bool
)
var rootCmd = &cobra.Command{
Use: "dankinstall",
Short: "Install DankMaterialShell and its dependencies",
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags.
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
configure passwordless sudo for your user.`,
Args: cobra.NoArgs,
RunE: runDankinstall,
SilenceErrors: true,
SilenceUsage: true,
}
func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
}
func main() {
if os.Getuid() == 0 {
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
os.Exit(1)
}
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runDankinstall(cmd *cobra.Command, args []string) error {
headlessMode := compositor != "" || term != ""
if !headlessMode {
// Reject headless-only flags when running in TUI mode.
headlessOnly := []string{
"include-deps",
"exclude-deps",
"replace-configs",
"replace-configs-all",
"yes",
}
var set []string
for _, name := range headlessOnly {
if cmd.Flags().Changed(name) {
set = append(set, "--"+name)
}
}
if len(set) > 0 {
return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", "))
}
}
if headlessMode {
return runHeadless()
}
return runTUI()
}
func runHeadless() error {
// Validate required flags
if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
}
if term == "" {
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
}
cfg := headless.Config{
Compositor: compositor,
Terminal: term,
IncludeDeps: includeDeps,
ExcludeDeps: excludeDeps,
ReplaceConfigs: replaceConfigs,
ReplaceConfigsAll: replaceConfigsAll,
Yes: yes,
}
runner := headless.NewRunner(cfg)
// Set up file logging
fileLogger, err := log.NewFileLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
}
if fileLogger != nil {
fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath())
fileLogger.StartListening(runner.GetLogChan())
defer func() {
if err := fileLogger.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err)
}
}()
} else {
// Drain the log channel to prevent blocking sends from deadlocking
// downstream components (distros, config deployer) that write to it.
// Use an explicit stop signal because this code does not own the
// runner log channel and cannot assume it will be closed.
defer drainLogChan(runner.GetLogChan())()
}
if err := runner.Run(); err != nil {
if fileLogger != nil {
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath())
}
return err
}
if fileLogger != nil {
fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath())
}
return nil
}
func runTUI() error {
fileLogger, err := log.NewFileLogger()
if err != nil {
fmt.Printf("Warning: Failed to create log file: %v\n", err)
@@ -170,50 +38,18 @@ func runTUI() error {
if fileLogger != nil {
fileLogger.StartListening(model.GetLogChan())
} else {
// Drain the log channel to prevent blocking sends from deadlocking
// downstream components (distros, config deployer) that write to it.
// Use an explicit stop signal because this code does not own the
// model log channel and cannot assume it will be closed.
defer drainLogChan(model.GetLogChan())()
}
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
if logFilePath != "" {
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath)
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
}
return fmt.Errorf("error running program: %w", err)
os.Exit(1)
}
if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
}
return nil
}
// drainLogChan starts a goroutine that discards all messages from logCh,
// preventing blocking sends from deadlocking downstream components. It returns
// a cleanup function that signals the goroutine to stop and waits for it to
// exit. Callers should defer the returned function.
func drainLogChan(logCh <-chan string) func() {
drainStop := make(chan struct{})
drainDone := make(chan struct{})
go func() {
defer close(drainDone)
for {
select {
case <-drainStop:
return
case _, ok := <-logCh:
if !ok {
return
}
}
}
}()
return func() {
close(drainStop)
<-drainDone
}
}

View File

@@ -1,77 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra"
)
var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage DMS authentication sync",
Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen",
}
var authSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS authentication configuration",
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
if term {
if err := syncAuthInTerminal(yes); err != nil {
log.Fatalf("Error launching auth sync in terminal: %v", err)
}
return
}
if err := syncAuth(yes); err != nil {
log.Fatalf("Error syncing authentication: %v", err)
}
},
}
func init() {
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
}
func syncAuth(nonInteractive bool) error {
if !nonInteractive {
fmt.Println("=== DMS Authentication Sync ===")
fmt.Println()
}
logFunc := func(msg string) {
fmt.Println(msg)
}
if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil {
return err
}
if !nonInteractive {
fmt.Println("\n=== Authentication Sync Complete ===")
fmt.Println("\nAuthentication changes have been applied.")
}
return nil
}
func syncAuthInTerminal(nonInteractive bool) error {
syncFlags := make([]string, 0, 1)
if nonInteractive {
syncFlags = append(syncFlags, "--yes")
}
shellSyncCmd := "dms auth sync"
if len(syncFlags) > 0 {
shellSyncCmd += " " + strings.Join(syncFlags, " ")
}
shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3`
return runCommandInTerminal(shellCmd)
}

View File

@@ -1,40 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/spf13/cobra"
)
var blurCmd = &cobra.Command{
Use: "blur",
Short: "Background blur utilities",
}
var blurCheckCmd = &cobra.Command{
Use: "check",
Short: "Check if the compositor supports background blur (ext-background-effect-v1)",
Args: cobra.NoArgs,
Run: runBlurCheck,
}
func init() {
blurCmd.AddCommand(blurCheckCmd)
}
func runBlurCheck(cmd *cobra.Command, args []string) {
supported, err := blur.ProbeSupport()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
switch supported {
case true:
fmt.Println("supported")
default:
fmt.Println("unsupported")
}
}

View File

@@ -236,7 +236,6 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
defer ddc.Close()
time.Sleep(100 * time.Millisecond)
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
ddc.WaitPending()
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
}

View File

@@ -37,9 +37,6 @@ Output format flags (mutually exclusive, default: --hex):
--cmyk - CMYK values (C% M% Y% K%)
--json - JSON with all formats
Optional:
--raw - Removes ANSI escape codes and background colors. Use this when piping to other commands
Examples:
dms color pick # Pick color, output as hex
dms color pick --rgb # Output as RGB
@@ -56,7 +53,6 @@ func init() {
colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)")
colorPickCmd.Flags().Bool("cmyk", false, "Output as CMYK (C% M% Y% K%)")
colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON")
colorPickCmd.Flags().Bool("raw", false, "Removes ANSI escape codes and background colors. Use this when piping to other commands")
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
@@ -117,15 +113,7 @@ func runColorPick(cmd *cobra.Command, args []string) {
if jsonOutput {
fmt.Println(output)
return
}
if raw, _ := cmd.Flags().GetBool("raw"); raw {
fmt.Printf("%s\n", output)
return
}
if color.IsDark() {
} else if color.IsDark() {
fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output)
} else {
fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output)

View File

@@ -26,17 +26,6 @@ var runCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon")
session, _ := cmd.Flags().GetBool("session")
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
}
}
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
}
}
log.ApplyEnvOverrides()
if daemon {
runShellDaemon(session)
} else {
@@ -536,8 +525,5 @@ func getCommonCommands() []*cobra.Command {
configCmd,
dlCmd,
randrCmd,
blurCmd,
trashCmd,
systemCmd,
}
}

View File

@@ -11,7 +11,6 @@ import (
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
@@ -83,7 +82,7 @@ func (ds *DoctorStatus) OKCount() int {
}
var (
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
@@ -91,7 +90,6 @@ var (
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
)
var doctorCmd = &cobra.Command{
@@ -470,7 +468,6 @@ func checkWindowManagers() []checkResult {
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
}
var results []checkResult
@@ -503,7 +500,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
"Install Hyprland, niri, Sway, River, or Wayfire",
doctorDocsURL + "#compositor-checks",
})
}
@@ -512,24 +509,9 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
}
results = append(results, checkCompositorBlurSupport())
return results
}
func checkCompositorBlurSupport() checkResult {
supported, err := blur.ProbeSupport()
if err != nil {
return checkResult{catCompositor, "Background Blur", statusInfo, "Unable to verify", err.Error(), doctorDocsURL + "#compositor-checks"}
}
if supported {
return checkResult{catCompositor, "Background Blur", statusOK, "Supported", "Compositor supports ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 {
@@ -553,8 +535,6 @@ func detectRunningWM() string {
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
return "niri"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP")
}
@@ -573,7 +553,6 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
qmlContent := `
import QtQuick
import Quickshell
import Quickshell.Wayland
ShellRoot {
id: root
@@ -582,7 +561,6 @@ ShellRoot {
property bool idleMonitorAvailable: false
property bool idleInhibitorAvailable: false
property bool shortcutInhibitorAvailable: false
property bool backgroundBlurAvailable: false
Timer {
interval: 50
@@ -600,18 +578,16 @@ ShellRoot {
try {
var testItem = Qt.createQmlObject(
'import Quickshell; import Quickshell.Wayland; import QtQuick; QtObject { ' +
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined"; ' +
'readonly property bool hasBackgroundBlur: typeof BackgroundEffect !== "undefined" ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
'}',
root
)
root.idleMonitorAvailable = testItem.hasIdleMonitor
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
testItem.destroy()
} catch (e) {}
@@ -620,8 +596,6 @@ ShellRoot {
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
console.warn(root.backgroundBlurAvailable ? "FEATURE:BackgroundBlur:OK" : "FEATURE:BackgroundBlur:UNAVAILABLE")
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
}
}
@@ -642,7 +616,6 @@ ShellRoot {
{"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
}
var results []checkResult
@@ -847,14 +820,10 @@ func checkOptionalDependencies() []checkResult {
results = append(results, checkImageFormatPlugins()...)
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
terminals = slices.DeleteFunc(terminals, func(t string) bool {
return !utils.CommandExists(t)
})
if len(terminals) > 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
} else {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL})
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
}
networkResult, err := network.DetectNetworkStack()
@@ -1110,14 +1079,14 @@ func formatResultsPlain(results []checkResult) string {
if currentCategory != -1 {
sb.WriteString("\n")
}
fmt.Fprintf(&sb, "**%s**\n", r.category.String())
sb.WriteString(fmt.Sprintf("**%s**\n", r.category.String()))
currentCategory = r.category
}
fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message)
sb.WriteString(fmt.Sprintf("- [%s] %s: %s\n", r.status, r.name, r.message))
if doctorVerbose && r.details != "" {
fmt.Fprintf(&sb, " - %s\n", r.details)
sb.WriteString(fmt.Sprintf(" - %s\n", r.details))
}
}
@@ -1127,8 +1096,8 @@ func formatResultsPlain(results []checkResult) string {
}
sb.WriteString("\n---\n")
fmt.Fprintf(&sb, "**Summary:** %d error(s), %d warning(s), %d ok\n",
ds.ErrorCount(), ds.WarningCount(), ds.OKCount())
sb.WriteString(fmt.Sprintf("**Summary:** %d error(s), %d warning(s), %d ok\n",
ds.ErrorCount(), ds.WarningCount(), ds.OKCount()))
return sb.String()
}

View File

@@ -4,7 +4,6 @@ package main
import (
"bufio"
"context"
"errors"
"fmt"
"os"
@@ -16,7 +15,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/spf13/cobra"
@@ -111,37 +109,16 @@ func updateArchLinux() error {
}
var packageName string
var isAUR bool
if isArchPackageInstalled("dms-shell") {
packageName = "dms-shell"
if isArchPackageInstalled("dms-shell-bin") {
packageName = "dms-shell-bin"
} else if isArchPackageInstalled("dms-shell-git") {
packageName = "dms-shell-git"
isAUR = true
} else if isArchPackageInstalled("dms-shell-bin") {
packageName = "dms-shell-bin"
isAUR = true
} else {
fmt.Println("Info: No dms-shell package found.")
fmt.Println("Info: Neither dms-shell-bin nor dms-shell-git package found.")
fmt.Println("Info: Falling back to git-based update method...")
return updateOtherDistros()
}
if !isAUR {
fmt.Printf("This will update %s using pacman.\n", packageName)
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Printf("\nRunning: pacman -S %s\n", packageName)
if err := privesc.Run(context.Background(), "", "pacman", "-S", "--noconfirm", packageName); err != nil {
fmt.Printf("Error: Failed to update using pacman: %v\n", err)
return err
}
fmt.Println("dms successfully updated")
return nil
}
var helper string
var updateCmd *exec.Cmd
@@ -477,7 +454,11 @@ func updateDMSBinary() error {
fmt.Printf("Installing to %s...\n", currentPath)
if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
replaceCmd.Stdin = os.Stdin
replaceCmd.Stdout = os.Stdout
replaceCmd.Stderr = os.Stderr
if err := replaceCmd.Run(); err != nil {
return fmt.Errorf("failed to replace binary: %w", err)
}

View File

@@ -2,7 +2,6 @@ package main
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
@@ -14,8 +13,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
@@ -28,16 +25,11 @@ var greeterCmd = &cobra.Command{
Long: "Manage DMS greeter (greetd)",
}
var (
greeterConfigSyncFn = greeter.SyncDMSConfigs
sharedAuthSyncFn = sharedpam.SyncAuthConfig
)
var greeterInstallCmd = &cobra.Command{
Use: "install",
Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface",
PreRunE: preRunPrivileged,
PreRunE: requireMutableSystemCommand,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -59,10 +51,9 @@ var greeterInstallCmd = &cobra.Command{
}
var greeterSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
PreRunE: preRunPrivileged,
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth")
@@ -91,7 +82,7 @@ var greeterEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter",
PreRunE: preRunPrivileged,
PreRunE: requireMutableSystemCommand,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -127,7 +118,7 @@ var greeterUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Remove DMS greeter configuration and restore previous display manager",
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
PreRunE: preRunPrivileged,
PreRunE: requireMutableSystemCommand,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
@@ -157,16 +148,6 @@ func init() {
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
}
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
return err
}
if beforeAuth != nil {
beforeAuth()
}
return sharedAuthSyncFn(logFunc, "", options)
}
func installGreeter(nonInteractive bool) error {
fmt.Println("=== DMS Greeter Installation ===")
@@ -262,9 +243,7 @@ func installGreeter(nonInteractive bool) error {
}
fmt.Println("\nSynchronizing DMS configurations...")
if err := syncGreeterConfigsAndAuth(dmsPath, selectedCompositor, logFunc, sharedpam.SyncAuthOptions{}, func() {
fmt.Println("\nConfiguring authentication...")
}); err != nil {
if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, "", false); err != nil {
return err
}
@@ -299,7 +278,7 @@ func uninstallGreeter(nonInteractive bool) error {
}
if !nonInteractive {
fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS-managed greeter auth block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ")
fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS PAM managed block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ")
var response string
fmt.Scanln(&response)
if strings.ToLower(strings.TrimSpace(response)) != "y" {
@@ -309,14 +288,17 @@ func uninstallGreeter(nonInteractive bool) error {
}
fmt.Println("\nDisabling greetd...")
if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd")
disableCmd.Stdout = os.Stdout
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
} else {
fmt.Println(" ✓ greetd disabled")
}
fmt.Println("\nRemoving DMS authentication configuration...")
if err := sharedpam.RemoveManagedGreeterPamBlock(logFunc, ""); err != nil {
fmt.Println("\nRemoving DMS PAM configuration...")
if err := greeter.RemoveGreeterPamManagedBlock(logFunc, ""); err != nil {
fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err)
}
@@ -375,10 +357,10 @@ func restorePreDMSGreetdConfig(sudoPassword string) error {
}
tmp.Close()
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to restore %s: %w", candidate, err)
}
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath); err != nil {
if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil {
return err
}
fmt.Printf(" ✓ Restored greetd config from %s\n", candidate)
@@ -406,14 +388,21 @@ command = "agreety --cmd /bin/bash"
}
tmp.Close()
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to write fallback greetd config: %w", err)
}
_ = privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath)
_ = runSudoCommand(sudoPassword, "chmod", "644", configPath)
fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)")
return nil
}
func runSudoCommand(_ string, command string, args ...string) error {
cmd := exec.Command("sudo", append([]string{command}, args...)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// suggestDisplayManagerRestore scans for installed DMs and re-enables one
func suggestDisplayManagerRestore(nonInteractive bool) {
knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
@@ -432,7 +421,10 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
enableDM := func(dm string) {
fmt.Printf(" Enabling %s...\n", dm)
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
} else {
fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm)
@@ -543,7 +535,7 @@ func resolveLocalWrapperShell() (string, error) {
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if !nonInteractive {
fmt.Println("=== DMS Greeter Sync ===")
fmt.Println("=== DMS Greeter Theme Sync ===")
fmt.Println()
}
@@ -631,7 +623,10 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if response != "n" && response != "no" {
fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
if err := privesc.Run(context.Background(), "", "usermod", "-aG", greeterGroup, currentUser.Username); err != nil {
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username)
addUserCmd.Stdout = os.Stdout
addUserCmd.Stderr = os.Stderr
if err := addUserCmd.Run(); err != nil {
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
}
fmt.Printf("✓ User added to %s group\n", greeterGroup)
@@ -726,11 +721,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
}
fmt.Println("\nSynchronizing DMS configurations...")
if err := syncGreeterConfigsAndAuth(dmsPath, compositor, logFunc, sharedpam.SyncAuthOptions{
ForceGreeterAuth: forceAuth,
}, func() {
fmt.Println("\nConfiguring authentication...")
}); err != nil {
if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, "", forceAuth); err != nil {
return err
}
@@ -743,9 +734,8 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
fmt.Println("\n=== Sync Complete ===")
fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.")
fmt.Println("Shared authentication settings were also checked and reconciled where needed.")
if forceAuth {
fmt.Println("Authentication has been configured for fingerprint and U2F (where modules exist).")
fmt.Println("PAM has been configured for fingerprint and U2F (where modules exist).")
}
fmt.Println("The changes will be visible on the next login screen.")
@@ -856,19 +846,22 @@ func disableDisplayManager(dmName string) (bool, error) {
actionTaken := false
if state.NeedsDisable {
var action, actionVerb string
switch state.EnabledState {
case "static":
var disableCmd *exec.Cmd
var actionVerb string
if state.EnabledState == "static" {
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
action = "mask"
disableCmd = exec.Command("sudo", "systemctl", "mask", dmName)
actionVerb = "masked"
default:
} else {
fmt.Printf(" Disabling %s...\n", dmName)
action = "disable"
disableCmd = exec.Command("sudo", "systemctl", "disable", dmName)
actionVerb = "disabled"
}
if err := privesc.Run(context.Background(), "", "systemctl", action, dmName); err != nil {
disableCmd.Stdout = os.Stdout
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
}
@@ -909,7 +902,10 @@ func ensureGreetdEnabled() error {
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
fmt.Println(" Unmasking greetd...")
if err := privesc.Run(context.Background(), "", "systemctl", "unmask", "greetd"); err != nil {
unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd")
unmaskCmd.Stdout = os.Stdout
unmaskCmd.Stderr = os.Stderr
if err := unmaskCmd.Run(); err != nil {
return fmt.Errorf("failed to unmask greetd: %w", err)
}
fmt.Println(" ✓ Unmasked greetd")
@@ -921,7 +917,10 @@ func ensureGreetdEnabled() error {
fmt.Println(" Enabling greetd service...")
}
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", "greetd"); err != nil {
enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd")
enableCmd.Stdout = os.Stdout
enableCmd.Stderr = os.Stderr
if err := enableCmd.Run(); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err)
}
@@ -951,7 +950,10 @@ func ensureGraphicalTarget() error {
currentTargetStr := strings.TrimSpace(string(currentTarget))
if currentTargetStr != "graphical.target" {
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
if err := privesc.Run(context.Background(), "", "systemctl", "set-default", "graphical.target"); err != nil {
setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target")
setDefaultCmd.Stdout = os.Stdout
setDefaultCmd.Stderr = os.Stderr
if err := setDefaultCmd.Run(); err != nil {
fmt.Println("⚠ Warning: Failed to set graphical.target as default")
fmt.Println(" Greeter may not start on boot. Run manually:")
fmt.Println(" sudo systemctl set-default graphical.target")
@@ -1295,7 +1297,39 @@ func extractGreeterPathOverrideFromCommand(command string) string {
}
func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) {
return sharedpam.ParseManagedGreeterPamAuth(pamText)
if pamText == "" {
return false, false, false, false
}
lines := strings.Split(pamText, "\n")
inManaged := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch trimmed {
case greeter.GreeterPamManagedBlockStart:
managed = true
inManaged = true
continue
case greeter.GreeterPamManagedBlockEnd:
inManaged = false
continue
}
if strings.HasPrefix(trimmed, "# DMS greeter fingerprint") || strings.HasPrefix(trimmed, "# DMS greeter U2F") {
legacy = true
}
if !inManaged {
continue
}
if strings.Contains(trimmed, "pam_fprintd") {
fingerprint = true
}
if strings.Contains(trimmed, "pam_u2f") {
u2f = true
}
}
return managed, fingerprint, u2f, legacy
}
func packageInstallHint() string {
@@ -1605,29 +1639,29 @@ func checkGreeterStatus() error {
fmt.Println(" No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)")
}
if legacyManaged {
fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms auth sync' to normalize.")
fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms greeter sync' to normalize.")
allGood = false
}
enableFprintToggle, enableU2fToggle := false, false
if enableFprint, enableU2f, settingsErr := sharedpam.ReadGreeterAuthToggles(homeDir); settingsErr == nil {
if enableFprint, enableU2f, settingsErr := greeter.ReadGreeterAuthToggles(homeDir); settingsErr == nil {
enableFprintToggle = enableFprint
enableU2fToggle = enableU2f
} else {
fmt.Printf(" Could not read greeter auth toggles from settings: %v\n", settingsErr)
}
includedFprintFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_fprintd.so")
includedU2fFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_u2f.so")
fprintAvailableForCurrentUser := sharedpam.FingerprintAuthAvailableForCurrentUser()
includedFprintFile := greeter.DetectIncludedPamModule(string(pamData), "pam_fprintd.so")
includedU2fFile := greeter.DetectIncludedPamModule(string(pamData), "pam_u2f.so")
fprintAvailableForCurrentUser := greeter.FingerprintAuthAvailableForCurrentUser()
if managedFprint && includedFprintFile != "" {
fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile)
fmt.Println(" Double fingerprint auth detected — run 'dms auth sync' to resolve.")
fmt.Println(" Double fingerprint auth detected — run 'dms greeter sync' to resolve.")
allGood = false
}
if managedU2f && includedU2fFile != "" {
fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", includedU2fFile)
fmt.Println(" Double security-key auth detected — run 'dms auth sync' to resolve.")
fmt.Println(" Double security-key auth detected — run 'dms greeter sync' to resolve.")
allGood = false
}

View File

@@ -1,87 +0,0 @@
package main
import (
"errors"
"reflect"
"testing"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
)
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
origGreeterConfigSyncFn := greeterConfigSyncFn
origSharedAuthSyncFn := sharedAuthSyncFn
t.Cleanup(func() {
greeterConfigSyncFn = origGreeterConfigSyncFn
sharedAuthSyncFn = origSharedAuthSyncFn
})
var calls []string
greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
if dmsPath != "/tmp/dms" {
t.Fatalf("unexpected dmsPath %q", dmsPath)
}
if compositor != "niri" {
t.Fatalf("unexpected compositor %q", compositor)
}
if sudoPassword != "" {
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
}
calls = append(calls, "configs")
return nil
}
var gotOptions sharedpam.SyncAuthOptions
sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error {
if sudoPassword != "" {
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
}
gotOptions = options
calls = append(calls, "auth")
return nil
}
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{
ForceGreeterAuth: true,
}, func() {
calls = append(calls, "before-auth")
})
if err != nil {
t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err)
}
wantCalls := []string{"configs", "before-auth", "auth"}
if !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("call order = %v, want %v", calls, wantCalls)
}
if !gotOptions.ForceGreeterAuth {
t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions)
}
}
func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
origGreeterConfigSyncFn := greeterConfigSyncFn
origSharedAuthSyncFn := sharedAuthSyncFn
t.Cleanup(func() {
greeterConfigSyncFn = origGreeterConfigSyncFn
sharedAuthSyncFn = origSharedAuthSyncFn
})
greeterConfigSyncFn = func(string, string, func(string), string) error {
return errors.New("config sync failed")
}
authCalled := false
sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error {
authCalled = true
return nil
}
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil)
if err == nil || err.Error() != "config sync failed" {
t.Fatalf("expected config sync error, got %v", err)
}
if authCalled {
t.Fatal("expected auth sync not to run after config sync failure")
}
}

View File

@@ -57,11 +57,10 @@ func init() {
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
}
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
matugenQueueCmd.Flags().Duration("timeout", 30*time.Second, "Timeout for waiting")
}
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -78,7 +77,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
contrast, _ := cmd.Flags().GetFloat64("contrast")
return matugen.Options{
StateDir: stateDir,
@@ -89,7 +87,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
Mode: matugen.ColorMode(mode),
IconTheme: iconTheme,
MatugenType: matugenType,
Contrast: contrast,
RunUserTemplates: runUserTemplates,
StockColors: stockColors,
SyncModeWithPortal: syncModeWithPortal,
@@ -131,7 +128,6 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"syncModeWithPortal": opts.SyncModeWithPortal,
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast,
"wait": wait,
},
}

View File

@@ -22,8 +22,6 @@ var (
ssNoClipboard bool
ssNoFile bool
ssNoNotify bool
ssNoConfirm bool
ssReset bool
ssStdout bool
)
@@ -52,10 +50,8 @@ Examples:
dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window (Hyprland)
dms screenshot last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only
dms screenshot --no-file # Clipboard only
dms screenshot --no-confirm # Region capture on mouse release
dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
}
@@ -123,8 +119,6 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssNoClipboard, "no-clipboard", false, "Don't copy to clipboard")
screenshotCmd.PersistentFlags().BoolVar(&ssNoFile, "no-file", false, "Don't save to file")
screenshotCmd.PersistentFlags().BoolVar(&ssNoNotify, "no-notify", false, "Don't show notification")
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.AddCommand(ssRegionCmd)
@@ -148,8 +142,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.Clipboard = !ssNoClipboard
config.SaveFile = !ssNoFile
config.Notify = !ssNoNotify
config.NoConfirm = ssNoConfirm
config.Reset = ssReset
config.Stdout = ssStdout
if ssOutputDir != "" {

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -12,7 +11,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -21,7 +19,7 @@ var setupCmd = &cobra.Command{
Use: "setup",
Short: "Deploy DMS configurations",
Long: "Deploy compositor and terminal configurations with interactive prompts",
PersistentPreRunE: preRunPrivileged,
PersistentPreRunE: requireMutableSystemCommand,
Run: func(cmd *cobra.Command, args []string) {
if err := runSetup(); err != nil {
log.Fatalf("Error during setup: %v", err)
@@ -269,8 +267,6 @@ func runSetupDmsConfig(name string) error {
func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===")
ensureInputGroup()
wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd()
@@ -344,37 +340,6 @@ func runSetup() error {
return nil
}
// Add user to the input group for the evdev manager for inut state tracking.
// Caps Lock OSD and the Caps Lock bar indicator.
func ensureInputGroup() {
if !utils.HasGroup("input") {
return
}
currentUser := os.Getenv("USER")
if currentUser == "" {
currentUser = os.Getenv("LOGNAME")
}
if currentUser == "" {
return
}
out, err := execGroups(currentUser)
if err == nil && strings.Contains(out, "input") {
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
return
}
fmt.Println("Adding user to input group for Caps Lock OSD support...")
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
} else {
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
}
}
func execGroups(user string) (string, error) {
out, err := exec.Command("groups", user).Output()
return string(out), err
}
func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")

View File

@@ -1,277 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/spf13/cobra"
)
var systemCmd = &cobra.Command{
Use: "system",
Short: "System operations",
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
}
var systemUpdateCmd = &cobra.Command{
Use: "update",
Short: "Apply or list system updates",
Long: `Apply or list system updates across detected package managers.
Default behavior is to apply available updates after prompting for confirmation.
Use --check to list updates without applying.
Examples:
dms system update --check # list available updates
dms system update # apply updates (interactive prompt)
dms system update --noconfirm # apply updates without prompting
dms system update --dry # simulate without changing anything
dms system update --no-flatpak --noconfirm # apply system updates only
dms system update --interval 3600 # set the server poll interval to 1h`,
Run: runSystemUpdate,
}
var (
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
)
func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
systemCmd.AddCommand(systemUpdateCmd)
}
func runSystemUpdate(cmd *cobra.Command, args []string) {
switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS)
case sysUpdateCheck:
runSystemUpdateCheck()
default:
runSystemUpdateApply()
}
}
func selectBackends(ctx context.Context) []sysupdate.Backend {
sel := sysupdate.Select(ctx)
backends := sel.All()
if !sysUpdateNoFlatpak {
return backends
}
out := backends[:0]
for _, b := range backends {
if b.Repo() == sysupdate.RepoFlatpak {
continue
}
out = append(out, b)
}
return out
}
func runSystemUpdateCheck() {
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer cancel()
backends := selectBackends(ctx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
type backendResult struct {
ID string `json:"id"`
Display string `json:"displayName"`
Packages []sysupdate.Package `json:"packages"`
}
var results []backendResult
var allPkgs []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs})
allPkgs = append(allPkgs, pkgs...)
}
if sysUpdateJSON {
out, _ := json.MarshalIndent(map[string]any{
"backends": results,
"packages": allPkgs,
"error": errOrEmpty(firstErr),
"count": len(allPkgs),
}, "", " ")
fmt.Println(string(out))
return
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(allPkgs))
if firstErr != nil {
fmt.Printf("Error: %v\n", firstErr)
}
if len(allPkgs) == 0 {
return
}
fmt.Println()
for _, p := range allPkgs {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
}
}
func runSystemUpdateApply() {
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer checkCancel()
backends := selectBackends(checkCtx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
pkgs, firstErr := collectUpdates(checkCtx, backends)
if firstErr != nil {
fmt.Printf("Warning: %v\n\n", firstErr)
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(pkgs))
if len(pkgs) == 0 {
fmt.Println("Nothing to upgrade.")
return
}
fmt.Println()
for _, p := range pkgs {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
}
fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [y/N]: ") {
fmt.Println("Aborted.")
return
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
opts := sysupdate.UpgradeOptions{
IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR,
DryRun: sysUpdateDry,
}
onLine := func(line string) { fmt.Println(line) }
for _, b := range backends {
fmt.Printf("\n== %s ==\n", b.DisplayName())
if err := b.Upgrade(ctx, opts, onLine); err != nil {
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
}
}
if sysUpdateDry {
fmt.Println("\nDry run complete (no changes applied).")
return
}
fmt.Println("\nUpgrade complete.")
}
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
var all []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
all = append(all, pkgs...)
}
return all, firstErr
}
func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "sysupdate.setInterval",
Params: map[string]any{"seconds": float64(seconds)},
})
if err != nil {
log.Fatalf("Failed: %v (is dms server running?)", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
fmt.Printf("Interval set to %d seconds.\n", seconds)
}
func promptYesNo(prompt string) bool {
if !stdinIsTTY() {
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
}
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
func printBackends(backends []sysupdate.Backend) {
if len(backends) == 0 {
return
}
names := make([]string, 0, len(backends))
for _, b := range backends {
names = append(names, b.DisplayName())
}
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
}
func stdinIsTTY() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
func errOrEmpty(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func defaultIfEmpty(s, def string) string {
if s == "" {
return def
}
return s
}

View File

@@ -1,122 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra"
)
var trashCmd = &cobra.Command{
Use: "trash",
Short: "Manage the user's trash (XDG Trash spec 1.0)",
}
var trashPutCmd = &cobra.Command{
Use: "put <path...>",
Short: "Move files or directories into the trash",
Args: cobra.MinimumNArgs(1),
Run: runTrashPut,
}
var trashListCmd = &cobra.Command{
Use: "list",
Short: "List trashed items across all known trash directories",
Run: runTrashList,
}
var trashCountCmd = &cobra.Command{
Use: "count",
Short: "Print the total number of trashed items",
Run: runTrashCount,
}
var trashEmptyCmd = &cobra.Command{
Use: "empty",
Short: "Permanently delete every trashed item",
Run: runTrashEmpty,
}
var trashRestoreCmd = &cobra.Command{
Use: "restore <name>",
Short: "Restore a trashed item to its original location",
Args: cobra.ExactArgs(1),
Run: runTrashRestore,
}
var (
trashJSONOutput bool
trashRestoreDir string
)
func init() {
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
}
func runTrashPut(cmd *cobra.Command, args []string) {
var failed int
for _, p := range args {
if _, err := trash.Put(p); err != nil {
log.Errorf("trash %s: %v", p, err)
failed++
continue
}
fmt.Println(p)
}
if failed > 0 {
os.Exit(1)
}
}
func runTrashList(cmd *cobra.Command, args []string) {
entries, err := trash.List()
if err != nil {
log.Fatalf("list trash: %v", err)
}
if trashJSONOutput {
if entries == nil {
entries = []trash.Entry{}
}
out, _ := json.MarshalIndent(entries, "", " ")
fmt.Println(string(out))
return
}
if len(entries) == 0 {
fmt.Println("Trash is empty")
return
}
for _, e := range entries {
marker := "F"
if e.IsDir {
marker = "D"
}
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
}
}
func runTrashCount(cmd *cobra.Command, args []string) {
n, err := trash.Count()
if err != nil {
log.Fatalf("count trash: %v", err)
}
fmt.Println(n)
}
func runTrashEmpty(cmd *cobra.Command, args []string) {
if err := trash.Empty(); err != nil {
log.Fatalf("empty trash: %v", err)
}
}
func runTrashRestore(cmd *cobra.Command, args []string) {
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
log.Fatalf("restore: %v", err)
}
}

View File

@@ -9,7 +9,6 @@ import (
"strings"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/spf13/cobra"
)
@@ -270,16 +269,3 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
}
// preRunPrivileged combines the immutable-system check with a privesc tool
// selection prompt (shown only when multiple tools are available and the
// $DMS_PRIVESC env var isn't set).
func preRunPrivileged(cmd *cobra.Command, args []string) error {
if err := requireMutableSystemCommand(cmd, args); err != nil {
return err
}
if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil {
return err
}
return nil
}

View File

@@ -5,7 +5,6 @@ package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
@@ -15,27 +14,21 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.SetHelpTemplate(getHelpTemplate())
}
func main() {
clipboard.MaybeServeAndExit()
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
if os.Geteuid() == 0 {
log.Fatal("This program should not be run as root. Exiting.")
}

View File

@@ -5,7 +5,6 @@ package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
@@ -15,24 +14,18 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
rootCmd.SetHelpTemplate(getHelpTemplate())
}
func main() {
clipboard.MaybeServeAndExit()
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
if os.Geteuid() == 0 {
log.Fatal("This program should not be run as root. Exiting.")
}

View File

@@ -80,16 +80,6 @@ func getRuntimeDir() string {
return os.TempDir()
}
func appendLogEnv(env []string) []string {
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
env = append(env, "DMS_LOG_LEVEL="+v)
}
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
env = append(env, "DMS_LOG_FILE="+v)
}
return env
}
func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run")
return err == nil
@@ -202,9 +192,6 @@ func runShellInteractive(session bool) {
}
}
// ! TODO - remove when QS 0.3 is up and we can use the pragma
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
}
@@ -226,8 +213,6 @@ func runShellInteractive(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -447,9 +432,6 @@ func runShellDaemon(session bool) {
}
}
// ! TODO - remove when QS 0.3 is up and we can use the pragma
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
}
@@ -471,8 +453,6 @@ func runShellDaemon(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil {
log.Fatalf("Error opening /dev/null: %v", err)

View File

@@ -7,22 +7,6 @@ import (
"strings"
)
// isReadOnlyCommand returns true if the CLI args indicate a command that is
// safe to run as root (e.g. shell completion, help).
func isReadOnlyCommand(args []string) bool {
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "-") {
continue
}
switch arg {
case "completion", "help", "__complete":
return true
}
return false
}
return false
}
func isArchPackageInstalled(packageName string) bool {
cmd := exec.Command("pacman", "-Q", packageName)
err := cmd.Run()

View File

@@ -1,16 +1,14 @@
module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.0
toolchain go1.26.1
go 1.25.0
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.24.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/log v0.4.2
github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
@@ -18,67 +16,61 @@ require (
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
golang.org/x/image v0.36.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-alpha.2
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-isatty v0.0.22
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -1,14 +1,14 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998EGi6Llk=
github.com/alecthomas/chroma/v2 v2.24.0/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
@@ -24,22 +24,22 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
@@ -52,33 +52,31 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 h1:QRpwB1ans3fB3Cmeuog1ATzvXg/xhqubqiQi97xNO6E=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -93,20 +91,20 @@ github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7Dmvb
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -117,14 +115,12 @@ github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
@@ -146,40 +142,36 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,35 +0,0 @@
package blur
import (
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
const extBackgroundEffectInterface = "ext_background_effect_manager_v1"
func ProbeSupport() (bool, error) {
display, err := client.Connect("")
if err != nil {
return false, err
}
defer display.Context().Close()
registry, err := display.GetRegistry()
if err != nil {
return false, err
}
found := false
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
switch e.Interface {
case extBackgroundEffectInterface:
found = true
}
})
if err := wlhelpers.Roundtrip(display, display.Context()); err != nil {
return false, err
}
return found, nil
}

View File

@@ -1,6 +1,7 @@
package clipboard
import (
"bytes"
"fmt"
"io"
"os"
@@ -12,166 +13,100 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
const envServe = "_DMS_CLIPBOARD_SERVE"
const envMime = "_DMS_CLIPBOARD_MIME"
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
// child. Reads source data into memory, deletes any cache file, then serves.
func MaybeServeAndExit() {
if os.Getenv(envServe) == "" {
return
}
mimeType := os.Getenv(envMime)
pasteOnce := os.Getenv(envPasteOnce) == "1"
cachePath := os.Getenv(envCacheFile)
var data []byte
var err error
switch {
case cachePath != "":
data, err = os.ReadFile(cachePath)
os.Remove(cachePath)
default:
data, err = io.ReadAll(os.Stdin)
}
if err != nil {
fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err)
os.Exit(1)
}
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
func Copy(data []byte, mimeType string) error {
return copyForkCached(data, mimeType, false)
return CopyReader(bytes.NewReader(data), mimeType, false, false)
}
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground {
return serveClipboard(data, mimeType, pasteOnce)
return copyServeWithWriter(func(writer io.Writer) error {
total := 0
for total < len(data) {
n, err := writer.Write(data[total:])
total += n
if err != nil {
return err
}
}
if total != len(data) {
return io.ErrShortWrite
}
return nil
}, mimeType, pasteOnce)
}
return copyForkCached(data, mimeType, pasteOnce)
return CopyReader(bytes.NewReader(data), mimeType, foreground, pasteOnce)
}
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if foreground {
buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
if !foreground {
return copyFork(data, mimeType, pasteOnce)
}
return copyFork(data, mimeType, pasteOnce)
}
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
cmd := exec.Command(os.Args[0])
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
cmd.Env = append(os.Environ(),
envServe+"=1",
envMime+"="+mimeType,
)
if pasteOnce {
cmd.Env = append(cmd.Env, envPasteOnce+"=1")
}
cmd.Env = append(cmd.Env, extra...)
return cmd
}
func waitReady(cmd *exec.Cmd) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
cacheFile, err := createClipboardCacheFile()
if err != nil {
return fmt.Errorf("create cache file: %w", err)
}
cachePath := cacheFile.Name()
if _, err := cacheFile.Write(data); err != nil {
cacheFile.Close()
os.Remove(cachePath)
return fmt.Errorf("write cache file: %w", err)
}
if err := cacheFile.Close(); err != nil {
os.Remove(cachePath)
return fmt.Errorf("close cache file: %w", err)
}
cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath)
cmd.Stdin = nil
if err := waitReady(cmd); err != nil {
os.Remove(cachePath)
return err
}
return nil
return copyServeReader(data, mimeType, pasteOnce)
}
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
cmd := newForkCmd(mimeType, pasteOnce)
switch src := data.(type) {
case *os.File:
cmd.Stdin = src
return waitReady(cmd)
default:
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
args := []string{os.Args[0], "cl", "copy", "--foreground"}
if pasteOnce {
args = append(args, "--paste-once")
}
args = append(args, "--type", mimeType)
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if stdinSource, ok := data.(*os.File); ok {
cmd.Stdin = stdinSource
return cmd.Start()
}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
return nil
}
func signalReady() {
if os.Getenv(envServe) == "" {
return
func copyServeReader(data io.Reader, mimeType string, pasteOnce bool) error {
cachedData, err := createClipboardCacheFile()
if err != nil {
return fmt.Errorf("create clipboard cache file: %w", err)
}
os.Stdout.Write([]byte{1})
defer os.Remove(cachedData.Name())
if _, err := io.Copy(cachedData, data); err != nil {
return fmt.Errorf("cache clipboard data: %w", err)
}
if err := cachedData.Close(); err != nil {
return fmt.Errorf("close temp cache file: %w", err)
}
return copyServeWithWriter(func(writer io.Writer) error {
cachedFile, err := os.Open(cachedData.Name())
if err != nil {
return fmt.Errorf("open temp cache file: %w", err)
}
defer cachedFile.Close()
if _, err := io.Copy(writer, cachedFile); err != nil {
return fmt.Errorf("write clipboard data: %w", err)
}
return nil
}, mimeType, pasteOnce)
}
func createClipboardCacheFile() (*os.File, error) {
@@ -194,7 +129,7 @@ func createClipboardCacheFile() (*os.File, error) {
return os.CreateTemp("", "dms-clipboard-*")
}
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
@@ -236,10 +171,12 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
@@ -278,12 +215,18 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
cancelled := make(chan struct{})
pasted := make(chan struct{}, 1)
sendErr := make(chan error, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
_ = syscall.SetNonblock(e.Fd, false)
defer syscall.Close(e.Fd)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
_, _ = file.Write(data)
if err := writeTo(file); err != nil {
select {
case sendErr <- err:
default:
}
}
select {
case pasted <- struct{}{}:
default:
@@ -299,12 +242,13 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
}
display.Roundtrip()
signalReady()
for {
select {
case <-cancelled:
return nil
case err := <-sendErr:
return err
case <-pasted:
if pasteOnce {
return nil
@@ -558,10 +502,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
@@ -589,12 +535,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
_ = syscall.SetNonblock(e.Fd, false)
defer syscall.Close(e.Fd)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerMap[e.MimeType]; ok {
_, _ = file.Write(data)
file.Write(data)
}
select {

View File

@@ -39,10 +39,11 @@ type LayerSurface struct {
wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport
wlPools [2]*client.ShmPool
wlBuffers [2]*client.Buffer
slotBusy [2]bool
needsRedraw bool
wlPool *client.ShmPool
wlBuffer *client.Buffer
bufferBusy bool
oldPool *client.ShmPool
oldBuffer *client.Buffer
scopyBuffer *client.Buffer
configured bool
hidden bool
@@ -135,7 +136,6 @@ func (p *Picker) Run() (*Color, error) {
break
}
p.flushRedraws()
p.checkDone()
}
@@ -164,15 +164,6 @@ func (p *Picker) checkDone() {
}
}
func (p *Picker) flushRedraws() {
for _, ls := range p.surfaces {
if !ls.needsRedraw {
continue
}
p.redrawSurface(ls)
}
}
func (p *Picker) connect() error {
display, err := client.Connect("")
if err != nil {
@@ -516,45 +507,47 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
}
func (p *Picker) redrawSurface(ls *LayerSurface) {
slot := ls.state.FrontIndex()
if ls.slotBusy[slot] {
ls.needsRedraw = true
return
}
var renderBuf *ShmBuffer
switch {
case ls.hidden:
if ls.hidden {
renderBuf = ls.state.RedrawScreenOnly()
default:
} else {
renderBuf = ls.state.Redraw()
}
if renderBuf == nil {
return
}
ls.needsRedraw = false
if ls.wlPools[slot] == nil {
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
if err != nil {
return
}
ls.wlPools[slot] = pool
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
if err != nil {
return
}
ls.wlBuffers[slot] = wlBuffer
s := slot
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
ls.slotBusy[s] = false
})
if ls.oldBuffer != nil {
ls.oldBuffer.Destroy()
ls.oldBuffer = nil
}
if ls.oldPool != nil {
ls.oldPool.Destroy()
ls.oldPool = nil
}
ls.slotBusy[slot] = true
ls.oldPool = ls.wlPool
ls.oldBuffer = ls.wlBuffer
ls.wlPool = nil
ls.wlBuffer = nil
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
if err != nil {
return
}
ls.wlPool = pool
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
if err != nil {
return
}
ls.wlBuffer = wlBuffer
lsRef := ls
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
lsRef.bufferBusy = false
})
ls.bufferBusy = true
logicalW, logicalH := ls.state.LogicalSize()
if logicalW == 0 || logicalH == 0 {
@@ -573,7 +566,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
}
_ = ls.wlSurface.SetBufferScale(bufferScale)
}
_ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0)
_ = ls.wlSurface.Attach(wlBuffer, 0, 0)
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
_ = ls.wlSurface.Commit()
@@ -641,7 +634,7 @@ func (p *Picker) setupPointerHandlers() {
}
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
p.activeSurface.needsRedraw = true
p.redrawSurface(p.activeSurface)
})
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
@@ -662,7 +655,7 @@ func (p *Picker) setupPointerHandlers() {
return
}
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
p.activeSurface.needsRedraw = true
p.redrawSurface(p.activeSurface)
})
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
@@ -686,13 +679,17 @@ func (p *Picker) cleanup() {
if ls.scopyBuffer != nil {
ls.scopyBuffer.Destroy()
}
for i := range ls.wlBuffers {
if ls.wlBuffers[i] != nil {
ls.wlBuffers[i].Destroy()
}
if ls.wlPools[i] != nil {
ls.wlPools[i].Destroy()
}
if ls.oldBuffer != nil {
ls.oldBuffer.Destroy()
}
if ls.oldPool != nil {
ls.oldPool.Destroy()
}
if ls.wlBuffer != nil {
ls.wlBuffer.Destroy()
}
if ls.wlPool != nil {
ls.wlPool.Destroy()
}
if ls.viewport != nil {
ls.viewport.Destroy()

View File

@@ -274,12 +274,6 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
return s.renderBufs[s.front]
}
func (s *SurfaceState) FrontIndex() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.front
}
func (s *SurfaceState) SwapBuffers() {
s.mu.Lock()
s.front ^= 1

View File

@@ -62,31 +62,12 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
var results []DeploymentResult
// Primary config file paths used to detect fresh installs.
configPrimaryPaths := map[string]string{
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
}
shouldReplaceConfig := func(configType string) bool {
if replaceConfigs == nil {
return true
}
replace, exists := replaceConfigs[configType]
if !exists || replace {
return true
}
// Config is explicitly set to "don't replace" — but still deploy
// if the config file doesn't exist yet (fresh install scenario).
if primaryPath, ok := configPrimaryPaths[configType]; ok {
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
return true
}
}
return false
return !exists || replace
}
switch wm {

View File

@@ -1,7 +1,6 @@
package config
import (
"context"
"os"
"path/filepath"
"testing"
@@ -625,168 +624,3 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
assert.Contains(t, string(newContent), "decorations = \"None\"")
})
}
func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
allFalse := map[string]bool{
"Niri": false,
"Hyprland": false,
"Ghostty": false,
"Kitty": false,
"Alacritty": false,
}
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
nil, // replaceConfigs
nil, // reinstallItems
)
require.NoError(t, err)
// With replaceConfigs=nil, all configs should be deployed
hasDeployed := false
for _, r := range results {
if r.Deployed {
hasDeployed = true
break
}
}
assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil")
})
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
allFalse, // replaceConfigs — all false
nil, // reinstallItems
)
require.NoError(t, err)
// Config files don't exist on disk, so they should still be deployed
hasDeployed := false
for _, r := range results {
if r.Deployed {
hasDeployed = true
break
}
}
assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false")
})
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
// Create the Ghostty primary config file so shouldReplaceConfig returns false
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
require.NoError(t, err)
// Also create the Niri primary config file
niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl")
err = os.MkdirAll(filepath.Dir(niriPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644)
require.NoError(t, err)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
allFalse, // replaceConfigs — all false
nil, // reinstallItems
)
require.NoError(t, err)
// Both Niri and Ghostty config files exist, so with all false they should be skipped
for _, r := range results {
assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType)
}
})
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
// Create the Ghostty primary config file
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
require.NoError(t, err)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
replaceConfigs := map[string]bool{
"Niri": false,
"Hyprland": false,
"Ghostty": true, // explicitly true
"Kitty": false,
"Alacritty": false,
}
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
replaceConfigs, // Ghostty=true, rest=false
nil, // reinstallItems
)
require.NoError(t, err)
// Ghostty should be deployed because replaceConfigs["Ghostty"]=true
foundGhostty := false
for _, r := range results {
if r.ConfigType == "Ghostty" && r.Deployed {
foundGhostty = true
}
}
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
})
}

View File

@@ -137,7 +137,7 @@ bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
bind = SUPER CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow

View File

@@ -94,7 +94,6 @@ windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
windowrule = float on, match:class ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$

View File

@@ -224,7 +224,6 @@ window-rule {
open-floating false
}
window-rule {
match app-id=r#"^org\.gnome\.Calculator$"#
match app-id=r#"^gnome-calculator$"#
match app-id=r#"^galculator$"#
match app-id=r#"^blueman-manager$"#

View File

@@ -11,7 +11,6 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -243,7 +242,11 @@ func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMap
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
if a.packageInstalled("dms-shell-bin") {
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
@@ -293,7 +296,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
LogOutput: "Installing base-devel development tools",
}
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
return fmt.Errorf("failed to install base-devel: %w", err)
}
@@ -325,13 +328,6 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
}
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
}
// Phase 3: System Packages
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
@@ -449,37 +445,6 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
return systemPkgs, aurPkgs, manualPkgs, variantMap
}
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if a.packageInstalled("quickshell-git") {
return nil
}
if a.packageInstalled("quickshell") {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.15,
Step: "Removing stable quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
}
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
return fmt.Errorf("failed to remove stable quickshell: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.18,
Step: "Building quickshell-git before system packages...",
IsComplete: false,
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
}
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
}
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
@@ -488,9 +453,6 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
if slices.Contains(packages, "dms-shell") {
args = append(args, "--assume-installed", "dms-shell-compositor=1")
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
@@ -502,7 +464,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
@@ -578,7 +540,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
var dmsShell []string
for _, pkg := range packages {
if pkg == "dms-shell-git" {
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
dmsShell = append(dmsShell, pkg)
} else {
isDep := false
@@ -659,7 +621,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
}
}
if pkg == "dms-shell-git" {
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
depsToRemove := []string{
"depends = quickshell",
@@ -682,7 +644,15 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
}
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
{
if pkg == "dms-shell-bin" {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.35*(endProgress-startProgress),
Step: fmt.Sprintf("Skipping dependency installation for %s (manually managed)...", pkg),
IsComplete: false,
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
}
} else {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.3*(endProgress-startProgress),
@@ -769,9 +739,42 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
CommandInfo: "sudo pacman -U built-package",
}
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
var files []string
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
files = matches
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
// For DMS split packages, install base package
pattern := filepath.Join(packageDir, fmt.Sprintf("%s-%s*.pkg.tar*", pkg, "*"))
matches, err := filepath.Glob(pattern)
if err == nil {
for _, match := range matches {
basename := filepath.Base(match)
// Always include base package
if !strings.Contains(basename, "hyprland") && !strings.Contains(basename, "niri") {
files = append(files, match)
}
}
}
// Also update compositor-specific packages if they're installed
if strings.HasSuffix(pkg, "-git") {
if a.packageInstalled("dms-shell-hyprland-git") {
hyprlandPattern := filepath.Join(packageDir, "dms-shell-hyprland-git-*.pkg.tar*")
if hyprlandMatches, err := filepath.Glob(hyprlandPattern); err == nil && len(hyprlandMatches) > 0 {
files = append(files, hyprlandMatches[0])
}
}
if a.packageInstalled("dms-shell-niri-git") {
niriPattern := filepath.Join(packageDir, "dms-shell-niri-git-*.pkg.tar*")
if niriMatches, err := filepath.Glob(niriPattern); err == nil && len(niriMatches) > 0 {
files = append(files, niriMatches[0])
}
}
}
} else {
// For other packages, install all built packages
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
files = matches
}
if len(files) == 0 {
return fmt.Errorf("no package files found after building %s", pkg)
@@ -780,7 +783,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
installArgs := []string{"pacman", "-U", "--noconfirm"}
installArgs = append(installArgs, files...)
installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
fileNames := make([]string, len(files))
for i, f := range files {

View File

@@ -14,7 +14,6 @@ import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
)
@@ -56,6 +55,27 @@ func (b *BaseDistribution) logError(message string, err error) {
b.log(errorMsg)
}
// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings.
// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote.
// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes.
func escapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
// MakeSudoCommand creates a command string that safely passes password to sudo.
// This helper escapes special characters in the password to prevent shell injection
// and syntax errors when passwords contain single quotes, apostrophes, or other special chars.
func MakeSudoCommand(sudoPassword string, command string) string {
return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command)
}
// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password.
// The password is properly escaped to prevent shell injection and syntax errors.
func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd {
cmdStr := MakeSudoCommand(sudoPassword, command)
return exec.CommandContext(ctx, "bash", "-c", cmdStr)
}
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
status := deps.StatusMissing
if b.commandExists(name) {
@@ -690,7 +710,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
}
// Install to /usr/local/bin
installCmd := privesc.ExecCommand(ctx, sudoPassword,
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install DMS binary: %w", err)

View File

@@ -7,7 +7,6 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -92,16 +91,9 @@ func (d *DebianDistribution) detectDMSGreeter() deps.Dependency {
}
func (d *DebianDistribution) packageInstalled(pkg string) bool {
return debianPackageInstalledPrecisely(pkg)
}
func debianPackageInstalledPrecisely(pkg string) bool {
cmd := exec.Command("dpkg-query", "-W", "-f=${db:Status-Status}", pkg)
output, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) == "installed"
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
}
func debianRepoArchitecture(arch string) string {
@@ -183,7 +175,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists",
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
@@ -200,7 +192,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
cmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
cmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
@@ -212,12 +204,12 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
@@ -397,14 +389,6 @@ func (d *DebianDistribution) extractPackageNames(packages []PackageMapping) []st
return names
}
func (d *DebianDistribution) aptInstallArgs(packages []string, minimal bool) []string {
args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y"}
if minimal {
args = append(args, "--no-install-recommends")
}
return append(args, packages...)
}
func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
@@ -442,7 +426,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
// Create keyrings directory if it doesn't exist
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
if err := mkdirCmd.Run(); err != nil {
d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err))
}
@@ -456,7 +440,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
}
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath)
cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd)
cmd := ExecSudoCommand(ctx, sudoPassword, keyCmd)
if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
@@ -472,7 +456,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
}
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
addRepoCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err)
@@ -492,7 +476,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: "sudo apt-get update",
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err)
}
@@ -508,46 +492,20 @@ func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []
d.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
groups := orderedMinimalInstallGroups(packages)
totalGroups := len(groups)
args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y"}
args = append(args, packages...)
groupIndex := 0
installGroup := func(groupPackages []string, minimal bool) error {
if len(groupPackages) == 0 {
return nil
}
groupIndex++
startProgress := 0.40
endProgress := 0.60
if totalGroups > 1 {
if groupIndex == 1 {
endProgress = 0.50
} else {
startProgress = 0.50
}
}
args := d.aptInstallArgs(groupPackages, minimal)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: startProgress,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
for _, group := range groups {
if err := installGroup(group.packages, group.minimal); err != nil {
return err
}
}
return nil
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -626,7 +584,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
@@ -644,7 +602,7 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
@@ -683,7 +641,7 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
installCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
}

View File

@@ -7,7 +7,6 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -255,7 +254,7 @@ func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
args := []string{"dnf", "install", "-y"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to install prerequisites", err)
@@ -438,7 +437,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
}
cmd := privesc.ExecCommand(ctx, sudoPassword,
cmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
output, err := cmd.CombinedOutput()
if err != nil {
@@ -462,7 +461,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
}
priorityCmd := privesc.ExecCommand(ctx, sudoPassword,
priorityCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
priorityOutput, err := priorityCmd.CombinedOutput()
if err != nil {
@@ -485,7 +484,28 @@ func (f *FedoraDistribution) installDNFPackages(ctx context.Context, packages []
f.log(fmt.Sprintf("Installing DNF packages: %s", strings.Join(packages, ", ")))
return f.installDNFGroups(ctx, packages, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60)
args := []string{"dnf", "install", "-y"}
for _, pkg := range packages {
if pkg == "niri" || pkg == "niri-git" {
args = append(args, "--setopt=install_weak_deps=False")
break
}
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (f *FedoraDistribution) installCOPRPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -495,57 +515,26 @@ func (f *FedoraDistribution) installCOPRPackages(ctx context.Context, packages [
f.log(fmt.Sprintf("Installing COPR packages: %s", strings.Join(packages, ", ")))
return f.installDNFGroups(ctx, packages, sudoPassword, progressChan, PhaseAURPackages, "Installing COPR packages...", 0.70, 0.85)
}
func (f *FedoraDistribution) dnfInstallArgs(packages []string, minimal bool) []string {
args := []string{"dnf", "install", "-y"}
if minimal {
args = append(args, "--setopt=install_weak_deps=False")
for _, pkg := range packages {
if pkg == "niri" || pkg == "niri-git" {
args = append(args, "--setopt=install_weak_deps=False")
break
}
}
return append(args, packages...)
}
func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error {
groups := orderedMinimalInstallGroups(packages)
totalGroups := len(groups)
groupIndex := 0
installGroup := func(groupPackages []string, minimal bool) error {
if len(groupPackages) == 0 {
return nil
}
groupIndex++
groupStart := startProgress
groupEnd := endProgress
if totalGroups > 1 {
midpoint := startProgress + ((endProgress - startProgress) / 2)
if groupIndex == 1 {
groupEnd = midpoint
} else {
groupStart = midpoint
}
}
args := f.dnfInstallArgs(groupPackages, minimal)
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: groupStart,
Step: step,
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
}
for _, group := range groups {
if err := installGroup(group.packages, group.minimal); err != nil {
return err
}
}
return nil
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing COPR packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
}

View File

@@ -8,7 +8,6 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
var GentooGlobalUseFlags = []string{
@@ -202,9 +201,9 @@ func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword
var cmd *exec.Cmd
if hasUse {
cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags))
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags))
} else {
cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags))
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags))
}
output, err := cmd.CombinedOutput()
@@ -282,7 +281,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Syncing Portage tree with emerge --sync",
}
syncCmd := privesc.ExecCommand(ctx, sudoPassword, "emerge --sync --quiet")
syncCmd := ExecSudoCommand(ctx, sudoPassword, "emerge --sync --quiet")
syncOutput, syncErr := syncCmd.CombinedOutput()
if syncErr != nil {
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput)))
@@ -303,7 +302,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
g.logError("failed to install prerequisites", err)
@@ -504,14 +503,14 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
}
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
packageUseDir := "/etc/portage/package.use"
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", packageUseDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
@@ -525,7 +524,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := privesc.ExecCommand(ctx, sudoPassword,
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
@@ -533,7 +532,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName
}
}
appendCmd := privesc.ExecCommand(ctx, sudoPassword,
appendCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
output, err := appendCmd.CombinedOutput()
@@ -558,7 +557,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
}
// Enable GURU repository
enableCmd := privesc.ExecCommand(ctx, sudoPassword,
enableCmd := ExecSudoCommand(ctx, sudoPassword,
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
output, err := enableCmd.CombinedOutput()
@@ -590,7 +589,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
LogOutput: "Syncing GURU repository",
}
syncCmd := privesc.ExecCommand(ctx, sudoPassword,
syncCmd := ExecSudoCommand(ctx, sudoPassword,
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput()
@@ -623,7 +622,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
acceptKeywordsDir := "/etc/portage/package.accept_keywords"
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
@@ -637,7 +636,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := privesc.ExecCommand(ctx, sudoPassword,
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
@@ -645,7 +644,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
}
}
appendCmd := privesc.ExecCommand(ctx, sudoPassword,
appendCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
output, err := appendCmd.CombinedOutput()
@@ -696,6 +695,6 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
}

View File

@@ -9,7 +9,6 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
// ManualPackageInstaller provides methods for installing packages from source
@@ -144,7 +143,7 @@ func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword s
CommandInfo: "sudo make install",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
m.logError("failed to install dgop", err)
@@ -214,7 +213,7 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
CommandInfo: "dpkg -i niri.deb",
}
installDebCmd := privesc.ExecCommand(ctx, sudoPassword,
installDebCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
output, err := installDebCmd.CombinedOutput()
@@ -325,7 +324,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
CommandInfo: "sudo cmake --install build",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
@@ -388,7 +387,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
CommandInfo: "sudo make install",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Hyprland: %w", err)
@@ -454,7 +453,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword,
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
@@ -493,11 +492,16 @@ func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPasswor
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
}
if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
// Make it executable
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make matugen executable: %w", err)
}
@@ -642,11 +646,15 @@ func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, s
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
}
if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
}

View File

@@ -1,44 +0,0 @@
package distros
type minimalInstallGroup struct {
packages []string
minimal bool
}
func shouldPreferMinimalInstall(pkg string) bool {
switch pkg {
case "niri", "niri-git":
return true
default:
return false
}
}
func splitMinimalInstallPackages(packages []string) (normal []string, minimal []string) {
for _, pkg := range packages {
if shouldPreferMinimalInstall(pkg) {
minimal = append(minimal, pkg)
continue
}
normal = append(normal, pkg)
}
return normal, minimal
}
func orderedMinimalInstallGroups(packages []string) []minimalInstallGroup {
normal, minimal := splitMinimalInstallPackages(packages)
groups := make([]minimalInstallGroup, 0, 2)
if len(minimal) > 0 {
groups = append(groups, minimalInstallGroup{
packages: minimal,
minimal: true,
})
}
if len(normal) > 0 {
groups = append(groups, minimalInstallGroup{
packages: normal,
minimal: false,
})
}
return groups
}

View File

@@ -6,11 +6,9 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -31,8 +29,6 @@ type OpenSUSEDistribution struct {
config DistroConfig
}
const openSUSENiriWaylandServerPackage = "libwayland-server0"
func NewOpenSUSEDistribution(config DistroConfig, logChan chan<- string) *OpenSUSEDistribution {
base := NewBaseDistribution(logChan)
return &OpenSUSEDistribution{
@@ -203,7 +199,35 @@ func (o *OpenSUSEDistribution) detectAccountsService() deps.Dependency {
}
func (o *OpenSUSEDistribution) getPrerequisites() []string {
return []string{}
return []string{
"make",
"unzip",
"gcc",
"gcc-c++",
"cmake",
"ninja",
"pkgconf-pkg-config",
"git",
"qt6-base-devel",
"qt6-declarative-devel",
"qt6-declarative-private-devel",
"qt6-shadertools",
"qt6-shadertools-devel",
"qt6-wayland-devel",
"qt6-waylandclient-private-devel",
"spirv-tools-devel",
"cli11-devel",
"wayland-protocols-devel",
"libgbm-devel",
"libdrm-devel",
"pipewire-devel",
"jemalloc-devel",
"wayland-utils",
"Mesa-libGLESv3-devel",
"pam-devel",
"glib2-devel",
"polkit-devel",
}
}
func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -251,7 +275,7 @@ func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPas
args := []string{"zypper", "install", "-y"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
o.logError("failed to install prerequisites", err)
@@ -273,10 +297,6 @@ func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies
LogOutput: "Starting prerequisite check...",
}
if err := o.disableInstallMediaRepos(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to disable install media repositories: %w", err)
}
if err := o.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
@@ -307,7 +327,7 @@ func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := o.installZypperPackages(ctx, systemPkgs, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60); err != nil {
if err := o.installZypperPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install zypper packages: %w", err)
}
}
@@ -322,7 +342,7 @@ func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies
IsComplete: false,
LogOutput: fmt.Sprintf("Installing OBS packages: %s", strings.Join(obsPkgNames, ", ")),
}
if err := o.installZypperPackages(ctx, obsPkgNames, sudoPassword, progressChan, PhaseAURPackages, "Installing OBS packages...", 0.70, 0.85); err != nil {
if err := o.installZypperPackages(ctx, obsPkgNames, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install OBS packages: %w", err)
}
}
@@ -412,32 +432,9 @@ func (o *OpenSUSEDistribution) categorizePackages(dependencies []deps.Dependency
}
}
systemPkgs = o.appendMissingSystemPackages(systemPkgs, openSUSENiriRuntimePackages(wm, disabledFlags))
return systemPkgs, obsPkgs, manualPkgs, variantMap
}
func openSUSENiriRuntimePackages(wm deps.WindowManager, disabledFlags map[string]bool) []string {
if wm != deps.WindowManagerNiri || disabledFlags["niri"] {
return nil
}
return []string{openSUSENiriWaylandServerPackage}
}
func (o *OpenSUSEDistribution) appendMissingSystemPackages(systemPkgs []string, extraPkgs []string) []string {
for _, pkg := range extraPkgs {
if slices.Contains(systemPkgs, pkg) || o.packageInstalled(pkg) {
continue
}
o.log(fmt.Sprintf("Adding openSUSE runtime package: %s", pkg))
systemPkgs = append(systemPkgs, pkg)
}
return systemPkgs
}
func (o *OpenSUSEDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
@@ -487,7 +484,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL),
}
cmd := privesc.ExecCommand(ctx, sudoPassword,
cmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("zypper addrepo -f %s", repoURL))
if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err))
@@ -508,7 +505,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: "sudo zypper --gpg-auto-import-keys refresh",
}
refreshCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
refreshCmd := ExecSudoCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to refresh repositories: %w", err)
}
@@ -517,146 +514,27 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
return nil
}
func isOpenSUSEInstallMediaURI(uri string) bool {
normalizedURI := strings.ToLower(strings.TrimSpace(uri))
return strings.HasPrefix(normalizedURI, "cd:/") ||
strings.HasPrefix(normalizedURI, "dvd:/") ||
strings.HasPrefix(normalizedURI, "hd:/") ||
strings.HasPrefix(normalizedURI, "iso:/")
}
func parseZypperInstallMediaAliases(output string) []string {
var aliases []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || !strings.Contains(line, "|") {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 7 {
continue
}
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
alias := parts[1]
enabled := strings.ToLower(parts[3])
uri := parts[len(parts)-1]
if alias == "" || strings.EqualFold(alias, "alias") {
continue
}
if enabled != "" && enabled != "yes" {
continue
}
if !isOpenSUSEInstallMediaURI(uri) {
continue
}
aliases = append(aliases, alias)
}
return aliases
}
func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
listCmd := exec.CommandContext(ctx, "zypper", "repos", "-u")
output, err := listCmd.CombinedOutput()
if err != nil {
o.log(fmt.Sprintf("Warning: failed to list zypper repositories: %s", strings.TrimSpace(string(output))))
return fmt.Errorf("failed to list zypper repositories: %w", err)
}
aliases := parseZypperInstallMediaAliases(string(output))
if len(aliases) == 0 {
return nil
}
o.log(fmt.Sprintf("Disabling install media repositories: %s", strings.Join(aliases, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.055,
Step: "Disabling install media repositories...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo zypper modifyrepo -d %s", strings.Join(aliases, " ")),
LogOutput: fmt.Sprintf("Disabling install media repositories: %s", strings.Join(aliases, ", ")),
}
for _, alias := range aliases {
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", privesc.EscapeSingleQuotes(alias)))
repoOutput, err := cmd.CombinedOutput()
if err != nil {
o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
return fmt.Errorf("failed to disable install media repo %s: %w", alias, err)
}
o.log(fmt.Sprintf("Disabled install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
}
return nil
}
func (o *OpenSUSEDistribution) zypperInstallArgs(packages []string, minimal bool) []string {
args := []string{"zypper", "install", "-y"}
if minimal {
args = append(args, "--no-recommends")
}
return append(args, packages...)
}
func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error {
func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
o.log(fmt.Sprintf("Installing zypper packages: %s", strings.Join(packages, ", ")))
groups := orderedMinimalInstallGroups(packages)
totalGroups := len(groups)
args := []string{"zypper", "install", "-y"}
args = append(args, packages...)
groupIndex := 0
installGroup := func(groupPackages []string, minimal bool) error {
if len(groupPackages) == 0 {
return nil
}
groupIndex++
groupStart := startProgress
groupEnd := endProgress
if totalGroups > 1 {
midpoint := startProgress + ((endProgress - startProgress) / 2)
if groupIndex == 1 {
groupEnd = midpoint
} else {
groupStart = midpoint
}
}
args := o.zypperInstallArgs(groupPackages, minimal)
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: groupStart,
Step: step,
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
for _, group := range groups {
if err := installGroup(group.packages, group.minimal); err != nil {
return err
}
}
return nil
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -775,7 +653,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
CommandInfo: "sudo cmake --install build",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
@@ -799,7 +677,7 @@ func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword str
CommandInfo: "sudo zypper install rustup",
}
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper install -y rustup")
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup")
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}

View File

@@ -7,7 +7,6 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -101,7 +100,9 @@ func (u *UbuntuDistribution) detectDMSGreeter() deps.Dependency {
}
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
return debianPackageInstalledPrecisely(pkg)
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
}
func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
@@ -178,7 +179,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists",
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
@@ -196,7 +197,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
// Not installed, install it
cmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y build-essential")
cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
@@ -212,7 +213,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Installing additional development tools",
}
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev")
if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
@@ -399,7 +400,7 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
installPPACmd := privesc.ExecCommand(ctx, sudoPassword,
installPPACmd := ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y software-properties-common")
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
return fmt.Errorf("failed to install software-properties-common: %w", err)
@@ -417,7 +418,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL),
}
cmd := privesc.ExecCommand(ctx, sudoPassword,
cmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
@@ -438,7 +439,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: "sudo apt-get update",
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
}
@@ -453,7 +454,21 @@ func (u *UbuntuDistribution) installAPTPackages(ctx context.Context, packages []
}
u.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
return u.installAPTGroups(ctx, packages, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60)
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (u *UbuntuDistribution) installPPAPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -462,59 +477,21 @@ func (u *UbuntuDistribution) installPPAPackages(ctx context.Context, packages []
}
u.log(fmt.Sprintf("Installing PPA packages: %s", strings.Join(packages, ", ")))
return u.installAPTGroups(ctx, packages, sudoPassword, progressChan, PhaseAURPackages, "Installing PPA packages...", 0.70, 0.85)
}
func (u *UbuntuDistribution) aptInstallArgs(packages []string, minimal bool) []string {
args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y"}
if minimal {
args = append(args, "--no-install-recommends")
}
return append(args, packages...)
}
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
func (u *UbuntuDistribution) installAPTGroups(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error {
groups := orderedMinimalInstallGroups(packages)
totalGroups := len(groups)
groupIndex := 0
installGroup := func(groupPackages []string, minimal bool) error {
if len(groupPackages) == 0 {
return nil
}
groupIndex++
groupStart := startProgress
groupEnd := endProgress
if totalGroups > 1 {
midpoint := startProgress + ((endProgress - startProgress) / 2)
if groupIndex == 1 {
groupEnd = midpoint
} else {
groupStart = midpoint
}
}
args := u.aptInstallArgs(groupPackages, minimal)
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: groupStart,
Step: step,
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing PPA packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
for _, group := range groups {
if err := installGroup(group.packages, group.minimal); err != nil {
return err
}
}
return nil
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
}
func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -592,7 +569,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
@@ -610,7 +587,7 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y rustup")
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
@@ -650,7 +627,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
}
addPPACmd := privesc.ExecCommand(ctx, sudoPassword,
addPPACmd := ExecSudoCommand(ctx, sudoPassword,
"add-apt-repository -y ppa:longsleep/golang-backports")
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
return fmt.Errorf("failed to add Go PPA: %w", err)
@@ -665,7 +642,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get update",
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
}
@@ -679,7 +656,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y golang-go")
installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
}

View File

@@ -1,42 +0,0 @@
package geolocation
import "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
func NewClient() Client {
geoclueClient, err := newGeoClueClient()
if err != nil {
log.Warnf("GeoClue2 unavailable: %v", err)
return newSeededIpClient()
}
loc, _ := geoclueClient.GetLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
log.Info("Using GeoClue2 location")
return geoclueClient
}
log.Info("GeoClue2 has no fix yet, seeding with IP location")
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location seed failed: %v", err)
return geoclueClient
}
log.Info("Seeded GeoClue2 with IP location")
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
return geoclueClient
}
func newSeededIpClient() *IpClient {
client := newIpClient()
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location also failed: %v", err)
return client
}
log.Info("Using IP location")
client.currLocation.Latitude = ipLoc.Latitude
client.currLocation.Longitude = ipLoc.Longitude
return client
}

View File

@@ -1,243 +0,0 @@
package geolocation
import (
"fmt"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"github.com/godbus/dbus/v5"
)
const (
dbusGeoClueService = "org.freedesktop.GeoClue2"
dbusGeoCluePath = "/org/freedesktop/GeoClue2"
dbusGeoClueInterface = dbusGeoClueService
dbusGeoClueManagerPath = dbusGeoCluePath + "/Manager"
dbusGeoClueManagerInterface = dbusGeoClueInterface + ".Manager"
dbusGeoClueManagerGetClient = dbusGeoClueManagerInterface + ".GetClient"
dbusGeoClueClientInterface = dbusGeoClueInterface + ".Client"
dbusGeoClueClientDesktopId = dbusGeoClueClientInterface + ".DesktopId"
dbusGeoClueClientTimeThreshold = dbusGeoClueClientInterface + ".TimeThreshold"
dbusGeoClueClientTimeStart = dbusGeoClueClientInterface + ".Start"
dbusGeoClueClientTimeStop = dbusGeoClueClientInterface + ".Stop"
dbusGeoClueClientLocationUpdated = dbusGeoClueClientInterface + ".LocationUpdated"
dbusGeoClueLocationInterface = dbusGeoClueInterface + ".Location"
dbusGeoClueLocationLatitude = dbusGeoClueLocationInterface + ".Latitude"
dbusGeoClueLocationLongitude = dbusGeoClueLocationInterface + ".Longitude"
)
type GeoClueClient struct {
currLocation *Location
locationMutex sync.RWMutex
dbusConn *dbus.Conn
clientPath dbus.ObjectPath
signals chan *dbus.Signal
stopChan chan struct{}
sigWG sync.WaitGroup
subscribers syncmap.Map[string, chan Location]
}
func newGeoClueClient() (*GeoClueClient, error) {
dbusConn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, fmt.Errorf("system bus connection failed: %w", err)
}
c := &GeoClueClient{
dbusConn: dbusConn,
stopChan: make(chan struct{}),
signals: make(chan *dbus.Signal, 256),
currLocation: &Location{
Latitude: 0.0,
Longitude: 0.0,
},
}
if err := c.setupClient(); err != nil {
dbusConn.Close()
return nil, err
}
if err := c.startSignalPump(); err != nil {
return nil, err
}
return c, nil
}
func (c *GeoClueClient) Close() {
close(c.stopChan)
c.sigWG.Wait()
if c.signals != nil {
c.dbusConn.RemoveSignal(c.signals)
close(c.signals)
}
c.subscribers.Range(func(key string, ch chan Location) bool {
close(ch)
c.subscribers.Delete(key)
return true
})
if c.dbusConn != nil {
c.dbusConn.Close()
}
}
func (c *GeoClueClient) Subscribe(id string) chan Location {
ch := make(chan Location, 64)
c.subscribers.Store(id, ch)
return ch
}
func (c *GeoClueClient) Unsubscribe(id string) {
if ch, ok := c.subscribers.LoadAndDelete(id); ok {
close(ch)
}
}
func (c *GeoClueClient) setupClient() error {
managerObj := c.dbusConn.Object(dbusGeoClueService, dbusGeoClueManagerPath)
if err := managerObj.Call(dbusGeoClueManagerGetClient, 0).Store(&c.clientPath); err != nil {
return fmt.Errorf("failed to create GeoClue2 client: %w", err)
}
clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath)
if err := clientObj.SetProperty(dbusGeoClueClientDesktopId, "dms"); err != nil {
return fmt.Errorf("failed to set desktop ID: %w", err)
}
if err := clientObj.SetProperty(dbusGeoClueClientTimeThreshold, uint(10)); err != nil {
return fmt.Errorf("failed to set time threshold: %w", err)
}
return nil
}
func (c *GeoClueClient) startSignalPump() error {
c.dbusConn.Signal(c.signals)
if err := c.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(c.clientPath),
dbus.WithMatchInterface(dbusGeoClueClientInterface),
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
); err != nil {
return err
}
c.sigWG.Add(1)
go func() {
defer c.sigWG.Done()
clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath)
clientObj.Call(dbusGeoClueClientTimeStart, 0)
defer clientObj.Call(dbusGeoClueClientTimeStop, 0)
for {
select {
case <-c.stopChan:
return
case sig, ok := <-c.signals:
if !ok {
return
}
if sig == nil {
continue
}
c.handleSignal(sig)
}
}
}()
return nil
}
func (c *GeoClueClient) handleSignal(sig *dbus.Signal) {
switch sig.Name {
case dbusGeoClueClientLocationUpdated:
if len(sig.Body) != 2 {
return
}
newLocationPath, ok := sig.Body[1].(dbus.ObjectPath)
if !ok {
return
}
if err := c.handleLocationUpdated(newLocationPath); err != nil {
log.Warn("GeoClue: Failed to handle location update: %v", err)
return
}
}
}
func (c *GeoClueClient) handleLocationUpdated(path dbus.ObjectPath) error {
locationObj := c.dbusConn.Object(dbusGeoClueService, path)
lat, err := locationObj.GetProperty(dbusGeoClueLocationLatitude)
if err != nil {
return err
}
long, err := locationObj.GetProperty(dbusGeoClueLocationLongitude)
if err != nil {
return err
}
c.locationMutex.Lock()
c.currLocation.Latitude = dbusutil.AsOr(lat, 0.0)
c.currLocation.Longitude = dbusutil.AsOr(long, 0.0)
c.locationMutex.Unlock()
c.notifySubscribers()
return nil
}
func (c *GeoClueClient) notifySubscribers() {
currentLocation, err := c.GetLocation()
if err != nil {
return
}
c.subscribers.Range(func(key string, ch chan Location) bool {
select {
case ch <- currentLocation:
default:
log.Warn("GeoClue: subscriber channel full, dropping update")
}
return true
})
}
func (c *GeoClueClient) SeedLocation(loc Location) {
c.locationMutex.Lock()
defer c.locationMutex.Unlock()
c.currLocation.Latitude = loc.Latitude
c.currLocation.Longitude = loc.Longitude
}
func (c *GeoClueClient) GetLocation() (Location, error) {
c.locationMutex.RLock()
defer c.locationMutex.RUnlock()
if c.currLocation == nil {
return Location{
Latitude: 0.0,
Longitude: 0.0,
}, nil
}
stateCopy := *c.currLocation
return stateCopy, nil
}

View File

@@ -1,91 +0,0 @@
package geolocation
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type IpClient struct {
currLocation *Location
}
type ipLocationResult struct {
Location
City string
}
type ipAPIResponse struct {
Status string `json:"status"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
City string `json:"city"`
}
func newIpClient() *IpClient {
return &IpClient{
currLocation: &Location{},
}
}
func (c *IpClient) Subscribe(id string) chan Location {
ch := make(chan Location, 1)
if location, err := c.GetLocation(); err == nil {
ch <- location
}
return ch
}
func (c *IpClient) Unsubscribe(id string) {}
func (c *IpClient) Close() {}
func (c *IpClient) GetLocation() (Location, error) {
if c.currLocation.Latitude != 0 || c.currLocation.Longitude != 0 {
return *c.currLocation, nil
}
result, err := fetchIPLocation()
if err != nil {
return Location{}, err
}
c.currLocation.Latitude = result.Latitude
c.currLocation.Longitude = result.Longitude
return *c.currLocation, nil
}
func fetchIPLocation() (ipLocationResult, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("http://ip-api.com/json/")
if err != nil {
return ipLocationResult{}, fmt.Errorf("failed to fetch IP location: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ipLocationResult{}, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return ipLocationResult{}, fmt.Errorf("failed to read response: %w", err)
}
var data ipAPIResponse
if err := json.Unmarshal(body, &data); err != nil {
return ipLocationResult{}, fmt.Errorf("failed to parse response: %w", err)
}
if data.Status == "fail" || (data.Lat == 0 && data.Lon == 0) {
return ipLocationResult{}, fmt.Errorf("ip-api.com returned no location data")
}
return ipLocationResult{
Location: Location{Latitude: data.Lat, Longitude: data.Lon},
City: data.City,
}, nil
}

View File

@@ -1,15 +0,0 @@
package geolocation
type Location struct {
Latitude float64
Longitude float64
}
type Client interface {
GetLocation() (Location, error)
Subscribe(id string) chan Location
Unsubscribe(id string)
Close()
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import (
"testing"
)
func writeTestFile(t *testing.T, path string, content string) {
func writeTestJSON(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("failed to create parent dir for %s: %v", path, err)
@@ -70,8 +70,8 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
t.Parallel()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
writeTestFile(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
writeTestJSON(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
writeTestJSON(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
state, err := resolveGreeterThemeSyncState(homeDir)
if err != nil {

View File

@@ -1,439 +0,0 @@
package headless
import (
"context"
"fmt"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
// ErrConfirmationRequired is returned when --yes is not set and the user
// must explicitly confirm the operation.
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
// validConfigNames maps lowercase CLI input to the deployer key used in
// replaceConfigs. Keep in sync with the config types checked by
// shouldReplaceConfig in deployer.go.
var validConfigNames = map[string]string{
"niri": "Niri",
"hyprland": "Hyprland",
"ghostty": "Ghostty",
"kitty": "Kitty",
"alacritty": "Alacritty",
}
// orderedConfigNames defines the canonical order for config names in output.
// Must be kept in sync with validConfigNames.
var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"}
// Config holds all CLI parameters for unattended installation.
type Config struct {
Compositor string // "niri" or "hyprland"
Terminal string // "ghostty", "kitty", or "alacritty"
IncludeDeps []string
ExcludeDeps []string
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
ReplaceConfigsAll bool // deploy/replace all configurations
Yes bool
}
// Runner orchestrates unattended (headless) installation.
type Runner struct {
cfg Config
logChan chan string
}
// NewRunner creates a new headless runner.
func NewRunner(cfg Config) *Runner {
return &Runner{
cfg: cfg,
logChan: make(chan string, 1000),
}
}
// GetLogChan returns the log channel for file logging.
func (r *Runner) GetLogChan() <-chan string {
return r.logChan
}
// Run executes the full unattended installation flow.
func (r *Runner) Run() error {
r.log("Starting headless installation")
// 1. Parse compositor and terminal selections
wm, err := r.parseWindowManager()
if err != nil {
return err
}
terminal, err := r.parseTerminal()
if err != nil {
return err
}
// 2. Build replace-configs map
replaceConfigs, err := r.buildReplaceConfigs()
if err != nil {
return err
}
// 3. Detect OS
r.log("Detecting operating system...")
osInfo, err := distros.GetOSInfo()
if err != nil {
return fmt.Errorf("OS detection failed: %w", err)
}
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
}
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
// 4. Create distribution instance
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
if err != nil {
return fmt.Errorf("failed to initialize distribution: %w", err)
}
// 5. Detect dependencies
r.log("Detecting dependencies...")
fmt.Fprintln(os.Stdout, "Detecting dependencies...")
dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
if err != nil {
return fmt.Errorf("dependency detection failed: %w", err)
}
// 5. Apply include/exclude filters and build the disabled-items map.
// Headless mode does not currently collect any explicit reinstall selections,
// so keep reinstallItems nil instead of constructing an always-empty map.
disabledItems, err := r.buildDisabledItems(dependencies)
if err != nil {
return err
}
var reinstallItems map[string]bool
// Print dependency summary
fmt.Fprintln(os.Stdout, "\nDependencies:")
for _, dep := range dependencies {
marker := " "
status := ""
if disabledItems[dep.Name] {
marker = " SKIP "
status = "(disabled)"
} else {
switch dep.Status {
case deps.StatusInstalled:
marker = " OK "
status = "(installed)"
case deps.StatusMissing:
marker = " NEW "
status = "(will install)"
case deps.StatusNeedsUpdate:
marker = " UPD "
status = "(will update)"
case deps.StatusNeedsReinstall:
marker = " RE "
status = "(will reinstall)"
}
}
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
}
fmt.Fprintln(os.Stdout)
// 6b. Require explicit confirmation unless --yes is set
if !r.cfg.Yes {
if replaceConfigs == nil {
// --replace-configs-all
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
} else if r.anyConfigEnabled(replaceConfigs) {
var names []string
for _, cliName := range orderedConfigNames {
deployerKey := validConfigNames[cliName]
if replaceConfigs[deployerKey] {
names = append(names, deployerKey)
}
}
fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", "))
} else {
fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.")
}
fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.")
r.log("Aborted: --yes not set")
return ErrConfirmationRequired
}
// 7. Authenticate sudo
sudoPassword, err := r.resolveSudoPassword()
if err != nil {
return err
}
// 8. Install packages
fmt.Fprintln(os.Stdout, "Installing packages...")
r.log("Starting package installation")
progressChan := make(chan distros.InstallProgressMsg, 100)
installErr := make(chan error, 1)
go func() {
defer close(progressChan)
installErr <- distro.InstallPackages(
context.Background(),
dependencies,
wm,
sudoPassword,
reinstallItems,
disabledItems,
false, // skipGlobalUseFlags
progressChan,
)
}()
// Consume progress messages and print them
for msg := range progressChan {
if msg.Error != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
} else if msg.Step != "" {
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
}
if msg.LogOutput != "" {
r.log(msg.LogOutput)
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
}
}
if err := <-installErr; err != nil {
return fmt.Errorf("package installation failed: %w", err)
}
// 9. Greeter setup (if dms-greeter was included)
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
compositorName := "niri"
if wm == deps.WindowManagerHyprland {
compositorName = "Hyprland"
}
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
logFunc := func(line string) {
r.log(line)
fmt.Fprintf(os.Stdout, " greeter: %s\n", line)
}
if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil {
// Non-fatal, matching TUI behavior
fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err)
}
}
// 10. Deploy configurations
fmt.Fprintln(os.Stdout, "Deploying configurations...")
r.log("Starting configuration deployment")
deployer := config.NewConfigDeployer(r.logChan)
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
wm,
terminal,
dependencies,
replaceConfigs,
reinstallItems,
)
if err != nil {
return fmt.Errorf("configuration deployment failed: %w", err)
}
for _, result := range results {
if result.Deployed {
msg := fmt.Sprintf(" Deployed: %s", result.ConfigType)
if result.BackupPath != "" {
msg += fmt.Sprintf(" (backup: %s)", result.BackupPath)
}
fmt.Fprintln(os.Stdout, msg)
}
if result.Error != nil {
fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error)
}
}
fmt.Fprintln(os.Stdout, "\nInstallation complete!")
r.log("Headless installation completed successfully")
return nil
}
// buildDisabledItems computes the set of dependencies that should be skipped
// during installation, applying the --include-deps and --exclude-deps filters.
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
disabledItems := make(map[string]bool)
// dms-greeter is opt-in (disabled by default), matching TUI behavior
for i := range dependencies {
if dependencies[i].Name == "dms-greeter" {
disabledItems["dms-greeter"] = true
break
}
}
// Process --include-deps (enable items that are disabled by default)
for _, name := range r.cfg.IncludeDeps {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !r.depExists(dependencies, name) {
return nil, fmt.Errorf("--include-deps: unknown dependency %q", name)
}
delete(disabledItems, name)
}
// Process --exclude-deps (disable items)
for _, name := range r.cfg.ExcludeDeps {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !r.depExists(dependencies, name) {
return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name)
}
// Don't allow excluding DMS itself
if name == "dms (DankMaterialShell)" {
return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name)
}
disabledItems[name] = true
}
return disabledItems, nil
}
// buildReplaceConfigs converts the --replace-configs / --replace-configs-all
// flags into the map[string]bool consumed by the config deployer.
//
// Returns:
// - nil when --replace-configs-all is set (deployer treats nil as "replace all")
// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk)
// - a map with requested configs true, all others false for --replace-configs
// - an error when both flags are set or an invalid config name is given
func (r *Runner) buildReplaceConfigs() (map[string]bool, error) {
hasSpecific := len(r.cfg.ReplaceConfigs) > 0
if hasSpecific && r.cfg.ReplaceConfigsAll {
return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive")
}
if r.cfg.ReplaceConfigsAll {
return nil, nil
}
// Build a map with all known configs explicitly set to false
result := make(map[string]bool, len(validConfigNames))
for _, cliName := range orderedConfigNames {
result[validConfigNames[cliName]] = false
}
// Enable only the requested configs
for _, name := range r.cfg.ReplaceConfigs {
name = strings.TrimSpace(name)
if name == "" {
continue
}
deployerKey, ok := validConfigNames[strings.ToLower(name)]
if !ok {
return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name)
}
result[deployerKey] = true
}
return result, nil
}
func (r *Runner) log(message string) {
select {
case r.logChan <- message:
default:
}
}
func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
switch strings.ToLower(r.cfg.Compositor) {
case "niri":
return deps.WindowManagerNiri, nil
case "hyprland":
return deps.WindowManagerHyprland, nil
default:
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
}
}
func (r *Runner) parseTerminal() (deps.Terminal, error) {
switch strings.ToLower(r.cfg.Terminal) {
case "ghostty":
return deps.TerminalGhostty, nil
case "kitty":
return deps.TerminalKitty, nil
case "alacritty":
return deps.TerminalAlacritty, nil
default:
return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal)
}
}
func (r *Runner) resolveSudoPassword() (string, error) {
tool, err := privesc.Detect()
if err != nil {
return "", err
}
if err := privesc.CheckCached(context.Background()); err == nil {
r.log(fmt.Sprintf("%s cache is valid, no password needed", tool.Name()))
fmt.Fprintf(os.Stdout, "%s: using cached credentials\n", tool.Name())
return "", nil
}
switch tool {
case privesc.ToolSudo:
return "", fmt.Errorf(
"sudo authentication required but no cached credentials found\n" +
"Options:\n" +
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
" 2. Configure passwordless sudo for your user",
)
case privesc.ToolDoas:
return "", fmt.Errorf(
"doas authentication required but no cached credentials found\n" +
"Options:\n" +
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
)
case privesc.ToolRun0:
return "", fmt.Errorf(
"run0 authentication required but no cached credentials found\n" +
"Configure a polkit rule granting your user passwordless privilege\n" +
"(see `man polkit` for details on rules in /etc/polkit-1/rules.d/)",
)
default:
return "", fmt.Errorf("unsupported privilege tool: %s", tool)
}
}
func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
for _, v := range m {
if v {
return true
}
}
return false
}
func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
for _, dep := range dependencies {
if dep.Name == name {
return true
}
}
return false
}

View File

@@ -1,459 +0,0 @@
package headless
import (
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func TestParseWindowManager(t *testing.T) {
tests := []struct {
name string
input string
want deps.WindowManager
wantErr bool
}{
{"niri lowercase", "niri", deps.WindowManagerNiri, false},
{"niri mixed case", "Niri", deps.WindowManagerNiri, false},
{"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false},
{"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false},
{"invalid", "sway", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{Compositor: tt.input})
got, err := r.parseWindowManager()
if (err != nil) != tt.wantErr {
t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseWindowManager() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseTerminal(t *testing.T) {
tests := []struct {
name string
input string
want deps.Terminal
wantErr bool
}{
{"ghostty lowercase", "ghostty", deps.TerminalGhostty, false},
{"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false},
{"kitty lowercase", "kitty", deps.TerminalKitty, false},
{"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false},
{"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false},
{"invalid", "wezterm", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{Terminal: tt.input})
got, err := r.parseTerminal()
if (err != nil) != tt.wantErr {
t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseTerminal() = %v, want %v", got, tt.want)
}
})
}
}
func TestDepExists(t *testing.T) {
dependencies := []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
{Name: "ghostty", Status: deps.StatusMissing},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
{Name: "dms-greeter", Status: deps.StatusMissing},
}
tests := []struct {
name string
dep string
want bool
}{
{"existing dep", "niri", true},
{"existing dep with special chars", "dms (DankMaterialShell)", true},
{"existing optional dep", "dms-greeter", true},
{"non-existing dep", "firefox", false},
{"empty name", "", false},
}
r := NewRunner(Config{})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := r.depExists(dependencies, tt.dep); got != tt.want {
t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want)
}
})
}
}
func TestNewRunner(t *testing.T) {
cfg := Config{
Compositor: "niri",
Terminal: "ghostty",
IncludeDeps: []string{"dms-greeter"},
ExcludeDeps: []string{"some-pkg"},
Yes: true,
}
r := NewRunner(cfg)
if r == nil {
t.Fatal("NewRunner returned nil")
}
if r.cfg.Compositor != "niri" {
t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri")
}
if r.cfg.Terminal != "ghostty" {
t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty")
}
if !r.cfg.Yes {
t.Error("cfg.Yes = false, want true")
}
if r.logChan == nil {
t.Error("logChan is nil")
}
}
func TestGetLogChan(t *testing.T) {
r := NewRunner(Config{})
ch := r.GetLogChan()
if ch == nil {
t.Fatal("GetLogChan returned nil")
}
// Verify the channel is readable by sending a message
go func() {
r.logChan <- "test message"
}()
msg := <-ch
if msg != "test message" {
t.Errorf("received %q, want %q", msg, "test message")
}
}
func TestLog(t *testing.T) {
r := NewRunner(Config{})
// log should not block even if channel is full
for i := 0; i < 1100; i++ {
r.log("message")
}
// If we reach here without hanging, the non-blocking send works
}
func TestRunRequiresYes(t *testing.T) {
// Verify that ErrConfirmationRequired is a distinct sentinel error
if ErrConfirmationRequired == nil {
t.Fatal("ErrConfirmationRequired should not be nil")
}
expected := "confirmation required: pass --yes to proceed"
if ErrConfirmationRequired.Error() != expected {
t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected)
}
}
func TestConfigYesStoredCorrectly(t *testing.T) {
// Yes=false (default) should be stored
rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false})
if rNo.cfg.Yes {
t.Error("cfg.Yes = true, want false")
}
// Yes=true should be stored
rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true})
if !rYes.cfg.Yes {
t.Error("cfg.Yes = false, want true")
}
}
func TestValidConfigNamesCompleteness(t *testing.T) {
// orderedConfigNames and validConfigNames must stay in sync.
if len(orderedConfigNames) != len(validConfigNames) {
t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d",
len(orderedConfigNames), len(validConfigNames))
}
// Every entry in orderedConfigNames must exist in validConfigNames.
for _, name := range orderedConfigNames {
if _, ok := validConfigNames[name]; !ok {
t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name)
}
}
// validConfigNames must have no extra keys not in orderedConfigNames.
ordered := make(map[string]bool, len(orderedConfigNames))
for _, name := range orderedConfigNames {
ordered[name] = true
}
for key := range validConfigNames {
if !ordered[key] {
t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key)
}
}
}
func TestBuildReplaceConfigs(t *testing.T) {
allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"}
tests := []struct {
name string
replaceConfigs []string
replaceAll bool
wantNil bool // expect nil (replace all)
wantEnabled []string // deployer keys that should be true
wantErr bool
}{
{
name: "neither flag set",
wantNil: false,
wantEnabled: nil, // all should be false
},
{
name: "replace-configs-all",
replaceAll: true,
wantNil: true,
},
{
name: "specific configs",
replaceConfigs: []string{"niri", "ghostty"},
wantNil: false,
wantEnabled: []string{"Niri", "Ghostty"},
},
{
name: "both flags set",
replaceConfigs: []string{"niri"},
replaceAll: true,
wantErr: true,
},
{
name: "invalid config name",
replaceConfigs: []string{"foo"},
wantErr: true,
},
{
name: "case insensitive",
replaceConfigs: []string{"NIRI", "Ghostty"},
wantNil: false,
wantEnabled: []string{"Niri", "Ghostty"},
},
{
name: "single config",
replaceConfigs: []string{"kitty"},
wantNil: false,
wantEnabled: []string{"Kitty"},
},
{
name: "whitespace entry",
replaceConfigs: []string{" ", "niri"},
wantNil: false,
wantEnabled: []string{"Niri"},
},
{
name: "duplicate entry",
replaceConfigs: []string{"niri", "niri"},
wantNil: false,
wantEnabled: []string{"Niri"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{
ReplaceConfigs: tt.replaceConfigs,
ReplaceConfigsAll: tt.replaceAll,
})
got, err := r.buildReplaceConfigs()
if (err != nil) != tt.wantErr {
t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if tt.wantNil {
if got != nil {
t.Fatalf("buildReplaceConfigs() = %v, want nil", got)
}
return
}
if got == nil {
t.Fatal("buildReplaceConfigs() = nil, want non-nil map")
}
// All known deployer keys must be present
for _, key := range allDeployerKeys {
if _, exists := got[key]; !exists {
t.Errorf("missing deployer key %q in result map", key)
}
}
// Build enabled set for easy lookup
enabledSet := make(map[string]bool)
for _, k := range tt.wantEnabled {
enabledSet[k] = true
}
for _, key := range allDeployerKeys {
want := enabledSet[key]
if got[key] != want {
t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want)
}
}
})
}
}
func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
r := NewRunner(Config{
Compositor: "niri",
Terminal: "ghostty",
ReplaceConfigs: []string{"niri", "ghostty"},
ReplaceConfigsAll: false,
})
if len(r.cfg.ReplaceConfigs) != 2 {
t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs))
}
if r.cfg.ReplaceConfigsAll {
t.Error("ReplaceConfigsAll = true, want false")
}
r2 := NewRunner(Config{
Compositor: "niri",
Terminal: "ghostty",
ReplaceConfigsAll: true,
})
if !r2.cfg.ReplaceConfigsAll {
t.Error("ReplaceConfigsAll = false, want true")
}
if len(r2.cfg.ReplaceConfigs) != 0 {
t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs))
}
}
func TestBuildDisabledItems(t *testing.T) {
dependencies := []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
{Name: "ghostty", Status: deps.StatusMissing},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
{Name: "dms-greeter", Status: deps.StatusMissing},
{Name: "waybar", Status: deps.StatusMissing},
}
tests := []struct {
name string
includeDeps []string
excludeDeps []string
deps []deps.Dependency // nil means use the shared fixture
wantErr bool
errContains string // substring expected in error message
wantDisabled []string // dep names that should be in disabledItems
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
}{
{
name: "no flags set, dms-greeter disabled by default",
wantDisabled: []string{"dms-greeter"},
wantEnabled: []string{"niri", "ghostty", "waybar"},
},
{
name: "include dms-greeter enables it",
includeDeps: []string{"dms-greeter"},
wantEnabled: []string{"dms-greeter"},
},
{
name: "exclude a regular dep",
excludeDeps: []string{"waybar"},
wantDisabled: []string{"dms-greeter", "waybar"},
},
{
name: "include unknown dep returns error",
includeDeps: []string{"nonexistent"},
wantErr: true,
errContains: "--include-deps",
},
{
name: "exclude unknown dep returns error",
excludeDeps: []string{"nonexistent"},
wantErr: true,
errContains: "--exclude-deps",
},
{
name: "exclude DMS itself is forbidden",
excludeDeps: []string{"dms (DankMaterialShell)"},
wantErr: true,
errContains: "cannot exclude required package",
},
{
name: "include and exclude same dep",
includeDeps: []string{"dms-greeter"},
excludeDeps: []string{"dms-greeter"},
wantDisabled: []string{"dms-greeter"},
},
{
name: "whitespace entries are skipped",
includeDeps: []string{" ", "dms-greeter"},
wantEnabled: []string{"dms-greeter"},
},
{
name: "no dms-greeter in deps, nothing disabled by default",
deps: []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
},
wantEnabled: []string{"niri"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{
IncludeDeps: tt.includeDeps,
ExcludeDeps: tt.excludeDeps,
})
d := tt.deps
if d == nil {
d = dependencies
}
got, err := r.buildDisabledItems(d)
if (err != nil) != tt.wantErr {
t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
return
}
if got == nil {
t.Fatal("buildDisabledItems() returned nil map, want non-nil")
}
// Check expected disabled items
for _, name := range tt.wantDisabled {
if !got[name] {
t.Errorf("expected %q to be disabled, but it is not", name)
}
}
// Check expected enabled items (should not be in the map or be false)
for _, name := range tt.wantEnabled {
if got[name] {
t.Errorf("expected %q to NOT be disabled, but it is", name)
}
}
// If wantDisabled is empty, the map should have length 0
if len(tt.wantDisabled) == 0 && len(got) != 0 {
t.Errorf("expected empty disabledItems map, got %v", got)
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document"
)
@@ -291,7 +292,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
parser := NewNiriParser(filepath.Dir(overridePath))
parser.currentSource = overridePath
doc, err := parseKDL(data)
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return nil, err
}

View File

@@ -50,103 +50,6 @@ type NiriParser struct {
conflictingConfigs map[string]*NiriKeyBinding
}
func parseKDL(data []byte) (*document.Document, error) {
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data))))
}
func normalizeKDLBraces(input string) string {
var sb strings.Builder
sb.Grow(len(input))
var prev byte
n := len(input)
for i := 0; i < n; {
c := input[i]
switch {
case c == '"':
end := findStringEnd(input, i)
sb.WriteString(input[i:end])
prev = '"'
i = end
case c == '/' && i+1 < n && input[i+1] == '/':
end := findLineCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = '\n'
i = end
case c == '/' && i+1 < n && input[i+1] == '*':
end := findBlockCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = '/'
i = end
case c == '{' && prev != 0 && !isBraceAdjacentSpace(prev):
sb.WriteByte(' ')
sb.WriteByte(c)
prev = c
i++
default:
sb.WriteByte(c)
prev = c
i++
}
}
return sb.String()
}
func findStringEnd(s string, start int) int {
n := len(s)
for i := start + 1; i < n; {
switch s[i] {
case '\\':
i += 2
case '"':
return i + 1
default:
i++
}
}
return n
}
func findLineCommentEnd(s string, start int) int {
for i := start + 2; i < len(s); i++ {
if s[i] == '\n' {
return i
}
}
return len(s)
}
func findBlockCommentEnd(s string, start int) int {
n := len(s)
depth := 1
for i := start + 2; i < n && depth > 0; {
switch {
case i+1 < n && s[i] == '/' && s[i+1] == '*':
depth++
i += 2
case i+1 < n && s[i] == '*' && s[i+1] == '/':
depth--
i += 2
if depth == 0 {
return i
}
default:
i++
}
}
return n
}
func isBraceAdjacentSpace(b byte) bool {
switch b {
case ' ', '\t', '\n', '\r', '{':
return true
}
return false
}
func NewNiriParser(configDir string) *NiriParser {
return &NiriParser{
configDir: configDir,
@@ -188,7 +91,7 @@ func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSec
return
}
doc, err := parseKDL(data)
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return
}
@@ -256,7 +159,7 @@ func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, erro
return nil, fmt.Errorf("failed to read %s: %w", absPath, err)
}
doc, err := parseKDL(data)
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
}

View File

@@ -3,74 +3,9 @@ package providers
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
config := `recent-windows {
binds {
Alt+Tab { next-window scope="output"; }
Alt+Shift+Tab { previous-window scope="output"; }
Alt+grave { next-window filter="app-id"; }
Alt+Shift+grave { previous-window filter="app-id"; }
Alt+Escape { next-window scope="all"; }
Alt+Shift+Escape{ previous-window scope="all"; }
}
}
`
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed on valid niri config: %v", err)
}
var found *NiriKeyBinding
for i := range result.Section.Keybinds {
kb := &result.Section.Keybinds[i]
if kb.Key == "Escape" && slices.Contains(kb.Mods, "Alt") && slices.Contains(kb.Mods, "Shift") {
found = kb
break
}
}
if found == nil {
t.Fatal("Alt+Shift+Escape bind missing — '{' without preceding space was not handled")
}
if found.Action != "previous-window" {
t.Errorf("Action = %q, want %q", found.Action, "previous-window")
}
}
func TestNormalizeKDLBraces(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{"already spaced", "node { child }\n", "node { child }\n"},
{"missing space", "node{ child }\n", "node { child }\n"},
{"niri keybind", "Alt+Shift+Escape{ previous-window; }", "Alt+Shift+Escape { previous-window; }"},
{"brace inside string", `node "a{b" { child }`, `node "a{b" { child }`},
{"brace in line comment", "// foo{bar\nnode { }", "// foo{bar\nnode { }"},
{"brace in block comment", "/* foo{bar */ node{ }", "/* foo{bar */ node { }"},
{"escaped quote in string", `node "a\"b{c" { }`, `node "a\"b{c" { }`},
{"leading brace", "{ child }", "{ child }"},
{"nested missing space", "a{b{ c }}", "a {b { c }}"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := normalizeKDLBraces(tc.in)
if got != tc.out {
t.Errorf("normalizeKDLBraces(%q) = %q, want %q", tc.in, got, tc.out)
}
})
}
}
func TestNiriParseKeyCombo(t *testing.T) {
tests := []struct {
combo string

View File

@@ -4,7 +4,6 @@ import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"sync"
"time"
@@ -22,16 +21,7 @@ type FileLogger struct {
func NewFileLogger() (*FileLogger, error) {
timestamp := time.Now().Unix()
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp.
logDir := os.Getenv("DANKINSTALL_LOG_DIR")
if logDir == "" {
logDir = "/tmp"
}
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
}
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp))
logPath := fmt.Sprintf("/tmp/dankinstall-%d.log", timestamp)
file, err := os.Create(logPath)
if err != nil {

View File

@@ -1,16 +1,12 @@
package log
import (
"io"
"os"
"regexp"
"strings"
"sync"
"github.com/charmbracelet/lipgloss"
cblog "github.com/charmbracelet/log"
"github.com/mattn/go-isatty"
"github.com/muesli/termenv"
)
// Logger embeds the Charm Logger and adds Printf/Fatalf
@@ -25,26 +21,8 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
var (
logger *Logger
initLogger sync.Once
logMu sync.Mutex
logFile *os.File
logStderr io.Writer = os.Stderr
ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
)
// ansiStripWriter strips ANSI escape sequences before forwarding to w. Used
// for the file sink so colored stderr stays colored while the file stays plain.
type ansiStripWriter struct{ w io.Writer }
func (a *ansiStripWriter) Write(p []byte) (int, error) {
stripped := ansiRe.ReplaceAll(p, nil)
if _, err := a.w.Write(stripped); err != nil {
return 0, err
}
return len(p), nil
}
func parseLogLevel(level string) cblog.Level {
switch strings.ToLower(level) {
case "debug":
@@ -108,7 +86,7 @@ func GetLogger() *Logger {
SetString(" DEBUG").
Foreground(lipgloss.Color("4"))
base := cblog.New(logStderr)
base := cblog.New(os.Stderr)
base.SetStyles(styles)
base.SetReportTimestamp(false)
@@ -120,85 +98,10 @@ func GetLogger() *Logger {
base.SetPrefix(" go")
logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path)
}
})
return logger
}
// SetLevel updates the active log level. Accepts the same strings as
// DMS_LOG_LEVEL. Unknown values default to info.
func SetLevel(level string) {
GetLogger().SetLevel(parseLogLevel(level))
}
// SetLogFile makes the logger append to path in addition to stderr. Passing an
// empty string detaches the file sink. Atomic per-line writes (≤PIPE_BUF) on
// O_APPEND keep concurrent Go and QML writers from corrupting each other.
//
// Color handling: charmbracelet/log auto-detects color support from its
// io.Writer, and io.MultiWriter doesn't pass that through, so we force the ANSI
// profile when stderr is a TTY and route the file through ansiStripWriter so
// the file stays plain while stderr keeps its colors.
func SetLogFile(path string) error {
logMu.Lock()
defer logMu.Unlock()
if logFile != nil {
logFile.Close()
logFile = nil
}
l := GetLogger()
if path == "" {
l.SetOutput(logStderr)
applyColorProfile(l, logStderr)
return nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
if err != nil {
return err
}
logFile = f
out := io.MultiWriter(logStderr, &ansiStripWriter{w: f})
l.SetOutput(out)
applyColorProfile(l, logStderr)
return nil
}
// applyColorProfile forces the renderer's color profile to match what stderr
// would produce on its own, undoing the auto-downgrade triggered by wrapping
// stderr in a non-TTY writer (e.g. io.MultiWriter).
func applyColorProfile(l *Logger, stderr io.Writer) {
f, ok := stderr.(*os.File)
if !ok {
l.SetColorProfile(termenv.Ascii)
return
}
if isatty.IsTerminal(f.Fd()) {
l.SetColorProfile(termenv.ANSI)
return
}
l.SetColorProfile(termenv.Ascii)
}
// ApplyEnvOverrides re-reads DMS_LOG_LEVEL and DMS_LOG_FILE and reconfigures
// the singleton. Safe to call after CLI flags have rewritten the environment.
func ApplyEnvOverrides() {
GetLogger()
if level := os.Getenv("DMS_LOG_LEVEL"); level != "" {
SetLevel(level)
}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
if err := SetLogFile(path); err != nil {
Warnf("Failed to open log file %q: %v", path, err)
}
}
}
// * Convenience wrappers
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }

View File

@@ -60,7 +60,6 @@ var templateRegistry = []TemplateDef{
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
@@ -100,7 +99,6 @@ type Options struct {
Mode ColorMode
IconTheme string
MatugenType string
Contrast float64
RunUserTemplates bool
ColorsOnly bool
StockColors string
@@ -230,7 +228,6 @@ func buildOnce(opts *Options) (bool, error) {
log.Info("Running matugen color hex with stock color overrides")
args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()}
args = appendContrastArg(args, opts.Contrast)
args = append(args, importArgs...)
if err := runMatugen(args); err != nil {
return false, err
@@ -267,7 +264,6 @@ func buildOnce(opts *Options) (bool, error) {
args = []string{opts.Kind, opts.Value}
}
args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name())
args = appendContrastArg(args, opts.Contrast)
args = append(args, importArgs...)
if err := runMatugen(args); err != nil {
return false, err
@@ -303,13 +299,6 @@ func buildOnce(opts *Options) (bool, error) {
return true, nil
}
func appendContrastArg(args []string, contrast float64) []string {
if contrast == 0 {
return args
}
return append(args, "--contrast", strconv.FormatFloat(contrast, 'f', -1, 64))
}
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
@@ -698,7 +687,6 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
baseArgs = []string{opts.Kind, opts.Value}
}
baseArgs = append(baseArgs, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run")
baseArgs = appendContrastArg(baseArgs, opts.Contrast)
if flags.isV4 {
baseArgs = append(baseArgs, "--source-color-index", "0", "--old-json-output")
}

View File

@@ -1,203 +0,0 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_geolocation
import (
geolocation "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
mock "github.com/stretchr/testify/mock"
)
// MockClient is an autogenerated mock type for the Client type
type MockClient struct {
mock.Mock
}
type MockClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
// Close provides a mock function with no fields
func (_m *MockClient) Close() {
_m.Called()
}
// MockClient_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
type MockClient_Close_Call struct {
*mock.Call
}
// Close is a helper method to define mock.On call
func (_e *MockClient_Expecter) Close() *MockClient_Close_Call {
return &MockClient_Close_Call{Call: _e.mock.On("Close")}
}
func (_c *MockClient_Close_Call) Run(run func()) *MockClient_Close_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockClient_Close_Call) Return() *MockClient_Close_Call {
_c.Call.Return()
return _c
}
func (_c *MockClient_Close_Call) RunAndReturn(run func()) *MockClient_Close_Call {
_c.Run(run)
return _c
}
// GetLocation provides a mock function with no fields
func (_m *MockClient) GetLocation() (geolocation.Location, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetLocation")
}
var r0 geolocation.Location
var r1 error
if rf, ok := ret.Get(0).(func() (geolocation.Location, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() geolocation.Location); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(geolocation.Location)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_GetLocation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLocation'
type MockClient_GetLocation_Call struct {
*mock.Call
}
// GetLocation is a helper method to define mock.On call
func (_e *MockClient_Expecter) GetLocation() *MockClient_GetLocation_Call {
return &MockClient_GetLocation_Call{Call: _e.mock.On("GetLocation")}
}
func (_c *MockClient_GetLocation_Call) Run(run func()) *MockClient_GetLocation_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockClient_GetLocation_Call) Return(_a0 geolocation.Location, _a1 error) *MockClient_GetLocation_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_GetLocation_Call) RunAndReturn(run func() (geolocation.Location, error)) *MockClient_GetLocation_Call {
_c.Call.Return(run)
return _c
}
// Subscribe provides a mock function with given fields: id
func (_m *MockClient) Subscribe(id string) chan geolocation.Location {
ret := _m.Called(id)
if len(ret) == 0 {
panic("no return value specified for Subscribe")
}
var r0 chan geolocation.Location
if rf, ok := ret.Get(0).(func(string) chan geolocation.Location); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(chan geolocation.Location)
}
}
return r0
}
// MockClient_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
type MockClient_Subscribe_Call struct {
*mock.Call
}
// Subscribe is a helper method to define mock.On call
// - id string
func (_e *MockClient_Expecter) Subscribe(id interface{}) *MockClient_Subscribe_Call {
return &MockClient_Subscribe_Call{Call: _e.mock.On("Subscribe", id)}
}
func (_c *MockClient_Subscribe_Call) Run(run func(id string)) *MockClient_Subscribe_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockClient_Subscribe_Call) Return(_a0 chan geolocation.Location) *MockClient_Subscribe_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_Subscribe_Call) RunAndReturn(run func(string) chan geolocation.Location) *MockClient_Subscribe_Call {
_c.Call.Return(run)
return _c
}
// Unsubscribe provides a mock function with given fields: id
func (_m *MockClient) Unsubscribe(id string) {
_m.Called(id)
}
// MockClient_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe'
type MockClient_Unsubscribe_Call struct {
*mock.Call
}
// Unsubscribe is a helper method to define mock.On call
// - id string
func (_e *MockClient_Expecter) Unsubscribe(id interface{}) *MockClient_Unsubscribe_Call {
return &MockClient_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", id)}
}
func (_c *MockClient_Unsubscribe_Call) Run(run func(id string)) *MockClient_Unsubscribe_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockClient_Unsubscribe_Call) Return() *MockClient_Unsubscribe_Call {
_c.Call.Return()
return _c
}
func (_c *MockClient_Unsubscribe_Call) RunAndReturn(run func(string)) *MockClient_Unsubscribe_Call {
_c.Run(run)
return _c
}
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockClient {
mock := &MockClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -1062,62 +1062,6 @@ func (_c *MockBackend_GetWiFiNetworkDetails_Call) RunAndReturn(run func(string)
return _c
}
// GetWiFiQRCodeContent provides a mock function with given fields: ssid
func (_m *MockBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
ret := _m.Called(ssid)
if len(ret) == 0 {
panic("no return value specified for GetWiFiQRCodeContent")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string) (string, error)); ok {
return rf(ssid)
}
if rf, ok := ret.Get(0).(func(string) string); ok {
r0 = rf(ssid)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(ssid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockBackend_GetWiFiQRCodeContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiQRCodeContent'
type MockBackend_GetWiFiQRCodeContent_Call struct {
*mock.Call
}
// GetWiFiQRCodeContent is a helper method to define mock.On call
// - ssid string
func (_e *MockBackend_Expecter) GetWiFiQRCodeContent(ssid interface{}) *MockBackend_GetWiFiQRCodeContent_Call {
return &MockBackend_GetWiFiQRCodeContent_Call{Call: _e.mock.On("GetWiFiQRCodeContent", ssid)}
}
func (_c *MockBackend_GetWiFiQRCodeContent_Call) Run(run func(ssid string)) *MockBackend_GetWiFiQRCodeContent_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockBackend_GetWiFiQRCodeContent_Call) Return(_a0 string, _a1 error) *MockBackend_GetWiFiQRCodeContent_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockBackend_GetWiFiQRCodeContent_Call) RunAndReturn(run func(string) (string, error)) *MockBackend_GetWiFiQRCodeContent_Call {
_c.Call.Return(run)
return _c
}
// GetWiredConnections provides a mock function with no fields
func (_m *MockBackend) GetWiredConnections() ([]network.WiredConnection, error) {
ret := _m.Called()

View File

@@ -1,874 +0,0 @@
package pam
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
const (
GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)"
GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH"
LockscreenPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN AUTH (managed by dms greeter sync)"
LockscreenPamManagedBlockEnd = "# END DMS LOCKSCREEN AUTH"
LockscreenU2FPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN U2F AUTH (managed by dms auth sync)"
LockscreenU2FPamManagedBlockEnd = "# END DMS LOCKSCREEN U2F AUTH"
legacyGreeterPamFprintComment = "# DMS greeter fingerprint"
legacyGreeterPamU2FComment = "# DMS greeter U2F"
GreetdPamPath = "/etc/pam.d/greetd"
DankshellPamPath = "/etc/pam.d/dankshell"
DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f"
)
var includedPamAuthFiles = []string{
"system-auth",
"common-auth",
"password-auth",
"system-login",
"system-local-login",
"common-auth-pc",
"login",
}
type AuthSettings struct {
EnableFprint bool `json:"enableFprint"`
EnableU2f bool `json:"enableU2f"`
GreeterEnableFprint bool `json:"greeterEnableFprint"`
GreeterEnableU2f bool `json:"greeterEnableU2f"`
}
type SyncAuthOptions struct {
HomeDir string
ForceGreeterAuth bool
}
type syncDeps struct {
pamDir string
greetdPath string
dankshellPath string
dankshellU2fPath string
isNixOS func() bool
readFile func(string) ([]byte, error)
stat func(string) (os.FileInfo, error)
createTemp func(string, string) (*os.File, error)
removeFile func(string) error
runSudoCmd func(string, string, ...string) error
pamModuleExists func(string) bool
fingerprintAvailableForCurrentUser func() bool
}
type lockscreenPamIncludeDirective struct {
target string
filterType string
}
type lockscreenPamResolver struct {
pamDir string
readFile func(string) ([]byte, error)
}
func defaultSyncDeps() syncDeps {
return syncDeps{
pamDir: "/etc/pam.d",
greetdPath: GreetdPamPath,
dankshellPath: DankshellPamPath,
dankshellU2fPath: DankshellU2FPamPath,
isNixOS: IsNixOS,
readFile: os.ReadFile,
stat: os.Stat,
createTemp: os.CreateTemp,
removeFile: os.Remove,
runSudoCmd: func(password, command string, args ...string) error {
return privesc.Run(context.Background(), password, append([]string{command}, args...)...)
},
pamModuleExists: pamModuleExists,
fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser,
}
}
func IsNixOS() bool {
_, err := os.Stat("/etc/NIXOS")
return err == nil
}
func ReadAuthSettings(homeDir string) (AuthSettings, error) {
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return AuthSettings{}, nil
}
return AuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err)
}
if strings.TrimSpace(string(data)) == "" {
return AuthSettings{}, nil
}
var settings AuthSettings
if err := json.Unmarshal(data, &settings); err != nil {
return AuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
}
return settings, nil
}
func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) {
settings, err := ReadAuthSettings(homeDir)
if err != nil {
return false, false, err
}
return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil
}
func SyncAuthConfig(logFunc func(string), sudoPassword string, options SyncAuthOptions) error {
return syncAuthConfigWithDeps(logFunc, sudoPassword, options, defaultSyncDeps())
}
func RemoveManagedGreeterPamBlock(logFunc func(string), sudoPassword string) error {
return removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, defaultSyncDeps())
}
func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options SyncAuthOptions, deps syncDeps) error {
homeDir := strings.TrimSpace(options.HomeDir)
if homeDir == "" {
var err error
homeDir, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
}
settings, err := ReadAuthSettings(homeDir)
if err != nil {
return err
}
if err := syncLockscreenPamConfigWithDeps(logFunc, sudoPassword, deps); err != nil {
return err
}
if err := syncLockscreenU2FPamConfigWithDeps(logFunc, sudoPassword, settings.EnableU2f, deps); err != nil {
return err
}
if _, err := deps.stat(deps.greetdPath); err != nil {
if os.IsNotExist(err) {
logFunc(" /etc/pam.d/greetd not found. Skipping greeter PAM sync.")
return nil
}
return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err)
}
if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil {
return err
}
return nil
}
func removeManagedGreeterPamBlockWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
if deps.isNixOS() {
return nil
}
data, err := deps.readFile(deps.greetdPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err)
}
originalContent := string(data)
stripped, removed := stripManagedGreeterPamBlock(originalContent)
strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped)
if !removed && !removedLegacy {
return nil
}
if err := writeManagedPamFile(strippedAgain, deps.greetdPath, sudoPassword, deps); err != nil {
return fmt.Errorf("failed to write %s: %w", deps.greetdPath, err)
}
logFunc("✓ Removed DMS managed PAM block from " + deps.greetdPath)
return nil
}
func ParseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) {
if pamText == "" {
return false, false, false, false
}
lines := strings.Split(pamText, "\n")
inManaged := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch trimmed {
case GreeterPamManagedBlockStart:
managed = true
inManaged = true
continue
case GreeterPamManagedBlockEnd:
inManaged = false
continue
}
if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) {
legacy = true
}
if !inManaged {
continue
}
if strings.Contains(trimmed, "pam_fprintd") {
fingerprint = true
}
if strings.Contains(trimmed, "pam_u2f") {
u2f = true
}
}
return managed, fingerprint, u2f, legacy
}
func StripManagedGreeterPamContent(pamText string) (string, bool) {
stripped, removed := stripManagedGreeterPamBlock(pamText)
stripped, removedLegacy := stripLegacyGreeterPamLines(stripped)
return stripped, removed || removedLegacy
}
func PamTextIncludesFile(pamText, filename string) bool {
lines := strings.Split(pamText, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.Contains(trimmed, filename) &&
(strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) {
return true
}
}
return false
}
func PamFileHasModule(pamFilePath, module string) bool {
data, err := os.ReadFile(pamFilePath)
if err != nil {
return false
}
return pamContentHasModule(string(data), module)
}
func DetectIncludedPamModule(pamText, module string) string {
return detectIncludedPamModule(pamText, module, defaultSyncDeps())
}
func detectIncludedPamModule(pamText, module string, deps syncDeps) string {
for _, includedFile := range includedPamAuthFiles {
if !PamTextIncludesFile(pamText, includedFile) {
continue
}
path := filepath.Join(deps.pamDir, includedFile)
data, err := deps.readFile(path)
if err != nil {
continue
}
if pamContentHasModule(string(data), module) {
return includedFile
}
}
return ""
}
func pamContentHasModule(content, module string) bool {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.Contains(trimmed, module) {
return true
}
}
return false
}
func hasManagedLockscreenPamFile(content string) bool {
return strings.Contains(content, LockscreenPamManagedBlockStart) &&
strings.Contains(content, LockscreenPamManagedBlockEnd)
}
func hasManagedLockscreenU2FPamFile(content string) bool {
return strings.Contains(content, LockscreenU2FPamManagedBlockStart) &&
strings.Contains(content, LockscreenU2FPamManagedBlockEnd)
}
func pamDirectiveType(line string) string {
fields := strings.Fields(line)
if len(fields) == 0 {
return ""
}
directiveType := strings.TrimPrefix(fields[0], "-")
switch directiveType {
case "auth", "account", "password", "session":
return directiveType
default:
return ""
}
}
func isExcludedLockscreenPamLine(line string) bool {
for _, field := range strings.Fields(line) {
if strings.HasPrefix(field, "#") {
break
}
if strings.Contains(field, "pam_u2f") || strings.Contains(field, "pam_fprintd") {
return true
}
}
return false
}
func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string) (lockscreenPamIncludeDirective, bool) {
fields := strings.Fields(trimmed)
if len(fields) >= 2 && fields[0] == "@include" {
return lockscreenPamIncludeDirective{
target: fields[1],
filterType: inheritedFilter,
}, true
}
if len(fields) >= 3 && (fields[1] == "include" || fields[1] == "substack") {
lineType := pamDirectiveType(trimmed)
if lineType == "" {
return lockscreenPamIncludeDirective{}, false
}
return lockscreenPamIncludeDirective{
target: fields[2],
filterType: lineType,
}, true
}
if len(fields) >= 3 && fields[1] == "@include" {
lineType := pamDirectiveType(trimmed)
if lineType == "" {
return lockscreenPamIncludeDirective{}, false
}
return lockscreenPamIncludeDirective{
target: fields[2],
filterType: lineType,
}, true
}
return lockscreenPamIncludeDirective{}, false
}
func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) {
if strings.TrimSpace(target) == "" {
return "", fmt.Errorf("empty PAM include target")
}
cleanPamDir := filepath.Clean(pamDir)
if filepath.IsAbs(target) {
cleanTarget := filepath.Clean(target)
if filepath.Dir(cleanTarget) != cleanPamDir {
return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target)
}
return cleanTarget, nil
}
cleanTarget := filepath.Clean(target)
if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid PAM include target: %s", target)
}
return filepath.Join(cleanPamDir, cleanTarget), nil
}
func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) {
path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName)
if err != nil {
return nil, err
}
for _, seen := range stack {
if seen == path {
chain := append(append([]string{}, stack...), path)
display := make([]string, 0, len(chain))
for _, item := range chain {
display = append(display, filepath.Base(item))
}
return nil, fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
}
}
data, err := r.readFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read PAM file %s: %w", path, err)
}
var resolved []string
for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
rawLine = strings.TrimRight(rawLine, "\r")
trimmed := strings.TrimSpace(rawLine)
if trimmed == "" || strings.HasPrefix(trimmed, "#") || trimmed == "#%PAM-1.0" {
continue
}
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
lineType := pamDirectiveType(trimmed)
if filterType != "" && lineType != "" && lineType != filterType {
continue
}
nested, err := r.resolveService(include.target, include.filterType, append(stack, path))
if err != nil {
return nil, err
}
resolved = append(resolved, nested...)
continue
}
lineType := pamDirectiveType(trimmed)
if lineType == "" {
return nil, fmt.Errorf("unsupported PAM directive in %s: %s", filepath.Base(path), trimmed)
}
if filterType != "" && lineType != filterType {
continue
}
if isExcludedLockscreenPamLine(trimmed) {
continue
}
resolved = append(resolved, rawLine)
}
return resolved, nil
}
func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) {
resolver := lockscreenPamResolver{
pamDir: pamDir,
readFile: readFile,
}
resolvedLines, err := resolver.resolveService("login", "", nil)
if err != nil {
return "", err
}
if len(resolvedLines) == 0 {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
}
hasAuth := false
for _, line := range resolvedLines {
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
hasAuth = true
break
}
}
if !hasAuth {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
}
var b strings.Builder
b.WriteString("#%PAM-1.0\n")
b.WriteString(LockscreenPamManagedBlockStart + "\n")
for _, line := range resolvedLines {
b.WriteString(line)
b.WriteByte('\n')
}
b.WriteString(LockscreenPamManagedBlockEnd + "\n")
return b.String(), nil
}
func buildManagedLockscreenU2FPamContent() string {
var b strings.Builder
b.WriteString("#%PAM-1.0\n")
b.WriteString(LockscreenU2FPamManagedBlockStart + "\n")
b.WriteString("auth required pam_u2f.so cue nouserok timeout=10\n")
b.WriteString(LockscreenU2FPamManagedBlockEnd + "\n")
return b.String()
}
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
if deps.isNixOS() {
logFunc(" NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.")
return nil
}
existingData, err := deps.readFile(deps.dankshellPath)
if err == nil {
if !hasManagedLockscreenPamFile(string(existingData)) {
logFunc(" Custom /etc/pam.d/dankshell found (no DMS block). Skipping.")
return nil
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
}
content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile)
if err != nil {
return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err)
}
if err := writeManagedPamFile(content, deps.dankshellPath, sudoPassword, deps); err != nil {
return fmt.Errorf("failed to write %s: %w", deps.dankshellPath, err)
}
logFunc("✓ Created or updated /etc/pam.d/dankshell for lock screen authentication")
return nil
}
func syncLockscreenU2FPamConfigWithDeps(logFunc func(string), sudoPassword string, enabled bool, deps syncDeps) error {
if deps.isNixOS() {
logFunc(" NixOS detected. DMS does not manage /etc/pam.d/dankshell-u2f on NixOS. Keep using the bundled U2F helper or configure a custom PAM service yourself.")
return nil
}
existingData, err := deps.readFile(deps.dankshellU2fPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read %s: %w", deps.dankshellU2fPath, err)
}
if enabled {
if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) {
logFunc(" Custom /etc/pam.d/dankshell-u2f found (no DMS block). Skipping.")
return nil
}
if err := writeManagedPamFile(buildManagedLockscreenU2FPamContent(), deps.dankshellU2fPath, sudoPassword, deps); err != nil {
return fmt.Errorf("failed to write %s: %w", deps.dankshellU2fPath, err)
}
logFunc("✓ Created or updated /etc/pam.d/dankshell-u2f for lock screen security-key authentication")
return nil
}
if os.IsNotExist(err) {
return nil
}
if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) {
logFunc(" Custom /etc/pam.d/dankshell-u2f found (no DMS block). Leaving it untouched.")
return nil
}
if err := deps.runSudoCmd(sudoPassword, "rm", "-f", deps.dankshellU2fPath); err != nil {
return fmt.Errorf("failed to remove %s: %w", deps.dankshellU2fPath, err)
}
logFunc("✓ Removed DMS-managed /etc/pam.d/dankshell-u2f")
return nil
}
func stripManagedGreeterPamBlock(content string) (string, bool) {
lines := strings.Split(content, "\n")
filtered := make([]string, 0, len(lines))
inManagedBlock := false
removed := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == GreeterPamManagedBlockStart {
inManagedBlock = true
removed = true
continue
}
if trimmed == GreeterPamManagedBlockEnd {
inManagedBlock = false
removed = true
continue
}
if inManagedBlock {
removed = true
continue
}
filtered = append(filtered, line)
}
return strings.Join(filtered, "\n"), removed
}
func stripLegacyGreeterPamLines(content string) (string, bool) {
lines := strings.Split(content, "\n")
filtered := make([]string, 0, len(lines))
removed := false
for i := 0; i < len(lines); i++ {
trimmed := strings.TrimSpace(lines[i])
if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) {
removed = true
if i+1 < len(lines) {
nextLine := strings.TrimSpace(lines[i+1])
if strings.HasPrefix(nextLine, "auth") &&
(strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) {
i++
}
}
continue
}
filtered = append(filtered, lines[i])
}
return strings.Join(filtered, "\n"), removed
}
func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) {
lines := strings.Split(content, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") {
block := strings.Join(blockLines, "\n")
prefix := strings.Join(lines[:i], "\n")
suffix := strings.Join(lines[i:], "\n")
switch {
case prefix == "":
return block + "\n" + suffix, nil
case suffix == "":
return prefix + "\n" + block, nil
default:
return prefix + "\n" + block + "\n" + suffix, nil
}
}
}
return "", fmt.Errorf("no auth directive found in %s", greetdPamPath)
}
func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, settings AuthSettings, forceAuth bool, deps syncDeps) error {
var wantFprint, wantU2f bool
fprintToggleEnabled := forceAuth
u2fToggleEnabled := forceAuth
if forceAuth {
wantFprint = deps.pamModuleExists("pam_fprintd.so")
wantU2f = deps.pamModuleExists("pam_u2f.so")
} else {
fprintToggleEnabled = settings.GreeterEnableFprint
u2fToggleEnabled = settings.GreeterEnableU2f
fprintModule := deps.pamModuleExists("pam_fprintd.so")
u2fModule := deps.pamModuleExists("pam_u2f.so")
wantFprint = settings.GreeterEnableFprint && fprintModule
wantU2f = settings.GreeterEnableU2f && u2fModule
if settings.GreeterEnableFprint && !fprintModule {
logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.")
}
if settings.GreeterEnableU2f && !u2fModule {
logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.")
}
}
if deps.isNixOS() {
logFunc(" NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.")
logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).")
return nil
}
pamData, err := deps.readFile(deps.greetdPath)
if err != nil {
return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err)
}
originalContent := string(pamData)
content, _ := stripManagedGreeterPamBlock(originalContent)
content, _ = stripLegacyGreeterPamLines(content)
includedFprintFile := detectIncludedPamModule(content, "pam_fprintd.so", deps)
includedU2fFile := detectIncludedPamModule(content, "pam_u2f.so", deps)
fprintAvailableForCurrentUser := deps.fingerprintAvailableForCurrentUser()
if wantFprint && includedFprintFile != "" {
logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.")
wantFprint = false
}
if wantU2f && includedU2fFile != "" {
logFunc("⚠ pam_u2f already present in included " + includedU2fFile + " (managed by authselect/pam-auth-update). Skipping DMS U2F block to avoid double security-key auth.")
wantU2f = false
}
if !wantFprint && includedFprintFile != "" {
if fprintToggleEnabled {
logFunc(" Fingerprint auth is still enabled via included " + includedFprintFile + ".")
if fprintAvailableForCurrentUser {
logFunc(" DMS toggle is enabled, and effective auth is provided by the included PAM stack.")
} else {
logFunc(" No enrolled fingerprints detected for the current user; password auth remains the effective path.")
}
} else {
if fprintAvailableForCurrentUser {
logFunc(" Fingerprint auth is active via included " + includedFprintFile + " while DMS fingerprint toggle is off.")
logFunc(" Password login will work but may be delayed while the fingerprint module runs first.")
logFunc(" To eliminate the delay, " + pamManagerHintForCurrentDistro())
} else {
logFunc(" pam_fprintd is present via included " + includedFprintFile + ", but no enrolled fingerprints were detected for the current user.")
logFunc(" Password auth remains the effective login path.")
}
}
}
if !wantU2f && includedU2fFile != "" {
if u2fToggleEnabled {
logFunc(" Security-key auth is still enabled via included " + includedU2fFile + ".")
logFunc(" DMS toggle is enabled, but effective auth is provided by the included PAM stack.")
} else {
logFunc("⚠ Security-key auth is active via included " + includedU2fFile + " while DMS security-key toggle is off.")
logFunc(" " + pamManagerHintForCurrentDistro())
}
}
if wantFprint || wantU2f {
blockLines := []string{GreeterPamManagedBlockStart}
if wantFprint {
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5")
}
if wantU2f {
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
}
blockLines = append(blockLines, GreeterPamManagedBlockEnd)
content, err = insertManagedGreeterPamBlock(content, blockLines, deps.greetdPath)
if err != nil {
return err
}
}
if content == originalContent {
return nil
}
if err := writeManagedPamFile(content, deps.greetdPath, sudoPassword, deps); err != nil {
return fmt.Errorf("failed to install updated PAM config at %s: %w", deps.greetdPath, err)
}
if wantFprint || wantU2f {
logFunc("✓ Configured greetd PAM for fingerprint/U2F")
} else {
logFunc("✓ Cleared DMS-managed greeter PAM auth block")
}
return nil
}
func writeManagedPamFile(content string, destPath string, sudoPassword string, deps syncDeps) error {
tmpFile, err := deps.createTemp("", "dms-pam-*.conf")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
defer func() {
_ = deps.removeFile(tmpPath)
}()
if _, err := tmpFile.WriteString(content); err != nil {
tmpFile.Close()
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := deps.runSudoCmd(sudoPassword, "cp", tmpPath, destPath); err != nil {
return err
}
if err := deps.runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", destPath, err)
}
return nil
}
func pamManagerHintForCurrentDistro() string {
osInfo, err := distros.GetOSInfo()
if err != nil {
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
}
config, exists := distros.Registry[osInfo.Distribution.ID]
if !exists {
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
}
switch config.Family {
case distros.FamilyFedora:
return "Disable it in authselect to force password-only greeter login."
case distros.FamilyDebian, distros.FamilyUbuntu:
return "Disable it in pam-auth-update to force password-only greeter login."
default:
return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
}
}
func pamModuleExists(module string) bool {
for _, libDir := range []string{
"/usr/lib64/security",
"/usr/lib/security",
"/lib64/security",
"/lib/security",
"/lib/x86_64-linux-gnu/security",
"/usr/lib/x86_64-linux-gnu/security",
"/lib/aarch64-linux-gnu/security",
"/usr/lib/aarch64-linux-gnu/security",
"/run/current-system/sw/lib64/security",
"/run/current-system/sw/lib/security",
} {
if _, err := os.Stat(filepath.Join(libDir, module)); err == nil {
return true
}
}
return false
}
func hasEnrolledFingerprintOutput(output string) bool {
lower := strings.ToLower(output)
if strings.Contains(lower, "no fingers enrolled") ||
strings.Contains(lower, "no fingerprints enrolled") ||
strings.Contains(lower, "no prints enrolled") {
return false
}
if strings.Contains(lower, "has fingers enrolled") ||
strings.Contains(lower, "has fingerprints enrolled") {
return true
}
for _, line := range strings.Split(lower, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "finger:") {
return true
}
if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") {
return true
}
}
return false
}
func FingerprintAuthAvailableForCurrentUser() bool {
username := strings.TrimSpace(os.Getenv("SUDO_USER"))
if username == "" {
username = strings.TrimSpace(os.Getenv("USER"))
}
if username == "" {
out, err := exec.Command("id", "-un").Output()
if err == nil {
username = strings.TrimSpace(string(out))
}
}
return fingerprintAuthAvailableForUser(username)
}
func fingerprintAuthAvailableForUser(username string) bool {
username = strings.TrimSpace(username)
if username == "" {
return false
}
if !pamModuleExists("pam_fprintd.so") {
return false
}
if _, err := exec.LookPath("fprintd-list"); err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput()
if err != nil {
return false
}
return hasEnrolledFingerprintOutput(string(out))
}

View File

@@ -1,671 +0,0 @@
package pam
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTestFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("failed to create parent dir for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write %s: %v", path, err)
}
}
type pamTestEnv struct {
pamDir string
greetdPath string
dankshellPath string
dankshellU2fPath string
tmpDir string
homeDir string
availableModules map[string]bool
fingerprintAvailable bool
}
func newPamTestEnv(t *testing.T) *pamTestEnv {
t.Helper()
root := t.TempDir()
pamDir := filepath.Join(root, "pam.d")
tmpDir := filepath.Join(root, "tmp")
homeDir := filepath.Join(root, "home")
for _, dir := range []string{pamDir, tmpDir, homeDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("failed to create %s: %v", dir, err)
}
}
return &pamTestEnv{
pamDir: pamDir,
greetdPath: filepath.Join(pamDir, "greetd"),
dankshellPath: filepath.Join(pamDir, "dankshell"),
dankshellU2fPath: filepath.Join(pamDir, "dankshell-u2f"),
tmpDir: tmpDir,
homeDir: homeDir,
availableModules: map[string]bool{},
}
}
func (e *pamTestEnv) writePamFile(t *testing.T, name string, content string) {
t.Helper()
writeTestFile(t, filepath.Join(e.pamDir, name), content)
}
func (e *pamTestEnv) writeSettings(t *testing.T, content string) {
t.Helper()
writeTestFile(t, filepath.Join(e.homeDir, ".config", "DankMaterialShell", "settings.json"), content)
}
func (e *pamTestEnv) deps(isNixOS bool) syncDeps {
return syncDeps{
pamDir: e.pamDir,
greetdPath: e.greetdPath,
dankshellPath: e.dankshellPath,
dankshellU2fPath: e.dankshellU2fPath,
isNixOS: func() bool { return isNixOS },
readFile: os.ReadFile,
stat: os.Stat,
createTemp: func(_ string, pattern string) (*os.File, error) {
return os.CreateTemp(e.tmpDir, pattern)
},
removeFile: os.Remove,
runSudoCmd: func(_ string, command string, args ...string) error {
switch command {
case "cp":
if len(args) != 2 {
return fmt.Errorf("unexpected cp args: %v", args)
}
data, err := os.ReadFile(args[0])
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(args[1]), 0o755); err != nil {
return err
}
return os.WriteFile(args[1], data, 0o644)
case "chmod":
if len(args) != 2 {
return fmt.Errorf("unexpected chmod args: %v", args)
}
return nil
case "rm":
if len(args) != 2 || args[0] != "-f" {
return fmt.Errorf("unexpected rm args: %v", args)
}
if err := os.Remove(args[1]); err != nil && !os.IsNotExist(err) {
return err
}
return nil
default:
return fmt.Errorf("unexpected sudo command: %s %v", command, args)
}
},
pamModuleExists: func(module string) bool {
return e.availableModules[module]
},
fingerprintAvailableForCurrentUser: func() bool {
return e.fingerprintAvailable
},
}
}
func readFileString(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read %s: %v", path, err)
}
return string(data)
}
func TestHasManagedLockscreenPamFile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
want bool
}{
{
name: "both markers present",
content: "#%PAM-1.0\n" +
LockscreenPamManagedBlockStart + "\n" +
"auth sufficient pam_unix.so\n" +
LockscreenPamManagedBlockEnd + "\n",
want: true,
},
{
name: "missing end marker is not managed",
content: "#%PAM-1.0\n" +
LockscreenPamManagedBlockStart + "\n" +
"auth sufficient pam_unix.so\n",
want: false,
},
{
name: "custom file is not managed",
content: "#%PAM-1.0\nauth sufficient pam_unix.so\n",
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := hasManagedLockscreenPamFile(tt.content); got != tt.want {
t.Fatalf("hasManagedLockscreenPamFile() = %v, want %v", got, tt.want)
}
})
}
}
func TestBuildManagedLockscreenPamContent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
files map[string]string
wantContains []string
wantNotContains []string
wantCounts map[string]int
wantErr string
}{
{
name: "preserves custom modules and strips direct u2f and fprint directives",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth include system-auth\n" +
"account include system-auth\n" +
"session include system-auth\n",
"system-auth": "auth requisite pam_nologin.so\n" +
"auth sufficient pam_unix.so try_first_pass nullok\n" +
"auth sufficient pam_u2f.so cue\n" +
"auth sufficient pam_fprintd.so max-tries=1\n" +
"auth required pam_radius_auth.so conf=/etc/raddb/server\n" +
"account required pam_access.so\n" +
"session optional pam_lastlog.so silent\n",
},
wantContains: []string{
"#%PAM-1.0",
LockscreenPamManagedBlockStart,
LockscreenPamManagedBlockEnd,
"auth requisite pam_nologin.so",
"auth sufficient pam_unix.so try_first_pass nullok",
"auth required pam_radius_auth.so conf=/etc/raddb/server",
"account required pam_access.so",
"session optional pam_lastlog.so silent",
},
wantNotContains: []string{
"pam_u2f",
"pam_fprintd",
},
wantCounts: map[string]int{
"auth required pam_radius_auth.so conf=/etc/raddb/server": 1,
"account required pam_access.so": 1,
},
},
{
name: "resolves nested include substack and @include transitively",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth include system-auth\n" +
"account include system-auth\n" +
"password include system-auth\n" +
"session include system-auth\n",
"system-auth": "auth substack custom-auth\n" +
"account include custom-auth\n" +
"password include custom-auth\n" +
"session @include common-session\n",
"custom-auth": "auth required pam_custom.so one=two\n" +
"account required pam_custom_account.so\n" +
"password required pam_custom_password.so\n",
"common-session": "session optional pam_fprintd.so max-tries=1\n" +
"session optional pam_lastlog.so silent\n",
},
wantContains: []string{
"auth required pam_custom.so one=two",
"account required pam_custom_account.so",
"password required pam_custom_password.so",
"session optional pam_lastlog.so silent",
},
wantNotContains: []string{
"pam_fprintd",
},
wantCounts: map[string]int{
"auth required pam_custom.so one=two": 1,
"account required pam_custom_account.so": 1,
"password required pam_custom_password.so": 1,
"session optional pam_lastlog.so silent": 1,
},
},
{
name: "missing include fails",
files: map[string]string{
"login": "#%PAM-1.0\nauth include missing-auth\n",
},
wantErr: "failed to read PAM file",
},
{
name: "cyclic include fails",
files: map[string]string{
"login": "#%PAM-1.0\nauth include system-auth\n",
"system-auth": "auth include login\n",
},
wantErr: "cyclic PAM include detected",
},
{
name: "no auth directives remain after filtering fails",
files: map[string]string{
"login": "#%PAM-1.0\nauth include system-auth\n",
"system-auth": "auth sufficient pam_u2f.so cue\n",
},
wantErr: "no auth directives remained after filtering",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
for name, content := range tt.files {
env.writePamFile(t, name, content)
}
content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
}
for _, want := range tt.wantContains {
if !strings.Contains(content, want) {
t.Errorf("missing expected string %q in output:\n%s", want, content)
}
}
for _, notWant := range tt.wantNotContains {
if strings.Contains(content, notWant) {
t.Errorf("unexpected string %q found in output:\n%s", notWant, content)
}
}
for want, wantCount := range tt.wantCounts {
if gotCount := strings.Count(content, want); gotCount != wantCount {
t.Errorf("count for %q = %d, want %d\noutput:\n%s", want, gotCount, wantCount, content)
}
}
})
}
}
func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
t.Parallel()
t.Run("custom dankshell file is skipped untouched", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
customContent := "#%PAM-1.0\nauth required pam_unix.so\n"
env.writePamFile(t, "dankshell", customContent)
var logs []string
err := syncLockscreenPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err)
}
if got := readFileString(t, env.dankshellPath); got != customContent {
t.Fatalf("custom dankshell content changed\ngot:\n%s\nwant:\n%s", got, customContent)
}
if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell found") {
t.Fatalf("expected custom-file skip log, got %v", logs)
}
})
t.Run("managed dankshell file is rewritten from resolved login stack", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\nauth sufficient pam_u2f.so cue\naccount required pam_access.so\n")
env.writePamFile(t, "dankshell", "#%PAM-1.0\n"+LockscreenPamManagedBlockStart+"\nauth required pam_env.so\n"+LockscreenPamManagedBlockEnd+"\n")
var logs []string
err := syncLockscreenPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err)
}
output := readFileString(t, env.dankshellPath)
for _, want := range []string{
LockscreenPamManagedBlockStart,
"auth sufficient pam_unix.so try_first_pass nullok",
"account required pam_access.so",
LockscreenPamManagedBlockEnd,
} {
if !strings.Contains(output, want) {
t.Errorf("missing expected string %q in rewritten dankshell:\n%s", want, output)
}
}
if strings.Contains(output, "pam_u2f") {
t.Errorf("rewritten dankshell still contains pam_u2f:\n%s", output)
}
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell") {
t.Fatalf("expected success log, got %v", logs)
}
})
t.Run("mutable systems fail when login stack cannot be converted safely", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
err := syncLockscreenPamConfigWithDeps(func(string) {}, "", env.deps(false))
if err == nil {
t.Fatal("expected error when login PAM file is missing, got nil")
}
if !strings.Contains(err.Error(), "failed to build") {
t.Fatalf("error = %q, want substring %q", err.Error(), "failed to build")
}
})
t.Run("NixOS remains informational and does not write dankshell", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
var logs []string
err := syncLockscreenPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", env.deps(true))
if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
}
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") {
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
}
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
}
})
}
func TestSyncLockscreenU2FPamConfigWithDeps(t *testing.T) {
t.Parallel()
t.Run("enabled creates managed file", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
var logs []string
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", true, env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
}
got := readFileString(t, env.dankshellU2fPath)
if got != buildManagedLockscreenU2FPamContent() {
t.Fatalf("unexpected managed dankshell-u2f content:\n%s", got)
}
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell-u2f") {
t.Fatalf("expected create log, got %v", logs)
}
})
t.Run("enabled rewrites existing managed file", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\n"+LockscreenU2FPamManagedBlockStart+"\nauth required pam_u2f.so old\n"+LockscreenU2FPamManagedBlockEnd+"\n")
if err := syncLockscreenU2FPamConfigWithDeps(func(string) {}, "", true, env.deps(false)); err != nil {
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
}
if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() {
t.Fatalf("managed dankshell-u2f was not rewritten:\n%s", got)
}
})
t.Run("disabled removes DMS-managed file", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "dankshell-u2f", buildManagedLockscreenU2FPamContent())
var logs []string
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", false, env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
}
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
t.Fatalf("expected managed dankshell-u2f to be removed, stat err = %v", err)
}
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Removed DMS-managed /etc/pam.d/dankshell-u2f") {
t.Fatalf("expected removal log, got %v", logs)
}
})
t.Run("disabled preserves custom file", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
customContent := "#%PAM-1.0\nauth required pam_u2f.so cue\n"
env.writePamFile(t, "dankshell-u2f", customContent)
var logs []string
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", false, env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
}
if got := readFileString(t, env.dankshellU2fPath); got != customContent {
t.Fatalf("custom dankshell-u2f content changed\ngot:\n%s\nwant:\n%s", got, customContent)
}
if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell-u2f found") {
t.Fatalf("expected custom-file log, got %v", logs)
}
})
}
func TestSyncGreeterPamConfigWithDeps(t *testing.T) {
t.Parallel()
t.Run("adds managed block for enabled auth modules", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
settings := AuthSettings{GreeterEnableFprint: true, GreeterEnableU2f: true}
if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil {
t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err)
}
got := readFileString(t, env.greetdPath)
for _, want := range []string{
GreeterPamManagedBlockStart,
"auth sufficient pam_fprintd.so max-tries=1 timeout=5",
"auth sufficient pam_u2f.so cue nouserok timeout=10",
GreeterPamManagedBlockEnd,
} {
if !strings.Contains(got, want) {
t.Errorf("missing expected string %q in greetd PAM:\n%s", want, got)
}
}
if strings.Index(got, GreeterPamManagedBlockStart) > strings.Index(got, "auth include system-auth") {
t.Fatalf("managed block was not inserted before first auth line:\n%s", got)
}
})
t.Run("avoids duplicate fingerprint when included stack already provides it", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.fingerprintAvailable = true
original := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n"
env.writePamFile(t, "greetd", original)
env.writePamFile(t, "system-auth", "auth sufficient pam_fprintd.so max-tries=1\nauth sufficient pam_unix.so\n")
settings := AuthSettings{GreeterEnableFprint: true}
if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil {
t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err)
}
got := readFileString(t, env.greetdPath)
if got != original {
t.Fatalf("greetd PAM changed despite included pam_fprintd stack\ngot:\n%s\nwant:\n%s", got, original)
}
if strings.Contains(got, GreeterPamManagedBlockStart) {
t.Fatalf("managed block should not be inserted when included stack already has pam_fprintd:\n%s", got)
}
})
}
func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "greetd", "#%PAM-1.0\n"+
legacyGreeterPamFprintComment+"\n"+
"auth sufficient pam_fprintd.so max-tries=1\n"+
GreeterPamManagedBlockStart+"\n"+
"auth sufficient pam_u2f.so cue nouserok timeout=10\n"+
GreeterPamManagedBlockEnd+"\n"+
"auth include system-auth\n")
if err := removeManagedGreeterPamBlockWithDeps(func(string) {}, "", env.deps(false)); err != nil {
t.Fatalf("removeManagedGreeterPamBlockWithDeps returned error: %v", err)
}
got := readFileString(t, env.greetdPath)
if strings.Contains(got, GreeterPamManagedBlockStart) || strings.Contains(got, legacyGreeterPamFprintComment) {
t.Fatalf("managed or legacy DMS auth lines remained in greetd PAM:\n%s", got)
}
if !strings.Contains(got, "auth include system-auth") {
t.Fatalf("expected non-DMS greetd auth lines to remain:\n%s", got)
}
}
func TestSyncAuthConfigWithDeps(t *testing.T) {
t.Parallel()
t.Run("creates lockscreen targets and skips greetd when greeter is not installed", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writeSettings(t, `{"enableU2f":true}`)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n")
var logs []string
err := syncAuthConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
if _, err := os.Stat(env.dankshellPath); err != nil {
t.Fatalf("expected dankshell to be created: %v", err)
}
if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() {
t.Fatalf("unexpected dankshell-u2f content:\n%s", got)
}
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "greetd not found") {
t.Fatalf("expected greetd skip log, got %v", logs)
}
})
t.Run("separate greeter and lockscreen toggles are respected", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.writeSettings(t, `{"enableU2f":false,"greeterEnableFprint":true,"greeterEnableU2f":false}`)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n")
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
err := syncAuthConfigWithDeps(func(string) {}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
dankshell := readFileString(t, env.dankshellPath)
if strings.Contains(dankshell, "pam_fprintd") || strings.Contains(dankshell, "pam_u2f") {
t.Fatalf("lockscreen PAM should strip fingerprint and U2F modules:\n%s", dankshell)
}
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
t.Fatalf("expected dankshell-u2f to remain absent when enableU2f is false, stat err = %v", err)
}
greetd := readFileString(t, env.greetdPath)
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") {
t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd)
}
if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") {
t.Fatalf("did not expect greetd PAM to receive U2F auth block:\n%s", greetd)
}
})
t.Run("NixOS remains informational and non-mutating", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writeSettings(t, `{"enableU2f":true,"greeterEnableFprint":true,"greeterEnableU2f":true}`)
originalGreetd := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n"
env.writePamFile(t, "greetd", originalGreetd)
var logs []string
err := syncAuthConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(true))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
t.Fatalf("expected dankshell to remain absent on NixOS path, stat err = %v", err)
}
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
t.Fatalf("expected dankshell-u2f to remain absent on NixOS path, stat err = %v", err)
}
if got := readFileString(t, env.greetdPath); got != originalGreetd {
t.Fatalf("expected greetd PAM to remain unchanged on NixOS path\ngot:\n%s\nwant:\n%s", got, originalGreetd)
}
if len(logs) < 2 || !strings.Contains(strings.Join(logs, "\n"), "NixOS detected") {
t.Fatalf("expected informational NixOS logs, got %v", logs)
}
})
}

View File

@@ -1,385 +0,0 @@
package privesc
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
)
// Tool identifies a privilege-escalation binary.
type Tool string
const (
ToolSudo Tool = "sudo"
ToolDoas Tool = "doas"
ToolRun0 Tool = "run0"
)
// EnvVar selects a specific tool when set to one of: sudo, doas, run0.
const EnvVar = "DMS_PRIVESC"
var detectionOrder = []Tool{ToolSudo, ToolDoas, ToolRun0}
var (
detectOnce sync.Once
detected Tool
detectErr error
userSelected bool
)
// Detect returns the tool that should be used for privilege escalation.
// The result is cached after the first call.
func Detect() (Tool, error) {
detectOnce.Do(func() {
detected, detectErr = detectTool()
})
return detected, detectErr
}
// ResetForTesting clears cached detection state.
func ResetForTesting() {
detectOnce = sync.Once{}
detected = ""
detectErr = nil
userSelected = false
}
// AvailableTools returns the set of supported tools that are installed on
// PATH, in detection-precedence order.
func AvailableTools() []Tool {
var out []Tool
for _, t := range detectionOrder {
if t.Available() {
out = append(out, t)
}
}
return out
}
// EnvOverride returns the tool selected by the $DMS_PRIVESC env var (if any)
// along with ok=true when the variable is set. An empty or unset variable
// returns ok=false.
func EnvOverride() (Tool, bool) {
v := strings.ToLower(strings.TrimSpace(os.Getenv(EnvVar)))
if v == "" {
return "", false
}
return Tool(v), true
}
// SetTool forces the detected tool to t, bypassing autodetection. Intended
// for use after the caller has prompted the user for a selection.
func SetTool(t Tool) error {
if !t.Available() {
return fmt.Errorf("%q is not installed", t.Name())
}
detectOnce = sync.Once{}
detectOnce.Do(func() {
detected = t
detectErr = nil
})
userSelected = true
return nil
}
func detectTool() (Tool, error) {
switch override := strings.ToLower(strings.TrimSpace(os.Getenv(EnvVar))); override {
case "":
// fall through to autodetect
case string(ToolSudo), string(ToolDoas), string(ToolRun0):
t := Tool(override)
if !t.Available() {
return "", fmt.Errorf("%s=%s but %q is not installed", EnvVar, override, t.Name())
}
return t, nil
default:
return "", fmt.Errorf("invalid %s=%q: must be one of sudo, doas, run0", EnvVar, override)
}
for _, t := range detectionOrder {
if t.Available() {
return t, nil
}
}
return "", fmt.Errorf("no supported privilege escalation tool found (tried: sudo, doas, run0)")
}
// Name returns the binary name.
func (t Tool) Name() string { return string(t) }
// Available reports whether this tool's binary is on PATH.
func (t Tool) Available() bool {
if t == "" {
return false
}
_, err := exec.LookPath(string(t))
return err == nil
}
// SupportsStdinPassword reports whether the tool can accept a password via
// stdin. Only sudo (-S) supports this.
func (t Tool) SupportsStdinPassword() bool {
return t == ToolSudo
}
// EscapeSingleQuotes escapes single quotes for safe inclusion inside a
// bash single-quoted string.
func EscapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
// MakeCommand returns a bash command string that runs `command` with the
// detected tool. When the tool supports stdin passwords and password is
// non-empty, the password is piped in. Otherwise the tool is invoked with
// no non-interactive flag so that an interactive TTY prompt is still
// possible for CLI callers.
//
// If detection fails, the returned shell string exits 1 with an error
// message so callers that treat the *exec.Cmd as infallible still fail
// deterministically.
func MakeCommand(password, command string) string {
t, err := Detect()
if err != nil {
return failingShell(err)
}
switch t {
case ToolSudo:
if password != "" {
return fmt.Sprintf("echo '%s' | sudo -S %s", EscapeSingleQuotes(password), command)
}
return fmt.Sprintf("sudo %s", command)
case ToolDoas:
return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command))
case ToolRun0:
return fmt.Sprintf("run0 sh -c '%s'", EscapeSingleQuotes(command))
default:
return failingShell(fmt.Errorf("unsupported privilege tool: %q", t))
}
}
// ExecCommand builds an exec.Cmd that runs `command` as root via the
// detected tool. Detection errors surface at Run() time as a failing
// command writing a clear error to stderr.
func ExecCommand(ctx context.Context, password, command string) *exec.Cmd {
return exec.CommandContext(ctx, "bash", "-c", MakeCommand(password, command))
}
// ExecArgv builds an exec.Cmd that runs argv as root via the detected tool.
// No stdin password is supplied; callers relying on non-interactive success
// should ensure cached credentials are present (see CheckCached).
func ExecArgv(ctx context.Context, argv ...string) *exec.Cmd {
if len(argv) == 0 {
return exec.CommandContext(ctx, "bash", "-c", failingShell(fmt.Errorf("privesc.ExecArgv: argv must not be empty")))
}
t, err := Detect()
if err != nil {
return exec.CommandContext(ctx, "bash", "-c", failingShell(err))
}
switch t {
case ToolSudo, ToolDoas:
return exec.CommandContext(ctx, string(t), argv...)
case ToolRun0:
return exec.CommandContext(ctx, "run0", argv...)
default:
return exec.CommandContext(ctx, "bash", "-c", failingShell(fmt.Errorf("unsupported privilege tool: %q", t)))
}
}
func failingShell(err error) string {
return fmt.Sprintf("printf 'privesc: %%s\\n' '%s' >&2; exit 1", EscapeSingleQuotes(err.Error()))
}
// CheckCached runs a non-interactive credential probe. Returns nil if the
// tool will run commands without prompting (cached credentials, nopass, or
// polkit rule).
func CheckCached(ctx context.Context) error {
t, err := Detect()
if err != nil {
return err
}
var cmd *exec.Cmd
switch t {
case ToolSudo:
cmd = exec.CommandContext(ctx, "sudo", "-n", "true")
case ToolDoas:
cmd = exec.CommandContext(ctx, "doas", "-n", "true")
case ToolRun0:
cmd = exec.CommandContext(ctx, "run0", "--no-ask-password", "true")
default:
return fmt.Errorf("unsupported privilege tool: %q", t)
}
return cmd.Run()
}
// ClearCache invalidates any cached credentials. No-op for tools that do
// not expose a cache-clear operation.
func ClearCache(ctx context.Context) error {
t, err := Detect()
if err != nil {
return err
}
switch t {
case ToolSudo:
return exec.CommandContext(ctx, "sudo", "-k").Run()
default:
return nil
}
}
// ValidateWithAskpass validates cached credentials using an askpass helper
// script. Only sudo supports this mechanism; the TUI uses it to trigger
// fingerprint authentication via PAM.
func ValidateWithAskpass(ctx context.Context, askpassScript string) error {
t, err := Detect()
if err != nil {
return err
}
if t != ToolSudo {
return fmt.Errorf("askpass validation requires sudo (detected: %s)", t)
}
cmd := exec.CommandContext(ctx, "sudo", "-A", "-v")
cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript))
return cmd.Run()
}
// ValidatePassword validates the given password. Only sudo supports this
// (via `sudo -S -v`); for other tools the caller should fall back to
// CheckCached.
func ValidatePassword(ctx context.Context, password string) error {
t, err := Detect()
if err != nil {
return err
}
if t != ToolSudo {
return fmt.Errorf("password validation requires sudo (detected: %s)", t)
}
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
if _, err := fmt.Fprintf(stdin, "%s\n", password); err != nil {
stdin.Close()
_ = cmd.Wait()
return err
}
stdin.Close()
return cmd.Wait()
}
// QuoteArgsForShell wraps each argv element in single quotes so the result
// can be safely passed to bash -c.
func QuoteArgsForShell(argv []string) string {
parts := make([]string, len(argv))
for i, a := range argv {
parts[i] = "'" + EscapeSingleQuotes(a) + "'"
}
return strings.Join(parts, " ")
}
// Run invokes argv with privilege escalation. When the tool supports stdin
// passwords and password is non-empty, the password is piped in. Otherwise
// argv is invoked directly, which may prompt on a TTY.
// Stdout and Stderr are inherited from the current process.
func Run(ctx context.Context, password string, argv ...string) error {
if len(argv) == 0 {
return fmt.Errorf("privesc.Run: argv must not be empty")
}
t, err := Detect()
if err != nil {
return err
}
var cmd *exec.Cmd
switch {
case t == ToolSudo && password != "":
cmd = ExecCommand(ctx, password, QuoteArgsForShell(argv))
default:
cmd = ExecArgv(ctx, argv...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// stdinIsTTY reports whether stdin is a character device (interactive
// terminal) rather than a pipe or file.
func stdinIsTTY() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
// PromptCLI interactively prompts the user to pick a privilege tool when more
// than one is installed and $DMS_PRIVESC is not set. If stdin is not a TTY,
// or only one tool is available, or the env var is set, the detected tool is
// returned without any prompt.
//
// The prompt is written to out (typically os.Stdout/os.Stderr) and input is
// read from in. EOF or empty input selects the first option.
func PromptCLI(out io.Writer, in io.Reader) (Tool, error) {
if userSelected {
return Detect()
}
if _, envSet := EnvOverride(); envSet {
return Detect()
}
tools := AvailableTools()
switch len(tools) {
case 0:
return "", fmt.Errorf("no supported privilege tool (sudo/doas/run0) found on PATH")
case 1:
if err := SetTool(tools[0]); err != nil {
return "", err
}
return tools[0], nil
}
if !stdinIsTTY() {
return Detect()
}
fmt.Fprintln(out, "Multiple privilege escalation tools detected:")
for i, t := range tools {
fmt.Fprintf(out, " [%d] %s\n", i+1, t.Name())
}
fmt.Fprintf(out, "Choose one [1-%d] (default 1, or set %s=<tool> to skip): ", len(tools), EnvVar)
reader := bufio.NewReader(in)
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return "", fmt.Errorf("failed to read selection: %w", err)
}
line = strings.TrimSpace(line)
idx := 1
if line != "" {
n, convErr := strconv.Atoi(line)
if convErr != nil || n < 1 || n > len(tools) {
return "", fmt.Errorf("invalid selection %q", line)
}
idx = n
}
chosen := tools[idx-1]
if err := SetTool(chosen); err != nil {
return "", err
}
return chosen, nil
}

View File

@@ -444,21 +444,20 @@ func GetFocusedMonitor() string {
type outputInfo struct {
x, y int32
scale float64
transform int32
}
func getAllOutputInfos() map[string]*outputInfo {
func getOutputInfo(outputName string) (*outputInfo, bool) {
display, err := client.Connect("")
if err != nil {
return nil
return nil, false
}
ctx := display.Context()
defer ctx.Close()
registry, err := display.GetRegistry()
if err != nil {
return nil
return nil, false
}
var outputManager *wlr_output_management.ZwlrOutputManagerV1
@@ -477,17 +476,16 @@ func getAllOutputInfos() map[string]*outputInfo {
})
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return nil
return nil, false
}
if outputManager == nil {
return nil
return nil, false
}
type headState struct {
name string
x, y int32
scale float64
transform int32
}
heads := make(map[*wlr_output_management.ZwlrOutputHeadV1]*headState)
@@ -503,9 +501,6 @@ func getAllOutputInfos() map[string]*outputInfo {
state.x = pe.X
state.y = pe.Y
})
e.Head.SetScaleHandler(func(se wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
state.scale = se.Scale
})
e.Head.SetTransformHandler(func(te wlr_output_management.ZwlrOutputHeadV1TransformEvent) {
state.transform = te.Transform
})
@@ -516,32 +511,21 @@ func getAllOutputInfos() map[string]*outputInfo {
for !done {
if err := ctx.Dispatch(); err != nil {
return nil
return nil, false
}
}
result := make(map[string]*outputInfo, len(heads))
for _, state := range heads {
if state.name == "" {
continue
}
result[state.name] = &outputInfo{
x: state.x,
y: state.y,
scale: state.scale,
transform: state.transform,
if state.name == outputName {
return &outputInfo{
x: state.x,
y: state.y,
transform: state.transform,
}, true
}
}
return result
}
func getOutputInfo(outputName string) (*outputInfo, bool) {
infos := getAllOutputInfos()
if infos == nil {
return nil, false
}
info, ok := infos[outputName]
return info, ok
return nil, false
}
func getDWLActiveWindow() (*WindowGeometry, error) {

View File

@@ -113,11 +113,7 @@ func NewRegionSelector(s *Screenshoter) *RegionSelector {
}
func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
if r.screenshoter != nil && r.screenshoter.config.Reset {
r.preSelect = Region{}
} else {
r.preSelect = GetLastRegion()
}
r.preSelect = GetLastRegion()
if err := r.connect(); err != nil {
return nil, false, fmt.Errorf("wayland connect: %w", err)

View File

@@ -114,9 +114,6 @@ func (r *RegionSelector) setupPointerHandlers() {
for _, os := range r.surfaces {
r.redrawSurface(os)
}
if r.screenshoter != nil && r.screenshoter.config.NoConfirm && r.selection.hasSelection {
r.finishSelection()
}
}
default:
r.cancelled = true

View File

@@ -138,13 +138,9 @@ func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uin
if !r.showCapturedCursor {
cursorLabel = "show"
}
captureKey := "Space/Enter"
if r.screenshoter != nil && r.screenshoter.config.NoConfirm {
captureKey = "Drag+Release"
}
items := []struct{ key, desc string }{
{captureKey, "capture"},
{"Space/Enter", "capture"},
{"P", cursorLabel + " cursor"},
{"Esc", "cancel"},
}

View File

@@ -2,7 +2,6 @@ package screenshot
import (
"fmt"
"math"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -107,12 +106,6 @@ func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
if s.config.Reset {
if err := SaveLastRegion(Region{}); err != nil {
log.Debug("failed to reset last region", "err", err)
}
}
selector := NewRegionSelector(s)
result, cancelled, err := selector.Run()
if err != nil {
@@ -305,20 +298,22 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
if len(outputs) == 0 {
return nil, fmt.Errorf("no outputs available")
}
if len(outputs) == 1 {
return s.captureWholeOutput(outputs[0])
}
wlrInfos := getAllOutputInfos()
type pendingOutput struct {
// Capture all outputs first to get actual buffer sizes
type capturedOutput struct {
output *WaylandOutput
result *CaptureResult
logX float64
logY float64
scale float64
physX int
physY int
}
var pending []pendingOutput
maxScale := 1.0
captured := make([]capturedOutput, 0, len(outputs))
var minX, minY, maxX, maxY int
first := true
for _, output := range outputs {
result, err := s.captureWholeOutput(output)
@@ -327,74 +322,50 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
continue
}
logX, logY := float64(output.x), float64(output.y)
outX, outY := output.x, output.y
scale := float64(output.scale)
switch DetectCompositor() {
case CompositorHyprland:
if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok {
logX, logY = float64(hx), float64(hy)
outX, outY = hx, hy
}
if hs := GetHyprlandMonitorScale(output.name); hs > 0 {
scale = hs
if s := GetHyprlandMonitorScale(output.name); s > 0 {
scale = s
}
default:
if wlrInfos != nil {
if info, ok := wlrInfos[output.name]; ok {
logX, logY = float64(info.x), float64(info.y)
if info.scale > 0 {
scale = info.scale
}
}
case CompositorDWL:
if info, ok := getOutputInfo(output.name); ok {
outX, outY = info.x, info.y
}
}
if scale <= 0 {
scale = 1.0
}
pending = append(pending, pendingOutput{result: result, logX: logX, logY: logY, scale: scale})
if scale > maxScale {
maxScale = scale
}
}
physX := int(float64(outX) * scale)
physY := int(float64(outY) * scale)
if len(pending) == 0 {
return nil, fmt.Errorf("failed to capture any outputs")
}
if len(pending) == 1 {
return pending[0].result, nil
}
captured = append(captured, capturedOutput{
output: output,
result: result,
physX: physX,
physY: physY,
})
type layoutEntry struct {
result *CaptureResult
canvasX int
canvasY int
canvasW int
canvasH int
}
entries := make([]layoutEntry, len(pending))
var minX, minY, maxX, maxY int
right := physX + result.Buffer.Width
bottom := physY + result.Buffer.Height
for i, p := range pending {
cx := int(math.Round(p.logX * maxScale))
cy := int(math.Round(p.logY * maxScale))
cw := int(math.Round(float64(p.result.Buffer.Width) * maxScale / p.scale))
ch := int(math.Round(float64(p.result.Buffer.Height) * maxScale / p.scale))
entries[i] = layoutEntry{result: p.result, canvasX: cx, canvasY: cy, canvasW: cw, canvasH: ch}
right := cx + cw
bottom := cy + ch
if i == 0 {
minX, minY, maxX, maxY = cx, cy, right, bottom
if first {
minX, minY = physX, physY
maxX, maxY = right, bottom
first = false
continue
}
if cx < minX {
minX = cx
if physX < minX {
minX = physX
}
if cy < minY {
minY = cy
if physY < minY {
minY = physY
}
if right > maxX {
maxX = right
@@ -404,26 +375,35 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
}
}
if len(captured) == 0 {
return nil, fmt.Errorf("failed to capture any outputs")
}
if len(captured) == 1 {
return captured[0].result, nil
}
totalW := maxX - minX
totalH := maxY - minY
composite, err := CreateShmBuffer(totalW, totalH, totalW*4)
compositeStride := totalW * 4
composite, err := CreateShmBuffer(totalW, totalH, compositeStride)
if err != nil {
for _, e := range entries {
e.result.Buffer.Close()
for _, c := range captured {
c.result.Buffer.Close()
}
return nil, fmt.Errorf("create composite buffer: %w", err)
}
composite.Clear()
var format uint32
for _, e := range entries {
for _, c := range captured {
if format == 0 {
format = e.result.Format
format = c.result.Format
}
s.blitBufferScaled(composite, e.result.Buffer,
e.canvasX-minX, e.canvasY-minY, e.canvasW, e.canvasH,
e.result.YInverted)
e.result.Buffer.Close()
s.blitBuffer(composite, c.result.Buffer, c.physX-minX, c.physY-minY, c.result.YInverted)
c.result.Buffer.Close()
}
return &CaptureResult{
@@ -433,44 +413,32 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
}, nil
}
func (s *Screenshoter) blitBufferScaled(dst, src *ShmBuffer, dstX, dstY, dstW, dstH int, yInverted bool) {
if dstW <= 0 || dstH <= 0 {
return
}
func (s *Screenshoter) blitBuffer(dst, src *ShmBuffer, dstX, dstY int, yInverted bool) {
srcData := src.Data()
dstData := dst.Data()
for dy := 0; dy < dstH; dy++ {
canvasY := dstY + dy
if canvasY < 0 || canvasY >= dst.Height {
continue
}
srcY := dy * src.Height / dstH
for srcY := 0; srcY < src.Height; srcY++ {
actualSrcY := srcY
if yInverted {
srcY = src.Height - 1 - srcY
actualSrcY = src.Height - 1 - srcY
}
if srcY < 0 || srcY >= src.Height {
dy := dstY + srcY
if dy < 0 || dy >= dst.Height {
continue
}
srcRowOff := srcY * src.Stride
dstRowOff := canvasY * dst.Stride
srcRowOff := actualSrcY * src.Stride
dstRowOff := dy * dst.Stride
for dx := 0; dx < dstW; dx++ {
canvasX := dstX + dx
if canvasX < 0 || canvasX >= dst.Width {
continue
}
srcX := dx * src.Width / dstW
if srcX >= src.Width {
for srcX := 0; srcX < src.Width; srcX++ {
dx := dstX + srcX
if dx < 0 || dx >= dst.Width {
continue
}
si := srcRowOff + srcX*4
di := dstRowOff + canvasX*4
di := dstRowOff + dx*4
if si+3 >= len(srcData) || di+3 >= len(dstData) {
continue

View File

@@ -52,8 +52,6 @@ type Config struct {
Mode Mode
OutputName string
Cursor CursorMode
NoConfirm bool
Reset bool
Format Format
Quality int
OutputDir string
@@ -68,8 +66,6 @@ func DefaultConfig() Config {
return Config{
Mode: ModeRegion,
Cursor: CursorOff,
NoConfirm: false,
Reset: false,
Format: FormatPNG,
Quality: 90,
OutputDir: "",

View File

@@ -215,34 +215,31 @@ func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential
callback: callback,
}
if existing, exists := b.debounceTimers[id]; exists {
if existing.Stop() {
b.debounceWg.Done()
}
if timer, exists := b.debounceTimers[id]; exists {
timer.Reset(200 * time.Millisecond)
} else {
b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
b.debounceMutex.Lock()
pending, exists := b.debouncePending[id]
if exists {
delete(b.debouncePending, id)
}
b.debounceMutex.Unlock()
if !exists {
return
}
err := b.setBrightnessImmediateWithExponent(id, pending.percent)
if err != nil {
log.Debugf("Failed to set brightness for %s: %v", id, err)
}
if pending.callback != nil {
pending.callback()
}
})
}
b.debounceWg.Add(1)
b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
defer b.debounceWg.Done()
b.debounceMutex.Lock()
pending, hasPending := b.debouncePending[id]
delete(b.debouncePending, id)
delete(b.debounceTimers, id)
b.debounceMutex.Unlock()
if !hasPending {
return
}
if err := b.setBrightnessImmediateWithExponent(id, pending.percent); err != nil {
log.Debugf("Failed to set brightness for %s: %v", id, err)
}
if pending.callback != nil {
pending.callback()
}
})
b.debounceMutex.Unlock()
return nil
@@ -493,19 +490,5 @@ func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int {
return percent
}
func (b *DDCBackend) WaitPending() {
done := make(chan struct{})
go func() {
b.debounceWg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
log.Debug("WaitPending timed out waiting for DDC writes")
}
}
func (b *DDCBackend) Close() {
}

View File

@@ -84,7 +84,6 @@ type DDCBackend struct {
debounceMutex sync.Mutex
debounceTimers map[string]*time.Timer
debouncePending map[string]ddcPendingSet
debounceWg sync.WaitGroup
}
type ddcPendingSet struct {

View File

@@ -6,20 +6,12 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/pilebones/go-udev/netlink"
)
const (
udevRecvBufSize = 8 * 1024 * 1024
udevMaxRetries = 5
udevBaseDelay = 2 * time.Second
udevMaxDelay = 60 * time.Second
)
type UdevMonitor struct {
stop chan struct{}
rescanMutex sync.Mutex
@@ -37,6 +29,13 @@ func NewUdevMonitor(manager *Manager) *UdevMonitor {
}
func (m *UdevMonitor) run(manager *Manager) {
conn := &netlink.UEventConn{}
if err := conn.Connect(netlink.UdevEvent); err != nil {
log.Errorf("Failed to connect to udev netlink: %v", err)
return
}
defer conn.Close()
matcher := &netlink.RuleDefinitions{
Rules: []netlink.RuleDefinition{
{Env: map[string]string{"SUBSYSTEM": "backlight"}},
@@ -49,46 +48,6 @@ func (m *UdevMonitor) run(manager *Manager) {
return
}
failures := 0
for {
if err := m.monitorLoop(manager, matcher); err != nil {
log.Errorf("Udev monitor error: %v", err)
}
select {
case <-m.stop:
return
default:
}
failures++
if failures > udevMaxRetries {
log.Errorf("Udev monitor exceeded %d retries, giving up", udevMaxRetries)
return
}
delay := min(udevBaseDelay*time.Duration(1<<(failures-1)), udevMaxDelay)
log.Infof("Udev monitor reconnecting in %v (attempt %d/%d)", delay, failures, udevMaxRetries)
select {
case <-m.stop:
return
case <-time.After(delay):
}
}
}
func (m *UdevMonitor) monitorLoop(manager *Manager, matcher *netlink.RuleDefinitions) error {
conn := &netlink.UEventConn{}
if err := conn.Connect(netlink.UdevEvent); err != nil {
return err
}
defer conn.Close()
if err := syscall.SetsockoptInt(conn.Fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, udevRecvBufSize); err != nil {
log.Warnf("Failed to set udev socket receive buffer: %v", err)
}
events := make(chan netlink.UEvent)
errs := make(chan error)
conn.Monitor(events, errs, matcher)
@@ -98,9 +57,10 @@ func (m *UdevMonitor) monitorLoop(manager *Manager, matcher *netlink.RuleDefinit
for {
select {
case <-m.stop:
return nil
return
case err := <-errs:
return err
log.Errorf("Udev monitor error: %v", err)
return
case event := <-events:
m.handleEvent(manager, event)
}

View File

@@ -212,10 +212,9 @@ func (m *Manager) setupDataDeviceSync() {
}
var offer any
switch {
case e.Id != nil:
if e.Id != nil {
offer = e.Id
case e.OfferId != 0:
} else if e.OfferId != 0 {
m.offerMutex.RLock()
offer = m.offerRegistry[e.OfferId]
m.offerMutex.RUnlock()
@@ -225,6 +224,10 @@ func (m *Manager) setupDataDeviceSync() {
wasOwner := m.isOwner
m.ownerLock.Unlock()
if offer == nil {
return
}
if wasOwner {
return
}
@@ -233,11 +236,9 @@ func (m *Manager) setupDataDeviceSync() {
m.currentOffer = offer
if prevOffer != nil && prevOffer != offer {
m.releaseOffer(prevOffer)
}
if offer == nil {
return
m.offerMutex.Lock()
delete(m.offerMimeTypes, prevOffer)
m.offerMutex.Unlock()
}
m.offerMutex.RLock()
@@ -291,33 +292,6 @@ func (m *Manager) setupDataDeviceSync() {
log.Info("Data device setup complete")
}
func (m *Manager) releaseOffer(offer any) {
if offer == nil {
return
}
typedOffer, ok := offer.(*ext_data_control.ExtDataControlOfferV1)
if !ok {
return
}
m.offerMutex.Lock()
delete(m.offerMimeTypes, offer)
delete(m.offerRegistry, typedOffer.ID())
m.offerMutex.Unlock()
typedOffer.Destroy()
}
func (m *Manager) releaseCurrentSource() {
if m.currentSource == nil {
return
}
source, ok := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
m.currentSource = nil
if !ok {
return
}
source.Destroy()
}
func (m *Manager) readAndStore(r *os.File, mimeType string) {
defer r.Close()
@@ -421,7 +395,7 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
if extractHash(v) != hash {
continue
}
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
continue
}
@@ -439,7 +413,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
c := b.Cursor()
var count int
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
continue
}
@@ -482,14 +456,6 @@ func encodeEntry(e Entry) ([]byte, error) {
}
func decodeEntry(data []byte) (Entry, error) {
return decodeEntryFields(data, true)
}
func decodeEntryMeta(data []byte) (Entry, error) {
return decodeEntryFields(data, false)
}
func decodeEntryFields(data []byte, withData bool) (Entry, error) {
buf := bytes.NewReader(data)
var e Entry
@@ -497,15 +463,8 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
var dataLen uint32
binary.Read(buf, binary.BigEndian, &dataLen)
switch {
case withData:
e.Data = make([]byte, dataLen)
buf.Read(e.Data)
default:
if _, err := buf.Seek(int64(dataLen), io.SeekCurrent); err != nil {
return e, err
}
}
e.Data = make([]byte, dataLen)
buf.Read(e.Data)
var mimeLen uint32
binary.Read(buf, binary.BigEndian, &mimeLen)
@@ -709,9 +668,14 @@ func sizeStr(size int) string {
func (m *Manager) updateState() {
history := m.GetHistory()
for i := range history {
history[i].Data = nil
}
var current *Entry
if len(history) > 0 {
c := history[0]
c.Data = nil
current = &c
}
@@ -786,7 +750,7 @@ func (m *Manager) GetHistory() []Entry {
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err != nil {
continue
}
@@ -971,7 +935,7 @@ func (m *Manager) ClearHistory() {
var toDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err != nil || !entry.Pinned {
toDelete = append(toDelete, k)
}
@@ -994,7 +958,7 @@ func (m *Manager) ClearHistory() {
if b != nil {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, _ := decodeEntryMeta(v)
entry, _ := decodeEntry(v)
if entry.Pinned {
pinnedCount++
}
@@ -1102,7 +1066,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
m.ownerLock.Unlock()
})
m.releaseCurrentSource()
m.currentSource = source
m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType}
@@ -1182,11 +1145,9 @@ func (m *Manager) Close() {
m.subscribers = make(map[string]chan State)
m.subMutex.Unlock()
m.releaseCurrentSource()
if m.currentOffer != nil {
m.releaseOffer(m.currentOffer)
m.currentOffer = nil
if m.currentSource != nil {
source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
source.Destroy()
}
if m.dataDevice != nil {
@@ -1230,10 +1191,11 @@ func (m *Manager) clearOldEntries(days int) error {
var toDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err != nil {
continue
}
// Skip pinned entries
if entry.Pinned {
continue
}
@@ -1348,7 +1310,7 @@ func (m *Manager) Search(params SearchParams) SearchResult {
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err != nil {
continue
}
@@ -1373,6 +1335,7 @@ func (m *Manager) Search(params SearchParams) SearchResult {
continue
}
entry.Data = nil
all = append(all, entry)
}
return nil
@@ -1547,7 +1510,7 @@ func (m *Manager) PinEntry(id uint64) error {
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err != nil || !entry.Pinned {
continue
}
@@ -1565,6 +1528,7 @@ func (m *Manager) PinEntry(id uint64) error {
return nil
}
// Check pinned count
cfg := m.getConfig()
pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error {
@@ -1574,7 +1538,7 @@ func (m *Manager) PinEntry(id uint64) error {
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
pinnedCount++
}
@@ -1665,11 +1629,12 @@ func (m *Manager) GetPinnedEntries() []Entry {
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err != nil {
continue
}
if entry.Pinned {
entry.Data = nil
pinned = append(pinned, entry)
}
}
@@ -1695,7 +1660,7 @@ func (m *Manager) GetPinnedCount() int {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v)
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
count++
}
@@ -1814,7 +1779,6 @@ func (m *Manager) CopyFile(filePath string) error {
m.ownerLock.Unlock()
})
m.releaseCurrentSource()
m.currentSource = source
m.ownerLock.Lock()

View File

@@ -2,10 +2,8 @@ package cups
import (
"errors"
"fmt"
"net"
"net/url"
"os/exec"
"strings"
"time"
@@ -277,42 +275,13 @@ func (m *Manager) GetClasses() ([]PrinterClass, error) {
return classes, nil
}
func createPrinterViaLpadmin(name, deviceURI, ppd, information, location string) error {
args := []string{"-p", name, "-E", "-v", deviceURI, "-m", ppd}
if information != "" {
args = append(args, "-D", information)
}
if location != "" {
args = append(args, "-L", location)
}
out, err := exec.Command("lpadmin", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func deletePrinterViaLpadmin(name string) error {
out, err := exec.Command("lpadmin", "-x", name).CombinedOutput()
if err != nil {
return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error {
usedPkHelper := false
err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location)
if isAuthError(err) && m.pkHelper != nil {
if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != nil {
// pkHelper failed (e.g., no polkit agent), try lpadmin as last resort.
// lpadmin -E enables the printer, so no further setup needed.
if lpadminErr := createPrinterViaLpadmin(name, deviceURI, ppd, information, location); lpadminErr != nil {
return err
}
m.RefreshState()
return nil
return err
}
usedPkHelper = true
} else if err != nil {
@@ -339,12 +308,6 @@ func (m *Manager) DeletePrinter(printerName string) error {
err := m.client.DeletePrinter(printerName)
if isAuthError(err) && m.pkHelper != nil {
err = m.pkHelper.PrinterDelete(printerName)
if err != nil {
// pkHelper failed, try lpadmin as last resort
if lpadminErr := deletePrinterViaLpadmin(printerName); lpadminErr == nil {
err = nil
}
}
}
if err == nil {
m.RefreshState()

View File

@@ -70,8 +70,6 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleRestartJob(conn, req, manager)
case "cups.holdJob":
handleHoldJob(conn, req, manager)
case "cups.testConnection":
handleTestConnection(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
@@ -466,22 +464,3 @@ func handleHoldJob(conn net.Conn, req models.Request, manager *Manager) {
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job held"})
}
func handleTestConnection(conn net.Conn, req models.Request, manager *Manager) {
host, err := params.StringNonEmpty(req.Params, "host")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
port := params.IntOpt(req.Params, "port", 631)
protocol := params.StringOpt(req.Params, "protocol", "ipp")
result, err := manager.TestRemotePrinter(host, port, protocol)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}

View File

@@ -1,176 +0,0 @@
package cups
import (
"errors"
"fmt"
"net"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
)
var validProtocols = map[string]bool{
"ipp": true,
"ipps": true,
"lpd": true,
"socket": true,
}
func validateTestConnectionParams(host string, port int, protocol string) error {
if host == "" {
return errors.New("host is required")
}
if strings.ContainsAny(host, " \t\n\r/\\") {
return errors.New("host contains invalid characters")
}
if port < 1 || port > 65535 {
return errors.New("port must be between 1 and 65535")
}
if protocol != "" && !validProtocols[protocol] {
return errors.New("protocol must be one of: ipp, ipps, lpd, socket")
}
return nil
}
const probeTimeout = 10 * time.Second
func probeRemotePrinter(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
// Fast fail: TCP reachability check
conn, err := net.DialTimeout("tcp", addr, probeTimeout)
if err != nil {
return &RemotePrinterInfo{
Reachable: false,
Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
}, nil
}
conn.Close()
// Create a temporary IPP client pointing at the remote host.
// The TCP dial above provides fast-fail for unreachable hosts.
// The IPP adapter's ResponseHeaderTimeout (90s) bounds stalling servers.
client := ipp.NewIPPClient(host, port, "", "", useTLS)
// Try /ipp/print first (modern driverless printers), then / (legacy)
info, err := probeIPPEndpoint(client, host, port, useTLS, "/ipp/print")
if err != nil {
// If we got an auth error, the printer exists but requires credentials.
// Report it as reachable with the URI that triggered the auth challenge.
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
Info: "authentication required",
}, nil
}
info, err = probeIPPEndpoint(client, host, port, useTLS, "/")
}
if err != nil {
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/", proto, host, port),
Info: "authentication required",
}, nil
}
// TCP reachable but not an IPP printer
return &RemotePrinterInfo{
Reachable: true,
Error: fmt.Sprintf("host is reachable but does not appear to be an IPP printer: %s", err.Error()),
}, nil
}
return info, nil
}
func probeIPPEndpoint(client *ipp.IPPClient, host string, port int, useTLS bool, resourcePath string) (*RemotePrinterInfo, error) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
printerURI := fmt.Sprintf("%s://%s:%d%s", proto, host, port, resourcePath)
httpProto := "http"
if useTLS {
httpProto = "https"
}
httpURL := fmt.Sprintf("%s://%s:%d%s", httpProto, host, port, resourcePath)
req := ipp.NewRequest(ipp.OperationGetPrinterAttributes, 1)
req.OperationAttributes[ipp.AttributePrinterURI] = printerURI
req.OperationAttributes[ipp.AttributeRequestedAttributes] = []string{
ipp.AttributePrinterName,
ipp.AttributePrinterMakeAndModel,
ipp.AttributePrinterState,
ipp.AttributePrinterInfo,
ipp.AttributePrinterUriSupported,
}
resp, err := client.SendRequest(httpURL, req, nil)
if err != nil {
return nil, err
}
if len(resp.PrinterAttributes) == 0 {
return nil, errors.New("no printer attributes returned")
}
attrs := resp.PrinterAttributes[0]
return &RemotePrinterInfo{
Reachable: true,
MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel),
Name: getStringAttr(attrs, ipp.AttributePrinterName),
Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
State: parsePrinterState(attrs),
URI: printerURI,
}, nil
}
// TestRemotePrinter validates inputs and probes a remote printer via IPP.
// For lpd/socket protocols, only TCP reachability is tested.
func (m *Manager) TestRemotePrinter(host string, port int, protocol string) (*RemotePrinterInfo, error) {
if protocol == "" {
protocol = "ipp"
}
if err := validateTestConnectionParams(host, port, protocol); err != nil {
return nil, err
}
// For non-IPP protocols, only check TCP reachability
if protocol == "lpd" || protocol == "socket" {
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, probeTimeout)
if err != nil {
return &RemotePrinterInfo{
Reachable: false,
Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
}, nil
}
conn.Close()
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d", protocol, host, port),
}, nil
}
useTLS := protocol == "ipps"
probeFn := m.probeRemoteFn
if probeFn == nil {
probeFn = probeRemotePrinter
}
return probeFn(host, port, useTLS)
}

View File

@@ -1,397 +0,0 @@
package cups
import (
"bytes"
"encoding/json"
"fmt"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
"github.com/stretchr/testify/assert"
)
func TestValidateTestConnectionParams(t *testing.T) {
tests := []struct {
name string
host string
port int
protocol string
wantErr string
}{
{
name: "valid ipp",
host: "192.168.0.5",
port: 631,
protocol: "ipp",
wantErr: "",
},
{
name: "valid ipps",
host: "printer.local",
port: 443,
protocol: "ipps",
wantErr: "",
},
{
name: "valid lpd",
host: "10.0.0.1",
port: 515,
protocol: "lpd",
wantErr: "",
},
{
name: "valid socket",
host: "10.0.0.1",
port: 9100,
protocol: "socket",
wantErr: "",
},
{
name: "empty host",
host: "",
port: 631,
protocol: "ipp",
wantErr: "host is required",
},
{
name: "port too low",
host: "192.168.0.5",
port: 0,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "port too high",
host: "192.168.0.5",
port: 70000,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "invalid protocol",
host: "192.168.0.5",
port: 631,
protocol: "ftp",
wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
},
{
name: "empty protocol treated as ipp",
host: "192.168.0.5",
port: 631,
protocol: "",
wantErr: "",
},
{
name: "host with slash",
host: "192.168.0.5/admin",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
{
name: "host with space",
host: "192.168.0.5 ",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
{
name: "host with newline",
host: "192.168.0.5\n",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTestConnectionParams(tt.host, tt.port, tt.protocol)
if tt.wantErr == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErr)
}
})
}
}
func TestManager_TestRemotePrinter_Validation(t *testing.T) {
m := NewTestManager(nil, nil)
tests := []struct {
name string
host string
port int
protocol string
wantErr string
}{
{
name: "empty host returns error",
host: "",
port: 631,
protocol: "ipp",
wantErr: "host is required",
},
{
name: "invalid port returns error",
host: "192.168.0.5",
port: 0,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "invalid protocol returns error",
host: "192.168.0.5",
port: 631,
protocol: "ftp",
wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := m.TestRemotePrinter(tt.host, tt.port, tt.protocol)
assert.EqualError(t, err, tt.wantErr)
})
}
}
func TestManager_TestRemotePrinter_IPP(t *testing.T) {
tests := []struct {
name string
protocol string
probeRet *RemotePrinterInfo
probeErr error
wantTLS bool
wantReach bool
wantModel string
}{
{
name: "successful ipp probe",
protocol: "ipp",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
Name: "OfficeJet",
State: "idle",
URI: "ipp://192.168.0.5:631/ipp/print",
},
wantTLS: false,
wantReach: true,
wantModel: "HP OfficeJet 8010",
},
{
name: "successful ipps probe",
protocol: "ipps",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
URI: "ipps://192.168.0.5:631/ipp/print",
},
wantTLS: true,
wantReach: true,
wantModel: "HP OfficeJet 8010",
},
{
name: "unreachable host",
protocol: "ipp",
probeRet: &RemotePrinterInfo{
Reachable: false,
Error: "cannot reach 192.168.0.5:631: connection refused",
},
wantReach: false,
},
{
name: "empty protocol defaults to ipp",
protocol: "",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "Test Printer",
},
wantTLS: false,
wantReach: true,
wantModel: "Test Printer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedTLS bool
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
capturedTLS = useTLS
return tt.probeRet, tt.probeErr
}
result, err := m.TestRemotePrinter("192.168.0.5", 631, tt.protocol)
if tt.probeErr != nil {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantReach, result.Reachable)
assert.Equal(t, tt.wantModel, result.MakeModel)
assert.Equal(t, tt.wantTLS, capturedTLS)
})
}
}
func TestManager_TestRemotePrinter_AuthRequired(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
// Simulate what happens when the printer returns HTTP 401
return probeRemotePrinterWithAuthError(host, port, useTLS)
}
result, err := m.TestRemotePrinter("192.168.0.107", 631, "ipp")
assert.NoError(t, err)
assert.True(t, result.Reachable)
assert.Equal(t, "authentication required", result.Info)
assert.Contains(t, result.URI, "ipp://192.168.0.107:631")
}
// probeRemotePrinterWithAuthError simulates a probe where the printer
// returns HTTP 401 on both endpoints.
func probeRemotePrinterWithAuthError(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
// This simulates what probeRemotePrinter does when both endpoints
// return auth errors. We test the auth detection logic directly.
err := ipp.HTTPError{Code: 401}
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
Info: "authentication required",
}, nil
}
return nil, err
}
func TestManager_TestRemotePrinter_NonIPPProtocol(t *testing.T) {
m := NewTestManager(nil, nil)
probeCalled := false
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
probeCalled = true
return nil, nil
}
// These will fail at TCP dial (no real server), but the important
// thing is that probeRemoteFn is NOT called for lpd/socket.
m.TestRemotePrinter("192.168.0.5", 9100, "socket")
assert.False(t, probeCalled, "probe function should not be called for socket protocol")
m.TestRemotePrinter("192.168.0.5", 515, "lpd")
assert.False(t, probeCalled, "probe function should not be called for lpd protocol")
}
func TestHandleTestConnection_Success(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
return &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
Name: "OfficeJet",
State: "idle",
URI: "ipp://192.168.0.5:631/ipp/print",
}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{
"host": "192.168.0.5",
},
}
handleTestConnection(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
assert.Equal(t, "HP OfficeJet 8010", resp.Result.MakeModel)
}
func TestHandleTestConnection_MissingHost(t *testing.T) {
m := NewTestManager(nil, nil)
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{},
}
handleTestConnection(conn, req, m)
var resp models.Response[any]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.Nil(t, resp.Result)
assert.NotNil(t, resp.Error)
}
func TestHandleTestConnection_CustomPortAndProtocol(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
assert.Equal(t, 9631, port)
assert.True(t, useTLS)
return &RemotePrinterInfo{Reachable: true, URI: "ipps://192.168.0.5:9631/ipp/print"}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{
"host": "192.168.0.5",
"port": float64(9631),
"protocol": "ipps",
},
}
handleTestConnection(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
}
func TestHandleRequest_TestConnection(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
return &RemotePrinterInfo{Reachable: true}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{"host": "192.168.0.5"},
}
HandleRequest(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
}

View File

@@ -55,16 +55,6 @@ type PPD struct {
Type string `json:"type"`
}
type RemotePrinterInfo struct {
Reachable bool `json:"reachable"`
MakeModel string `json:"makeModel"`
Name string `json:"name"`
Info string `json:"info"`
State string `json:"state"`
URI string `json:"uri"`
Error string `json:"error,omitempty"`
}
type PrinterClass struct {
Name string `json:"name"`
URI string `json:"uri"`
@@ -87,7 +77,6 @@ type Manager struct {
notifierWg sync.WaitGroup
lastNotifiedState *CUPSState
baseURL string
probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error)
}
type SubscriptionManagerInterface interface {

View File

@@ -391,7 +391,7 @@ func (m *Manager) Close() {
func InitializeManager() (*Manager, error) {
if os.Getuid() != 0 && !hasInputGroupAccess() {
return nil, fmt.Errorf("insufficient permissions to access input devices. Add your user to the 'input' group: `sudo usermod -a -G input $USER` or run `dms setup`")
return nil, fmt.Errorf("insufficient permissions to access input devices")
}
return NewManager()

View File

@@ -104,7 +104,7 @@ func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface
return false
}
if reply != dbus.RequestNameReplyPrimaryOwner {
log.Infof("Screensaver name %s already owned by another process (e.g. hypridle/swayidle)", name)
log.Warnf("Screensaver name %s already owned by another process", name)
return false
}
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {

View File

@@ -1,61 +0,0 @@
package location
import (
"encoding/json"
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
type LocationEvent struct {
Type string `json:"type"`
Data State `json:"data"`
}
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
switch req.Method {
case "location.getState":
handleGetState(conn, req, manager)
case "location.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) {
models.Respond(conn, req.ID, manager.GetState())
}
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()
event := LocationEvent{
Type: "state_changed",
Data: initialState,
}
if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{
ID: req.ID,
Result: &event,
}); err != nil {
return
}
for state := range stateChan {
event := LocationEvent{
Type: "state_changed",
Data: state,
}
if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{
Result: &event,
}); err != nil {
return
}
}
}

View File

@@ -1,175 +0,0 @@
package location
import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func NewManager(client geolocation.Client) (*Manager, error) {
currLocation, err := client.GetLocation()
if err != nil {
log.Warnf("Failed to get initial location: %v", err)
}
m := &Manager{
client: client,
dirty: make(chan struct{}),
stopChan: make(chan struct{}),
state: &State{
Latitude: currLocation.Latitude,
Longitude: currLocation.Longitude,
},
}
if err := m.startSignalPump(); err != nil {
return nil, err
}
m.notifierWg.Add(1)
go m.notifier()
return m, nil
}
func (m *Manager) Close() {
close(m.stopChan)
m.notifierWg.Wait()
m.sigWG.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
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 ch, ok := m.subscribers.LoadAndDelete(id); ok {
close(ch)
}
}
func (m *Manager) startSignalPump() error {
m.sigWG.Add(1)
go func() {
defer m.sigWG.Done()
subscription := m.client.Subscribe("locationManager")
defer m.client.Unsubscribe("locationManager")
for {
select {
case <-m.stopChan:
return
case location, ok := <-subscription:
if !ok {
return
}
m.handleLocationChange(location)
}
}
}()
return nil
}
func (m *Manager) handleLocationChange(location geolocation.Location) {
m.stateMutex.Lock()
defer m.stateMutex.Unlock()
m.state.Latitude = location.Latitude
m.state.Longitude = location.Longitude
m.notifySubscribers()
}
func (m *Manager) notifySubscribers() {
select {
case m.dirty <- struct{}{}:
default:
}
}
func (m *Manager) GetState() State {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
return State{
Latitude: 0.0,
Longitude: 0.0,
}
}
stateCopy := *m.state
return stateCopy
}
func (m *Manager) notifier() {
defer m.notifierWg.Done()
const minGap = 200 * 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("Location: subscriber channel full, dropping update")
}
return true
})
stateCopy := currentState
m.lastNotified = &stateCopy
pending = false
}
}
}
func stateChanged(old, new *State) bool {
if old == nil || new == nil {
return true
}
if old.Latitude != new.Latitude {
return true
}
if old.Longitude != new.Longitude {
return true
}
return false
}

View File

@@ -1,28 +0,0 @@
package location
import (
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
type State struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
type Manager struct {
state *State
stateMutex sync.RWMutex
client geolocation.Client
stopChan chan struct{}
sigWG sync.WaitGroup
subscribers syncmap.Map[string, chan State]
dirty chan struct{}
notifierWg sync.WaitGroup
lastNotified *State
}

View File

@@ -35,7 +35,12 @@ type SessionState struct {
type EventType string
const (
EventStateChanged EventType = "state_changed"
EventStateChanged EventType = "state_changed"
EventLock EventType = "lock"
EventUnlock EventType = "unlock"
EventPrepareForSleep EventType = "prepare_for_sleep"
EventIdleHintChanged EventType = "idle_hint_changed"
EventLockedHintChanged EventType = "locked_hint_changed"
)
type SessionEvent struct {

View File

@@ -8,6 +8,11 @@ import (
func TestEventType_Constants(t *testing.T) {
assert.Equal(t, EventType("state_changed"), EventStateChanged)
assert.Equal(t, EventType("lock"), EventLock)
assert.Equal(t, EventType("unlock"), EventUnlock)
assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep)
assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged)
assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged)
}
func TestSessionState_Struct(t *testing.T) {
@@ -35,11 +40,11 @@ func TestSessionEvent_Struct(t *testing.T) {
}
event := SessionEvent{
Type: EventStateChanged,
Type: EventLock,
Data: state,
}
assert.Equal(t, EventStateChanged, event.Type)
assert.Equal(t, EventLock, event.Type)
assert.Equal(t, "1", event.Data.SessionID)
assert.True(t, event.Data.Locked)
}

View File

@@ -29,7 +29,6 @@ func handleMatugenQueue(conn net.Conn, req models.Request) {
SyncModeWithPortal: models.GetOr(req, "syncModeWithPortal", false),
TerminalsAlwaysDark: models.GetOr(req, "terminalsAlwaysDark", false),
SkipTemplates: models.GetOr(req, "skipTemplates", ""),
Contrast: models.GetOr(req, "contrast", 0.0),
}
wait := models.GetOr(req, "wait", true)

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