1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-10 14:29:36 -04:00

Compare commits

..

286 Commits

Author SHA1 Message Date
Amaan Qureshi
eb5afcdc40 flake: let module callers supply pkgs so overlays reach the build (#2244)
The nixosModule/homeModule path previously called `buildDmsPkgs pkgs` but
internally referenced `self.packages.${system}.default`, which was
instantiated via `nixpkgs.legacyPackages`, an unoverlayed pkgs. That
meant downstream flakes couldn't reach through their own overlays to
the dms-shell build (e.g. to swap `kdePackages.sonnet` or trim perl
out of the aspell closure).

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

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

Co-authored-by: Lucas <43530291+LuckShiba@users.noreply.github.com>
2026-04-28 22:59:59 -04:00
Youseffo13
dd2a3f3efc Fixed mux tab having same id as locale tab (#2031)
* Feat: fix mux tab having same id as locale tab

* Feat: updated some icon

* Update KeybindsModal.qml
2026-04-28 16:34:31 -04:00
Walid Salah
161fa2dba5 Add terminal multiplexer launcher (#1687)
* Add tmux

* Add mux modal

* Restore the settings config version

* Revert typo

* Use DankModal for InputModal

* Simplify terminal flags

* use showWithOptions for inputModals instead

* Fix translation

* use Quickshell.env("TERMINAL") to choose terminal

* Fix typo

* Hide muxModal after creating new session

* Add mux check, moved exclusion to service, And use ScriptModel

* Revert unrelated change

* Add blank line
2026-04-28 16:34:29 -04:00
mihem
f5f50f7add feat(running-apps): stronger active app highlight + indicator bar (#2190)
The focused app background used 20% primary opacity which was barely
visible. Increase to 45% to make the active window unambiguous at a glance.
2026-04-28 16:26:33 -04:00
bbedward
372cf2f566 core: add privesc package for privilege escalation
- Adds support for run0 and doas
fixes #998
2026-04-28 16:03:24 -04:00
bbedward
b70acbc283 revert greeter clock keys 2026-04-28 15:05:55 -04:00
purian23
571c1158bf (dms): Add input group to dms setup
- Suppress fix/warnings
2026-04-28 14:34:24 -04:00
bbedward
ac03a2e4b2 niri overlay: fix state binding
fixes #2301
2026-04-28 13:19:57 -04:00
Nic Ficca
07460dc3b7 Fix: close notification center after clicking action buttons (#2276)
* Close notification center after clicking action buttons

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

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

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

Fixes #2178

* Sync notificationHistoryVisible with shouldBeVisible

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

Sync the property from onShouldBeVisibleChanged so any caller going
through open()/close() gets the state corrected automatically.
2026-04-28 11:50:40 -04:00
Kangheng Liu
7412fee590 feat(desktop): expose accept keyboard focus to desktop widgets (#2285)
Opt in by setting acceptsKeyboardFocus: true
2026-04-28 11:46:43 -04:00
bbedward
85c2954958 osd(media): workaround for firefox reporting youtube thumbnails as
players
fixes #2298
2026-04-28 11:46:26 -04:00
bbedward
7c9e805cbe port audioservice fix 2026-04-28 10:25:12 -04:00
bbedward
6926470b04 v 1.4.6 2026-04-28 10:12:56 -04:00
bbedward
713ba1efbb idle/lock: add option to turn off monitors after lock explicitly 2026-04-28 10:12:35 -04:00
bbedward
1919ca7243 fix cherry pick issues 2026-04-28 10:01:09 -04:00
Archit Arora
bcf41ed5ca feat(system-tray): add icon tinting (#2266) 2026-04-25 15:32:14 -04:00
purian23
5033bdc630 (settings): Update monocrhrome & settings 2026-04-25 15:29:27 -04:00
purian23
b8bfaf9a26 (dbar): Settings reorg 2026-04-25 14:52:38 -04:00
Kangheng Liu
da45714c54 keybinds: add move workspace to monitor keybinds (#2268)
and distinguish with move columns
2026-04-25 14:45:23 -04:00
bbedward
0c2d00b79c fix transparency 2026-04-25 11:54:19 -04:00
purian23
c10b42f599 dms(blur): Dank all the things 2026-04-25 11:54:18 -04:00
bbedward
4c617cf022 fix popout transparency 2026-04-25 11:54:18 -04:00
bbedward
e75b95b854 blur: revise general blur styling and refine it
cherry-pick of 1abb2210 from master, translated to hotfix-1.4.5
(omits ElevationShadow gating since shadow overhaul isn't on this
branch; gates existing MultiEffect-based shadows by BlurService.enabled
instead). Drops DoNotDisturbDetail.qml hunk since the file does not
exist on this branch.
2026-04-25 11:54:18 -04:00
Lucas
6b15670918 nix: update quickshell version (#2263)
Updated the quickshell revision to 783c95, matching the "stable" package in other DMS distributions.
2026-04-24 17:17:36 -04:00
Walid Salah
c52b9e19a1 Fix focused app when switching to empty workspace (#2259)
* Fix multiple screens on niri, when switching to an empty wokspace the other screen focused app widget would get confused

* Blank workspace fix
2026-04-24 17:17:36 -04:00
purian23
7a3444bd30 dankinstall(distros): Enhance DMS minimal install logic
-Updated for Debian, Ubuntu, Fedora, and OpenSUSE
- New shared minimal installation logic to streamline package handling across distros
2026-04-23 18:41:02 -04:00
bbedward
a733d760e4 clipboard: decode metadata only 2026-04-23 09:29:12 -04:00
bbedward
1b33079e39 missing import 2026-04-22 11:28:20 -04:00
Kristijan Ribarić
1cf0dd1031 fix(quickshell): restore night mode and OSD surfaces after resume (#2254) 2026-04-22 11:13:18 -04:00
bbedward
8d49a5cbfc doctor: add Miracle WM to checks 2026-04-22 11:12:20 -04:00
Jos Dehaes
f5928b09d3 Labwc service (#2248)
* services: add LabwcService with quit

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

* session: dispatch labwc logout via LabwcService

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

Add a labwc branch alongside the existing niri/dwl/sway branches
so the power menu logout works out of the box.
2026-04-22 11:12:20 -04:00
Walid Salah
38373aa5f2 Fix: Expand tilde from config paths (#2242)
* Expand tilde to the home directory for paths from config

* Remove extra line
2026-04-22 11:12:20 -04:00
bbedward
665680e15e keybinds(niri): add preprocessors to KDL parsing
fixes #2230
2026-04-22 11:11:48 -04:00
bbedward
210607cfbc fix(ddc): prevent negative WaitGroup counter on rapid brightness changes 2026-04-22 11:11:48 -04:00
bbedward
69fca14611 audio: defensive checks on PwNode objects 2026-04-22 11:11:48 -04:00
DavutHaxor
10a235e686 Fix ddc brightness not applying because process exits before debounce timer runs (#2217)
* Fix ddc brightness not applying because process exits before debounce timer runs

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

* go fmt

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-04-22 11:10:34 -04:00
bbedward
253cc7f8a3 network: report negotiated link rate when connected
fixes #2214
2026-04-22 11:10:34 -04:00
bbedward
a63ad99684 doctor: fix quickshell regex
fixes #2204
2026-04-22 11:10:34 -04:00
Thomas Kroll
c44c032879 fix(privacy): detect screen casting on Niri via PipeWire (#2185)
Screen sharing was not detected by PrivacyService on Niri because:

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

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

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

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 11:10:34 -04:00
Marcus Ramberg
dc1ce55971 core(doctor): show all detected terminals (#2163) 2026-04-22 11:10:34 -04:00
Al- Amin
9f65882a12 fix: Add match rule for new version of Gnome Calculator app ID (#2157) 2026-04-22 11:10:34 -04:00
Walid Salah
97bf83cce6 Make focused app widget only show focused app on the current screen (#2152) 2026-04-22 11:10:34 -04:00
Al- Amin
96bf0162d6 fix:update resizeactive binding to include height to make it work (#2126) 2026-04-22 11:10:22 -04:00
Al- Amin
73b833731a fix:add window rule for the new version of Gnome Calculator (#2125) 2026-04-22 11:10:22 -04:00
bbedward
84522aeaad screenshot: fix scaling of global coordinate space when using all
screens
2026-04-22 11:10:22 -04:00
bbedward
faf1a277d2 workspace: fix mouse area to edges
fixes #2108
2026-04-22 11:10:22 -04:00
Graeme Foster
60515736e6 fix(osd): coerce optional chain to bool in VolumeOSD enabled bindings (#2101)
Fixes #2100
2026-04-22 11:09:56 -04:00
bbedward
1715e2eab7 fix syncWallpaperForCurrentMode 2026-04-22 11:09:56 -04:00
bbedward
4e14cf5cce add notification card inset 2026-04-22 10:58:11 -04:00
bbedward
a644c93b1b fix blur port and warnings 2026-04-22 10:53:46 -04:00
Lucas
f9428a1009 doctor: add blur support (#2236) 2026-04-22 10:41:18 -04:00
purian23
b4b51785e5 feat:(Notepad): Add blur & update animation track 2026-04-22 10:41:05 -04:00
bbedward
0a97df6d49 launcher: some polishes for blur 2026-04-22 10:40:57 -04:00
bbedward
352ba77677 blur: stylize control center for blur mode 2026-04-22 10:39:56 -04:00
bbedward
d320035d97 blur: add probe to check compositor for ext-bg-effect 2026-04-22 10:38:13 -04:00
bbedward
8d262a9555 blur: add blur support with ext-bg-effect 2026-04-22 10:38:04 -04:00
bbedward
9bfa8310d2 dankinstall: workarounds for arch/extra change 2026-04-11 12:42:07 -04:00
bbedward
088ed806ae core: allow RO commands to run as root 2026-04-07 15:01:36 -04:00
bbedward
07d2c94676 clipboard: make CLI keep CL item in-memory again 2026-04-07 15:01:21 -04:00
bbedward
0bc1b7a3c2 clipboard: fix reliability of modal/popout 2026-04-07 15:01:16 -04:00
bbedward
c5987b28c0 fix(clipboard): wait for forked child to register Wayland source before returning 2026-04-07 15:01:10 -04:00
bbedward
18901c7cde core: migrate to dms-shell arch package 2026-04-06 10:10:36 -04:00
purian23
519a8357a1 fix(lock/greeter): sync auth files with master 2026-03-26 11:37:17 -04:00
purian23
799773c62b refactor: Remove faillock support and related properties from settings 2026-03-26 10:55:32 -04:00
purian23
247a674c79 fix(pam): Update config selection logic for PAM context 2026-03-26 10:55:05 -04:00
purian23
72b598057c fix(lock): Restore system PAM fallback, faillock support, and auth feedback
- Re-add loginConfigWatcher so installs can still fall through to
  /etc/pam.d instead of the bundled PAM assets
- Add login-faillock bundled PAM asset at runtime. Use it as the bundled fallback when dankshell config is absent
- Fix invalid bare property writes (u2fPending, u2fState, unlockInProgress,
  state) in Pam.qml
- Improve lockscreen auth feedback
2026-03-26 10:51:45 -04:00
Patrick Fischer
8180e30e8e fix: restore lock screen U2F/fingerprint auth to working state (#2052)
* fix: restore lock screen U2F/fingerprint auth to working state

* fix(pam): Keep SettingsData as single source of truth for auth availability
- Restores SettingsData for fingerprint/U2F, keeping lock screen and New Greeter Settings UI in sync

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-03-26 10:48:10 -04:00
purian23
dd9851b4f0 fix(Dock): Replace hardcoded max height mask in vertical mode 2026-03-26 10:41:02 -04:00
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
432 changed files with 33005 additions and 97584 deletions

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
name: Nix flake and NixOS tests name: Check nix flake
on: on:
pull_request: pull_request:
@@ -9,35 +9,15 @@ on:
jobs: jobs:
check-flake: check-flake:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 120
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Nix - name: Install Nix
uses: cachix/install-nix-action@v31 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 - name: Check the flake
run: nix flake check -L run: nix flake check
- 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,11 +20,3 @@ repos:
language: system language: system
files: ^core/.*\.(go|mod|sum)$ files: ^core/.*\.(go|mod|sum)$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: 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. 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 # 1.4.0
- Overhauled system monitor, graphs, styling - Overhauled system monitor, graphs, styling

View File

@@ -86,9 +86,7 @@ touch .qmlls.ini
4. Restart dms to generate the `.qmlls.ini` file 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`. 5. Make your changes, test, and open a pull request.
6. Make your changes, test, and open a pull request.
### I18n/Localization ### I18n/Localization

View File

@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications 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 all: build
@@ -32,9 +32,6 @@ clean:
@$(MAKE) -C $(CORE_DIR) clean @$(MAKE) -C $(CORE_DIR) clean
@echo "Clean complete" @echo "Clean complete"
lint-qml:
@./quickshell/scripts/qmllint-entrypoints.sh
# Installation targets # Installation targets
install-bin: install-bin:
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@@ -79,7 +76,7 @@ install-desktop:
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entry installed" @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 ""
@echo "Installation complete!" @echo "Installation complete!"
@echo "" @echo ""
@@ -133,7 +130,6 @@ help:
@echo " all (default) - Build the DMS binary" @echo " all (default) - Build the DMS binary"
@echo " build - Same as 'all'" @echo " build - Same as 'all'"
@echo " clean - Clean build artifacts" @echo " clean - Clean build artifacts"
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
@echo "" @echo ""
@echo "Install:" @echo "Install:"
@echo " install - Build and install everything (requires sudo)" @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 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 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) [![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) [![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) [![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 outpkg: mocks_brightness
interfaces: interfaces:
DBusConn: 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: github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config: config:
dir: "internal/mocks/network" dir: "internal/mocks/network"

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/golangci/golangci-lint - repo: https://github.com/golangci/golangci-lint
rev: v2.10.1 rev: v2.9.0
hooks: hooks:
- id: golangci-lint-fmt - id: golangci-lint-fmt
require_serial: true require_serial: true

View File

@@ -63,19 +63,19 @@ endif
build-all: build dankinstall build-all: build dankinstall
install: install: build
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Installation complete" @echo "Installation complete"
install-all: install-all: build-all
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Installation complete" @echo "Installation complete"
install-dankinstall: install-dankinstall: dankinstall
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Installation complete" @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. Command-line interface and daemon for shell management and system control.
**dankinstall** **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 ## System Integration
@@ -147,50 +147,10 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
## Installation via dankinstall ## Installation via dankinstall
**Interactive (TUI):**
```bash ```bash
curl -fsSL https://install.danklinux.com | sh 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 ## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives) Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)

View File

@@ -3,152 +3,20 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui" "github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
) )
var Version = "dev" 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() { func main() {
if os.Getuid() == 0 { if os.Getuid() == 0 {
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root") fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
os.Exit(1) 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() fileLogger, err := log.NewFileLogger()
if err != nil { if err != nil {
fmt.Printf("Warning: Failed to create log file: %v\n", err) fmt.Printf("Warning: Failed to create log file: %v\n", err)
@@ -170,50 +38,18 @@ func runTUI() error {
if fileLogger != nil { if fileLogger != nil {
fileLogger.StartListening(model.GetLogChan()) 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()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
if logFilePath != "" { 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 != "" { if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", 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

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

View File

@@ -1110,14 +1110,14 @@ func formatResultsPlain(results []checkResult) string {
if currentCategory != -1 { if currentCategory != -1 {
sb.WriteString("\n") sb.WriteString("\n")
} }
fmt.Fprintf(&sb, "**%s**\n", r.category.String()) sb.WriteString(fmt.Sprintf("**%s**\n", r.category.String()))
currentCategory = r.category 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 != "" { if doctorVerbose && r.details != "" {
fmt.Fprintf(&sb, " - %s\n", r.details) sb.WriteString(fmt.Sprintf(" - %s\n", r.details))
} }
} }
@@ -1127,8 +1127,8 @@ func formatResultsPlain(results []checkResult) string {
} }
sb.WriteString("\n---\n") sb.WriteString("\n---\n")
fmt.Fprintf(&sb, "**Summary:** %d error(s), %d warning(s), %d ok\n", sb.WriteString(fmt.Sprintf("**Summary:** %d error(s), %d warning(s), %d ok\n",
ds.ErrorCount(), ds.WarningCount(), ds.OKCount()) ds.ErrorCount(), ds.WarningCount(), ds.OKCount()))
return sb.String() return sb.String()
} }

View File

@@ -14,7 +14,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "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/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -28,11 +27,6 @@ var greeterCmd = &cobra.Command{
Long: "Manage DMS greeter (greetd)", Long: "Manage DMS greeter (greetd)",
} }
var (
greeterConfigSyncFn = greeter.SyncDMSConfigs
sharedAuthSyncFn = sharedpam.SyncAuthConfig
)
var greeterInstallCmd = &cobra.Command{ var greeterInstallCmd = &cobra.Command{
Use: "install", Use: "install",
Short: "Install and configure DMS greeter", Short: "Install and configure DMS greeter",
@@ -157,16 +151,6 @@ func init() {
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") 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 { func installGreeter(nonInteractive bool) error {
fmt.Println("=== DMS Greeter Installation ===") fmt.Println("=== DMS Greeter Installation ===")
@@ -262,9 +246,7 @@ func installGreeter(nonInteractive bool) error {
} }
fmt.Println("\nSynchronizing DMS configurations...") fmt.Println("\nSynchronizing DMS configurations...")
if err := syncGreeterConfigsAndAuth(dmsPath, selectedCompositor, logFunc, sharedpam.SyncAuthOptions{}, func() { if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, "", false); err != nil {
fmt.Println("\nConfiguring authentication...")
}); err != nil {
return err return err
} }
@@ -299,7 +281,7 @@ func uninstallGreeter(nonInteractive bool) error {
} }
if !nonInteractive { 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 var response string
fmt.Scanln(&response) fmt.Scanln(&response)
if strings.ToLower(strings.TrimSpace(response)) != "y" { if strings.ToLower(strings.TrimSpace(response)) != "y" {
@@ -315,8 +297,8 @@ func uninstallGreeter(nonInteractive bool) error {
fmt.Println(" ✓ greetd disabled") fmt.Println(" ✓ greetd disabled")
} }
fmt.Println("\nRemoving DMS authentication configuration...") fmt.Println("\nRemoving DMS PAM configuration...")
if err := sharedpam.RemoveManagedGreeterPamBlock(logFunc, ""); err != nil { if err := greeter.RemoveGreeterPamManagedBlock(logFunc, ""); err != nil {
fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err) fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err)
} }
@@ -543,7 +525,7 @@ func resolveLocalWrapperShell() (string, error) {
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if !nonInteractive { if !nonInteractive {
fmt.Println("=== DMS Greeter Sync ===") fmt.Println("=== DMS Greeter Theme Sync ===")
fmt.Println() fmt.Println()
} }
@@ -726,11 +708,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
} }
fmt.Println("\nSynchronizing DMS configurations...") fmt.Println("\nSynchronizing DMS configurations...")
if err := syncGreeterConfigsAndAuth(dmsPath, compositor, logFunc, sharedpam.SyncAuthOptions{ if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, "", forceAuth); err != nil {
ForceGreeterAuth: forceAuth,
}, func() {
fmt.Println("\nConfiguring authentication...")
}); err != nil {
return err return err
} }
@@ -743,9 +721,8 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
fmt.Println("\n=== Sync Complete ===") fmt.Println("\n=== Sync Complete ===")
fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.") 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 { 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.") fmt.Println("The changes will be visible on the next login screen.")
@@ -1295,7 +1272,39 @@ func extractGreeterPathOverrideFromCommand(command string) string {
} }
func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) { 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 { func packageInstallHint() string {
@@ -1605,29 +1614,29 @@ func checkGreeterStatus() error {
fmt.Println(" No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)") fmt.Println(" No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)")
} }
if legacyManaged { 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 allGood = false
} }
enableFprintToggle, enableU2fToggle := false, 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 enableFprintToggle = enableFprint
enableU2fToggle = enableU2f enableU2fToggle = enableU2f
} else { } else {
fmt.Printf(" Could not read greeter auth toggles from settings: %v\n", settingsErr) fmt.Printf(" Could not read greeter auth toggles from settings: %v\n", settingsErr)
} }
includedFprintFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_fprintd.so") includedFprintFile := greeter.DetectIncludedPamModule(string(pamData), "pam_fprintd.so")
includedU2fFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_u2f.so") includedU2fFile := greeter.DetectIncludedPamModule(string(pamData), "pam_u2f.so")
fprintAvailableForCurrentUser := sharedpam.FingerprintAuthAvailableForCurrentUser() fprintAvailableForCurrentUser := greeter.FingerprintAuthAvailableForCurrentUser()
if managedFprint && includedFprintFile != "" { if managedFprint && includedFprintFile != "" {
fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", 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 allGood = false
} }
if managedU2f && includedU2fFile != "" { if managedU2f && includedU2fFile != "" {
fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", 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 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("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().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().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().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 { 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") syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark") terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
skipTemplates, _ := cmd.Flags().GetString("skip-templates") skipTemplates, _ := cmd.Flags().GetString("skip-templates")
contrast, _ := cmd.Flags().GetFloat64("contrast")
return matugen.Options{ return matugen.Options{
StateDir: stateDir, StateDir: stateDir,
@@ -89,7 +87,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
Mode: matugen.ColorMode(mode), Mode: matugen.ColorMode(mode),
IconTheme: iconTheme, IconTheme: iconTheme,
MatugenType: matugenType, MatugenType: matugenType,
Contrast: contrast,
RunUserTemplates: runUserTemplates, RunUserTemplates: runUserTemplates,
StockColors: stockColors, StockColors: stockColors,
SyncModeWithPortal: syncModeWithPortal, SyncModeWithPortal: syncModeWithPortal,
@@ -131,7 +128,6 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"syncModeWithPortal": opts.SyncModeWithPortal, "syncModeWithPortal": opts.SyncModeWithPortal,
"terminalsAlwaysDark": opts.TerminalsAlwaysDark, "terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"skipTemplates": opts.SkipTemplates, "skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast,
"wait": wait, "wait": wait,
}, },
} }

View File

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

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

@@ -15,18 +15,14 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") 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("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") 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") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd) updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateCmd)
rootCmd.SetHelpTemplate(getHelpTemplate()) rootCmd.SetHelpTemplate(getHelpTemplate())

View File

@@ -15,16 +15,12 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") 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("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") 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") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
rootCmd.SetHelpTemplate(getHelpTemplate()) rootCmd.SetHelpTemplate(getHelpTemplate())
} }

View File

@@ -80,16 +80,6 @@ func getRuntimeDir() string {
return os.TempDir() 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 { func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run") _, err := exec.LookPath("systemd-run")
return err == nil 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() { if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope") 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 = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
} }
cmd.Env = appendLogEnv(cmd.Env)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr 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() { if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope") 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 = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
} }
cmd.Env = appendLogEnv(cmd.Env)
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0) devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil { if err != nil {
log.Fatalf("Error opening /dev/null: %v", err) log.Fatalf("Error opening /dev/null: %v", err)

View File

@@ -1,16 +1,14 @@
module github.com/AvengeMedia/DankMaterialShell/core module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.0 go 1.25.0
toolchain go1.26.1
require ( require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0 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/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 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/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
@@ -18,67 +16,61 @@ require (
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yuin/goldmark v1.7.16
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
golang.org/x/image v0.39.0 golang.org/x/image v0.36.0
) )
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // 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/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/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/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/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.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/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/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/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.22 github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // 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/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // 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/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.43.0 golang.org/x/sys v0.41.0
golang.org/x/text v0.36.0 golang.org/x/text v0.34.0
gopkg.in/yaml.v3 v3.0.1 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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= 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 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg= 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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 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.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998EGi6Llk= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.24.0/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= 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.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 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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/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 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 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.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= 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 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 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 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 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 v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= 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 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 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.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= 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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3 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/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.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.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 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 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= 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-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU= 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/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc= 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/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= 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-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI= 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-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= 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 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= 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.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= 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/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/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 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/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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= 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 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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= 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/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 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 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/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 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= 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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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 h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 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 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= 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 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-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.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

@@ -274,12 +274,6 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
return s.renderBufs[s.front] return s.renderBufs[s.front]
} }
func (s *SurfaceState) FrontIndex() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.front
}
func (s *SurfaceState) SwapBuffers() { func (s *SurfaceState) SwapBuffers() {
s.mu.Lock() s.mu.Lock()
s.front ^= 1 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) { 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 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 { shouldReplaceConfig := func(configType string) bool {
if replaceConfigs == nil { if replaceConfigs == nil {
return true return true
} }
replace, exists := replaceConfigs[configType] replace, exists := replaceConfigs[configType]
if !exists || replace { return !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
} }
switch wm { switch wm {

View File

@@ -1,7 +1,6 @@
package config package config
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -625,168 +624,3 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
assert.Contains(t, string(newContent), "decorations = \"None\"") 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

@@ -104,6 +104,15 @@ func debianPackageInstalledPrecisely(pkg string) bool {
return strings.TrimSpace(string(output)) == "installed" return strings.TrimSpace(string(output)) == "installed"
} }
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func debianRepoArchitecture(arch string) string { func debianRepoArchitecture(arch string) string {
switch arch { switch arch {
case "amd64", "x86_64": case "amd64", "x86_64":

View File

@@ -6,7 +6,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
@@ -427,7 +426,7 @@ func openSUSENiriRuntimePackages(wm deps.WindowManager, disabledFlags map[string
func (o *OpenSUSEDistribution) appendMissingSystemPackages(systemPkgs []string, extraPkgs []string) []string { func (o *OpenSUSEDistribution) appendMissingSystemPackages(systemPkgs []string, extraPkgs []string) []string {
for _, pkg := range extraPkgs { for _, pkg := range extraPkgs {
if slices.Contains(systemPkgs, pkg) || o.packageInstalled(pkg) { if containsString(systemPkgs, pkg) || o.packageInstalled(pkg) {
continue continue
} }

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()
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/sblinch/kdl-go" "github.com/sblinch/kdl-go"
@@ -27,7 +26,26 @@ var appArmorProfileData []byte
const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter" const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter"
const GreeterCacheDir = "/var/cache/dms-greeter" const (
GreeterCacheDir = "/var/cache/dms-greeter"
GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)"
GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH"
legacyGreeterPamFprintComment = "# DMS greeter fingerprint"
legacyGreeterPamU2FComment = "# DMS greeter U2F"
)
// Common PAM auth stack names referenced by greetd across supported distros.
var includedPamAuthFiles = []string{
"system-auth",
"common-auth",
"password-auth",
"system-login",
"system-local-login",
"common-auth-pc",
"login",
}
func DetectDMSPath() (string, error) { func DetectDMSPath() (string, error) {
return config.LocateDMSConfig() return config.LocateDMSConfig()
@@ -574,6 +592,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
runtimeDirs := []string{ runtimeDirs := []string{
filepath.Join(cacheDir, ".local"), filepath.Join(cacheDir, ".local"),
filepath.Join(cacheDir, ".local", "state"), filepath.Join(cacheDir, ".local", "state"),
filepath.Join(cacheDir, ".local", "state", "wireplumber"),
filepath.Join(cacheDir, ".local", "share"), filepath.Join(cacheDir, ".local", "share"),
filepath.Join(cacheDir, ".cache"), filepath.Join(cacheDir, ".cache"),
} }
@@ -693,6 +712,49 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
return nil return nil
} }
// RemoveGreeterPamManagedBlock strips the DMS managed auth block from /etc/pam.d/greetd
func RemoveGreeterPamManagedBlock(logFunc func(string), sudoPassword string) error {
if IsNixOS() {
return nil
}
const greetdPamPath = "/etc/pam.d/greetd"
data, err := os.ReadFile(greetdPamPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read %s: %w", greetdPamPath, err)
}
stripped, removed := stripManagedGreeterPamBlock(string(data))
strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped)
if !removed && !removedLegacy {
return nil
}
tmp, err := os.CreateTemp("", "dms-pam-greetd-*")
if err != nil {
return fmt.Errorf("failed to create temp PAM file: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.WriteString(strippedAgain); err != nil {
tmp.Close()
return fmt.Errorf("failed to write temp PAM file: %w", err)
}
tmp.Close()
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, greetdPamPath); err != nil {
return fmt.Errorf("failed to write PAM config: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", greetdPamPath); err != nil {
return fmt.Errorf("failed to set PAM config permissions: %w", err)
}
logFunc(" ✓ Removed DMS managed PAM block from " + greetdPamPath)
return nil
}
// UninstallAppArmorProfile removes the DMS AppArmor profile and reloads AppArmor. // UninstallAppArmorProfile removes the DMS AppArmor profile and reloads AppArmor.
// It is a no-op when AppArmor is not active or the profile does not exist. // It is a no-op when AppArmor is not active or the profile does not exist.
func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error { func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
@@ -1190,7 +1252,7 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
return nil return nil
} }
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string, forceAuth bool) error {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err) return fmt.Errorf("failed to get user home directory: %w", err)
@@ -1255,6 +1317,10 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
return fmt.Errorf("greeter wallpaper override sync failed: %w", err) return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
} }
if err := syncGreeterPamConfig(homeDir, logFunc, sudoPassword, forceAuth); err != nil {
return fmt.Errorf("greeter PAM config sync failed: %w", err)
}
if strings.ToLower(compositor) != "niri" { if strings.ToLower(compositor) != "niri" {
return nil return nil
} }
@@ -1303,6 +1369,378 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas
return nil return nil
} }
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 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 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
}
lines := strings.Split(string(data), "\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 DetectIncludedPamModule(pamText, module string) string {
for _, includedFile := range includedPamAuthFiles {
if PamTextIncludesFile(pamText, includedFile) && PamFileHasModule("/etc/pam.d/"+includedFile, module) {
return includedFile
}
}
return ""
}
type greeterAuthSettings struct {
GreeterEnableFprint bool `json:"greeterEnableFprint"`
GreeterEnableU2f bool `json:"greeterEnableU2f"`
}
func readGreeterAuthSettings(homeDir string) (greeterAuthSettings, error) {
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return greeterAuthSettings{}, nil
}
return greeterAuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err)
}
if strings.TrimSpace(string(data)) == "" {
return greeterAuthSettings{}, nil
}
var settings greeterAuthSettings
if err := json.Unmarshal(data, &settings); err != nil {
return greeterAuthSettings{}, 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 := readGreeterAuthSettings(homeDir)
if err != nil {
return false, false, err
}
return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil
}
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 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))
}
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 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 syncGreeterPamConfig(homeDir string, logFunc func(string), sudoPassword string, forceAuth bool) error {
var wantFprint, wantU2f bool
fprintToggleEnabled := forceAuth
u2fToggleEnabled := forceAuth
if forceAuth {
wantFprint = pamModuleExists("pam_fprintd.so")
wantU2f = pamModuleExists("pam_u2f.so")
} else {
settings, err := readGreeterAuthSettings(homeDir)
if err != nil {
return err
}
fprintToggleEnabled = settings.GreeterEnableFprint
u2fToggleEnabled = settings.GreeterEnableU2f
fprintModule := pamModuleExists("pam_fprintd.so")
u2fModule := 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 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
}
greetdPamPath := "/etc/pam.d/greetd"
pamData, err := os.ReadFile(greetdPamPath)
if err != nil {
return fmt.Errorf("failed to read %s: %w", greetdPamPath, err)
}
originalContent := string(pamData)
content, _ := stripManagedGreeterPamBlock(originalContent)
content, _ = stripLegacyGreeterPamLines(content)
includedFprintFile := DetectIncludedPamModule(content, "pam_fprintd.so")
includedU2fFile := DetectIncludedPamModule(content, "pam_u2f.so")
fprintAvailableForCurrentUser := FingerprintAuthAvailableForCurrentUser()
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, greetdPamPath)
if err != nil {
return err
}
}
if content == originalContent {
return nil
}
tmpFile, err := os.CreateTemp("", "greetd-pam-*.conf")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := tmpFile.WriteString(content); err != nil {
tmpFile.Close()
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, greetdPamPath); err != nil {
return fmt.Errorf("failed to install updated PAM config at %s: %w", greetdPamPath, err)
}
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", greetdPamPath); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", greetdPamPath, err)
}
if wantFprint || wantU2f {
logFunc("✓ Configured greetd PAM for fingerprint/U2F")
} else {
logFunc("✓ Cleared DMS-managed greeter PAM auth block")
}
return nil
}
type niriGreeterSync struct { type niriGreeterSync struct {
processed map[string]bool processed map[string]bool
nodes []*document.Node nodes []*document.Node
@@ -1710,6 +2148,8 @@ vt = 1
wrapperCmd := resolveGreeterWrapperPath() wrapperCmd := resolveGreeterWrapperPath()
// Build command based on compositor and dms path
// When dmsPath is empty (packaged greeter), omit -p; wrapper finds /usr/share/quickshell/dms-greeter
compositorLower := strings.ToLower(compositor) compositorLower := strings.ToLower(compositor)
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir) commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir)
if dmsPath != "" { if dmsPath != "" {
@@ -1955,15 +2395,10 @@ func AutoSetupGreeter(compositor, sudoPassword string, logFunc func(string)) err
} }
logFunc("Synchronizing DMS configurations...") logFunc("Synchronizing DMS configurations...")
if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword); err != nil { if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword, false); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: config sync error: %v", err)) logFunc(fmt.Sprintf("⚠ Warning: config sync error: %v", err))
} }
logFunc("Configuring authentication...")
if err := sharedpam.SyncAuthConfig(logFunc, sudoPassword, sharedpam.SyncAuthOptions{}); err != nil {
return fmt.Errorf("failed to sync authentication: %w", err)
}
logFunc("Checking for conflicting display managers...") logFunc("Checking for conflicting display managers...")
if err := DisableConflictingDisplayManagers(sudoPassword, logFunc); err != nil { if err := DisableConflictingDisplayManagers(sudoPassword, logFunc); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: %v", err)) logFunc(fmt.Sprintf("⚠ Warning: %v", err))

View File

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

@@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"fmt" "fmt"
"os" "os"
"path/filepath"
"regexp" "regexp"
"sync" "sync"
"time" "time"
@@ -22,16 +21,7 @@ type FileLogger struct {
func NewFileLogger() (*FileLogger, error) { func NewFileLogger() (*FileLogger, error) {
timestamp := time.Now().Unix() timestamp := time.Now().Unix()
logPath := fmt.Sprintf("/tmp/dankinstall-%d.log", timestamp)
// 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))
file, err := os.Create(logPath) file, err := os.Create(logPath)
if err != nil { if err != nil {

View File

@@ -1,16 +1,12 @@
package log package log
import ( import (
"io"
"os" "os"
"regexp"
"strings" "strings"
"sync" "sync"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
cblog "github.com/charmbracelet/log" cblog "github.com/charmbracelet/log"
"github.com/mattn/go-isatty"
"github.com/muesli/termenv"
) )
// Logger embeds the Charm Logger and adds Printf/Fatalf // 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 ( var (
logger *Logger logger *Logger
initLogger sync.Once 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 { func parseLogLevel(level string) cblog.Level {
switch strings.ToLower(level) { switch strings.ToLower(level) {
case "debug": case "debug":
@@ -108,7 +86,7 @@ func GetLogger() *Logger {
SetString(" DEBUG"). SetString(" DEBUG").
Foreground(lipgloss.Color("4")) Foreground(lipgloss.Color("4"))
base := cblog.New(logStderr) base := cblog.New(os.Stderr)
base.SetStyles(styles) base.SetStyles(styles)
base.SetReportTimestamp(false) base.SetReportTimestamp(false)
@@ -120,85 +98,10 @@ func GetLogger() *Logger {
base.SetPrefix(" go") base.SetPrefix(" go")
logger = &Logger{base} logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path)
}
}) })
return logger 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 // * Convenience wrappers
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) } 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: "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: "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: "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: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal}, {ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal}, {ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
@@ -100,7 +99,6 @@ type Options struct {
Mode ColorMode Mode ColorMode
IconTheme string IconTheme string
MatugenType string MatugenType string
Contrast float64
RunUserTemplates bool RunUserTemplates bool
ColorsOnly bool ColorsOnly bool
StockColors string StockColors string
@@ -230,7 +228,6 @@ func buildOnce(opts *Options) (bool, error) {
log.Info("Running matugen color hex with stock color overrides") 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 := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()}
args = appendContrastArg(args, opts.Contrast)
args = append(args, importArgs...) args = append(args, importArgs...)
if err := runMatugen(args); err != nil { if err := runMatugen(args); err != nil {
return false, err return false, err
@@ -267,7 +264,6 @@ func buildOnce(opts *Options) (bool, error) {
args = []string{opts.Kind, opts.Value} args = []string{opts.Kind, opts.Value}
} }
args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()) args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name())
args = appendContrastArg(args, opts.Contrast)
args = append(args, importArgs...) args = append(args, importArgs...)
if err := runMatugen(args); err != nil { if err := runMatugen(args); err != nil {
return false, err return false, err
@@ -303,13 +299,6 @@ func buildOnce(opts *Options) (bool, error) {
return true, nil 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 { func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml") 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 = []string{opts.Kind, opts.Value}
} }
baseArgs = append(baseArgs, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run") baseArgs = append(baseArgs, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run")
baseArgs = appendContrastArg(baseArgs, opts.Contrast)
if flags.isV4 { if flags.isV4 {
baseArgs = append(baseArgs, "--source-color-index", "0", "--old-json-output") 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 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 // GetWiredConnections provides a mock function with no fields
func (_m *MockBackend) GetWiredConnections() ([]network.WiredConnection, error) { func (_m *MockBackend) GetWiredConnections() ([]network.WiredConnection, error) {
ret := _m.Called() 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

@@ -113,11 +113,7 @@ func NewRegionSelector(s *Screenshoter) *RegionSelector {
} }
func (r *RegionSelector) Run() (*CaptureResult, bool, error) { 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 { if err := r.connect(); err != nil {
return nil, false, fmt.Errorf("wayland connect: %w", err) return nil, false, fmt.Errorf("wayland connect: %w", err)

View File

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

View File

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

View File

@@ -107,12 +107,6 @@ func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) {
} }
func (s *Screenshoter) captureRegion() (*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) selector := NewRegionSelector(s)
result, cancelled, err := selector.Run() result, cancelled, err := selector.Run()
if err != nil { if err != nil {

View File

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

View File

@@ -6,20 +6,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/pilebones/go-udev/netlink" "github.com/pilebones/go-udev/netlink"
) )
const (
udevRecvBufSize = 8 * 1024 * 1024
udevMaxRetries = 5
udevBaseDelay = 2 * time.Second
udevMaxDelay = 60 * time.Second
)
type UdevMonitor struct { type UdevMonitor struct {
stop chan struct{} stop chan struct{}
rescanMutex sync.Mutex rescanMutex sync.Mutex
@@ -37,6 +29,13 @@ func NewUdevMonitor(manager *Manager) *UdevMonitor {
} }
func (m *UdevMonitor) run(manager *Manager) { 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{ matcher := &netlink.RuleDefinitions{
Rules: []netlink.RuleDefinition{ Rules: []netlink.RuleDefinition{
{Env: map[string]string{"SUBSYSTEM": "backlight"}}, {Env: map[string]string{"SUBSYSTEM": "backlight"}},
@@ -49,46 +48,6 @@ func (m *UdevMonitor) run(manager *Manager) {
return 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) events := make(chan netlink.UEvent)
errs := make(chan error) errs := make(chan error)
conn.Monitor(events, errs, matcher) conn.Monitor(events, errs, matcher)
@@ -98,9 +57,10 @@ func (m *UdevMonitor) monitorLoop(manager *Manager, matcher *netlink.RuleDefinit
for { for {
select { select {
case <-m.stop: case <-m.stop:
return nil return
case err := <-errs: case err := <-errs:
return err log.Errorf("Udev monitor error: %v", err)
return
case event := <-events: case event := <-events:
m.handleEvent(manager, event) m.handleEvent(manager, event)
} }

View File

@@ -2,10 +2,8 @@ package cups
import ( import (
"errors" "errors"
"fmt"
"net" "net"
"net/url" "net/url"
"os/exec"
"strings" "strings"
"time" "time"
@@ -277,43 +275,14 @@ func (m *Manager) GetClasses() ([]PrinterClass, error) {
return classes, nil 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 { func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error {
usedPkHelper := false usedPkHelper := false
err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location) err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location)
if isAuthError(err) && m.pkHelper != nil { if isAuthError(err) && m.pkHelper != nil {
if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != 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 return err
} }
m.RefreshState()
return nil
}
usedPkHelper = true usedPkHelper = true
} else if err != nil { } else if err != nil {
return err return err
@@ -339,12 +308,6 @@ func (m *Manager) DeletePrinter(printerName string) error {
err := m.client.DeletePrinter(printerName) err := m.client.DeletePrinter(printerName)
if isAuthError(err) && m.pkHelper != nil { if isAuthError(err) && m.pkHelper != nil {
err = m.pkHelper.PrinterDelete(printerName) 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 { if err == nil {
m.RefreshState() m.RefreshState()

View File

@@ -70,8 +70,6 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleRestartJob(conn, req, manager) handleRestartJob(conn, req, manager)
case "cups.holdJob": case "cups.holdJob":
handleHoldJob(conn, req, manager) handleHoldJob(conn, req, manager)
case "cups.testConnection":
handleTestConnection(conn, req, manager)
default: default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
} }
@@ -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"}) 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 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 { type PrinterClass struct {
Name string `json:"name"` Name string `json:"name"`
URI string `json:"uri"` URI string `json:"uri"`
@@ -87,7 +77,6 @@ type Manager struct {
notifierWg sync.WaitGroup notifierWg sync.WaitGroup
lastNotifiedState *CUPSState lastNotifiedState *CUPSState
baseURL string baseURL string
probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error)
} }
type SubscriptionManagerInterface interface { type SubscriptionManagerInterface interface {

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

@@ -36,6 +36,11 @@ type EventType string
const ( 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 { type SessionEvent struct {

View File

@@ -8,6 +8,11 @@ import (
func TestEventType_Constants(t *testing.T) { func TestEventType_Constants(t *testing.T) {
assert.Equal(t, EventType("state_changed"), EventStateChanged) 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) { func TestSessionState_Struct(t *testing.T) {
@@ -35,11 +40,11 @@ func TestSessionEvent_Struct(t *testing.T) {
} }
event := SessionEvent{ event := SessionEvent{
Type: EventStateChanged, Type: EventLock,
Data: state, Data: state,
} }
assert.Equal(t, EventStateChanged, event.Type) assert.Equal(t, EventLock, event.Type)
assert.Equal(t, "1", event.Data.SessionID) assert.Equal(t, "1", event.Data.SessionID)
assert.True(t, event.Data.Locked) 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), SyncModeWithPortal: models.GetOr(req, "syncModeWithPortal", false),
TerminalsAlwaysDark: models.GetOr(req, "terminalsAlwaysDark", false), TerminalsAlwaysDark: models.GetOr(req, "terminalsAlwaysDark", false),
SkipTemplates: models.GetOr(req, "skipTemplates", ""), SkipTemplates: models.GetOr(req, "skipTemplates", ""),
Contrast: models.GetOr(req, "contrast", 0.0),
} }
wait := models.GetOr(req, "wait", true) wait := models.GetOr(req, "wait", true)

View File

@@ -10,7 +10,6 @@ type Backend interface {
ScanWiFi() error ScanWiFi() error
ScanWiFiDevice(device string) error ScanWiFiDevice(device string) error
GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error)
GetWiFiQRCodeContent(ssid string) (string, error)
GetWiFiDevices() []WiFiDevice GetWiFiDevices() []WiFiDevice
ConnectWiFi(req ConnectionRequest) error ConnectWiFi(req ConnectionRequest) error

View File

@@ -111,10 +111,6 @@ func (b *HybridIwdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkI
return b.wifi.GetWiFiNetworkDetails(ssid) return b.wifi.GetWiFiNetworkDetails(ssid)
} }
func (b *HybridIwdNetworkdBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
return b.wifi.GetWiFiQRCodeContent(ssid)
}
func (b *HybridIwdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error { func (b *HybridIwdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error {
if err := b.wifi.ConnectWiFi(req); err != nil { if err := b.wifi.ConnectWiFi(req); err != nil {
return err return err

View File

@@ -1,9 +1,6 @@
package network package network
import ( import "fmt"
"fmt"
"os"
)
func (b *IWDBackend) GetWiredConnections() ([]WiredConnection, error) { func (b *IWDBackend) GetWiredConnections() ([]WiredConnection, error) {
return nil, fmt.Errorf("wired connections not supported by iwd") return nil, fmt.Errorf("wired connections not supported by iwd")
@@ -115,19 +112,3 @@ func (b *IWDBackend) getWiFiDevicesLocked() []WiFiDevice {
Networks: b.state.WiFiNetworks, Networks: b.state.WiFiNetworks,
}} }}
} }
func (b *IWDBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
path := iwdConfigPath(ssid)
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("no saved iwd config for `%s`: %w", ssid, err)
}
passphrase, err := parseIWDPassphrase(string(data))
if err != nil {
return "", fmt.Errorf("failed to read passphrase for `%s`: %w", ssid, err)
}
return FormatWiFiQRString("WPA", ssid, passphrase), nil
}

View File

@@ -18,10 +18,6 @@ func (b *SystemdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInf
return nil, fmt.Errorf("WiFi details not supported by networkd backend") return nil, fmt.Errorf("WiFi details not supported by networkd backend")
} }
func (b *SystemdNetworkdBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
return "", fmt.Errorf("WiFi QR Code not supported by networkd backend")
}
func (b *SystemdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error { func (b *SystemdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error {
return fmt.Errorf("WiFi connect not supported by networkd backend") return fmt.Errorf("WiFi connect not supported by networkd backend")
} }

View File

@@ -204,65 +204,6 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo
}, nil }, nil
} }
func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
conn, err := b.findConnection(ssid)
if err != nil {
return "", fmt.Errorf("no saved connection for `%s`: %w", ssid, err)
}
connSettings, err := conn.GetSettings()
if err != nil {
return "", fmt.Errorf("failed to get settings for `%s`: %w", ssid, err)
}
secSettings, ok := connSettings["802-11-wireless-security"]
if !ok {
return "", fmt.Errorf("network `%s` has no security settings", ssid)
}
keyMgmt, ok := secSettings["key-mgmt"].(string)
if !ok {
return "", fmt.Errorf("failed to identify security type of network `%s`", ssid)
}
var securityType string
switch keyMgmt {
case "none":
authAlg, _ := secSettings["auth-alg"].(string)
switch authAlg {
case "open":
securityType = "nopass"
default:
securityType = "WEP"
}
case "ieee8021x":
securityType = "WEP"
default:
securityType = "WPA"
}
if securityType != "WPA" {
return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType)
}
secrets, err := conn.GetSecrets("802-11-wireless-security")
if err != nil {
return "", fmt.Errorf("failed to retrieve connection secrets for `%s`: %w", ssid, err)
}
secSecrets, ok := secrets["802-11-wireless-security"]
if !ok {
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
}
psk, ok := secSecrets["psk"].(string)
if !ok {
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
}
return FormatWiFiQRString(securityType, ssid, psk), nil
}
func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error { func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
devInfo, err := b.getWifiDeviceForConnection(req.Device) devInfo, err := b.getWifiDeviceForConnection(req.Device)
if err != nil { if err != nil {

View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
@@ -41,10 +40,6 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleSetPreference(conn, req, manager) handleSetPreference(conn, req, manager)
case "network.info": case "network.info":
handleGetNetworkInfo(conn, req, manager) handleGetNetworkInfo(conn, req, manager)
case "network.qrcode":
handleGetNetworkQRCode(conn, req, manager)
case "network.delete-qrcode":
handleDeleteQRCode(conn, req, manager)
case "network.ethernet.info": case "network.ethernet.info":
handleGetWiredNetworkInfo(conn, req, manager) handleGetWiredNetworkInfo(conn, req, manager)
case "network.subscribe": case "network.subscribe":
@@ -325,42 +320,6 @@ func handleGetNetworkInfo(conn net.Conn, req models.Request, manager *Manager) {
models.Respond(conn, req.ID, network) models.Respond(conn, req.ID, network)
} }
func handleGetNetworkQRCode(conn net.Conn, req models.Request, manager *Manager) {
ssid, err := params.String(req.Params, "ssid")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
content, err := manager.GetNetworkQRCode(ssid)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, content)
}
func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) {
path, err := params.String(req.Params, "path")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if !isValidQRCodePath(path) {
models.RespondError(conn, req.ID, "invalid QR code path")
return
}
if err := os.Remove(path); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "QR code file deleted"})
}
func handleGetWiredNetworkInfo(conn net.Conn, req models.Request, manager *Manager) { func handleGetWiredNetworkInfo(conn net.Conn, req models.Request, manager *Manager) {
uuid, err := params.String(req.Params, "uuid") uuid, err := params.String(req.Params, "uuid")
if err != nil { if err != nil {

View File

@@ -6,8 +6,6 @@ import (
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/yeqown/go-qrcode/v2"
"github.com/yeqown/go-qrcode/writer/standard"
) )
func NewManager() (*Manager, error) { func NewManager() (*Manager, error) {
@@ -440,43 +438,6 @@ func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, err
return m.backend.GetWiFiNetworkDetails(ssid) return m.backend.GetWiFiNetworkDetails(ssid)
} }
func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) {
content, err := m.backend.GetWiFiQRCodeContent(ssid)
if err != nil {
return [2]string{}, err
}
qrc, err := qrcode.New(content)
if err != nil {
return [2]string{}, fmt.Errorf("failed to create QR code for `%s`: %w", ssid, err)
}
pathThemed, pathNormal := qrCodePaths(ssid)
wThemed, err := standard.New(
pathThemed,
standard.WithBuiltinImageEncoder(standard.PNG_FORMAT),
standard.WithBgTransparent(),
standard.WithFgColorRGBHex("#ffffff"),
)
if err != nil {
return [2]string{}, fmt.Errorf("failed to create QR code writer: %w", err)
}
if err := qrc.Save(wThemed); err != nil {
return [2]string{}, fmt.Errorf("failed to save QR code for `%s`: %w", ssid, err)
}
wNormal, err := standard.New(pathNormal, standard.WithBuiltinImageEncoder(standard.PNG_FORMAT))
if err != nil {
return [2]string{}, fmt.Errorf("failed to create QR code writer: %w", err)
}
if err := qrc.Save(wNormal); err != nil {
return [2]string{}, fmt.Errorf("failed to save QR code for `%s`: %w", ssid, err)
}
return [2]string{pathThemed, pathNormal}, nil
}
func (m *Manager) ToggleWiFi() error { func (m *Manager) ToggleWiFi() error {
enabled, err := m.backend.GetWiFiEnabled() enabled, err := m.backend.GetWiFiEnabled()
if err != nil { if err != nil {

View File

@@ -1,59 +0,0 @@
package network
import (
"fmt"
"path/filepath"
"regexp"
"strings"
)
const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-"
func FormatWiFiQRString(securityType, ssid, password string) string {
return fmt.Sprintf("WIFI:T:%s;S:%s;P:%s;;", securityType, ssid, password)
}
func qrCodePaths(ssid string) (themed, normal string) {
safe := sanitizeSSIDForPath(ssid)
themed = fmt.Sprintf("%s%s-themed.png", qrCodeTmpPrefix, safe)
normal = fmt.Sprintf("%s%s-normal.png", qrCodeTmpPrefix, safe)
return
}
func isValidQRCodePath(path string) bool {
clean := filepath.Clean(path)
return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png")
}
var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
func sanitizeSSIDForPath(ssid string) string {
return safePathChar.ReplaceAllString(ssid, "_")
}
var iwdVerbatimSSID = regexp.MustCompile(`^[a-zA-Z0-9 _-]+$`)
func iwdConfigPath(ssid string) string {
switch {
case iwdVerbatimSSID.MatchString(ssid):
return fmt.Sprintf("/var/lib/iwd/%s.psk", ssid)
default:
return fmt.Sprintf("/var/lib/iwd/=%x.psk", []byte(ssid))
}
}
func parseIWDPassphrase(data string) (string, error) {
inSecurity := false
for _, line := range strings.Split(data, "\n") {
line = strings.TrimSpace(line)
switch {
case line == "[Security]":
inSecurity = true
case strings.HasPrefix(line, "["):
inSecurity = false
case inSecurity && strings.HasPrefix(line, "Passphrase="):
return strings.TrimPrefix(line, "Passphrase="), nil
}
}
return "", fmt.Errorf("no passphrase found in iwd config")
}

View File

@@ -15,12 +15,10 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -194,24 +192,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "location.") {
if locationManager == nil {
models.RespondError(conn, req.ID, "location manager not initialized")
return
}
location.HandleRequest(conn, req, locationManager)
return
}
if strings.HasPrefix(req.Method, "sysupdate.") {
if sysUpdateManager == nil {
models.RespondError(conn, req.ID, "sysupdate manager not initialized")
return
}
sysupdate.HandleRequest(conn, req, sysUpdateManager)
return
}
switch req.Method { switch req.Method {
case "ping": case "ping":
models.Respond(conn, req.ID, "pong") models.Respond(conn, req.ID, "pong")

View File

@@ -14,7 +14,6 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
@@ -26,13 +25,10 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -74,10 +70,6 @@ var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager var themeModeManager *thememode.Manager
var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager
var sysUpdateManager *sysupdate.Manager
var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client" const dbusClientID = "dms-dbus-client"
@@ -398,44 +390,6 @@ func InitializeThemeModeManager() error {
return nil return nil
} }
func InitializeTrayRecoveryManager() error {
manager, err := trayrecovery.NewManager()
if err != nil {
return err
}
trayRecoveryManager = manager
log.Info("TrayRecovery manager initialized")
return nil
}
func InitializeLocationManager(geoClient geolocation.Client) error {
manager, err := location.NewManager(geoClient)
if err != nil {
log.Warnf("Failed to initialize location manager: %v", err)
return err
}
locationManager = manager
log.Info("Location manager initialized")
return nil
}
func InitializeSysUpdateManager() error {
manager, err := sysupdate.NewManager()
if err != nil {
log.Warnf("Failed to initialize sysupdate manager: %v", err)
return err
}
sysUpdateManager = manager
log.Info("Sysupdate manager initialized")
return nil
}
func handleConnection(conn net.Conn) { func handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
@@ -521,10 +475,6 @@ func getCapabilities() Capabilities {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return Capabilities{Capabilities: caps} return Capabilities{Capabilities: caps}
} }
@@ -587,18 +537,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "theme.auto") caps = append(caps, "theme.auto")
} }
if locationManager != nil {
caps = append(caps, "location")
}
if dbusManager != nil { if dbusManager != nil {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return ServerInfo{ return ServerInfo{
APIVersion: APIVersion, APIVersion: APIVersion,
CLIVersion: CLIVersion, CLIVersion: CLIVersion,
@@ -1266,38 +1208,6 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}() }()
} }
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
wg.Add(1)
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
go func() {
defer wg.Done()
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
initialState := sysUpdateManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-sysupdateChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("dbus") && dbusManager != nil { if shouldSubscribe("dbus") && dbusManager != nil {
wg.Add(1) wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClientID) dbusChan := dbusManager.SubscribeSignals(dbusClientID)
@@ -1394,21 +1304,9 @@ func cleanupManagers() {
if themeModeManager != nil { if themeModeManager != nil {
themeModeManager.Close() themeModeManager.Close()
} }
if trayRecoveryManager != nil {
trayRecoveryManager.Close()
}
if wlContext != nil { if wlContext != nil {
wlContext.Close() wlContext.Close()
} }
if locationManager != nil {
locationManager.Close()
}
if sysUpdateManager != nil {
sysUpdateManager.Close()
}
if geoClientInstance != nil {
geoClientInstance.Close()
}
} }
func Start(printDocs bool) error { func Start(printDocs bool) error {
@@ -1590,9 +1488,6 @@ func Start(printDocs bool) error {
log.Info(" clipboard.getConfig - Get clipboard configuration") log.Info(" clipboard.getConfig - Get clipboard configuration")
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)") log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)") log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
log.Info("Location:")
log.Info(" location.getState - Get current location state")
log.Info(" location.subscribe - Subscribe to location changes (streaming)")
log.Info("") log.Info("")
} }
log.Info("Initializing managers...") log.Info("Initializing managers...")
@@ -1672,49 +1567,6 @@ func Start(printDocs bool) error {
log.Warnf("Wayland manager unavailable: %v", err) log.Warnf("Wayland manager unavailable: %v", err)
} }
if err := InitializeThemeModeManager(); err != nil {
log.Warnf("Theme mode manager unavailable: %v", err)
} else {
notifyCapabilityChange()
go func() {
<-loginctlReady
if loginctlManager == nil {
return
}
themeModeManager.WatchLoginctl(loginctlManager)
}()
}
go func() {
<-loginctlReady
if loginctlManager == nil {
return
}
if err := InitializeTrayRecoveryManager(); err != nil {
log.Warnf("TrayRecovery manager unavailable: %v", err)
} else {
trayRecoveryManager.WatchLoginctl(loginctlManager)
}
}()
go func() {
geoClient := geolocation.NewClient()
geoClientInstance = geoClient
if waylandManager != nil {
waylandManager.SetGeoClient(geoClient)
}
if themeModeManager != nil {
themeModeManager.SetGeoClient(geoClient)
}
if err := InitializeLocationManager(geoClient); err != nil {
log.Warnf("Location manager unavailable: %v", err)
} else {
notifyCapabilityChange()
}
}()
go func() { go func() {
if err := InitializeBluezManager(); err != nil { if err := InitializeBluezManager(); err != nil {
log.Warnf("Bluez manager unavailable: %v", err) log.Warnf("Bluez manager unavailable: %v", err)
@@ -1743,6 +1595,19 @@ func Start(printDocs bool) error {
log.Debugf("WlrOutput manager unavailable: %v", err) log.Debugf("WlrOutput manager unavailable: %v", err)
} }
if err := InitializeThemeModeManager(); err != nil {
log.Warnf("Theme mode manager unavailable: %v", err)
} else {
notifyCapabilityChange()
go func() {
<-loginctlReady
if loginctlManager == nil {
return
}
themeModeManager.WatchLoginctl(loginctlManager)
}()
}
fatalErrChan := make(chan error, 1) fatalErrChan := make(chan error, 1)
if wlrOutputManager != nil { if wlrOutputManager != nil {
go func() { go func() {
@@ -1791,10 +1656,6 @@ func Start(printDocs bool) error {
} }
}() }()
if err := InitializeSysUpdateManager(); err != nil {
log.Warnf("Sysupdate manager unavailable: %v", err)
}
log.Info("") log.Info("")
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities) log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)

View File

@@ -1,96 +0,0 @@
package sysupdate
import (
"context"
"os/exec"
"sync"
)
type Backend interface {
ID() string
DisplayName() string
Repo() RepoKind
IsAvailable(ctx context.Context) bool
NeedsAuth() bool
RunsInTerminal() bool
CheckUpdates(ctx context.Context) ([]Package, error)
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
}
type Selection struct {
System Backend
Overlay []Backend
}
func (s Selection) All() []Backend {
if s.System == nil {
return s.Overlay
}
out := make([]Backend, 0, 1+len(s.Overlay))
out = append(out, s.System)
out = append(out, s.Overlay...)
return out
}
func (s Selection) Info() []BackendInfo {
all := s.All()
out := make([]BackendInfo, 0, len(all))
for _, b := range all {
out = append(out, BackendInfo{
ID: b.ID(),
DisplayName: b.DisplayName(),
Repo: b.Repo(),
NeedsAuth: b.NeedsAuth(),
RunsInTerminal: b.RunsInTerminal(),
})
}
return out
}
var (
registryMu sync.RWMutex
systemCandidates []func() Backend
overlayCandidate []func() Backend
)
func RegisterSystemBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
systemCandidates = append(systemCandidates, factory)
}
func RegisterOverlayBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
overlayCandidate = append(overlayCandidate, factory)
}
func Select(ctx context.Context) Selection {
registryMu.RLock()
sys := append([]func() Backend(nil), systemCandidates...)
ov := append([]func() Backend(nil), overlayCandidate...)
registryMu.RUnlock()
var sel Selection
for _, factory := range sys {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.System = b
break
}
for _, factory := range ov {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.Overlay = append(sel.Overlay, b)
}
return sel
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -1,79 +0,0 @@
package sysupdate
import (
"context"
"os/exec"
"regexp"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &aptBackend{} })
}
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
type aptBackend struct{}
func (aptBackend) ID() string { return "apt" }
func (aptBackend) DisplayName() string { return "APT" }
func (aptBackend) Repo() RepoKind { return RepoSystem }
func (aptBackend) NeedsAuth() bool { return true }
func (aptBackend) RunsInTerminal() bool { return false }
func (aptBackend) IsAvailable(_ context.Context) bool {
return commandExists("apt") || commandExists("apt-get")
}
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
out, err := cmd.Output()
if err != nil {
return nil, err
}
return parseAptUpgradable(string(out)), nil
}
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
bin := "apt-get"
if !commandExists(bin) {
bin = "apt"
}
if opts.DryRun {
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
OnLine: onLine,
})
}
names := pickTargetNames(opts.Targets, "apt", true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "install", "-y", "--only-upgrade"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func parseAptUpgradable(text string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := aptUpgradableLine.FindStringSubmatch(line)
if m == nil {
continue
}
pkgs = append(pkgs, Package{
Name: m[1],
Repo: RepoSystem,
Backend: "apt",
FromVersion: m[3],
ToVersion: m[2],
})
}
return pkgs
}

View File

@@ -1,72 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseAptUpgradable(t *testing.T) {
tests := []struct {
name string
input string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "header line only",
input: `Listing... Done
`,
want: nil,
},
{
name: "single upgradable",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
{
name: "multiple architectures and suites",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "package name with hyphens, dots, plus signs",
input: `Listing... Done
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
want: []Package{
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
},
},
{
name: "non-matching lines ignored",
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseAptUpgradable(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,125 +0,0 @@
package sysupdate
import (
"context"
"errors"
"os/exec"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
}
type dnfBackend struct {
bin string
}
func (b dnfBackend) ID() string { return b.bin }
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
func (b dnfBackend) NeedsAuth() bool { return true }
func (b dnfBackend) RunsInTerminal() bool { return false }
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
if !commandExists(b.bin) {
return false
}
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
return false
}
return true
}
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := dnfListUpgrades(ctx, b.bin)
if err != nil {
return nil, err
}
installed := rpmInstalledVersions(ctx)
return parseDnfList(out, b.bin, installed), nil
}
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.bin, true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", b.bin, "upgrade", "-y"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--quiet")
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 {
return "", nil
}
return "", err
}
func rpmInstalledVersions(ctx context.Context) map[string]string {
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
if err != nil {
return nil
}
m := make(map[string]string)
for line := range strings.SplitSeq(string(out), "\n") {
name, ver, ok := strings.Cut(line, "\t")
if !ok {
continue
}
m[name] = ver
}
return m
}
func parseDnfList(text, backendID string, installed map[string]string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
nameArch := fields[0]
version := fields[1]
dot := strings.LastIndex(nameArch, ".")
if dot <= 0 {
continue
}
if !looksLikeRpmVersion(version) {
continue
}
name := nameArch[:dot]
pkgs = append(pkgs, Package{
Name: nameArch,
Repo: RepoSystem,
Backend: backendID,
FromVersion: installed[name],
ToVersion: version,
})
}
return pkgs
}
func looksLikeRpmVersion(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r >= '0' && r <= '9' {
return true
}
}
return false
}

View File

@@ -1,80 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseDnfList(t *testing.T) {
tests := []struct {
name string
input string
backendID string
installed map[string]string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "single package with installed cross-ref",
input: "bash.x86_64 5.2.40-1.fc41 updates",
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "noarch package and missing installed entry",
input: `bash.x86_64 5.2.40-1.fc41 updates
fonts-misc.noarch 1.0.5-2.fc41 updates`,
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
},
},
{
name: "skips header rows",
input: `Available
Upgrades
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "skips lines with too few fields",
input: "incomplete",
backendID: "dnf",
want: nil,
},
{
name: "skips dnf5 banner / column header lines",
input: `Updates available
Last metadata expiration check: 0:01:23 ago on Tue Apr 29 14:00:00 2026.
Package Version Repository Size
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDnfList(tt.input, tt.backendID, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,169 +0,0 @@
package sysupdate
import (
"context"
"os/exec"
"strings"
)
func init() {
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
}
type flatpakBackend struct{}
func (flatpakBackend) ID() string { return "flatpak" }
func (flatpakBackend) DisplayName() string { return "Flatpak" }
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
func (flatpakBackend) NeedsAuth() bool { return false }
func (flatpakBackend) RunsInTerminal() bool { return false }
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name")
out, err := cmd.Output()
if err != nil {
return nil, err
}
installed := flatpakInstalled(ctx)
return parseFlatpakUpdates(string(out), installed), nil
}
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").Output()
if err != nil {
return nil
}
m := make(map[string]flatpakInstalledEntry)
for line := range strings.SplitSeq(string(out), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
entry := flatpakInstalledEntry{}
if len(fields) > 1 {
entry.version = fields[1]
}
if len(fields) > 2 {
entry.branch = fields[2]
}
if len(fields) > 3 {
entry.commit = fields[3]
}
key := appID
if entry.branch != "" {
key = appID + "//" + entry.branch
}
m[key] = entry
}
return m
}
type flatpakInstalledEntry struct {
version string
branch string
commit string
}
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine})
}
refs := flatpakTargetRefs(opts.Targets)
if len(refs) == 0 {
return nil
}
argv := append([]string{"flatpak", "update", "-y", "--noninteractive"}, refs...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func flatpakTargetRefs(targets []Package) []string {
out := make([]string, 0, len(targets))
for _, p := range targets {
if p.Backend != "flatpak" {
continue
}
ref := p.Ref
if ref == "" {
ref = p.Name
}
out = append(out, ref)
}
return out
}
func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
version, branch, commit := "", "", ""
if len(fields) > 1 {
version = fields[1]
}
if len(fields) > 2 {
branch = fields[2]
}
if len(fields) > 3 {
commit = fields[3]
}
display := appID
if len(fields) > 4 && fields[4] != "" {
display = fields[4]
}
key := appID
if branch != "" {
key = appID + "//" + branch
}
inst := installed[key]
if inst.commit != "" && commit != "" && strings.HasPrefix(commit, inst.commit) {
continue
}
from, to := flatpakVersionPair(inst.version, inst.commit, version, commit)
ref := appID
if branch != "" {
ref = appID + "//" + branch
}
pkgs = append(pkgs, Package{
Name: display,
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: from,
ToVersion: to,
Ref: ref,
})
}
return pkgs
}
func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) {
if remoteVer != "" {
return installedVer, remoteVer
}
return shortCommit(installedCommit), shortCommit(remoteCommit)
}
func shortCommit(c string) string {
if len(c) > 8 {
return c[:8]
}
return c
}

View File

@@ -1,150 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseFlatpakUpdates(t *testing.T) {
tests := []struct {
name string
input string
installed map[string]flatpakInstalledEntry
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "real flathub-style row with empty version, falls back to commit",
// columns: application,version,branch,commit,name
input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord",
installed: map[string]flatpakInstalledEntry{
"com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"},
},
want: []Package{
{
Name: "Discord",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "8b16fa1a",
ToVersion: "43a1e5d2",
Ref: "com.discordapp.Discord//stable",
},
},
},
{
name: "remote provides version, installed version known",
input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App",
installed: map[string]flatpakInstalledEntry{
"com.example.App//stable": {version: "1.4.2"},
},
want: []Package{
{
Name: "Example App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "1.4.2",
ToVersion: "1.5.0",
Ref: "com.example.App//stable",
},
},
},
{
name: "no installed entry, remote has no version, falls back to commit on both sides",
input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform",
installed: nil,
want: []Package{
{
Name: "gnome platform",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "badcd4af",
Ref: "org.gnome.Platform//49",
},
},
},
{
name: "missing display name falls back to application id",
input: "com.example.NoName\t2.0\tstable\tabcdef123456\t",
want: []Package{
{
Name: "com.example.NoName",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "2.0",
Ref: "com.example.NoName//stable",
},
},
},
{
name: "skips blank lines and rows with empty application id",
input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App",
want: []Package{
{
Name: "Real App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "1.0",
Ref: "org.real.App//stable",
},
},
},
{
name: "skips phantom updates where remote commit matches installed",
input: "com.phantom.App\t\tstable\tabc12345deadbeef\tPhantom",
installed: map[string]flatpakInstalledEntry{
"com.phantom.App//stable": {commit: "abc12345"},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFlatpakUpdates(tt.input, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}
func TestFlatpakVersionPair(t *testing.T) {
tests := []struct {
name string
installedVer, installedCommit, remoteVer, remoteCommit string
wantFrom, wantTo string
}{
{
name: "remote has version - prefer versions",
installedVer: "1.0.0", remoteVer: "1.1.0",
wantFrom: "1.0.0", wantTo: "1.1.0",
},
{
name: "remote has no version - both sides fall to short commit",
installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855",
remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586",
wantFrom: "8b16fa1a", wantTo: "43a1e5d2",
},
{
name: "short commits left as-is",
installedCommit: "abc123", remoteCommit: "def456",
wantFrom: "abc123", wantTo: "def456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit)
if from != tt.wantFrom || to != tt.wantTo {
t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo)
}
})
}
}

View File

@@ -1,258 +0,0 @@
package sysupdate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func init() {
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
}
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
type pacmanBackend struct{}
func (pacmanBackend) ID() string { return "pacman" }
func (pacmanBackend) DisplayName() string { return "Pacman" }
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
func (pacmanBackend) NeedsAuth() bool { return true }
func (pacmanBackend) RunsInTerminal() bool { return false }
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
return parseArchUpdates(out, b.ID(), RepoSystem), nil
}
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.ID(), opts.IncludeAUR)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "pacman", "-Sy", "--noconfirm", "--needed"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
type archHelperBackend struct {
id string
}
func (b archHelperBackend) ID() string { return b.id }
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
func (b archHelperBackend) NeedsAuth() bool { return true }
func (b archHelperBackend) RunsInTerminal() bool {
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
}
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
func (b archHelperBackend) DisplayName() string {
switch b.id {
case "paru":
return "Paru (AUR)"
case "yay":
return "Yay (AUR)"
default:
return b.id
}
}
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
repoOut, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
if err != nil {
return nil, err
}
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
return pkgs, nil
}
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.id, opts.IncludeAUR)
if len(names) == 0 {
return nil
}
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
argv := append([]string{"pkexec", b.id, "-Sy", "--noconfirm", "--needed"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
cmd := fmt.Sprintf("%s -Sy --noconfirm --needed %s", b.id, strings.Join(names, " "))
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
}
func pickTargetNames(targets []Package, backendID string, includeAUR bool) []string {
out := make([]string, 0, len(targets))
for _, p := range targets {
if p.Backend != backendID {
continue
}
if !includeAUR && p.Repo == RepoAUR {
continue
}
out = append(out, p.Name)
}
return out
}
func pacmanRepoUpdates(ctx context.Context) (string, error) {
if commandExists("checkupdates") {
return capturePermissive(ctx, "checkupdates")
}
if commandExists("fakeroot") {
out, err := pacmanCheckViaFakeroot(ctx)
if err == nil {
return out, nil
}
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
}
return capturePermissive(ctx, "pacman", "-Qu")
}
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
dir, err := pacmanPrivateDB()
if err != nil {
return "", err
}
if err := seedPacmanDB(dir); err != nil {
return "", fmt.Errorf("seed sync db: %w", err)
}
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
if out, err := refresh.CombinedOutput(); err != nil {
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
}
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
}
func seedPacmanDB(dir string) error {
syncDir := filepath.Join(dir, "sync")
if err := os.MkdirAll(syncDir, 0o755); err != nil {
return err
}
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
if err != nil {
return err
}
for _, src := range dbs {
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
return err
}
}
localLink := filepath.Join(dir, "local")
if fi, err := os.Lstat(localLink); err == nil {
if fi.Mode()&os.ModeSymlink == 0 {
if err := os.RemoveAll(localLink); err != nil {
return err
}
} else {
return nil
}
}
return os.Symlink("/var/lib/pacman/local", localLink)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func pacmanPrivateDB() (string, error) {
tmp := os.Getenv("TMPDIR")
if tmp == "" {
tmp = "/tmp"
}
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 1, 2:
return string(out), nil
}
}
return "", err
}
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := archUpdateLine.FindStringSubmatch(line)
if m == nil {
continue
}
p := Package{
Name: m[1],
Repo: repo,
Backend: backendID,
FromVersion: m[2],
ToVersion: m[3],
}
if repo == RepoAUR {
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}

View File

@@ -1,114 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseArchUpdates(t *testing.T) {
tests := []struct {
name string
input string
backendID string
repo RepoKind
want []Package
}{
{
name: "empty",
input: "",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "whitespace only",
input: " \n\n \n",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "single repo update",
input: "bat 0.26.0-1 -> 0.26.1-2",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "multiple updates with epoch versions",
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
linux 6.18.0-1 -> 6.18.1-1
mesa 26.4.0-1 -> 26.4.1-1`,
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
},
},
{
name: "AUR update with changelog url",
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
backendID: "paru",
repo: RepoAUR,
want: []Package{
{
Name: "google-chrome",
Repo: RepoAUR,
Backend: "paru",
FromVersion: "147.0.7727.116-1",
ToVersion: "147.0.7727.137-1",
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
},
},
},
{
name: "git package latest-commit marker",
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
backendID: "yay",
repo: RepoAUR,
want: []Package{
{
Name: "niri-git",
Repo: RepoAUR,
Backend: "yay",
FromVersion: "26.04.r5.ga85b922-1",
ToVersion: "latest-commit",
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
},
},
},
{
name: "skips lines that don't match arrow format",
input: `bat 0.26.0-1 -> 0.26.1-2
this is not an update line
foo`,
backendID: "pacman",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "extra whitespace tolerated",
input: " bat 0.26.0-1 -> 0.26.1-2 ",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,125 +0,0 @@
package sysupdate
import (
"context"
"encoding/json"
"errors"
"os/exec"
)
const ostreeExitUpdateAvailable = 77
func init() {
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
}
type rpmOstreeBackend struct{}
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
func (rpmOstreeBackend) NeedsAuth() bool { return true }
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
if !commandExists("rpm-ostree") {
return false
}
return ostreeBooted(ctx)
}
type ostreeStatus struct {
Deployments []ostreeDeployment `json:"deployments"`
CachedUpdate *ostreeCached `json:"cached-update"`
}
type ostreeDeployment struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Booted bool `json:"booted"`
}
type ostreeCached struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Checksum string `json:"checksum"`
}
func ostreeBooted(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
out, err := cmd.Output()
if err != nil {
return false
}
var s ostreeStatus
if err := json.Unmarshal(out, &s); err != nil {
return false
}
return len(s.Deployments) > 0
}
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
if err := cmd.Run(); err != nil {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
return nil, err
}
}
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
if err != nil {
return nil, err
}
return parseRpmOstreeStatus(statusOut)
}
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
var s ostreeStatus
if err := json.Unmarshal(statusOut, &s); err != nil {
return nil, err
}
if s.CachedUpdate == nil {
return nil, nil
}
booted := bootedDeployment(s.Deployments)
from := ""
if booted != nil {
from = booted.Version
}
if from == s.CachedUpdate.Version {
return nil, nil
}
name := s.CachedUpdate.Origin
if name == "" {
name = "system"
}
return []Package{{
Name: name,
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: from,
ToVersion: s.CachedUpdate.Version,
}}, nil
}
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
for i := range deps {
if deps[i].Booted {
return &deps[i]
}
}
return nil
}
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
argv := []string{"rpm-ostree", "upgrade"}
if opts.DryRun {
argv = append(argv, "--check")
}
return Run(ctx, argv, RunOptions{OnLine: onLine})
}

View File

@@ -1,104 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseRpmOstreeStatus(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "no cached update",
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
want: nil,
},
{
name: "cached update available, booted version differs",
input: `{
"deployments": [
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
],
"cached-update": {
"origin": "fedora:fedora/x86_64/silverblue",
"version": "39.20240115.0",
"checksum": "abc123"
}
}`,
want: []Package{
{
Name: "fedora:fedora/x86_64/silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "39.20240101.0",
ToVersion: "39.20240115.0",
},
},
},
{
name: "cached update equals booted version (no real update)",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": true}],
"cached-update": {"origin": "x", "version": "39.20240101.0"}
}`,
want: nil,
},
{
name: "no booted deployment falls back to empty from",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": false}],
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
}`,
want: []Package{
{
Name: "fedora:silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "",
ToVersion: "39.20240115.0",
},
},
},
{
name: "missing origin defaults to system",
input: `{
"deployments": [{"version": "1.0", "booted": true}],
"cached-update": {"version": "1.1"}
}`,
want: []Package{
{
Name: "system",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "1.0",
ToVersion: "1.1",
},
},
},
{
name: "malformed JSON",
input: `{not json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseRpmOstreeStatus([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,83 +0,0 @@
package sysupdate
import (
"context"
"encoding/xml"
"errors"
"os/exec"
)
func init() {
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
}
type zypperBackend struct{}
func (zypperBackend) ID() string { return "zypper" }
func (zypperBackend) DisplayName() string { return "Zypper" }
func (zypperBackend) Repo() RepoKind { return RepoSystem }
func (zypperBackend) NeedsAuth() bool { return true }
func (zypperBackend) RunsInTerminal() bool { return false }
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
type zypperUpdateList struct {
XMLName xml.Name `xml:"stream"`
Updates []zypperUpdate `xml:"update-list>update"`
}
type zypperUpdate struct {
Name string `xml:"name,attr"`
Edition string `xml:"edition,attr"`
EditionOld string `xml:"edition-old,attr"`
Kind string `xml:"kind,attr"`
}
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
out, err := cmd.Output()
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 100, 101, 102, 103:
err = nil
}
}
if err != nil {
return nil, err
}
}
return parseZypperXML(out)
}
func parseZypperXML(out []byte) ([]Package, error) {
var list zypperUpdateList
if err := xml.Unmarshal(out, &list); err != nil {
return nil, err
}
pkgs := make([]Package, 0, len(list.Updates))
for _, u := range list.Updates {
if u.Kind != "" && u.Kind != "package" {
continue
}
pkgs = append(pkgs, Package{
Name: u.Name,
Repo: RepoSystem,
Backend: "zypper",
FromVersion: u.EditionOld,
ToVersion: u.Edition,
})
}
return pkgs, nil
}
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, "zypper", true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "zypper", "--non-interactive", "update"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}

View File

@@ -1,80 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseZypperXML(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "empty stream",
input: `<?xml version="1.0"?><stream><update-list></update-list></stream>`,
want: []Package{},
},
{
name: "single package update",
input: `<?xml version="1.0"?>
<stream>
<update-list>
<update name="zsh" edition="5.9-6" edition-old="5.9-5" kind="package" arch="x86_64">
<source url="https://download.opensuse.org/" alias="repo-oss"/>
</update>
</update-list>
</stream>`,
want: []Package{
{Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "skips non-package kinds",
input: `<?xml version="1.0"?>
<stream>
<update-list>
<update name="foo" edition="2.0" edition-old="1.0" kind="package"/>
<update name="security-patch" edition="1" edition-old="0" kind="patch"/>
<update name="bar" edition="3.0" edition-old="2.0" kind="package"/>
</update-list>
</stream>`,
want: []Package{
{Name: "foo", Repo: RepoSystem, Backend: "zypper", FromVersion: "1.0", ToVersion: "2.0"},
{Name: "bar", Repo: RepoSystem, Backend: "zypper", FromVersion: "2.0", ToVersion: "3.0"},
},
},
{
name: "treats missing kind as package",
input: `<?xml version="1.0"?>
<stream><update-list>
<update name="kernel" edition="6.18.1-1" edition-old="6.18.0-1"/>
</update-list></stream>`,
want: []Package{
{Name: "kernel", Repo: RepoSystem, Backend: "zypper", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
},
},
{
name: "malformed XML returns error",
input: `not xml at all`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseZypperXML([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseZypperXML() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseZypperXML() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,125 +0,0 @@
package sysupdate
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"sync"
"syscall"
)
type RunOptions struct {
Env []string
OnLine func(string)
}
func Run(ctx context.Context, argv []string, opts RunOptions) error {
if len(argv) == 0 {
return fmt.Errorf("sysupdate.Run: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
if len(opts.Env) > 0 {
cmd.Env = append(cmd.Environ(), opts.Env...)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
var wg sync.WaitGroup
wg.Add(2)
go pump(stdout, opts.OnLine, &wg)
go pump(stderr, opts.OnLine, &wg)
wg.Wait()
return cmd.Wait()
}
func pump(r io.Reader, onLine func(string), wg *sync.WaitGroup) {
defer wg.Done()
if onLine == nil {
_, _ = io.Copy(io.Discard, r)
return
}
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
onLine(scanner.Text())
}
}
func Capture(ctx context.Context, argv []string) (string, error) {
if len(argv) == 0 {
return "", fmt.Errorf("sysupdate.Capture: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
return string(out), err
}
func findTerminal(override string) string {
if override != "" && commandExists(override) {
return override
}
if t := os.Getenv("TERMINAL"); t != "" && commandExists(t) {
return t
}
for _, t := range []string{"ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"} {
if commandExists(t) {
return t
}
}
return ""
}
func wrapInTerminal(term, title, shellCmd string) []string {
const appID = "dms-sysupdate"
banner := fmt.Sprintf(
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
title, shellCmd,
)
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer
switch term {
case "kitty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "alacritty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "foot":
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "ghostty":
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "xterm":
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
case "konsole":
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
case "gnome-terminal":
return []string{term, "--title=" + title, "--", "sh", "-c", full}
default:
return []string{term, "-e", "sh", "-c", full}
}
}

View File

@@ -1,55 +0,0 @@
package sysupdate
import (
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
switch req.Method {
case "sysupdate.getState":
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.refresh":
force := params.BoolOpt(req.Params, "force", false)
m.Refresh(RefreshOptions{Force: force})
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.upgrade":
handleUpgrade(conn, req, m)
case "sysupdate.cancel":
m.Cancel()
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.acquire":
m.Acquire()
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
case "sysupdate.release":
m.Release()
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
case "sysupdate.setInterval":
seconds, err := params.Int(req.Params, "seconds")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
m.SetInterval(seconds)
models.Respond(conn, req.ID, m.GetState())
default:
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
}
}
func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
opts := UpgradeOptions{
IncludeFlatpak: params.BoolOpt(req.Params, "includeFlatpak", true),
IncludeAUR: params.BoolOpt(req.Params, "includeAUR", true),
DryRun: params.BoolOpt(req.Params, "dry", false),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""),
}
if err := m.Upgrade(opts); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, m.GetState())
}

View File

@@ -1,506 +0,0 @@
package sysupdate
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
const (
defaultIntervalSeconds = 30 * 60
minIntervalSeconds = 5 * 60
recentLogCapacity = 200
checkTimeout = 5 * time.Minute
upgradeTimeout = 30 * time.Minute
)
type Manager struct {
mu sync.RWMutex
state State
subscribers syncmap.Map[string, chan State]
selection Selection
notifyDirty chan struct{}
stopChan chan struct{}
notifierWG sync.WaitGroup
schedulerWG sync.WaitGroup
acquireCount int32
wakeSched chan struct{}
refreshSerial sync.Mutex
opMu sync.Mutex
opCtx context.Context
opCancel context.CancelFunc
}
func NewManager() (*Manager, error) {
m := &Manager{
notifyDirty: make(chan struct{}, 1),
stopChan: make(chan struct{}),
wakeSched: make(chan struct{}, 1),
}
m.state = State{
Phase: PhaseIdle,
IntervalSeconds: defaultIntervalSeconds,
Backends: []BackendInfo{},
Packages: []Package{},
}
id, pretty := readOSRelease()
m.state.Distro = id
m.state.DistroPretty = pretty
m.selection = Select(context.Background())
m.state.Backends = m.selection.Info()
if len(m.state.Backends) == 0 {
m.state.Error = &ErrorInfo{
Code: ErrCodeNoBackend,
Message: "no supported package manager found",
Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak",
}
}
m.notifierWG.Add(1)
go m.notifier()
m.schedulerWG.Add(1)
go m.scheduler()
go m.runRefresh(context.Background())
return m, nil
}
func (m *Manager) GetState() State {
m.mu.RLock()
defer m.mu.RUnlock()
return cloneState(m.state)
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 16)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) Close() {
select {
case <-m.stopChan:
return
default:
close(m.stopChan)
}
m.opMu.Lock()
if m.opCancel != nil {
m.opCancel()
}
m.opMu.Unlock()
select {
case m.wakeSched <- struct{}{}:
default:
}
m.schedulerWG.Wait()
m.notifierWG.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
func (m *Manager) SetInterval(seconds int) {
if seconds < minIntervalSeconds {
seconds = minIntervalSeconds
}
m.mu.Lock()
m.state.IntervalSeconds = seconds
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) Refresh(opts RefreshOptions) {
m.mu.RLock()
phase := m.state.Phase
m.mu.RUnlock()
switch {
case phase == PhaseUpgrading:
return
case phase == PhaseRefreshing && !opts.Force:
m.refreshSerial.Lock()
m.refreshSerial.Unlock()
return
}
m.runRefresh(context.Background())
}
func (m *Manager) Upgrade(opts UpgradeOptions) error {
if len(m.selection.All()) == 0 {
return errors.New("no backend available")
}
m.opMu.Lock()
if m.opCancel != nil {
m.opMu.Unlock()
return errors.New("operation already running")
}
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
m.opCtx = ctx
m.opCancel = cancel
m.opMu.Unlock()
go m.runUpgrade(ctx, opts)
return nil
}
func (m *Manager) Cancel() {
m.opMu.Lock()
cancel := m.opCancel
m.opMu.Unlock()
if cancel == nil {
return
}
cancel()
}
func (m *Manager) Acquire() {
first := atomic.AddInt32(&m.acquireCount, 1) == 1
select {
case m.wakeSched <- struct{}{}:
default:
}
if first {
go m.runRefresh(context.Background())
}
}
func (m *Manager) Release() {
if atomic.AddInt32(&m.acquireCount, -1) < 0 {
atomic.StoreInt32(&m.acquireCount, 0)
}
}
func (m *Manager) scheduler() {
defer m.schedulerWG.Done()
for {
if atomic.LoadInt32(&m.acquireCount) == 0 {
select {
case <-m.stopChan:
return
case <-m.wakeSched:
}
continue
}
m.mu.RLock()
interval := m.state.IntervalSeconds
m.mu.RUnlock()
if interval < minIntervalSeconds {
interval = minIntervalSeconds
}
t := time.NewTimer(time.Duration(interval) * time.Second)
select {
case <-m.stopChan:
t.Stop()
return
case <-m.wakeSched:
t.Stop()
case <-t.C:
m.runRefresh(context.Background())
}
}
}
func (m *Manager) runRefresh(parent context.Context) {
m.refreshSerial.Lock()
defer m.refreshSerial.Unlock()
if len(m.selection.All()) == 0 {
return
}
ctx, cancel := context.WithTimeout(parent, checkTimeout)
defer cancel()
m.mu.Lock()
if m.state.Phase == PhaseUpgrading {
m.mu.Unlock()
return
}
m.state.Phase = PhaseRefreshing
m.state.Error = nil
m.state.RecentLog = nil
m.mu.Unlock()
m.markDirty()
type backendResult struct {
pkgs []Package
err error
}
backends := m.selection.All()
results := make([]backendResult, len(backends))
var wg sync.WaitGroup
for i, b := range backends {
wg.Add(1)
go func(i int, b Backend) {
defer wg.Done()
pkgs, err := b.CheckUpdates(ctx)
results[i] = backendResult{pkgs: pkgs, err: err}
}(i, b)
}
wg.Wait()
now := time.Now().Unix()
m.mu.Lock()
m.state.LastCheckUnix = now
m.state.Packages = m.state.Packages[:0]
var firstErr error
for i, r := range results {
if r.err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
}
continue
}
m.state.Packages = append(m.state.Packages, r.pkgs...)
}
m.state.Count = len(m.state.Packages)
if firstErr != nil {
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
} else {
m.state.Phase = PhaseIdle
m.state.LastSuccessUnix = now
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
defer func() {
m.opMu.Lock()
if m.opCancel != nil {
m.opCancel = nil
m.opCtx = nil
}
m.opMu.Unlock()
}()
if opts.CustomCommand != "" {
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
return
}
backends := upgradeBackends(m.selection, opts)
if len(backends) == 0 {
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
return
}
if len(opts.Targets) == 0 {
m.mu.RLock()
opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock()
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
m.state.OperationID = opID
m.state.OperationStarted = time.Now().Unix()
m.state.RecentLog = m.state.RecentLog[:0]
m.state.Error = nil
m.mu.Unlock()
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
for _, b := range backends {
m.appendLog(fmt.Sprintf("== %s ==", b.DisplayName()))
if err := b.Upgrade(ctx, opts, onLine); err != nil {
code := ErrCodeBackendFailed
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
code = ErrCodeTimeout
} else if errors.Is(ctx.Err(), context.Canceled) {
code = ErrCodeCancelled
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: fmt.Sprintf("%s: %v", b.ID(), err)}
m.mu.Unlock()
m.markDirty()
return
}
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
}
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
term := findTerminal(terminalOverride)
if term == "" {
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
return
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
m.state.OperationID = opID
m.state.OperationStarted = time.Now().Unix()
m.state.RecentLog = m.state.RecentLog[:0]
m.state.Error = nil
m.mu.Unlock()
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
code = ErrCodeTimeout
case errors.Is(ctx.Err(), context.Canceled):
code = ErrCodeCancelled
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
return
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
}
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
var out []Backend
if sel.System != nil {
out = append(out, sel.System)
}
for _, b := range sel.Overlay {
switch {
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
continue
}
out = append(out, b)
}
return out
}
func (m *Manager) appendLog(line string) {
m.mu.Lock()
if cap(m.state.RecentLog) == 0 {
m.state.RecentLog = make([]string, 0, recentLogCapacity)
}
if len(m.state.RecentLog) >= recentLogCapacity {
copy(m.state.RecentLog, m.state.RecentLog[1:])
m.state.RecentLog = m.state.RecentLog[:recentLogCapacity-1]
}
m.state.RecentLog = append(m.state.RecentLog, line)
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) setError(code ErrorCode, msg string) {
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: msg}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) markDirty() {
select {
case m.notifyDirty <- struct{}{}:
default:
}
}
func (m *Manager) notifier() {
defer m.notifierWG.Done()
for {
select {
case <-m.stopChan:
return
case <-m.notifyDirty:
snap := m.GetState()
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- snap:
default:
}
return true
})
}
}
}
func cloneState(s State) State {
out := s
out.Backends = append([]BackendInfo(nil), s.Backends...)
out.Packages = append([]Package(nil), s.Packages...)
out.RecentLog = append([]string(nil), s.RecentLog...)
if s.Error != nil {
errCopy := *s.Error
out.Error = &errCopy
}
return out
}
func readOSRelease() (id, pretty string) {
f, err := os.Open("/etc/os-release")
if err != nil {
return "", ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
k, v, ok := strings.Cut(scanner.Text(), "=")
if !ok {
continue
}
v = strings.Trim(v, "\"")
switch k {
case "ID":
id = v
case "PRETTY_NAME":
pretty = v
}
}
if err := scanner.Err(); err != nil {
log.Debugf("[sysupdate] read os-release: %v", err)
}
return id, pretty
}

View File

@@ -1,86 +0,0 @@
package sysupdate
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseRefreshing Phase = "refreshing"
PhaseUpgrading Phase = "upgrading"
PhaseError Phase = "error"
)
type RepoKind string
const (
RepoSystem RepoKind = "system"
RepoAUR RepoKind = "aur"
RepoFlatpak RepoKind = "flatpak"
RepoOSTree RepoKind = "ostree"
)
type ErrorCode string
const (
ErrCodeNone ErrorCode = ""
ErrCodeNoBackend ErrorCode = "no-backend"
ErrCodeBusy ErrorCode = "busy"
ErrCodeBackendFailed ErrorCode = "backend-failed"
ErrCodeTimeout ErrorCode = "timeout"
ErrCodeCancelled ErrorCode = "cancelled"
ErrCodeInvalidRequest ErrorCode = "invalid-request"
)
type Package struct {
Name string `json:"name"`
Repo RepoKind `json:"repo"`
Backend string `json:"backend"`
FromVersion string `json:"fromVersion,omitempty"`
ToVersion string `json:"toVersion,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
ChangelogURL string `json:"changelogUrl,omitempty"`
Ref string `json:"-"`
}
type BackendInfo struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Repo RepoKind `json:"repo"`
NeedsAuth bool `json:"needsAuth"`
RunsInTerminal bool `json:"runsInTerminal"`
}
type ErrorInfo struct {
Code ErrorCode `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Hint string `json:"hint,omitempty"`
}
type State struct {
Phase Phase `json:"phase"`
Distro string `json:"distro,omitempty"`
DistroPretty string `json:"distroPretty,omitempty"`
Backends []BackendInfo `json:"backends"`
Packages []Package `json:"packages"`
Count int `json:"count"`
IntervalSeconds int `json:"intervalSeconds"`
LastCheckUnix int64 `json:"lastCheckUnix,omitempty"`
LastSuccessUnix int64 `json:"lastSuccessUnix,omitempty"`
NextCheckUnix int64 `json:"nextCheckUnix,omitempty"`
OperationID string `json:"operationId,omitempty"`
OperationStarted int64 `json:"operationStartedUnix,omitempty"`
RecentLog []string `json:"recentLog,omitempty"`
Error *ErrorInfo `json:"error,omitempty"`
}
type UpgradeOptions struct {
IncludeFlatpak bool
IncludeAUR bool
DryRun bool
CustomCommand string
Terminal string
Targets []Package
}
type RefreshOptions struct {
Force bool
}

View File

@@ -5,7 +5,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
@@ -33,8 +32,6 @@ type Manager struct {
cachedIPLat *float64 cachedIPLat *float64
cachedIPLon *float64 cachedIPLon *float64
geoClient geolocation.Client
stopChan chan struct{} stopChan chan struct{}
updateTrigger chan struct{} updateTrigger chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
@@ -314,10 +311,6 @@ func (m *Manager) getConfig() Config {
return m.config return m.config
} }
func (m *Manager) SetGeoClient(client geolocation.Client) {
m.geoClient = client
}
func (m *Manager) getLocation(config Config) (*float64, *float64) { func (m *Manager) getLocation(config Config) (*float64, *float64) {
if config.Latitude != nil && config.Longitude != nil { if config.Latitude != nil && config.Longitude != nil {
return config.Latitude, config.Longitude return config.Latitude, config.Longitude
@@ -325,9 +318,6 @@ func (m *Manager) getLocation(config Config) (*float64, *float64) {
if !config.UseIPLocation { if !config.UseIPLocation {
return nil, nil return nil, nil
} }
if m.geoClient == nil {
return nil, nil
}
m.locationMutex.RLock() m.locationMutex.RLock()
if m.cachedIPLat != nil && m.cachedIPLon != nil { if m.cachedIPLat != nil && m.cachedIPLon != nil {
@@ -337,17 +327,17 @@ func (m *Manager) getLocation(config Config) (*float64, *float64) {
} }
m.locationMutex.RUnlock() m.locationMutex.RUnlock()
location, err := m.geoClient.GetLocation() lat, lon, err := wayland.FetchIPLocation()
if err != nil { if err != nil {
return nil, nil return nil, nil
} }
m.locationMutex.Lock() m.locationMutex.Lock()
m.cachedIPLat = &location.Latitude m.cachedIPLat = lat
m.cachedIPLon = &location.Longitude m.cachedIPLon = lon
m.locationMutex.Unlock() m.locationMutex.Unlock()
return m.cachedIPLat, m.cachedIPLon return lat, lon
} }
func statesEqual(a, b *State) bool { func statesEqual(a, b *State) bool {

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