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

Compare commits

...

228 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
318 changed files with 26720 additions and 8748 deletions

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
## DankMaterialShell Bug Report
Limit your report to one issue per submission unless closely related
Limit your report to one issue per submission unless similarly related
- type: dropdown
id: compositor
attributes:
@@ -53,9 +53,9 @@ body:
validations:
required: true
- type: dropdown
id: original_installation_method_different
id: original_installation_method
attributes:
label: Was your original Installation method different?
label: Was this your original Installation method?
options:
- "Yes"
- No (specify below)
@@ -73,7 +73,7 @@ body:
id: dms_doctor
attributes:
label: dms doctor -vC
description: Output of `dms doctor -vC` command — paste between the lines below to keep it collapsed in the issue
description: Output of `dms doctor -vC` command — paste between the details tags below to keep it collapsed in the issue
placeholder: Paste the output of `dms doctor -vC` here
value: |
<details>

View File

@@ -10,6 +10,7 @@ on:
options:
- dms
- dms-git
- dms-greeter
- all
default: "dms"
rebuild_release:
@@ -72,12 +73,27 @@ jobs:
fi
}
# Helper function to check dms-greeter stable tag
check_dms_greeter_stable() {
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:danklinux/dms-greeter/dms-greeter.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs | sed 's/^v//')
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "v$OBS_VERSION" ]]; then
echo "📋 dms-greeter: Tag $LATEST_TAG already exists, skipping"
return 1 # No update needed
else
echo "📋 dms-greeter: New tag ${LATEST_TAG:-unknown} (OBS has ${OBS_VERSION:-none})"
return 0 # Update needed
fi
}
# Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}"
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag selected or pushed - always update stable package
echo "packages=dms" >> $GITHUB_OUTPUT
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then
# Run from tag with no package specified - update both stable packages
echo "packages=dms dms-greeter" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
@@ -112,6 +128,10 @@ jobs:
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
fi
if check_dms_greeter_stable; then
PACKAGES_TO_UPDATE+=("dms-greeter")
[[ -n "$LATEST_TAG" ]] && echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
@@ -144,6 +164,18 @@ jobs:
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms-greeter" ]]; then
if check_dms_greeter_stable; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
else
# Unknown package - proceed anyway
echo "packages=$PKG" >> $GITHUB_OUTPUT
@@ -171,15 +203,8 @@ jobs:
- name: Determine packages to update
id: packages
run: |
# Check if GITHUB_REF points to a tag (works for both push events and workflow_dispatch with tag selected)
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag selected or pushed - use the tag from GITHUB_REF
echo "packages=dms" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using tag from GITHUB_REF: $VERSION"
# Check if check-updates already determined a version (from auto-detection)
elif [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
# Use check-updates outputs when available
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
# Use version from check-updates job
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
@@ -191,40 +216,16 @@ jobs:
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
# Manual workflow dispatch
# Determine version for dms stable
if [[ "${{ github.event.inputs.package }}" == "dms" ]]; then
# Use github.ref if tag selected, otherwise auto-detect latest
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using tag from GITHUB_REF: $VERSION"
# Determine version for dms stable and dms-greeter using the API
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "dms-greeter" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Using latest release from API: $LATEST_TAG"
else
# Auto-detect latest release for dms
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Auto-detected latest release: $LATEST_TAG"
else
echo "ERROR: Could not auto-detect latest release"
exit 1
fi
fi
elif [[ "${{ github.event.inputs.package }}" == "all" ]]; then
# Use github.ref if tag selected, otherwise auto-detect latest
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using tag from GITHUB_REF: $VERSION"
else
# Auto-detect latest release for "all"
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Auto-detected latest release: $LATEST_TAG"
else
echo "ERROR: Could not auto-detect latest release"
exit 1
fi
echo "ERROR: Could not fetch latest release from API"
exit 1
fi
fi
@@ -283,55 +284,66 @@ jobs:
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
} > "distro/debian/dms-git/debian/changelog"
- name: Update dms stable version
- name: Update stable version (dms + dms-greeter)
if: steps.packages.outputs.version != ''
run: |
VERSION="${{ steps.packages.outputs.version }}"
VERSION_NO_V="${VERSION#v}"
PACKAGES="${{ steps.packages.outputs.packages }}"
echo "==> Updating packaging files to version: $VERSION_NO_V"
# Update spec file
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
# Update dms spec and changelog when dms is in the upload list
if [[ "$PACKAGES" == *"dms"* ]]; then
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
UPDATED_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1)
echo "✓ dms spec now shows Version: $UPDATED_VERSION"
# Verify the update
UPDATED_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1)
echo "✓ Spec file now shows Version: $UPDATED_VERSION"
DATE_STR=$(date "+%a %b %d %Y")
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
{
echo "$LOCAL_SPEC_HEAD"
echo "%changelog"
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
echo "- Update to stable $VERSION release"
} > distro/opensuse/dms.spec
# Single changelog entry (full history on OBS website)
DATE_STR=$(date "+%a %b %d %Y")
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
{
echo "$LOCAL_SPEC_HEAD"
echo "%changelog"
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
echo "- Update to stable $VERSION release"
} > distro/opensuse/dms.spec
# Update Debian _service files (both tar_scm and download_url formats)
for service in distro/debian/*/_service; do
if [[ -f "$service" ]]; then
# Update tar_scm revision parameter (for dms-git)
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
# Update download_url paths (for dms stable)
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
CHANGELOG_DATE=$(date -R)
{
echo "dms (${VERSION_NO_V}db1) stable; urgency=medium"
echo ""
echo " * Update to $VERSION stable release"
echo ""
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
} > "distro/debian/dms/debian/changelog"
echo "✓ Updated dms changelog to ${VERSION_NO_V}db1"
fi
done
fi
# Update Debian changelog for dms stable (single entry, history on OBS website)
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
# Update dms-greeter changelog when dms-greeter is in the upload list
if [[ "$PACKAGES" == *"dms-greeter"* ]] && [[ -f "distro/debian/dms-greeter/debian/changelog" ]]; then
CHANGELOG_DATE=$(date -R)
{
echo "dms (${VERSION_NO_V}db1) stable; urgency=medium"
echo "dms-greeter (${VERSION_NO_V}db1) unstable; urgency=medium"
echo ""
echo " * Update to $VERSION stable release"
echo ""
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
} > "distro/debian/dms/debian/changelog"
echo "✓ Updated Debian changelog to ${VERSION_NO_V}db1"
} > "distro/debian/dms-greeter/debian/changelog"
echo "✓ Updated dms-greeter changelog to ${VERSION_NO_V}db1"
fi
# Update Debian _service files for packages in upload list (download_url paths)
for service in distro/debian/*/_service; do
if [[ -f "$service" ]]; then
# Update tar_scm revision parameter (for dms-git)
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
# Update download_url paths (for dms, dms-greeter stable)
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
fi
done
- name: Install Go
uses: actions/setup-go@v5
with:
@@ -354,6 +366,7 @@ jobs:
chmod 600 ~/.config/osc/oscrc
- name: Upload to OBS
id: upload
env:
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
TAG_VERSION: ${{ steps.packages.outputs.version }}
@@ -362,6 +375,8 @@ jobs:
if [[ -z "$PACKAGES" ]]; then
echo "✓ No packages need uploading. All up to date!"
echo "uploaded_packages=" >> $GITHUB_OUTPUT
echo "skipped_packages=" >> $GITHUB_OUTPUT
exit 0
fi
@@ -371,6 +386,9 @@ jobs:
echo "==> Version being uploaded: ${{ steps.packages.outputs.version }}"
fi
UPLOADED_PACKAGES=()
SKIPPED_PACKAGES=()
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
# Loop through each package and upload
for PKG in $PACKAGES; do
@@ -382,13 +400,37 @@ jobs:
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
LOG_FILE=$(mktemp)
set +e
if [[ "$PKG" == "dms-git" ]]; then
bash distro/scripts/obs-upload.sh dms-git "Automated git update"
bash distro/scripts/obs-upload.sh dms-git "Automated git update" >"$LOG_FILE" 2>&1
else
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE"
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE" >"$LOG_FILE" 2>&1
fi
STATUS=$?
set -e
cat "$LOG_FILE"
if [[ $STATUS -ne 0 ]]; then
rm -f "$LOG_FILE"
echo "❌ Upload failed for $PKG"
exit $STATUS
fi
if grep -Eq "Exiting gracefully \(no changes needed\)|No changes needed for this package\. Exiting gracefully\." "$LOG_FILE"; then
echo " $PKG is already up to date. Skipped."
SKIPPED_PACKAGES+=("$PKG")
else
UPLOADED_PACKAGES+=("$PKG")
fi
rm -f "$LOG_FILE"
done
echo "uploaded_packages=${UPLOADED_PACKAGES[*]}" >> $GITHUB_OUTPUT
echo "skipped_packages=${SKIPPED_PACKAGES[*]}" >> $GITHUB_OUTPUT
- name: Summary
if: always()
run: |
@@ -402,20 +444,59 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "All packages are current. Run completed successfully." >> $GITHUB_STEP_SUMMARY
else
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
UPLOADED_PACKAGES="${{ steps.upload.outputs.uploaded_packages }}"
SKIPPED_PACKAGES="${{ steps.upload.outputs.skipped_packages }}"
TOTAL_COUNT=$(wc -w <<<"$PACKAGES" | tr -d ' ')
UPLOADED_COUNT=0
SKIPPED_COUNT=0
if [[ -n "$UPLOADED_PACKAGES" ]]; then
UPLOADED_COUNT=$(wc -w <<<"$UPLOADED_PACKAGES" | tr -d ' ')
fi
if [[ -n "$SKIPPED_PACKAGES" ]]; then
SKIPPED_COUNT=$(wc -w <<<"$SKIPPED_PACKAGES" | tr -d ' ')
fi
in_list() {
local item="$1"
local list="$2"
[[ " $list " == *" $item "* ]]
}
if [[ "${{ job.status }}" == "success" ]]; then
echo "**Status:** ✅ Completed successfully" >> $GITHUB_STEP_SUMMARY
else
echo "**Status:** ❌ Completed with errors" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Processed:** $TOTAL_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
echo "**Uploaded:** $UPLOADED_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
echo "**Skipped (up to date):** $SKIPPED_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Packages:**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
for PKG in $PACKAGES; do
STATUS_ICON="✅"
STATUS_TEXT="uploaded"
if in_list "$PKG" "$SKIPPED_PACKAGES"; then
STATUS_ICON=""
STATUS_TEXT="up to date (skipped)"
elif ! in_list "$PKG" "$UPLOADED_PACKAGES"; then
STATUS_ICON="❌"
STATUS_TEXT="failed"
fi
case "$PKG" in
dms)
echo "- ✅ **dms** → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
;;
dms-git)
echo "- ✅ **dms-git** → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
;;
dms-greeter)
echo "- $STATUS_ICON **dms-greeter** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:danklinux/dms-greeter)" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then

View File

@@ -40,7 +40,7 @@ jobs:
echo "Build succeeded, no hash update needed"
exit 0
fi
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1)
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1 || true)
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
@@ -59,8 +59,8 @@ jobs:
git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.nix
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
git pull --rebase origin master
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:master
git pull --rebase origin ${{ github.ref_name }}
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
else
echo "No changes to flake.nix"
fi

View File

@@ -0,0 +1,10 @@
{
"policy_version": 1,
"blocked_commands": [
"greeter install",
"greeter enable",
"greeter uninstall",
"setup"
],
"message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."
}

View File

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

View File

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

View File

@@ -222,16 +222,19 @@ func init() {
func runClipCopy(cmd *cobra.Command, args []string) {
var data []byte
copyFromStdin := false
switch {
case len(args) > 0:
data = []byte(args[0])
default:
case clipCopyDownload || clipCopyType == "__multi__":
var err error
data, err = io.ReadAll(os.Stdin)
if err != nil {
log.Fatalf("read stdin: %v", err)
}
default:
copyFromStdin = true
}
if clipCopyDownload {
@@ -257,6 +260,13 @@ func runClipCopy(cmd *cobra.Command, args []string) {
return
}
if copyFromStdin {
if err := clipboard.CopyReader(os.Stdin, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
log.Fatalf("copy: %v", err)
}
return
}
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
log.Fatalf("copy: %v", err)
}

View File

@@ -64,9 +64,8 @@ var killCmd = &cobra.Command{
}
var ipcCmd = &cobra.Command{
Use: "ipc [target] [function] [args...]",
Short: "Send IPC commands to running DMS shell",
PreRunE: findConfig,
Use: "ipc [target] [function] [args...]",
Short: "Send IPC commands to running DMS shell",
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = findConfig(cmd, args)
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
@@ -525,5 +524,7 @@ func getCommonCommands() []*cobra.Command {
doctorCmd,
configCmd,
dlCmd,
randrCmd,
blurCmd,
}
}

View File

@@ -11,6 +11,7 @@ import (
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
@@ -82,7 +83,7 @@ func (ds *DoctorStatus) OKCount() int {
}
var (
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
@@ -90,6 +91,7 @@ var (
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
)
var doctorCmd = &cobra.Command{
@@ -468,6 +470,7 @@ func checkWindowManagers() []checkResult {
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
}
var results []checkResult
@@ -500,7 +503,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, or Wayfire",
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
doctorDocsURL + "#compositor-checks",
})
}
@@ -509,9 +512,24 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
}
results = append(results, checkCompositorBlurSupport())
return results
}
func checkCompositorBlurSupport() checkResult {
supported, err := blur.ProbeSupport()
if err != nil {
return checkResult{catCompositor, "Background Blur", statusInfo, "Unable to verify", err.Error(), doctorDocsURL + "#compositor-checks"}
}
if supported {
return checkResult{catCompositor, "Background Blur", statusOK, "Supported", "Compositor supports ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 {
@@ -535,6 +553,8 @@ func detectRunningWM() string {
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
return "niri"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP")
}
@@ -553,6 +573,7 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
qmlContent := `
import QtQuick
import Quickshell
import Quickshell.Wayland
ShellRoot {
id: root
@@ -561,6 +582,7 @@ ShellRoot {
property bool idleMonitorAvailable: false
property bool idleInhibitorAvailable: false
property bool shortcutInhibitorAvailable: false
property bool backgroundBlurAvailable: false
Timer {
interval: 50
@@ -578,16 +600,18 @@ ShellRoot {
try {
var testItem = Qt.createQmlObject(
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
'import Quickshell; import Quickshell.Wayland; import QtQuick; QtObject { ' +
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined"; ' +
'readonly property bool hasBackgroundBlur: typeof BackgroundEffect !== "undefined" ' +
'}',
root
)
root.idleMonitorAvailable = testItem.hasIdleMonitor
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
testItem.destroy()
} catch (e) {}
@@ -596,6 +620,8 @@ ShellRoot {
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
console.warn(root.backgroundBlurAvailable ? "FEATURE:BackgroundBlur:OK" : "FEATURE:BackgroundBlur:UNAVAILABLE")
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
}
}
@@ -616,6 +642,7 @@ ShellRoot {
{"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
}
var results []checkResult
@@ -652,16 +679,14 @@ func checkI2CAvailability() checkResult {
func checkImageFormatPlugins() []checkResult {
url := doctorDocsURL + "#optional-features"
pluginDir := findQtPluginDir()
if pluginDir == "" {
pluginDirs := findQtPluginDirs()
if len(pluginDirs) == 0 {
return []checkResult{
{catOptionalFeatures, "qt6-imageformats", statusInfo, "Cannot detect (plugin dir not found)", "WebP, TIFF, JP2 support", url},
{catOptionalFeatures, "kimageformats", statusInfo, "Cannot detect (plugin dir not found)", "AVIF, HEIF, JXL support", url},
}
}
imageFormatsDir := filepath.Join(pluginDir, "imageformats")
type pluginCheck struct {
name string
desc string
@@ -695,9 +720,18 @@ func checkImageFormatPlugins() []checkResult {
var results []checkResult
for _, c := range checks {
var found []string
for _, p := range c.plugins {
if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil {
found = append(found, p.format)
var foundDirs []string
for _, pluginDir := range pluginDirs {
imageFormatsDir := filepath.Join(pluginDir, "imageformats")
for _, p := range c.plugins {
if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil {
if !slices.Contains(found, p.format) {
found = append(found, p.format)
}
if !slices.Contains(foundDirs, imageFormatsDir) {
foundDirs = append(foundDirs, imageFormatsDir)
}
}
}
}
@@ -708,7 +742,7 @@ func checkImageFormatPlugins() []checkResult {
default:
details := ""
if doctorVerbose {
details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), imageFormatsDir)
details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), strings.Join(foundDirs, ":"))
}
result = checkResult{catOptionalFeatures, c.name, statusOK, fmt.Sprintf("Installed (%d formats)", len(found)), details, url}
}
@@ -718,22 +752,28 @@ func checkImageFormatPlugins() []checkResult {
return results
}
func findQtPluginDir() string {
// Check QT_PLUGIN_PATH env var first (used by NixOS and custom setups)
func findQtPluginDirs() []string {
var dirs []string
addDir := func(dir string) {
if dir != "" {
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
dirs = append(dirs, dir)
}
}
}
// Check all paths in QT_PLUGIN_PATH env var (used by NixOS and custom setups)
if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" {
for dir := range strings.SplitSeq(envPath, ":") {
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
return dir
}
addDir(dir)
}
}
// Try qtpaths
for _, cmd := range []string{"qtpaths6", "qtpaths"} {
if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil {
if dir := strings.TrimSpace(string(output)); dir != "" {
return dir
}
addDir(strings.TrimSpace(string(output)))
}
}
@@ -744,12 +784,10 @@ func findQtPluginDir() string {
"/usr/lib/x86_64-linux-gnu/qt6/plugins",
"/usr/lib/aarch64-linux-gnu/qt6/plugins",
} {
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
return dir
}
addDir(dir)
}
return ""
return dirs
}
func detectNetworkBackend(stackResult *network.DetectResult) string {
@@ -809,10 +847,14 @@ func checkOptionalDependencies() []checkResult {
results = append(results, checkImageFormatPlugins()...)
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
terminals = slices.DeleteFunc(terminals, func(t string) bool {
return !utils.CommandExists(t)
})
if len(terminals) > 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
} else {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL})
}
networkResult, err := network.DetectNetworkStack()

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,9 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -95,7 +97,11 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
func runMatugenGenerate(cmd *cobra.Command, args []string) {
opts := buildMatugenOptions(cmd)
if err := matugen.Run(opts); err != nil {
err := matugen.Run(opts)
switch {
case errors.Is(err, matugen.ErrNoChanges):
os.Exit(2)
case err != nil:
log.Fatalf("Theme generation failed: %v", err)
}
}
@@ -129,7 +135,11 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
if !wait {
if err := sendServerRequestFireAndForget(request); err != nil {
log.Info("Server unavailable, running synchronously")
if err := matugen.Run(opts); err != nil {
err := matugen.Run(opts)
switch {
case errors.Is(err, matugen.ErrNoChanges):
os.Exit(2)
case err != nil:
log.Fatalf("Theme generation failed: %v", err)
}
return
@@ -146,11 +156,15 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
resp, ok := tryServerRequest(request)
if !ok {
log.Info("Server unavailable, running synchronously")
if err := matugen.Run(opts); err != nil {
err := matugen.Run(opts)
switch {
case errors.Is(err, matugen.ErrNoChanges):
resultCh <- matugen.ErrNoChanges
case err != nil:
resultCh <- err
return
default:
resultCh <- nil
}
resultCh <- nil
return
}
if resp.Error != "" {
@@ -162,7 +176,10 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
select {
case err := <-resultCh:
if err != nil {
switch {
case errors.Is(err, matugen.ErrNoChanges):
os.Exit(2)
case err != nil:
log.Fatalf("Theme generation failed: %v", err)
}
fmt.Println("Theme generation completed")

View File

@@ -0,0 +1,58 @@
package main
import (
"encoding/json"
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var randrCmd = &cobra.Command{
Use: "randr",
Short: "Query output display information",
Long: "Query Wayland compositor for output names, scales, resolutions and refresh rates via zwlr-output-management",
Run: runRandr,
}
func init() {
randrCmd.Flags().Bool("json", false, "Output in JSON format")
}
type randrJSON struct {
Outputs []randrOutput `json:"outputs"`
}
func runRandr(cmd *cobra.Command, args []string) {
outputs, err := queryRandr()
if err != nil {
log.Fatalf("%v", err)
}
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
data, err := json.Marshal(randrJSON{Outputs: outputs})
if err != nil {
log.Fatalf("failed to marshal JSON: %v", err)
}
fmt.Println(string(data))
return
}
for i, out := range outputs {
if i > 0 {
fmt.Println()
}
status := "enabled"
if !out.Enabled {
status = "disabled"
}
fmt.Printf("%s (%s)\n", out.Name, status)
fmt.Printf(" Scale: %.4g\n", out.Scale)
fmt.Printf(" Resolution: %dx%d\n", out.Width, out.Height)
if out.Refresh > 0 {
fmt.Printf(" Refresh: %.2f Hz\n", float64(out.Refresh)/1000.0)
}
}
}

View File

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

View File

@@ -0,0 +1,285 @@
package main
import (
"bufio"
_ "embed"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/spf13/cobra"
)
const (
cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json"
cliPolicyAdminPath = "/etc/dms/cli-policy.json"
)
var (
immutablePolicyOnce sync.Once
immutablePolicy immutableCommandPolicy
immutablePolicyErr error
)
//go:embed assets/cli-policy.default.json
var defaultCLIPolicyJSON []byte
type immutableCommandPolicy struct {
ImmutableSystem bool
ImmutableReason string
BlockedCommands []string
Message string
}
type cliPolicyFile struct {
PolicyVersion int `json:"policy_version"`
ImmutableSystem *bool `json:"immutable_system"`
BlockedCommands *[]string `json:"blocked_commands"`
Message *string `json:"message"`
}
func normalizeCommandSpec(raw string) string {
normalized := strings.ToLower(strings.TrimSpace(raw))
normalized = strings.TrimPrefix(normalized, "dms ")
return strings.Join(strings.Fields(normalized), " ")
}
func normalizeBlockedCommands(raw []string) []string {
normalized := make([]string, 0, len(raw))
seen := make(map[string]bool)
for _, cmd := range raw {
spec := normalizeCommandSpec(cmd)
if spec == "" || seen[spec] {
continue
}
seen[spec] = true
normalized = append(normalized, spec)
}
return normalized
}
func commandBlockedByPolicy(commandPath string, blocked []string) bool {
normalizedPath := normalizeCommandSpec(commandPath)
if normalizedPath == "" {
return false
}
for _, entry := range blocked {
spec := normalizeCommandSpec(entry)
if spec == "" {
continue
}
if normalizedPath == spec || strings.HasPrefix(normalizedPath, spec+" ") {
return true
}
}
return false
}
func loadPolicyFile(path string) (*cliPolicyFile, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read %s: %w", path, err)
}
var policy cliPolicyFile
if err := json.Unmarshal(data, &policy); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
}
return &policy, nil
}
func mergePolicyFile(base *immutableCommandPolicy, path string) error {
policyFile, err := loadPolicyFile(path)
if err != nil {
return err
}
if policyFile == nil {
return nil
}
if policyFile.ImmutableSystem != nil {
base.ImmutableSystem = *policyFile.ImmutableSystem
}
if policyFile.BlockedCommands != nil {
base.BlockedCommands = normalizeBlockedCommands(*policyFile.BlockedCommands)
}
if policyFile.Message != nil {
msg := strings.TrimSpace(*policyFile.Message)
if msg != "" {
base.Message = msg
}
}
return nil
}
func readOSReleaseMap(path string) map[string]string {
values := make(map[string]string)
file, err := os.Open(path)
if err != nil {
return values
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.ToUpper(strings.TrimSpace(parts[0]))
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
values[key] = strings.ToLower(value)
}
return values
}
func hasAnyToken(text string, tokens ...string) bool {
if text == "" {
return false
}
for _, token := range tokens {
if strings.Contains(text, token) {
return true
}
}
return false
}
func detectImmutableSystem() (bool, string) {
if _, err := os.Stat("/run/ostree-booted"); err == nil {
return true, "/run/ostree-booted is present"
}
osRelease := readOSReleaseMap("/etc/os-release")
if len(osRelease) == 0 {
return false, ""
}
id := osRelease["ID"]
idLike := osRelease["ID_LIKE"]
variantID := osRelease["VARIANT_ID"]
name := osRelease["NAME"]
prettyName := osRelease["PRETTY_NAME"]
immutableIDs := map[string]bool{
"bluefin": true,
"bazzite": true,
"silverblue": true,
"kinoite": true,
"sericea": true,
"onyx": true,
"aurora": true,
"fedora-iot": true,
"fedora-coreos": true,
}
if immutableIDs[id] {
return true, "os-release ID=" + id
}
markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"}
if hasAnyToken(variantID, markers...) {
return true, "os-release VARIANT_ID=" + variantID
}
if hasAnyToken(idLike, "ostree", "rpm-ostree") {
return true, "os-release ID_LIKE=" + idLike
}
if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) {
return true, "os-release identifies an atomic/ostree variant"
}
return false, ""
}
func getImmutablePolicy() (*immutableCommandPolicy, error) {
immutablePolicyOnce.Do(func() {
detectedImmutable, reason := detectImmutableSystem()
immutablePolicy = immutableCommandPolicy{
ImmutableSystem: detectedImmutable,
ImmutableReason: reason,
BlockedCommands: []string{"greeter install", "greeter enable", "setup"},
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
}
var defaultPolicy cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil {
immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err)
return
}
if defaultPolicy.BlockedCommands != nil {
immutablePolicy.BlockedCommands = normalizeBlockedCommands(*defaultPolicy.BlockedCommands)
}
if defaultPolicy.Message != nil {
msg := strings.TrimSpace(*defaultPolicy.Message)
if msg != "" {
immutablePolicy.Message = msg
}
}
if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil {
immutablePolicyErr = err
return
}
if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil {
immutablePolicyErr = err
return
}
})
if immutablePolicyErr != nil {
return nil, immutablePolicyErr
}
return &immutablePolicy, nil
}
func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
policy, err := getImmutablePolicy()
if err != nil {
return err
}
if !policy.ImmutableSystem {
return nil
}
commandPath := normalizeCommandSpec(cmd.CommandPath())
if !commandBlockedByPolicy(commandPath, policy.BlockedCommands) {
return nil
}
reason := ""
if policy.ImmutableReason != "" {
reason = "Detected immutable system: " + policy.ImmutableReason + "\n"
}
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
}
// preRunPrivileged combines the immutable-system check with a privesc tool
// selection prompt (shown only when multiple tools are available and the
// $DMS_PRIVESC env var isn't set).
func preRunPrivileged(cmd *cobra.Command, args []string) error {
if err := requireMutableSystemCommand(cmd, args); err != nil {
return err
}
if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil {
return err
}
return nil
}

View File

@@ -5,6 +5,7 @@ package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
@@ -16,19 +17,10 @@ func init() {
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().MarkHidden("daemon-child")
// Add subcommands to greeter
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to setup
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
// Add subcommands to update
updateCmd.AddCommand(updateCheckCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
// Add common commands to root
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(updateCmd)
@@ -37,7 +29,9 @@ func init() {
}
func main() {
if os.Geteuid() == 0 {
clipboard.MaybeServeAndExit()
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
log.Fatal("This program should not be run as root. Exiting.")
}

View File

@@ -5,36 +5,30 @@ package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
var Version = "dev"
func init() {
// Add flags
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().MarkHidden("daemon-child")
// Add subcommands to greeter
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to setup
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
// Add common commands to root
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.SetHelpTemplate(getHelpTemplate())
}
func main() {
// Block root
if os.Geteuid() == 0 {
clipboard.MaybeServeAndExit()
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
log.Fatal("This program should not be run as root. Exiting.")
}

View File

@@ -0,0 +1,172 @@
package main
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type randrOutput struct {
Name string `json:"name"`
Scale float64 `json:"scale"`
Width int32 `json:"width"`
Height int32 `json:"height"`
Refresh int32 `json:"refresh"`
Enabled bool `json:"enabled"`
}
type randrHead struct {
name string
enabled bool
scale float64
currentModeID uint32
modeIDs []uint32
}
type randrMode struct {
width int32
height int32
refresh int32
}
type randrClient struct {
display *wlclient.Display
ctx *wlclient.Context
manager *wlr_output_management.ZwlrOutputManagerV1
heads map[uint32]*randrHead
modes map[uint32]*randrMode
done bool
err error
}
func queryRandr() ([]randrOutput, error) {
display, err := wlclient.Connect("")
if err != nil {
return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
}
c := &randrClient{
display: display,
ctx: display.Context(),
heads: make(map[uint32]*randrHead),
modes: make(map[uint32]*randrMode),
}
defer c.ctx.Close()
registry, err := display.GetRegistry()
if err != nil {
return nil, fmt.Errorf("failed to get registry: %w", err)
}
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName {
mgr := wlr_output_management.NewZwlrOutputManagerV1(c.ctx)
version := min(e.Version, 4)
mgr.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
c.handleHead(e)
})
mgr.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) {
c.done = true
})
if err := registry.Bind(e.Name, e.Interface, version, mgr); err == nil {
c.manager = mgr
}
}
})
// First roundtrip: discover globals and bind manager
syncCallback, err := display.Sync()
if err != nil {
return nil, fmt.Errorf("failed to sync display: %w", err)
}
syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) {
if c.manager == nil {
c.err = fmt.Errorf("zwlr_output_manager_v1 protocol not supported by compositor")
c.done = true
}
// Otherwise wait for manager's DoneHandler
})
for !c.done {
if err := c.ctx.Dispatch(); err != nil {
return nil, fmt.Errorf("dispatch error: %w", err)
}
}
if c.err != nil {
return nil, c.err
}
return c.buildOutputs(), nil
}
func (c *randrClient) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
handle := e.Head
headID := handle.ID()
head := &randrHead{
modeIDs: make([]uint32, 0),
}
c.heads[headID] = head
handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) {
head.name = e.Name
})
handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) {
head.enabled = e.Enabled != 0
})
handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
head.scale = e.Scale
})
handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) {
head.currentModeID = e.Mode.ID()
})
handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) {
modeHandle := e.Mode
modeID := modeHandle.ID()
head.modeIDs = append(head.modeIDs, modeID)
mode := &randrMode{}
c.modes[modeID] = mode
modeHandle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) {
mode.width = e.Width
mode.height = e.Height
})
modeHandle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) {
mode.refresh = e.Refresh
})
})
}
func (c *randrClient) buildOutputs() []randrOutput {
outputs := make([]randrOutput, 0, len(c.heads))
for _, head := range c.heads {
out := randrOutput{
Name: head.name,
Scale: head.scale,
Enabled: head.enabled,
}
if mode, ok := c.modes[head.currentModeID]; ok {
out.Width = mode.width
out.Height = mode.height
out.Refresh = mode.refresh
}
outputs = append(outputs, out)
}
return outputs
}

View File

@@ -616,6 +616,43 @@ func getShellIPCCompletions(args []string, _ string) []string {
return nil
}
func getFirstDMSPID() (int, bool) {
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return 0, false
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
continue
}
proc, err := os.FindProcess(pid)
if err != nil {
continue
}
if proc.Signal(syscall.Signal(0)) != nil {
continue
}
return pid, true
}
return 0, false
}
func runShellIPCCommand(args []string) {
if len(args) == 0 {
printIPCHelp()
@@ -627,10 +664,21 @@ func runShellIPCCommand(args []string) {
}
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
switch pid, ok := getFirstDMSPID(); {
case ok:
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
default:
if err := findConfig(nil, nil); err != nil {
log.Fatalf("Error finding config: %v", err)
}
// ! TODO - remove check when QS 0.3 is released
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
}
cmdArgs = append(cmdArgs, "-p", configPath)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("qs", cmdArgs...)
cmd.Stdin = os.Stdin

View File

@@ -7,12 +7,20 @@ import (
"strings"
)
func findCommandPath(cmd string) (string, error) {
path, err := exec.LookPath(cmd)
if err != nil {
return "", fmt.Errorf("command '%s' not found in PATH", cmd)
// isReadOnlyCommand returns true if the CLI args indicate a command that is
// safe to run as root (e.g. shell completion, help).
func isReadOnlyCommand(args []string) bool {
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "-") {
continue
}
switch arg {
case "completion", "help", "__complete":
return true
}
return false
}
return path, nil
return false
}
func isArchPackageInstalled(packageName string) bool {

View File

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

View File

@@ -5,55 +5,196 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
const envServe = "_DMS_CLIPBOARD_SERVE"
const envMime = "_DMS_CLIPBOARD_MIME"
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
// child. Reads source data into memory, deletes any cache file, then serves.
func MaybeServeAndExit() {
if os.Getenv(envServe) == "" {
return
}
mimeType := os.Getenv(envMime)
pasteOnce := os.Getenv(envPasteOnce) == "1"
cachePath := os.Getenv(envCacheFile)
var data []byte
var err error
switch {
case cachePath != "":
data, err = os.ReadFile(cachePath)
os.Remove(cachePath)
default:
data, err = io.ReadAll(os.Stdin)
}
if err != nil {
fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err)
os.Exit(1)
}
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
func Copy(data []byte, mimeType string) error {
return CopyOpts(data, mimeType, false, false)
return copyForkCached(data, mimeType, false)
}
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if !foreground {
return copyFork(data, mimeType, pasteOnce)
if foreground {
return serveClipboard(data, mimeType, pasteOnce)
}
return copyServe(data, mimeType, pasteOnce)
return copyForkCached(data, mimeType, pasteOnce)
}
func copyFork(data []byte, mimeType string, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground"}
if pasteOnce {
args = append(args, "--paste-once")
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if foreground {
buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
}
args = append(args, "--type", mimeType)
return copyFork(data, mimeType, pasteOnce)
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
cmd := exec.Command(os.Args[0])
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
cmd.Env = append(os.Environ(),
envServe+"=1",
envMime+"="+mimeType,
)
if pasteOnce {
cmd.Env = append(cmd.Env, envPasteOnce+"=1")
}
cmd.Env = append(cmd.Env, extra...)
return cmd
}
func waitReady(cmd *exec.Cmd) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := stdin.Write(data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
stdin.Close()
return nil
}
func copyServe(data []byte, mimeType string, pasteOnce bool) error {
func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
cacheFile, err := createClipboardCacheFile()
if err != nil {
return fmt.Errorf("create cache file: %w", err)
}
cachePath := cacheFile.Name()
if _, err := cacheFile.Write(data); err != nil {
cacheFile.Close()
os.Remove(cachePath)
return fmt.Errorf("write cache file: %w", err)
}
if err := cacheFile.Close(); err != nil {
os.Remove(cachePath)
return fmt.Errorf("close cache file: %w", err)
}
cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath)
cmd.Stdin = nil
if err := waitReady(cmd); err != nil {
os.Remove(cachePath)
return err
}
return nil
}
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
cmd := newForkCmd(mimeType, pasteOnce)
switch src := data.(type) {
case *os.File:
cmd.Stdin = src
return waitReady(cmd)
default:
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
}
func signalReady() {
if os.Getenv(envServe) == "" {
return
}
os.Stdout.Write([]byte{1})
}
func createClipboardCacheFile() (*os.File, error) {
preferredDirs := []string{}
if cacheDir, err := os.UserCacheDir(); err == nil {
preferredDirs = append(preferredDirs, filepath.Join(cacheDir, "dms", "clipboard"))
}
preferredDirs = append(preferredDirs, "/var/tmp/dms/clipboard")
for _, dir := range preferredDirs {
if err := os.MkdirAll(dir, 0o700); err != nil {
continue
}
cachedData, err := os.CreateTemp(dir, "dms-clipboard-*")
if err == nil {
return cachedData, nil
}
}
return os.CreateTemp("", "dms-clipboard-*")
}
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
@@ -95,12 +236,10 @@ func copyServe(data []byte, mimeType string, pasteOnce bool) error {
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
@@ -141,10 +280,10 @@ func copyServe(data []byte, mimeType string, pasteOnce bool) error {
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
defer syscall.Close(e.Fd)
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
file.Write(data)
_, _ = file.Write(data)
select {
case pasted <- struct{}{}:
default:
@@ -160,6 +299,7 @@ func copyServe(data []byte, mimeType string, pasteOnce bool) error {
}
display.Roundtrip()
signalReady()
for {
select {
@@ -418,12 +558,10 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
@@ -451,12 +589,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
defer syscall.Close(e.Fd)
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerMap[e.MimeType]; ok {
file.Write(data)
_, _ = file.Write(data)
}
select {

View File

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

View File

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

View File

@@ -224,6 +224,7 @@ window-rule {
open-floating false
}
window-rule {
match app-id=r#"^org\.gnome\.Calculator$"#
match app-id=r#"^gnome-calculator$"#
match app-id=r#"^galculator$"#
match app-id=r#"^blueman-manager$"#
@@ -252,6 +253,7 @@ window-rule {
// Open dms windows as floating by default
window-rule {
match app-id=r#"org.quickshell$"#
match app-id=r#"com.danklinux.dms$"#
open-floating true
}
debug {

View File

@@ -11,6 +11,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -97,6 +98,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectGit())
dependencies = append(dependencies, a.detectWindowManager(wm))
dependencies = append(dependencies, a.detectQuickshell())
dependencies = append(dependencies, a.detectDMSGreeter())
dependencies = append(dependencies, a.detectXDGPortal())
dependencies = append(dependencies, a.detectAccountsService())
@@ -124,12 +126,52 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
return a.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", a.packageInstalled("accountsservice"))
}
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
}
func (a *ArchDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("pacman", "-Q", pkg)
err := cmd.Run()
return err == nil
}
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
func parseSRCINFODeps(srcinfoPath string) (deps []string, makedeps []string, err error) {
data, err := os.ReadFile(srcinfoPath)
if err != nil {
return nil, nil, err
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
var pkg string
var target *[]string
switch {
case strings.HasPrefix(line, "makedepends = "):
pkg = strings.TrimPrefix(line, "makedepends = ")
target = &makedeps
case strings.HasPrefix(line, "depends = "):
pkg = strings.TrimPrefix(line, "depends = ")
target = &deps
default:
continue
}
// Strip version constraint (>=, <=, >, <, =) and colon-descriptions
if idx := strings.IndexAny(pkg, "><:="); idx >= 0 {
pkg = pkg[:idx]
}
pkg = strings.TrimSpace(pkg)
if pkg != "" {
*target = append(*target, pkg)
}
}
return deps, makedeps, nil
}
func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
return exec.Command("pacman", "-Si", pkg).Run() == nil
}
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
@@ -139,6 +181,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
"git": {Name: "git", Repository: RepoTypeSystem},
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
"matugen": a.getMatugenMapping(variants["matugen"]),
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
@@ -200,11 +243,7 @@ func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMap
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
}
if a.packageInstalled("dms-shell-bin") {
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
@@ -254,7 +293,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
LogOutput: "Installing base-devel development tools",
}
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
return fmt.Errorf("failed to install base-devel: %w", err)
}
@@ -286,6 +325,13 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
}
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
}
// Phase 3: System Packages
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
@@ -403,6 +449,37 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
return systemPkgs, aurPkgs, manualPkgs, variantMap
}
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if a.packageInstalled("quickshell-git") {
return nil
}
if a.packageInstalled("quickshell") {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.15,
Step: "Removing stable quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
}
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
return fmt.Errorf("failed to remove stable quickshell: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.18,
Step: "Building quickshell-git before system packages...",
IsComplete: false,
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
}
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
}
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
@@ -411,6 +488,9 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
if slices.Contains(packages, "dms-shell") {
args = append(args, "--assume-installed", "dms-shell-compositor=1")
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
@@ -422,7 +502,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
@@ -434,29 +514,10 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
a.log(fmt.Sprintf("Installing AUR packages manually: %s", strings.Join(packages, ", ")))
hasNiri := false
hasQuickshell := false
for _, pkg := range packages {
if pkg == "niri-git" {
hasNiri = true
}
if pkg == "quickshell" || pkg == "quickshell-git" {
hasQuickshell = true
}
}
// If quickshell is in the list, always reinstall google-breakpad first
if hasQuickshell {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.63,
Step: "Reinstalling google-breakpad for quickshell...",
IsComplete: false,
CommandInfo: "Reinstalling prerequisite AUR package for quickshell",
}
if err := a.installSingleAURPackage(ctx, "google-breakpad", sudoPassword, progressChan, 0.63, 0.65); err != nil {
return fmt.Errorf("failed to reinstall google-breakpad prerequisite for quickshell: %w", err)
}
}
// If niri is in the list, install makepkg-git-lfs-proto first if not already installed
@@ -517,7 +578,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
var dmsShell []string
for _, pkg := range packages {
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
if pkg == "dms-shell-git" {
dmsShell = append(dmsShell, pkg)
} else {
isDep := false
@@ -537,6 +598,16 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
}
func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error {
return a.installSingleAURPackageInternal(ctx, pkg, sudoPassword, progressChan, startProgress, endProgress, make(map[string]bool))
}
func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64, visited map[string]bool) error {
if visited[pkg] {
a.log(fmt.Sprintf("Skipping %s (already being installed, cycle detected)", pkg))
return nil
}
visited[pkg] = true
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
@@ -588,7 +659,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
}
}
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
if pkg == "dms-shell-git" {
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
depsToRemove := []string{
"depends = quickshell",
@@ -610,54 +681,66 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
return fmt.Errorf("failed to remove optdepends from .SRCINFO for %s: %w", pkg, err)
}
// Skip dependency installation for dms-shell-git and dms-shell-bin
// since we manually manage those dependencies
if pkg != "dms-shell-git" && pkg != "dms-shell-bin" {
// Pre-install dependencies from .SRCINFO
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
{
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.3*(endProgress-startProgress),
Step: fmt.Sprintf("Installing dependencies for %s...", pkg),
Step: fmt.Sprintf("Resolving dependencies for %s...", pkg),
IsComplete: false,
CommandInfo: "Installing package dependencies and makedepends",
CommandInfo: "Classifying dependencies as system or AUR",
}
// Install dependencies and makedepends explicitly
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
depsCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf(`
deps=$(grep "depends = " "%s" | grep -v "makedepends" | sed 's/.*depends = //' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
if [[ "%s" == *"quickshell"* ]]; then
deps=$(echo "$deps" | sed 's/google-breakpad//g' | sed 's/ / /g' | sed 's/^ *//g' | sed 's/ *$//g')
fi
if [ ! -z "$deps" ] && [ "$deps" != " " ]; then
echo '%s' | sudo -S pacman -S --needed --noconfirm $deps
fi
`, srcinfoPath, pkg, sudoPassword))
if err := a.runWithProgress(depsCmd, progressChan, PhaseAURPackages, startProgress+0.3*(endProgress-startProgress), startProgress+0.35*(endProgress-startProgress)); err != nil {
return fmt.Errorf("FAILED to install runtime dependencies for %s: %w", pkg, err)
runtimeDeps, makeDeps, err := parseSRCINFODeps(srcinfoPath)
if err != nil {
return fmt.Errorf("failed to parse .SRCINFO for %s: %w", pkg, err)
}
makedepsCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf(`
makedeps=$(grep -E "^[[:space:]]*makedepends = " "%s" | sed 's/^[[:space:]]*makedepends = //' | tr '\n' ' ')
if [ ! -z "$makedeps" ]; then
echo '%s' | sudo -S pacman -S --needed --noconfirm $makedeps
fi
`, srcinfoPath, sudoPassword))
seen := make(map[string]bool)
var systemPkgs []string
var aurPkgs []string
if err := a.runWithProgress(makedepsCmd, progressChan, PhaseAURPackages, startProgress+0.35*(endProgress-startProgress), startProgress+0.4*(endProgress-startProgress)); err != nil {
return fmt.Errorf("FAILED to install make dependencies for %s: %w", pkg, err)
for _, dep := range append(runtimeDeps, makeDeps...) {
if seen[dep] || a.packageInstalled(dep) {
continue
}
seen[dep] = true
if a.isInSystemRepo(dep) {
systemPkgs = append(systemPkgs, dep)
} else {
aurPkgs = append(aurPkgs, dep)
}
}
} else {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.35*(endProgress-startProgress),
Step: fmt.Sprintf("Skipping dependency installation for %s (manually managed)...", pkg),
IsComplete: false,
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.32*(endProgress-startProgress),
Step: fmt.Sprintf("Installing %d system dependencies for %s...", len(systemPkgs), pkg),
IsComplete: false,
CommandInfo: fmt.Sprintf("sudo pacman -S --needed --noconfirm %s", strings.Join(systemPkgs, " ")),
}
if err := a.installSystemPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install system dependencies for %s: %w", pkg, err)
}
}
for _, aurDep := range aurPkgs {
a.log(fmt.Sprintf("Dependency %s is AUR-only, building from source...", aurDep))
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.35*(endProgress-startProgress),
Step: fmt.Sprintf("Installing AUR dependency %s for %s...", aurDep, pkg),
IsComplete: false,
CommandInfo: fmt.Sprintf("Building AUR dependency: %s", aurDep),
}
if err := a.installSingleAURPackageInternal(ctx, aurDep, sudoPassword, progressChan,
startProgress+0.35*(endProgress-startProgress),
startProgress+0.39*(endProgress-startProgress),
visited,
); err != nil {
return fmt.Errorf("failed to install AUR dependency %s for %s: %w", aurDep, pkg, err)
}
}
}
@@ -671,7 +754,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
buildCmd := exec.CommandContext(ctx, "makepkg", "--noconfirm")
buildCmd.Dir = packageDir
buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar") // Disable compression for speed
buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar")
if err := a.runWithProgress(buildCmd, progressChan, PhaseAURPackages, startProgress+0.4*(endProgress-startProgress), startProgress+0.7*(endProgress-startProgress)); err != nil {
return fmt.Errorf("failed to build %s: %w", pkg, err)
@@ -686,42 +769,9 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
CommandInfo: "sudo pacman -U built-package",
}
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
var files []string
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
// For DMS split packages, install base package
pattern := filepath.Join(packageDir, fmt.Sprintf("%s-%s*.pkg.tar*", pkg, "*"))
matches, err := filepath.Glob(pattern)
if err == nil {
for _, match := range matches {
basename := filepath.Base(match)
// Always include base package
if !strings.Contains(basename, "hyprland") && !strings.Contains(basename, "niri") {
files = append(files, match)
}
}
}
// Also update compositor-specific packages if they're installed
if strings.HasSuffix(pkg, "-git") {
if a.packageInstalled("dms-shell-hyprland-git") {
hyprlandPattern := filepath.Join(packageDir, "dms-shell-hyprland-git-*.pkg.tar*")
if hyprlandMatches, err := filepath.Glob(hyprlandPattern); err == nil && len(hyprlandMatches) > 0 {
files = append(files, hyprlandMatches[0])
}
}
if a.packageInstalled("dms-shell-niri-git") {
niriPattern := filepath.Join(packageDir, "dms-shell-niri-git-*.pkg.tar*")
if niriMatches, err := filepath.Glob(niriPattern); err == nil && len(niriMatches) > 0 {
files = append(files, niriMatches[0])
}
}
}
} else {
// For other packages, install all built packages
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
files = matches
}
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
files = matches
if len(files) == 0 {
return fmt.Errorf("no package files found after building %s", pkg)
@@ -730,7 +780,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
installArgs := []string{"pacman", "-U", "--noconfirm"}
installArgs = append(installArgs, files...)
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
fileNames := make([]string, len(files))
for i, f := range files {

View File

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

View File

@@ -4,10 +4,10 @@ import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -61,6 +61,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, d.detectGit())
dependencies = append(dependencies, d.detectWindowManager(wm))
dependencies = append(dependencies, d.detectQuickshell())
dependencies = append(dependencies, d.detectDMSGreeter())
dependencies = append(dependencies, d.detectXDGPortal())
dependencies = append(dependencies, d.detectAccountsService())
@@ -86,10 +87,41 @@ func (d *DebianDistribution) detectAccountsService() deps.Dependency {
return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice"))
}
func (d *DebianDistribution) detectDMSGreeter() deps.Dependency {
return d.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", d.packageInstalled("dms-greeter"))
}
func (d *DebianDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
return debianPackageInstalledPrecisely(pkg)
}
func debianPackageInstalledPrecisely(pkg string) bool {
cmd := exec.Command("dpkg-query", "-W", "-f=${db:Status-Status}", pkg)
output, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) == "installed"
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func debianRepoArchitecture(arch string) string {
switch arch {
case "amd64", "x86_64":
return "amd64"
case "arm64", "aarch64":
return "arm64"
default:
return arch
}
}
func (d *DebianDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
@@ -108,6 +140,7 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
// DMS packages from OBS with variant support
"dms (DankMaterialShell)": d.getDmsMapping(variants["dms (DankMaterialShell)"]),
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
@@ -159,7 +192,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
@@ -176,7 +209,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
cmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
cmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
@@ -188,12 +221,12 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
@@ -373,6 +406,14 @@ func (d *DebianDistribution) extractPackageNames(packages []PackageMapping) []st
return names
}
func (d *DebianDistribution) aptInstallArgs(packages []string, minimal bool) []string {
args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y"}
if minimal {
args = append(args, "--no-install-recommends")
}
return append(args, packages...)
}
func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
@@ -410,7 +451,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
// Create keyrings directory if it doesn't exist
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
if err := mkdirCmd.Run(); err != nil {
d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err))
}
@@ -424,13 +465,13 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
}
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath)
cmd := ExecSudoCommand(ctx, sudoPassword, keyCmd)
cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd)
if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
// Add repository
repoLine := fmt.Sprintf("deb [signed-by=%s arch=%s] %s/ /", keyringPath, runtime.GOARCH, baseURL)
repoLine := fmt.Sprintf("deb [signed-by=%s arch=%s] %s/ /", keyringPath, debianRepoArchitecture(osInfo.Architecture), baseURL)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
@@ -440,7 +481,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
}
addRepoCmd := ExecSudoCommand(ctx, sudoPassword,
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err)
@@ -460,7 +501,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: "sudo apt-get update",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err)
}
@@ -476,20 +517,46 @@ func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []
d.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y"}
args = append(args, packages...)
groups := orderedMinimalInstallGroups(packages)
totalGroups := len(groups)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
groupIndex := 0
installGroup := func(groupPackages []string, minimal bool) error {
if len(groupPackages) == 0 {
return nil
}
groupIndex++
startProgress := 0.40
endProgress := 0.60
if totalGroups > 1 {
if groupIndex == 1 {
endProgress = 0.50
} else {
startProgress = 0.50
}
}
args := d.aptInstallArgs(groupPackages, minimal)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: startProgress,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress)
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
for _, group := range groups {
if err := installGroup(group.packages, group.minimal); err != nil {
return err
}
}
return nil
}
func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -568,7 +635,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
@@ -586,7 +653,7 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
@@ -625,7 +692,7 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
}

View File

@@ -7,12 +7,16 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
Register("fedora", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("evernight", "#72B8DC", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("nobara", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
@@ -75,6 +79,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectGit())
dependencies = append(dependencies, f.detectWindowManager(wm))
dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectDMSGreeter())
dependencies = append(dependencies, f.detectXDGPortal())
dependencies = append(dependencies, f.detectAccountsService())
@@ -120,6 +125,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
// COPR packages
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
@@ -191,6 +197,10 @@ func (f *FedoraDistribution) detectAccountsService() deps.Dependency {
}
}
func (f *FedoraDistribution) detectDMSGreeter() deps.Dependency {
return f.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", f.packageInstalled("dms-greeter"))
}
func (f *FedoraDistribution) getPrerequisites() []string {
return []string{
"dnf-plugins-core",
@@ -245,7 +255,7 @@ func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
args := []string{"dnf", "install", "-y"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to install prerequisites", err)
@@ -428,7 +438,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
output, err := cmd.CombinedOutput()
if err != nil {
@@ -452,7 +462,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
}
priorityCmd := ExecSudoCommand(ctx, sudoPassword,
priorityCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
priorityOutput, err := priorityCmd.CombinedOutput()
if err != nil {
@@ -475,28 +485,7 @@ func (f *FedoraDistribution) installDNFPackages(ctx context.Context, packages []
f.log(fmt.Sprintf("Installing DNF packages: %s", strings.Join(packages, ", ")))
args := []string{"dnf", "install", "-y"}
for _, pkg := range packages {
if pkg == "niri" || pkg == "niri-git" {
args = append(args, "--setopt=install_weak_deps=False")
break
}
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
return f.installDNFGroups(ctx, packages, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60)
}
func (f *FedoraDistribution) installCOPRPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -506,26 +495,57 @@ func (f *FedoraDistribution) installCOPRPackages(ctx context.Context, packages [
f.log(fmt.Sprintf("Installing COPR packages: %s", strings.Join(packages, ", ")))
args := []string{"dnf", "install", "-y"}
return f.installDNFGroups(ctx, packages, sudoPassword, progressChan, PhaseAURPackages, "Installing COPR packages...", 0.70, 0.85)
}
for _, pkg := range packages {
if pkg == "niri" || pkg == "niri-git" {
args = append(args, "--setopt=install_weak_deps=False")
break
func (f *FedoraDistribution) dnfInstallArgs(packages []string, minimal bool) []string {
args := []string{"dnf", "install", "-y"}
if minimal {
args = append(args, "--setopt=install_weak_deps=False")
}
return append(args, packages...)
}
func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error {
groups := orderedMinimalInstallGroups(packages)
totalGroups := len(groups)
groupIndex := 0
installGroup := func(groupPackages []string, minimal bool) error {
if len(groupPackages) == 0 {
return nil
}
groupIndex++
groupStart := startProgress
groupEnd := endProgress
if totalGroups > 1 {
midpoint := startProgress + ((endProgress - startProgress) / 2)
if groupIndex == 1 {
groupEnd = midpoint
} else {
groupStart = midpoint
}
}
args := f.dnfInstallArgs(groupPackages, minimal)
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: groupStart,
Step: step,
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
}
for _, group := range groups {
if err := installGroup(group.packages, group.minimal); err != nil {
return err
}
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing COPR packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
return nil
}

View File

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

View File

@@ -55,6 +55,7 @@ const (
PhaseAURPackages
PhaseCursorTheme
PhaseConfiguration
PhaseGreeterSetup
PhaseComplete
)

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -29,6 +30,8 @@ type OpenSUSEDistribution struct {
config DistroConfig
}
const openSUSENiriWaylandServerPackage = "libwayland-server0"
func NewOpenSUSEDistribution(config DistroConfig, logChan chan<- string) *OpenSUSEDistribution {
base := NewBaseDistribution(logChan)
return &OpenSUSEDistribution{
@@ -71,6 +74,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
dependencies = append(dependencies, o.detectGit())
dependencies = append(dependencies, o.detectWindowManager(wm))
dependencies = append(dependencies, o.detectQuickshell())
dependencies = append(dependencies, o.detectDMSGreeter())
dependencies = append(dependencies, o.detectXDGPortal())
dependencies = append(dependencies, o.detectAccountsService())
@@ -100,6 +104,10 @@ func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool {
return err == nil
}
func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency {
return o.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", o.packageInstalled("dms-greeter"))
}
func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return o.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
@@ -116,6 +124,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
// DMS packages from OBS
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
"quickshell": o.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
@@ -193,35 +202,7 @@ func (o *OpenSUSEDistribution) detectAccountsService() deps.Dependency {
}
func (o *OpenSUSEDistribution) getPrerequisites() []string {
return []string{
"make",
"unzip",
"gcc",
"gcc-c++",
"cmake",
"ninja",
"pkgconf-pkg-config",
"git",
"qt6-base-devel",
"qt6-declarative-devel",
"qt6-declarative-private-devel",
"qt6-shadertools",
"qt6-shadertools-devel",
"qt6-wayland-devel",
"qt6-waylandclient-private-devel",
"spirv-tools-devel",
"cli11-devel",
"wayland-protocols-devel",
"libgbm-devel",
"libdrm-devel",
"pipewire-devel",
"jemalloc-devel",
"wayland-utils",
"Mesa-libGLESv3-devel",
"pam-devel",
"glib2-devel",
"polkit-devel",
}
return []string{}
}
func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -269,7 +250,7 @@ func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPas
args := []string{"zypper", "install", "-y"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
o.logError("failed to install prerequisites", err)
@@ -291,6 +272,10 @@ func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies
LogOutput: "Starting prerequisite check...",
}
if err := o.disableInstallMediaRepos(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to disable install media repositories: %w", err)
}
if err := o.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
@@ -321,7 +306,7 @@ func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := o.installZypperPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
if err := o.installZypperPackages(ctx, systemPkgs, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60); err != nil {
return fmt.Errorf("failed to install zypper packages: %w", err)
}
}
@@ -336,7 +321,7 @@ func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies
IsComplete: false,
LogOutput: fmt.Sprintf("Installing OBS packages: %s", strings.Join(obsPkgNames, ", ")),
}
if err := o.installZypperPackages(ctx, obsPkgNames, sudoPassword, progressChan); err != nil {
if err := o.installZypperPackages(ctx, obsPkgNames, sudoPassword, progressChan, PhaseAURPackages, "Installing OBS packages...", 0.70, 0.85); err != nil {
return fmt.Errorf("failed to install OBS packages: %w", err)
}
}
@@ -426,9 +411,32 @@ func (o *OpenSUSEDistribution) categorizePackages(dependencies []deps.Dependency
}
}
systemPkgs = o.appendMissingSystemPackages(systemPkgs, openSUSENiriRuntimePackages(wm, disabledFlags))
return systemPkgs, obsPkgs, manualPkgs, variantMap
}
func openSUSENiriRuntimePackages(wm deps.WindowManager, disabledFlags map[string]bool) []string {
if wm != deps.WindowManagerNiri || disabledFlags["niri"] {
return nil
}
return []string{openSUSENiriWaylandServerPackage}
}
func (o *OpenSUSEDistribution) appendMissingSystemPackages(systemPkgs []string, extraPkgs []string) []string {
for _, pkg := range extraPkgs {
if containsString(systemPkgs, pkg) || o.packageInstalled(pkg) {
continue
}
o.log(fmt.Sprintf("Adding openSUSE runtime package: %s", pkg))
systemPkgs = append(systemPkgs, pkg)
}
return systemPkgs
}
func (o *OpenSUSEDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
@@ -478,7 +486,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("zypper addrepo -f %s", repoURL))
if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err))
@@ -499,7 +507,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: "sudo zypper --gpg-auto-import-keys refresh",
}
refreshCmd := ExecSudoCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
refreshCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to refresh repositories: %w", err)
}
@@ -508,27 +516,146 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
return nil
}
func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
func isOpenSUSEInstallMediaURI(uri string) bool {
normalizedURI := strings.ToLower(strings.TrimSpace(uri))
return strings.HasPrefix(normalizedURI, "cd:/") ||
strings.HasPrefix(normalizedURI, "dvd:/") ||
strings.HasPrefix(normalizedURI, "hd:/") ||
strings.HasPrefix(normalizedURI, "iso:/")
}
func parseZypperInstallMediaAliases(output string) []string {
var aliases []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || !strings.Contains(line, "|") {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 7 {
continue
}
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
alias := parts[1]
enabled := strings.ToLower(parts[3])
uri := parts[len(parts)-1]
if alias == "" || strings.EqualFold(alias, "alias") {
continue
}
if enabled != "" && enabled != "yes" {
continue
}
if !isOpenSUSEInstallMediaURI(uri) {
continue
}
aliases = append(aliases, alias)
}
return aliases
}
func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
listCmd := exec.CommandContext(ctx, "zypper", "repos", "-u")
output, err := listCmd.CombinedOutput()
if err != nil {
o.log(fmt.Sprintf("Warning: failed to list zypper repositories: %s", strings.TrimSpace(string(output))))
return fmt.Errorf("failed to list zypper repositories: %w", err)
}
aliases := parseZypperInstallMediaAliases(string(output))
if len(aliases) == 0 {
return nil
}
o.log(fmt.Sprintf("Disabling install media repositories: %s", strings.Join(aliases, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.055,
Step: "Disabling install media repositories...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo zypper modifyrepo -d %s", strings.Join(aliases, " ")),
LogOutput: fmt.Sprintf("Disabling install media repositories: %s", strings.Join(aliases, ", ")),
}
for _, alias := range aliases {
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", privesc.EscapeSingleQuotes(alias)))
repoOutput, err := cmd.CombinedOutput()
if err != nil {
o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
return fmt.Errorf("failed to disable install media repo %s: %w", alias, err)
}
o.log(fmt.Sprintf("Disabled install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
}
return nil
}
func (o *OpenSUSEDistribution) zypperInstallArgs(packages []string, minimal bool) []string {
args := []string{"zypper", "install", "-y"}
if minimal {
args = append(args, "--no-recommends")
}
return append(args, packages...)
}
func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error {
if len(packages) == 0 {
return nil
}
o.log(fmt.Sprintf("Installing zypper packages: %s", strings.Join(packages, ", ")))
args := []string{"zypper", "install", "-y"}
args = append(args, packages...)
groups := orderedMinimalInstallGroups(packages)
totalGroups := len(groups)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
groupIndex := 0
installGroup := func(groupPackages []string, minimal bool) error {
if len(groupPackages) == 0 {
return nil
}
groupIndex++
groupStart := startProgress
groupEnd := endProgress
if totalGroups > 1 {
midpoint := startProgress + ((endProgress - startProgress) / 2)
if groupIndex == 1 {
groupEnd = midpoint
} else {
groupStart = midpoint
}
}
args := o.zypperInstallArgs(groupPackages, minimal)
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: groupStart,
Step: step,
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
for _, group := range groups {
if err := installGroup(group.packages, group.minimal); err != nil {
return err
}
}
return nil
}
func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -647,7 +774,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
CommandInfo: "sudo cmake --install build",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
@@ -671,7 +798,7 @@ func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword str
CommandInfo: "sudo zypper install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup")
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper install -y rustup")
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
func init() {
@@ -63,6 +64,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, u.detectGit())
dependencies = append(dependencies, u.detectWindowManager(wm))
dependencies = append(dependencies, u.detectQuickshell())
dependencies = append(dependencies, u.detectDMSGreeter())
dependencies = append(dependencies, u.detectXDGPortal())
dependencies = append(dependencies, u.detectAccountsService())
@@ -94,10 +96,12 @@ func (u *UbuntuDistribution) detectAccountsService() deps.Dependency {
return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice"))
}
func (u *UbuntuDistribution) detectDMSGreeter() deps.Dependency {
return u.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", u.packageInstalled("dms-greeter"))
}
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
return debianPackageInstalledPrecisely(pkg)
}
func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
@@ -116,6 +120,7 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
// DMS packages from PPAs
"dms (DankMaterialShell)": u.getDmsMapping(variants["dms (DankMaterialShell)"]),
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
@@ -173,7 +178,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
@@ -191,7 +196,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
// Not installed, install it
cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
cmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
@@ -207,7 +212,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Installing additional development tools",
}
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev")
if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
@@ -394,7 +399,7 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
installPPACmd := ExecSudoCommand(ctx, sudoPassword,
installPPACmd := privesc.ExecCommand(ctx, sudoPassword,
"apt-get install -y software-properties-common")
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
return fmt.Errorf("failed to install software-properties-common: %w", err)
@@ -412,7 +417,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
@@ -433,7 +438,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: "sudo apt-get update",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
}
@@ -448,21 +453,7 @@ func (u *UbuntuDistribution) installAPTPackages(ctx context.Context, packages []
}
u.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
return u.installAPTGroups(ctx, packages, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60)
}
func (u *UbuntuDistribution) installPPAPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -471,21 +462,59 @@ func (u *UbuntuDistribution) installPPAPackages(ctx context.Context, packages []
}
u.log(fmt.Sprintf("Installing PPA packages: %s", strings.Join(packages, ", ")))
return u.installAPTGroups(ctx, packages, sudoPassword, progressChan, PhaseAURPackages, "Installing PPA packages...", 0.70, 0.85)
}
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
func (u *UbuntuDistribution) aptInstallArgs(packages []string, minimal bool) []string {
args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y"}
if minimal {
args = append(args, "--no-install-recommends")
}
return append(args, packages...)
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing PPA packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
func (u *UbuntuDistribution) installAPTGroups(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error {
groups := orderedMinimalInstallGroups(packages)
totalGroups := len(groups)
groupIndex := 0
installGroup := func(groupPackages []string, minimal bool) error {
if len(groupPackages) == 0 {
return nil
}
groupIndex++
groupStart := startProgress
groupEnd := endProgress
if totalGroups > 1 {
midpoint := startProgress + ((endProgress - startProgress) / 2)
if groupIndex == 1 {
groupEnd = midpoint
} else {
groupStart = midpoint
}
}
args := u.aptInstallArgs(groupPackages, minimal)
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: groupStart,
Step: step,
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
for _, group := range groups {
if err := installGroup(group.packages, group.minimal); err != nil {
return err
}
}
return nil
}
func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -563,7 +592,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
@@ -581,7 +610,7 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
@@ -621,7 +650,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
}
addPPACmd := ExecSudoCommand(ctx, sudoPassword,
addPPACmd := privesc.ExecCommand(ctx, sudoPassword,
"add-apt-repository -y ppa:longsleep/golang-backports")
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
return fmt.Errorf("failed to add Go PPA: %w", err)
@@ -636,7 +665,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get update",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
}
@@ -650,7 +679,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
installCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
}

View File

@@ -0,0 +1,91 @@
# AppArmor profile for dms-greeter
#
# Managed by DMS — regenerated on every `dms greeter install` / `dms greeter sync`.
# Manual edits will be overwritten on next sync.
#
# Mode: complain (denials are logged, nothing is blocked)
# To switch to enforce after validating with `aa-logprof`:
# sudo aa-enforce /etc/apparmor.d/usr.bin.dms-greeter
#
#include <tunables/global>
profile dms-greeter /usr/bin/dms-greeter flags=(complain) {
#include <abstractions/base>
#include <abstractions/bash>
# The launcher script itself
/usr/bin/dms-greeter r,
# Cache directory — created by dms greeter sync/enable with greeter:greeter ownership
/var/cache/dms-greeter/ rw,
/var/cache/dms-greeter/** rwlk,
# DMS config — packaged path
/usr/share/quickshell/dms-greeter/ r,
/usr/share/quickshell/dms-greeter/** r,
/usr/share/quickshell/ r,
/usr/share/quickshell/** r,
# DMS config — system and user overrides
/etc/dms/ r,
/etc/dms/** r,
/usr/share/dms/ r,
/usr/share/dms/** r,
/home/*/.config/quickshell/ r,
/home/*/.config/quickshell/** r,
/root/.config/quickshell/ r,
/root/.config/quickshell/** r,
# greetd / PAM — read-only for session setup
/etc/greetd/ r,
/etc/greetd/** r,
/etc/pam.d/ r,
/etc/pam.d/** r,
/usr/lib/pam.d/ r,
/usr/lib/pam.d/** r,
# Compositor binaries — run unconfined so each compositor uses its own profile
/usr/bin/niri Ux,
/usr/bin/hyprland Ux,
/usr/bin/Hyprland Ux,
/usr/bin/sway Ux,
/usr/bin/labwc Ux,
/usr/bin/scroll Ux,
/usr/bin/miracle-wm Ux,
/usr/bin/mango Ux,
# Quickshell — run unconfined (has its own compositor profile on some distros)
/usr/bin/qs Ux,
/usr/bin/quickshell Ux,
# Wayland / XDG runtime (pipewire, wireplumber, wayland socket)
/run/user/[0-9]*/ rw,
/run/user/[0-9]*/** rw,
# DRM / GPU devices (required for Wayland compositor startup)
/dev/dri/ r,
/dev/dri/* rw,
/dev/udmabuf rw,
# Input devices
/dev/input/ r,
/dev/input/* r,
# Systemd journal / logging
/run/systemd/journal/socket rw,
/dev/log rw,
# Shell helper binaries invoked by the launcher script
/usr/bin/env ix,
/usr/bin/mkdir ix,
/usr/bin/cat ix,
/usr/bin/grep ix,
/usr/bin/dirname ix,
/usr/bin/basename ix,
/usr/bin/command ix,
/bin/env ix,
/bin/mkdir ix,
# Signal management (compositor lifecycle)
signal (send, receive) set=("term", "int", "hup", "kill"),
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
package greeter
import (
"os"
"path/filepath"
"testing"
)
func writeTestJSON(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("failed to create parent dir for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write %s: %v", path, err)
}
}
func TestResolveGreeterThemeSyncState(t *testing.T) {
t.Parallel()
tests := []struct {
name string
settingsJSON string
sessionJSON string
wantSourcePath string
wantResolvedWallpaper string
wantDynamicOverrideUsed bool
}{
{
name: "dynamic theme with greeter wallpaper override uses generated greeter colors",
settingsJSON: `{
"currentThemeName": "dynamic",
"greeterWallpaperPath": "Pictures/blue.jpg",
"matugenScheme": "scheme-tonal-spot",
"iconTheme": "Papirus"
}`,
sessionJSON: `{"isLightMode":true}`,
wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "greeter-colors", "dms-colors.json"),
wantResolvedWallpaper: filepath.Join("Pictures", "blue.jpg"),
wantDynamicOverrideUsed: true,
},
{
name: "dynamic theme without override uses desktop colors",
settingsJSON: `{
"currentThemeName": "dynamic",
"greeterWallpaperPath": ""
}`,
sessionJSON: `{"isLightMode":false}`,
wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "dms-colors.json"),
wantResolvedWallpaper: "",
wantDynamicOverrideUsed: false,
},
{
name: "non-dynamic theme keeps desktop colors even with override wallpaper",
settingsJSON: `{
"currentThemeName": "purple",
"greeterWallpaperPath": "/tmp/blue.jpg"
}`,
sessionJSON: `{"isLightMode":false}`,
wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "dms-colors.json"),
wantResolvedWallpaper: "/tmp/blue.jpg",
wantDynamicOverrideUsed: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
homeDir := t.TempDir()
writeTestJSON(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
writeTestJSON(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
state, err := resolveGreeterThemeSyncState(homeDir)
if err != nil {
t.Fatalf("resolveGreeterThemeSyncState returned error: %v", err)
}
if got := state.effectiveColorsSource(homeDir); got != filepath.Join(homeDir, tt.wantSourcePath) {
t.Fatalf("effectiveColorsSource = %q, want %q", got, filepath.Join(homeDir, tt.wantSourcePath))
}
wantResolvedWallpaper := tt.wantResolvedWallpaper
if wantResolvedWallpaper != "" && !filepath.IsAbs(wantResolvedWallpaper) {
wantResolvedWallpaper = filepath.Join(homeDir, wantResolvedWallpaper)
}
if state.ResolvedGreeterWallpaperPath != wantResolvedWallpaper {
t.Fatalf("ResolvedGreeterWallpaperPath = %q, want %q", state.ResolvedGreeterWallpaperPath, wantResolvedWallpaper)
}
if state.UsesDynamicWallpaperOverride != tt.wantDynamicOverrideUsed {
t.Fatalf("UsesDynamicWallpaperOverride = %v, want %v", state.UsesDynamicWallpaperOverride, tt.wantDynamicOverrideUsed)
}
})
}
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
package matugen
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"os"
@@ -19,6 +21,8 @@ import (
"github.com/lucasb-eyer/go-colorful"
)
var ErrNoChanges = errors.New("no color changes")
type ColorMode string
const (
@@ -54,7 +58,7 @@ var templateRegistry = []TemplateDef{
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser"}, 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: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
@@ -67,6 +71,7 @@ var templateRegistry = []TemplateDef{
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
{ID: "vscode", Kind: TemplateKindVSCode},
{ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml", Kind: TemplateKindEmacs},
{ID: "zed", Commands: []string{"zed", "zeditor", "zedit"}, ConfigFile: "zed.toml"},
}
func (c *ColorMode) GTKTheme() string {
@@ -95,6 +100,7 @@ type Options struct {
IconTheme string
MatugenType string
RunUserTemplates bool
ColorsOnly bool
StockColors string
SyncModeWithPortal bool
TerminalsAlwaysDark bool
@@ -160,8 +166,14 @@ func Run(opts Options) error {
log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode)
if err := buildOnce(&opts); err != nil {
return err
changed, buildErr := buildOnce(&opts)
if buildErr != nil {
return buildErr
}
if !changed {
log.Info("No color changes detected, skipping refresh")
return ErrNoChanges
}
if opts.SyncModeWithPortal {
@@ -172,25 +184,27 @@ func Run(opts Options) error {
return nil
}
func buildOnce(opts *Options) error {
func buildOnce(opts *Options) (bool, error) {
cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp config: %w", err)
return false, fmt.Errorf("failed to create temp config: %w", err)
}
defer os.Remove(cfgFile.Name())
defer cfgFile.Close()
tmpDir, err := os.MkdirTemp("", "matugen-templates-*")
if err != nil {
return fmt.Errorf("failed to create temp dir: %w", err)
return false, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := buildMergedConfig(opts, cfgFile, tmpDir); err != nil {
return fmt.Errorf("failed to build config: %w", err)
return false, fmt.Errorf("failed to build config: %w", err)
}
cfgFile.Close()
oldColors, _ := os.ReadFile(opts.ColorsOutput())
var primaryDark, primaryLight, surface string
var dank16JSON string
var importArgs []string
@@ -202,7 +216,7 @@ func buildOnce(opts *Options) error {
surface = extractNestedColor(opts.StockColors, "surface", "dark")
if primaryDark == "" {
return fmt.Errorf("failed to extract primary dark from stock colors")
return false, fmt.Errorf("failed to extract primary dark from stock colors")
}
if primaryLight == "" {
primaryLight = primaryDark
@@ -216,14 +230,14 @@ func buildOnce(opts *Options) error {
args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()}
args = append(args, importArgs...)
if err := runMatugen(args); err != nil {
return err
return false, err
}
} else {
log.Infof("Using dynamic theme from %s: %s", opts.Kind, opts.Value)
matJSON, err := runMatugenDryRun(opts)
if err != nil {
return fmt.Errorf("matugen dry-run failed: %w", err)
return false, fmt.Errorf("matugen dry-run failed: %w", err)
}
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
@@ -231,7 +245,7 @@ func buildOnce(opts *Options) error {
surface = extractMatugenColor(matJSON, "surface", "dark")
if primaryDark == "" {
return fmt.Errorf("failed to extract primary color")
return false, fmt.Errorf("failed to extract primary color")
}
if primaryLight == "" {
primaryLight = primaryDark
@@ -252,10 +266,19 @@ func buildOnce(opts *Options) error {
args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name())
args = append(args, importArgs...)
if err := runMatugen(args); err != nil {
return err
return false, err
}
}
newColors, _ := os.ReadFile(opts.ColorsOutput())
if bytes.Equal(oldColors, newColors) && len(oldColors) > 0 {
return false, nil
}
if opts.ColorsOnly {
return true, nil
}
if isDMSGTKActive(opts.ConfigDir) {
switch opts.Mode {
case ColorModeLight:
@@ -273,7 +296,7 @@ func buildOnce(opts *Options) error {
signalTerminals(opts)
return nil
return true, nil
}
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
@@ -313,6 +336,10 @@ output_path = '%s'
`, opts.ShellDir, opts.ColorsOutput())
if opts.ColorsOnly {
return nil
}
homeDir, _ := os.UserHomeDir()
for _, tmpl := range templateRegistry {
if opts.ShouldSkipTemplate(tmpl.ID) {
@@ -579,10 +606,10 @@ func detectMatugenVersionLocked() (matugenFlags, error) {
matugenVersionOK = true
if matugenSupportsCOE {
log.Infof("Matugen %s supports --continue-on-error", versionStr)
log.Debugf("Matugen %s detected: continue-on-error support enabled", versionStr)
}
if matugenIsV4 {
log.Infof("Matugen %s: using v4 flags", versionStr)
log.Debugf("Matugen %s detected: using v4 compatibility flags", versionStr)
}
return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil
}

View File

@@ -3,6 +3,7 @@ package matugen
import (
"os"
"path/filepath"
"strings"
"testing"
mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
@@ -392,3 +393,51 @@ func TestSubstituteVars(t *testing.T) {
})
}
}
func TestBuildMergedConfigColorsOnly(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
baseConfig := "[config]\ncustom_keywords = []\n"
if err := os.WriteFile(filepath.Join(configsDir, "base.toml"), []byte(baseConfig), 0o644); err != nil {
t.Fatalf("failed to write base config: %v", err)
}
cfgFile, err := os.CreateTemp(tempDir, "merged-*.toml")
if err != nil {
t.Fatalf("failed to create temp config: %v", err)
}
defer os.Remove(cfgFile.Name())
defer cfgFile.Close()
opts := &Options{
ShellDir: shellDir,
ConfigDir: filepath.Join(tempDir, "config"),
StateDir: filepath.Join(tempDir, "state"),
ColorsOnly: true,
}
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
t.Fatalf("buildMergedConfig failed: %v", err)
}
if err := cfgFile.Close(); err != nil {
t.Fatalf("failed to close merged config: %v", err)
}
output, err := os.ReadFile(cfgFile.Name())
if err != nil {
t.Fatalf("failed to read merged config: %v", err)
}
content := string(output)
assert.Contains(t, content, "[templates.dank]")
assert.Contains(t, content, "output_path = '"+filepath.Join(opts.StateDir, "dms-colors.json")+"'")
assert.NotContains(t, content, "[templates.gtk]")
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
}

View File

@@ -2,6 +2,7 @@ package matugen
import (
"context"
"errors"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -93,10 +94,13 @@ func (q *Queue) runWorker() {
err := Run(job.Options)
var result Result
if err != nil {
result = Result{Success: false, Error: err}
} else {
switch {
case err == nil:
result = Result{Success: true}
case errors.Is(err, ErrNoChanges):
result = Result{Success: true}
default:
result = Result{Success: false, Error: err}
}
q.finishJob(result)

View File

@@ -6,6 +6,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/godbus/dbus/v5"
@@ -59,7 +60,11 @@ func Send(n Notification) error {
hints := map[string]dbus.Variant{}
if n.FilePath != "" {
hints["image_path"] = dbus.MakeVariant(n.FilePath)
imgPath := n.FilePath
if !strings.HasPrefix(imgPath, "file://") {
imgPath = "file://" + imgPath
}
hints["image_path"] = dbus.MakeVariant(imgPath)
}
obj := conn.Object(notifyDest, notifyPath)

View File

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

View File

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

View File

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

View File

@@ -311,6 +311,10 @@ func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed ma
select {
case m.eventQueue <- func() {
time.Sleep(300 * time.Millisecond)
log.Infof("[Bluetooth] Auto-trusting newly paired device: %s", devicePath)
if err := m.TrustDevice(devicePath, true); err != nil {
log.Warnf("[Bluetooth] Auto-trust failed: %v", err)
}
log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
if err := m.ConnectDevice(devicePath); err != nil {
log.Warnf("[Bluetooth] Auto-connect failed: %v", err)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,5 +5,6 @@ const (
dbusPath = "/org/freedesktop/login1"
dbusManagerInterface = "org.freedesktop.login1.Manager"
dbusSessionInterface = "org.freedesktop.login1.Session"
dbusUserInterface = "org.freedesktop.login1.User"
dbusPropsInterface = "org.freedesktop.DBus.Properties"
)

View File

@@ -17,15 +17,8 @@ func NewManager() (*Manager, error) {
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
}
sessionID := os.Getenv("XDG_SESSION_ID")
if sessionID == "" {
sessionID = "self"
}
m := &Manager{
state: &SessionState{
SessionID: sessionID,
},
state: &SessionState{},
stateMutex: sync.RWMutex{},
stopChan: make(chan struct{}),
@@ -60,12 +53,13 @@ func (m *Manager) initialize() error {
m.initializeFallbackDelay()
sessionPath, err := m.getSession(m.state.SessionID)
sessionID, sessionPath, err := m.discoverSession()
if err != nil {
return fmt.Errorf("failed to get session path: %w", err)
}
m.stateMutex.Lock()
m.state.SessionID = sessionID
m.state.SessionPath = string(sessionPath)
m.sessionPath = sessionPath
m.stateMutex.Unlock()
@@ -79,6 +73,41 @@ func (m *Manager) initialize() error {
return nil
}
func (m *Manager) discoverSession() (string, dbus.ObjectPath, error) {
// 1. Explicit XDG_SESSION_ID
if id := os.Getenv("XDG_SESSION_ID"); id != "" {
if path, err := m.getSession(id); err == nil {
fmt.Fprintf(os.Stderr, "loginctl: using XDG_SESSION_ID=%s\n", id)
return id, path, nil
}
}
// 2. PID-based lookup (works when caller is inside a session cgroup)
if id, path, err := m.getSessionByPID(uint32(os.Getpid())); err == nil {
fmt.Fprintf(os.Stderr, "loginctl: found session %s via PID\n", id)
return id, path, nil
}
// 3. User's primary display session (handles UWSM and similar)
if id, path, err := m.getUserDisplaySession(); err == nil {
fmt.Fprintf(os.Stderr, "loginctl: found session %s via User.Display\n", id)
return id, path, nil
}
// 4. Score all sessions for current UID
if id, path, err := m.findBestSession(); err == nil {
fmt.Fprintf(os.Stderr, "loginctl: found session %s via ListSessions scoring\n", id)
return id, path, nil
}
// 5. Last resort: "self"
path, err := m.getSession("self")
if err != nil {
return "", "", fmt.Errorf("%w", err)
}
return "self", path, nil
}
func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
var out dbus.ObjectPath
err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out)
@@ -88,6 +117,166 @@ func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
return out, nil
}
func (m *Manager) getSessionByPID(pid uint32) (string, dbus.ObjectPath, error) {
var path dbus.ObjectPath
if err := m.managerObj.Call(dbusManagerInterface+".GetSessionByPID", 0, pid).Store(&path); err != nil {
return "", "", err
}
sessionObj := m.conn.Object(dbusDest, path)
var id dbus.Variant
if err := sessionObj.Call(dbusPropsInterface+".Get", 0, dbusSessionInterface, "Id").Store(&id); err != nil {
return "", "", err
}
return id.Value().(string), path, nil
}
func (m *Manager) getUserDisplaySession() (string, dbus.ObjectPath, error) {
uid := uint32(os.Getuid())
var userPath dbus.ObjectPath
if err := m.managerObj.Call(dbusManagerInterface+".GetUser", 0, uid).Store(&userPath); err != nil {
return "", "", err
}
userObj := m.conn.Object(dbusDest, userPath)
var display dbus.Variant
if err := userObj.Call(dbusPropsInterface+".Get", 0, dbusUserInterface, "Display").Store(&display); err != nil {
return "", "", err
}
pair, ok := display.Value().([]any)
if !ok || len(pair) < 2 {
return "", "", fmt.Errorf("unexpected Display format")
}
sessionID, _ := pair[0].(string)
sessionPath, _ := pair[1].(dbus.ObjectPath)
if sessionID == "" || sessionPath == "" {
return "", "", fmt.Errorf("empty Display session")
}
return sessionID, sessionPath, nil
}
type sessionCandidate struct {
id string
path dbus.ObjectPath
}
func (m *Manager) findBestSession() (string, dbus.ObjectPath, error) {
// ListSessions returns a(susso): [][]any where each entry is [id, uid, name, seat, path]
var raw [][]any
if err := m.managerObj.Call(dbusManagerInterface+".ListSessions", 0).Store(&raw); err != nil {
return "", "", err
}
uid := uint32(os.Getuid())
var candidates []sessionCandidate
for _, entry := range raw {
if len(entry) < 5 {
continue
}
entryUID, _ := entry[1].(uint32)
if entryUID != uid {
continue
}
id, _ := entry[0].(string)
path, _ := entry[4].(dbus.ObjectPath)
if id != "" && path != "" {
candidates = append(candidates, sessionCandidate{id: id, path: path})
}
}
if len(candidates) == 0 {
return "", "", fmt.Errorf("no sessions for uid %d", uid)
}
bestScore := -1
var best sessionCandidate
for _, c := range candidates {
score := m.scoreSession(c.path)
if score > bestScore {
bestScore = score
best = c
}
}
if bestScore < 0 {
return "", "", fmt.Errorf("no viable session found")
}
return best.id, best.path, nil
}
func (m *Manager) scoreSession(path dbus.ObjectPath) int {
obj := m.conn.Object(dbusDest, path)
var props map[string]dbus.Variant
if err := obj.Call(dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props); err != nil {
return -1
}
getStr := func(key string) string {
if v, ok := props[key]; ok {
if s, ok := v.Value().(string); ok {
return s
}
}
return ""
}
getBool := func(key string) bool {
if v, ok := props[key]; ok {
if b, ok := v.Value().(bool); ok {
return b
}
}
return false
}
getUint32 := func(key string) uint32 {
if v, ok := props[key]; ok {
if u, ok := v.Value().(uint32); ok {
return u
}
}
return 0
}
class := getStr("Class")
if class != "user" {
return -1
}
if getBool("Remote") {
return -1
}
score := 0
if getBool("Active") {
score += 100
}
switch getStr("Type") {
case "wayland", "x11":
score += 80
case "tty":
score += 10
}
if v, ok := props["Seat"]; ok {
if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
if seat, ok := seatArr[0].(string); ok && seat != "" {
score += 40
if seat == "seat0" {
score += 10
}
}
}
}
if getUint32("VTNr") > 0 {
score += 20
}
return score
}
func (m *Manager) refreshSessionBinding() error {
if m.managerObj == nil || m.conn == nil {
return fmt.Errorf("manager not fully initialized")

View File

@@ -158,18 +158,26 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo
channel := frequencyToChannel(freq)
isConnected := ssid == currentSSID && bssid == currentBSSID
rate := maxBitrate / 1000
if isConnected {
if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 {
rate = devBitrate / 1000
}
}
network := WiFiNetwork{
SSID: ssid,
BSSID: bssid,
Signal: strength,
Secured: secured,
Enterprise: enterprise,
Connected: ssid == currentSSID && bssid == currentBSSID,
Connected: isConnected,
Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid],
Frequency: freq,
Mode: modeStr,
Rate: maxBitrate / 1000,
Rate: rate,
Channel: channel,
}
@@ -455,19 +463,27 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
channel := frequencyToChannel(freq)
isConnected := ssid == currentSSID
rate := maxBitrate / 1000
if isConnected {
if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 {
rate = devBitrate / 1000
}
}
network := WiFiNetwork{
SSID: ssid,
BSSID: bssid,
Signal: strength,
Secured: secured,
Enterprise: enterprise,
Connected: ssid == currentSSID,
Connected: isConnected,
Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid],
Hidden: hiddenSSIDs[ssid],
Frequency: freq,
Mode: modeStr,
Rate: maxBitrate / 1000,
Rate: rate,
Channel: channel,
}
@@ -1003,19 +1019,27 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
channel := frequencyToChannel(freq)
isConnected := connected && apSSID == ssid
rate := maxBitrate / 1000
if isConnected {
if devBitrate, err := devInfo.wireless.GetPropertyBitrate(); err == nil && devBitrate > 0 {
rate = devBitrate / 1000
}
}
network := WiFiNetwork{
SSID: apSSID,
BSSID: apBSSID,
Signal: strength,
Secured: secured,
Enterprise: enterprise,
Connected: connected && apSSID == ssid,
Connected: isConnected,
Saved: savedSSIDs[apSSID],
Autoconnect: autoconnectMap[apSSID],
Hidden: hiddenSSIDs[apSSID],
Frequency: freq,
Mode: modeStr,
Rate: maxBitrate / 1000,
Rate: rate,
Channel: channel,
Device: name,
}

View File

@@ -1,6 +1,7 @@
package network
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -28,7 +29,13 @@ func TestDetectResult_HasNetworkdField(t *testing.T) {
func TestDetectNetworkStack_Integration(t *testing.T) {
result, err := DetectNetworkStack()
if err != nil && strings.Contains(err.Error(), "connect system bus") {
t.Skipf("system D-Bus unavailable: %v", err)
}
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotEmpty(t, result.ChosenReason)
if assert.NotNil(t, result) {
assert.NotEmpty(t, result.ChosenReason)
}
}

View File

@@ -1599,6 +1599,13 @@ func Start(printDocs bool) error {
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)

View File

@@ -5,6 +5,7 @@ import (
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
@@ -187,6 +188,29 @@ func (m *Manager) Close() {
})
}
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
ch := lm.Subscribe("thememode")
m.wg.Add(1)
go func() {
defer m.wg.Done()
defer lm.Unsubscribe("thememode")
for {
select {
case <-m.stopChan:
return
case state, ok := <-ch:
if !ok {
return
}
if state.PreparingForSleep {
continue
}
m.TriggerUpdate()
}
}
}()
}
func (m *Manager) schedulerLoop() {
defer m.wg.Done()
@@ -327,10 +351,12 @@ func statesEqual(a, b *State) bool {
}
func (m *Manager) computeSchedule(now time.Time, config Config) (bool, time.Time) {
if config.Mode == "location" {
switch config.Mode {
case "location":
return m.computeLocationSchedule(now, config)
default:
return computeTimeSchedule(now, config)
}
return computeTimeSchedule(now, config)
}
func computeTimeSchedule(now time.Time, config Config) (bool, time.Time) {
@@ -381,10 +407,10 @@ func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, t
}
times, cond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
if cond != wayland.SunNormal {
if cond == wayland.SunMidnightSun {
return true, startOfNextDay(now)
}
switch cond {
case wayland.SunMidnightSun:
return true, startOfNextDay(now)
case wayland.SunPolarNight:
return false, startOfNextDay(now)
}
@@ -397,10 +423,10 @@ func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, t
nextDay := startOfNextDay(now)
nextTimes, nextCond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, nextDay, config.ElevationTwilight, config.ElevationDaylight)
if nextCond != wayland.SunNormal {
if nextCond == wayland.SunMidnightSun {
return true, startOfNextDay(nextDay)
}
switch nextCond {
case wayland.SunMidnightSun:
return true, startOfNextDay(nextDay)
case wayland.SunPolarNight:
return false, startOfNextDay(nextDay)
}
@@ -413,13 +439,7 @@ func startOfNextDay(t time.Time) time.Time {
}
func validateHourMinute(hour, minute int) bool {
if hour < 0 || hour > 23 {
return false
}
if minute < 0 || minute > 59 {
return false
}
return true
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59
}
func (m *Manager) ValidateSchedule(startHour, startMinute, endHour, endMinute int) error {

View File

@@ -2,10 +2,10 @@ package wlcontext
import (
"fmt"
"golang.org/x/sys/unix"
"os"
"sync"
"golang.org/x/sys/unix"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -123,6 +123,9 @@ func (sc *SharedContext) eventDispatcher() {
{Fd: int32(sc.wakeR), Events: unix.POLLIN},
}
consecutiveErrors := 0
const maxConsecutiveErrors = 20
for {
sc.drainCmdQueue()
@@ -153,9 +156,19 @@ func (sc *SharedContext) eventDispatcher() {
}
if err := ctx.Dispatch(); err != nil && !os.IsTimeout(err) {
log.Errorf("Wayland connection error: %v", err)
return
consecutiveErrors++
log.Warnf("Wayland connection error (%d/%d): %v", consecutiveErrors, maxConsecutiveErrors, err)
if consecutiveErrors >= maxConsecutiveErrors {
log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors)
return
}
time.Sleep(100 * time.Millisecond * time.Duration(consecutiveErrors))
continue
}
consecutiveErrors = 0
}
}

View File

@@ -13,7 +13,7 @@ func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
m := &Manager{
display: display,
ctx: display.Context(),
cmdq: make(chan cmd, 128),
cmdq: make(chan cmd, 512),
stopChan: make(chan struct{}),
dirty: make(chan struct{}, 1),
fatalError: make(chan error, 1),

View File

@@ -3,6 +3,7 @@ package tui
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
@@ -42,6 +43,9 @@ type Model struct {
sudoPassword string
existingConfigs []ExistingConfigInfo
fingerprintFailed bool
availablePrivesc []privesc.Tool
selectedPrivesc int
}
func NewModel(version string, logFilePath string) Model {
@@ -147,6 +151,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateGentooUseFlagsState(msg)
case StateGentooGCCCheck:
return m.updateGentooGCCCheckState(msg)
case StateSelectPrivesc:
return m.updateSelectPrivescState(msg)
case StateAuthMethodChoice:
return m.updateAuthMethodChoiceState(msg)
case StateFingerprintAuth:
@@ -189,6 +195,8 @@ func (m Model) View() string {
return m.viewGentooUseFlags()
case StateGentooGCCCheck:
return m.viewGentooGCCCheck()
case StateSelectPrivesc:
return m.viewSelectPrivesc()
case StateAuthMethodChoice:
return m.viewAuthMethodChoice()
case StateFingerprintAuth:

View File

@@ -10,6 +10,7 @@ const (
StateDependencyReview
StateGentooUseFlags
StateGentooGCCCheck
StateSelectPrivesc
StateAuthMethodChoice
StateFingerprintAuth
StatePasswordPrompt

View File

@@ -7,6 +7,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
tea "github.com/charmbracelet/bubbletea"
)
@@ -80,19 +81,24 @@ func (m Model) viewDependencyReview() string {
}
}
note := ""
if dep.Name == "dms-greeter" {
note = m.styles.Subtle.Render(" (selection replaces your current display manager)")
}
var line string
if i == m.selectedDep {
line = fmt.Sprintf("▶ %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status)
if dep.Version != "" {
line += fmt.Sprintf(" (%s)", dep.Version)
}
line = m.styles.SelectedOption.Render(line)
line = m.styles.SelectedOption.Render(line) + note
} else {
line = fmt.Sprintf(" %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status)
if dep.Version != "" {
line += fmt.Sprintf(" (%s)", dep.Version)
}
line = m.styles.Normal.Render(line)
line = m.styles.Normal.Render(line) + note
}
b.WriteString(line)
@@ -115,6 +121,13 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = StateError
} else {
m.dependencies = depsMsg.deps
// dms-greeter is opt-in skipped by default
for _, dep := range depsMsg.deps {
if dep.Name == "dms-greeter" {
m.disabledItems["dms-greeter"] = true
break
}
}
m.state = StateDependencyReview
}
return m, m.listenForLogs()
@@ -167,16 +180,7 @@ func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
// Check if fingerprint is enabled
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0 // Default to fingerprint
return m, nil
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
return m, nil
}
return m.enterAuthPhase()
case "esc":
m.state = StateSelectWindowManager
return m, nil
@@ -230,6 +234,41 @@ func (m Model) installPackages() tea.Cmd {
// Convert installer messages to TUI messages
go func() {
for msg := range installerProgressChan {
// Run optional greeter setup
if msg.Phase == distros.PhaseComplete && msg.IsComplete && msg.Error == nil {
greeterSelected := false
for _, dep := range m.dependencies {
if dep.Name == "dms-greeter" && !m.disabledItems["dms-greeter"] {
greeterSelected = true
break
}
}
if greeterSelected {
compositorName := "niri"
if m.selectedWM == 1 {
compositorName = "Hyprland"
}
m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.92,
step: "Configuring DMS greeter...",
logOutput: "Starting automated greeter setup...",
}
greeterLogFunc := func(line string) {
m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.94,
step: "Configuring DMS greeter...",
logOutput: line,
}
}
if err := greeter.AutoSetupGreeter(compositorName, m.sudoPassword, greeterLogFunc); err != nil {
m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.96,
step: "Greeter setup warning",
logOutput: fmt.Sprintf("⚠ Greeter auto-setup warning (non-fatal): %v", err),
}
}
}
}
tuiMsg := packageInstallProgressMsg{
progress: msg.Progress,
step: msg.Step,

View File

@@ -56,14 +56,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = StateGentooGCCCheck
return m, nil
}
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
}
return m, nil
return m.enterAuthPhase()
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
@@ -75,14 +68,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.selectedWM == 1 {
return m, m.checkGCCVersion()
}
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
}
return m, nil
return m.enterAuthPhase()
case "esc":
m.state = StateDependencyReview
return m, nil

View File

@@ -139,7 +139,7 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string {
if isGit {
return "dms-shell-git"
}
return "dms-shell-bin"
return "dms-shell"
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE:
if isGit {
return "dms-git"

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
tea "github.com/charmbracelet/bubbletea"
)
@@ -274,8 +275,7 @@ func (m Model) delayThenReturn() tea.Cmd {
func (m Model) tryFingerprint() tea.Cmd {
return func() tea.Msg {
clearCmd := exec.Command("sudo", "-k")
clearCmd.Run()
_ = privesc.ClearCache(context.Background())
tmpDir := os.TempDir()
askpassScript := filepath.Join(tmpDir, fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano()))
@@ -289,15 +289,9 @@ func (m Model) tryFingerprint() tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-A", "-v")
cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript))
err := cmd.Run()
if err != nil {
if err := privesc.ValidateWithAskpass(ctx, askpassScript); err != nil {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: true}
}
}
@@ -307,32 +301,9 @@ func (m Model) validatePassword(password string) tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
if err := privesc.ValidatePassword(ctx, password); err != nil {
return passwordValidMsg{password: "", valid: false}
}
if err := cmd.Start(); err != nil {
return passwordValidMsg{password: "", valid: false}
}
_, err = fmt.Fprintf(stdin, "%s\n", password)
stdin.Close()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
err = cmd.Wait()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: password, valid: true}
}
}

View File

@@ -0,0 +1,133 @@
package tui
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) viewSelectPrivesc() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
b.WriteString(m.styles.Title.Render("Privilege Escalation Tool"))
b.WriteString("\n\n")
b.WriteString(m.styles.Normal.Render("Multiple privilege tools are available. Choose one for installation:"))
b.WriteString("\n\n")
for i, t := range m.availablePrivesc {
label := fmt.Sprintf("%s — %s", t.Name(), privescToolDescription(t))
switch i {
case m.selectedPrivesc:
b.WriteString(m.styles.SelectedOption.Render("▶ " + label))
default:
b.WriteString(m.styles.Normal.Render(" " + label))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.styles.Subtle.Render(fmt.Sprintf("Set %s=<tool> to skip this prompt in future runs.", privesc.EnvVar)))
b.WriteString("\n\n")
b.WriteString(m.styles.Subtle.Render("↑/↓: Navigate, Enter: Select, Esc: Back"))
return b.String()
}
func (m Model) updateSelectPrivescState(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, m.listenForLogs()
}
switch keyMsg.String() {
case "up":
if m.selectedPrivesc > 0 {
m.selectedPrivesc--
}
case "down":
if m.selectedPrivesc < len(m.availablePrivesc)-1 {
m.selectedPrivesc++
}
case "enter":
chosen := m.availablePrivesc[m.selectedPrivesc]
if err := privesc.SetTool(chosen); err != nil {
m.err = fmt.Errorf("failed to select %s: %w", chosen.Name(), err)
m.state = StateError
return m, nil
}
return m.routeToAuthAfterPrivesc()
case "esc":
m.state = StateDependencyReview
return m, nil
}
return m, nil
}
func privescToolDescription(t privesc.Tool) string {
switch t {
case privesc.ToolSudo:
return "classic sudo (supports password prompt in this installer)"
case privesc.ToolDoas:
return "OpenBSD-style doas (requires persist or nopass in /etc/doas.conf)"
case privesc.ToolRun0:
return "systemd run0 (authenticated via polkit)"
default:
return string(t)
}
}
// routeToAuthAfterPrivesc advances from the privesc-selection screen to the
// right auth flow. Sudo goes through the fingerprint/password path; doas and
// run0 skip password entry and proceed to install.
func (m Model) routeToAuthAfterPrivesc() (tea.Model, tea.Cmd) {
tool, err := privesc.Detect()
if err != nil {
m.err = err
m.state = StateError
return m, nil
}
if tool == privesc.ToolSudo {
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0
return m, nil
}
m.state = StatePasswordPrompt
m.passwordInput.Focus()
return m, nil
}
m.sudoPassword = ""
m.packageProgress = packageInstallProgressMsg{}
m.state = StateInstallingPackages
m.isLoading = true
return m, tea.Batch(m.spinner.Tick, m.installPackages())
}
// enterAuthPhase is called when dependency review (or the Gentoo screens)
// finish. It either routes directly to the sudo/fingerprint flow or shows
// the privesc-tool selection screen when multiple tools are available and
// no $DMS_PRIVESC override is set.
func (m Model) enterAuthPhase() (tea.Model, tea.Cmd) {
tools := privesc.AvailableTools()
_, envSet := privesc.EnvOverride()
if len(tools) == 0 {
m.err = fmt.Errorf("no supported privilege tool (sudo/doas/run0) found on PATH")
m.state = StateError
return m, nil
}
if envSet || len(tools) == 1 {
return m.routeToAuthAfterPrivesc()
}
m.availablePrivesc = tools
m.selectedPrivesc = 0
m.state = StateSelectPrivesc
return m, nil
}

View File

@@ -864,10 +864,12 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
func formatSizeProperty(name, value string) string {
parts := strings.SplitN(value, " ", 2)
if len(parts) != 2 {
return fmt.Sprintf(" %s { }", name)
if len(parts) == 2 {
return fmt.Sprintf(" %s { %s %s; }", name, parts[0], parts[1])
}
sizeType := parts[0]
sizeValue := parts[1]
return fmt.Sprintf(" %s { %s %s; }", name, sizeType, sizeValue)
// Bare number without type prefix — default to "fixed"
if _, err := strconv.Atoi(value); err == nil {
return fmt.Sprintf(" %s { fixed %s; }", name, value)
}
return fmt.Sprintf(" %s { }", name)
}

View File

@@ -29,6 +29,7 @@ Depends: ${misc:Depends},
qml6-module-qtquick-templates,
qml6-module-qtquick-window,
qt6ct
Suggests: cups-pk-helper
Provides: dms
Conflicts: dms
Replaces: dms

View File

@@ -0,0 +1,9 @@
<services>
<!-- Download dms-qml source tarball from GitHub releases (greeter + quickshell content) -->
<service name="download_url">
<param name="protocol">https</param>
<param name="host">github.com</param>
<param name="path">/AvengeMedia/DankMaterialShell/releases/download/v1.4.2/dms-qml.tar.gz</param>
<param name="filename">dms-qml.tar.gz</param>
</service>
</services>

View File

@@ -0,0 +1,6 @@
dms-greeter (1.4.2db8) unstable; urgency=medium
* Initial Debian OBS package
* Port from Ubuntu/Fedora packaging
-- Avenge Media <AvengeMedia.US@gmail.com> Sat, 21 Feb 2026 00:00:00 +0000

View File

@@ -0,0 +1,23 @@
Source: dms-greeter
Section: x11
Priority: optional
Maintainer: Avenge Media <AvengeMedia.US@gmail.com>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Homepage: https://github.com/AvengeMedia/DankMaterialShell
Vcs-Browser: https://github.com/AvengeMedia/DankMaterialShell
Vcs-Git: https://github.com/AvengeMedia/DankMaterialShell.git
Package: dms-greeter
Architecture: any
Depends: ${misc:Depends},
greetd,
quickshell-git | quickshell
Suggests: niri | hyprland | sway
Description: DankMaterialShell greeter for greetd
DankMaterialShell greeter for greetd login manager. A modern, Material Design 3
inspired greeter interface built with Quickshell for Wayland compositors.
.
Supports multiple compositors including Niri, Hyprland, and Sway with automatic
compositor detection and configuration. Features session selection, user
authentication, and dynamic theming.

View File

@@ -0,0 +1,27 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: dms-greeter
Upstream-Contact: Avenge Media LLC <AvengeMedia.US@gmail.com>
Source: https://github.com/AvengeMedia/DankMaterialShell
Files: *
Copyright: 2026 Avenge Media LLC
License: MIT
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,108 @@
#!/bin/sh
set -e
case "$1" in
configure)
# Create greeter user/group if they don't exist
if ! getent group greeter >/dev/null; then
addgroup --system greeter
fi
if ! getent passwd greeter >/dev/null; then
adduser --system --ingroup greeter --home /var/lib/greeter \
--shell /bin/bash --gecos "System Greeter" greeter
fi
if [ -d /var/cache/dms-greeter ]; then
chown -R greeter:greeter /var/cache/dms-greeter 2>/dev/null || true
fi
if [ -d /var/lib/greeter ]; then
chown -R greeter:greeter /var/lib/greeter 2>/dev/null || true
fi
# Check and set graphical.target as default
CURRENT_TARGET=$(systemctl get-default 2>/dev/null || echo "unknown")
if [ "$CURRENT_TARGET" != "graphical.target" ]; then
systemctl set-default graphical.target >/dev/null 2>&1 || true
TARGET_STATUS="Set to graphical.target (was: $CURRENT_TARGET) ✓"
else
TARGET_STATUS="Already graphical.target ✓"
fi
GREETD_CONFIG="/etc/greetd/config.toml"
CONFIG_STATUS="Not modified (already configured)"
# Check if niri or hyprland exists
COMPOSITOR="niri"
if ! command -v niri >/dev/null 2>&1; then
if command -v Hyprland >/dev/null 2>&1; then
COMPOSITOR="hyprland"
fi
fi
# If config doesn't exist, create a default one
if [ ! -f "$GREETD_CONFIG" ]; then
mkdir -p /etc/greetd
cat > "$GREETD_CONFIG" << 'GREETD_EOF'
[terminal]
vt = 1
[default_session]
user = "greeter"
command = "/usr/bin/dms-greeter --command COMPOSITOR_PLACEHOLDER"
GREETD_EOF
sed -i "s|COMPOSITOR_PLACEHOLDER|$COMPOSITOR|" "$GREETD_CONFIG"
CONFIG_STATUS="Created new config with $COMPOSITOR ✓"
elif ! grep -q "dms-greeter" "$GREETD_CONFIG"; then
# Backup existing config
BACKUP_FILE="${GREETD_CONFIG}.backup-$(date +%Y%m%d-%H%M%S)"
cp "$GREETD_CONFIG" "$BACKUP_FILE" 2>/dev/null || true
# Update command in default_session section
sed -i "/^\[default_session\]/,/^\[/ s|^command =.*|command = \"/usr/bin/dms-greeter --command $COMPOSITOR\"|" "$GREETD_CONFIG"
sed -i '/^\[default_session\]/,/^\[/ s|^user =.*|user = "greeter"|' "$GREETD_CONFIG"
CONFIG_STATUS="Updated existing config (backed up) with $COMPOSITOR ✓"
fi
# Only show banner on initial install
if [ -z "$2" ]; then
cat << 'EOF'
=========================================================================
DMS Greeter Installation Complete!
=========================================================================
Status:
EOF
echo " ✓ Greetd config: $CONFIG_STATUS"
echo " ✓ Default target: $TARGET_STATUS"
cat << 'EOF'
✓ Greeter user: Created
✓ Greeter directories: /var/cache/dms-greeter, /var/lib/greeter
Next steps:
1. Enable the greeter:
dms greeter enable
(This will automatically disable conflicting display managers,
set graphical.target, and enable greetd)
2. Sync your theme with the greeter (optional):
dms greeter sync
3. Check your setup:
dms greeter status
Ready to test? Run: sudo systemctl start greetd
Documentation: https://danklinux.com/docs/dankgreeter/
=========================================================================
EOF
fi
;;
esac
#DEBHELPER#
exit 0

View File

@@ -0,0 +1,14 @@
#!/bin/sh
set -e
case "$1" in
purge)
# Remove greeter cache directory on purge
rm -rf /var/cache/dms-greeter 2>/dev/null || true
;;
esac
#DEBHELPER#
exit 0

View File

@@ -0,0 +1,48 @@
#!/usr/bin/make -f
export DH_VERBOSE = 1
DEB_VERSION := $(shell dpkg-parsechangelog -S Version)
UPSTREAM_VERSION := $(shell echo $(DEB_VERSION) | sed 's/-[^-]*$$//')
%:
dh $@
override_dh_auto_build:
: nothing to build, we use prebuilt tarball content
override_dh_auto_install:
# Same pattern as dms: upstream from combined tarball (native format)
# Build root is either . (we're inside dms-qml) or has dms-qml/ subdir
SOURCE_DIR=""; \
if [ -d dms-qml ]; then SOURCE_DIR="dms-qml"; \
elif [ -f Modules/Greetd/assets/dms-greeter ]; then SOURCE_DIR="."; \
fi; \
if [ -n "$$SOURCE_DIR" ]; then \
mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter && \
( cd $$SOURCE_DIR && tar cf - --exclude=debian . ) | \
( cd debian/dms-greeter/usr/share/quickshell/dms-greeter && tar xf - ) && \
install -Dm755 $$SOURCE_DIR/Modules/Greetd/assets/dms-greeter \
debian/dms-greeter/usr/bin/dms-greeter && \
install -Dm644 $$SOURCE_DIR/Modules/Greetd/README.md \
debian/dms-greeter/usr/share/doc/dms-greeter/README.md && \
install -Dm644 $$SOURCE_DIR/LICENSE \
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE && \
install -Dpm0644 $$SOURCE_DIR/systemd/tmpfiles-dms-greeter.conf \
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf; \
else \
echo "ERROR: No upstream source (dms-qml or Modules/Greetd/assets/dms-greeter)!" && \
echo "Contents of current directory:" && ls -la && exit 1; \
fi
# Remove build and development files
rm -rf debian/dms-greeter/usr/share/quickshell/dms-greeter/core
rm -rf debian/dms-greeter/usr/share/quickshell/dms-greeter/distro
rm -rf debian/dms-greeter/usr/share/quickshell/dms-greeter/.git*
rm -f debian/dms-greeter/usr/share/quickshell/dms-greeter/.gitignore
rm -rf debian/dms-greeter/usr/share/quickshell/dms-greeter/.github
override_dh_auto_clean:
rm -rf dms-qml
# When build root is dms-qml itself, we're inside it - nothing extra to remove
dh_auto_clean

View File

@@ -0,0 +1 @@
3.0 (native)

View File

@@ -0,0 +1 @@
# OBS _service downloads dms-qml.tar.gz; no extra excludes needed

View File

@@ -9,7 +9,7 @@ Vcs-Browser: https://github.com/AvengeMedia/DankMaterialShell
Vcs-Git: https://github.com/AvengeMedia/DankMaterialShell.git
Package: dms
Architecture: amd64
Architecture: amd64 arm64
Depends: ${misc:Depends},
quickshell | quickshell-git,
accountsservice,
@@ -28,6 +28,7 @@ Depends: ${misc:Depends},
qml6-module-qtquick-templates,
qml6-module-qtquick-window,
qt6ct
Suggests: cups-pk-helper
Conflicts: dms-git
Replaces: dms-git
Description: DankMaterialShell - Modern Wayland Desktop Shell

View File

@@ -1,2 +1,3 @@
dms-distropkg-amd64.gz
dms-distropkg-arm64.gz
dms-source.tar.gz

View File

@@ -1,4 +1,5 @@
# Include files that are normally excluded by .gitignore
# These are needed for the build process on Launchpad
tar-ignore = !dms-distropkg-amd64.gz
tar-ignore = !dms-distropkg-arm64.gz
tar-ignore = !dms-source.tar.gz

View File

@@ -37,6 +37,7 @@ Recommends: quickshell-git
# Recommended system packages
Recommends: NetworkManager
Recommends: qt6-qtmultimedia
Suggests: cups-pk-helper
Suggests: qt6ct
%description

View File

@@ -28,6 +28,7 @@ Recommends: danksearch
Recommends: matugen
Recommends: NetworkManager
Recommends: qt6-qtmultimedia
Suggests: cups-pk-helper
Suggests: qt6ct
%description

View File

@@ -2,7 +2,6 @@
config,
lib,
pkgs,
dmsPkgs,
...
}:
let
@@ -10,7 +9,7 @@ let
in
{
packages = [
dmsPkgs.dms-shell
cfg.package
]
++ lib.optional cfg.enableSystemMonitoring cfg.dgop.package
++ lib.optionals cfg.enableVPN [

View File

@@ -8,6 +8,7 @@
let
inherit (lib) types;
cfg = config.programs.dank-material-shell.greeter;
cfgDms = config.programs.dank-material-shell;
inherit (config.services.greetd.settings.default_session) user;
@@ -23,19 +24,20 @@ let
lib.makeBinPath [
cfg.quickshell.package
compositorPackage
pkgs.glib # provides gdbus, used by the fprintd hardware probe in GreeterContent.qml
]
}
${
lib.escapeShellArgs (
[
"sh"
"${../../quickshell/Modules/Greetd/assets/dms-greeter}"
"${cfg.package}/share/quickshell/dms/Modules/Greetd/assets/dms-greeter"
"--cache-dir"
cacheDir
"--command"
cfg.compositor.name
"-p"
"${dmsPkgs.dms-shell}/share/quickshell/dms"
"${cfg.package}/share/quickshell/dms"
]
++ lib.optionals (cfg.compositor.customConfig != "") [
"-C"
@@ -65,6 +67,21 @@ in
options.programs.dank-material-shell.greeter = {
enable = lib.mkEnableOption "DankMaterialShell greeter";
package = lib.mkOption {
type = types.package;
default = if cfgDms.enable or false then cfgDms.package else dmsPkgs.dms-shell;
defaultText = lib.literalExpression ''
if config.programs.dank-material-shell.enable
then config.programs.dank-material-shell.package
else built from source;
'';
description = ''
The DankMaterialShell package to use for the greeter.
Defaults to the package from `programs.dank-material-shell` if it is enabled,
otherwise defaults to building from source.
'';
};
compositor.name = lib.mkOption {
type = types.enum [
"niri"
@@ -179,7 +196,9 @@ in
fi
if [ -f settings.json ]; then
if cp "$(${jq} -r '.customThemeFile' settings.json)" custom-theme.json; then
theme_file="$(${jq} -r '.customThemeFile // empty' settings.json)"
if [ -f "$theme_file" ] && [ -r "$theme_file" ]; then
cp "$theme_file" custom-theme.json
mv settings.json settings.orig.json
${jq} '.customThemeFile = "${cacheDir}/custom-theme.json"' settings.orig.json > settings.json
fi

View File

@@ -2,7 +2,6 @@
config,
pkgs,
lib,
dmsPkgs,
...
}@args:
let
@@ -13,7 +12,6 @@ let
config
pkgs
lib
dmsPkgs
;
};
hasPluginSettings = lib.any (plugin: plugin.settings != { }) (
@@ -96,7 +94,7 @@ in
};
Service = {
ExecStart = lib.getExe dmsPkgs.dms-shell + " run --session";
ExecStart = lib.getExe cfg.package + " run --session";
Restart = "on-failure";
};

View File

@@ -2,7 +2,6 @@
config,
pkgs,
lib,
dmsPkgs,
...
}@args:
let
@@ -12,7 +11,6 @@ let
config
pkgs
lib
dmsPkgs
;
};
in
@@ -36,7 +34,7 @@ in
restartIfChanged = cfg.systemd.restartIfChanged;
serviceConfig = {
ExecStart = lib.getExe dmsPkgs.dms-shell + " run --session";
ExecStart = lib.getExe cfg.package + " run --session";
Restart = "on-failure";
};
};

View File

@@ -26,6 +26,9 @@ in
options.programs.dank-material-shell = {
enable = lib.mkEnableOption "DankMaterialShell";
package = lib.mkPackageOption dmsPkgs "dms-shell" {
extraDescription = "The DankMaterialShell package to use (defaults to be built from source)";
};
systemd = {
enable = lib.mkEnableOption "DankMaterialShell systemd startup";

View File

@@ -25,6 +25,7 @@ Recommends: matugen
Recommends: quickshell-git
Recommends: NetworkManager
Recommends: qt6-qtmultimedia
Suggests: cups-pk-helper
Suggests: qt6ct
Provides: dms

View File

@@ -0,0 +1,322 @@
# Spec for DMS Greeter - OpenSUSE/OBS
%global debug_package %{nil}
%global version VERSION_PLACEHOLDER
%global pkg_summary DankMaterialShell greeter for greetd
Name: dms-greeter
Version: %{version}
Release: RELEASE_PLACEHOLDER%{?dist}
Summary: %{pkg_summary}
License: MIT
URL: https://github.com/AvengeMedia/DankMaterialShell
Source0: dms-qml.tar.gz
BuildRequires: gzip
BuildRequires: wget
BuildRequires: systemd-rpm-macros
Requires: greetd
Requires: (quickshell-git or quickshell)
Requires(post): /usr/sbin/useradd
Requires(post): /usr/sbin/groupadd
Recommends: policycoreutils-python-utils
Recommends: acl
Suggests: niri
Suggests: hyprland
Suggests: sway
%description
DankMaterialShell greeter for greetd login manager. A modern, Material Design 3
inspired greeter interface built with Quickshell for Wayland compositors.
Supports multiple compositors including Niri, Hyprland, and Sway with automatic
compositor detection and configuration. Features session selection, user
authentication, and dynamic theming.
%prep
%setup -q -c -n dms-qml
%build
%install
# Install greeter files to shared data location
install -dm755 %{buildroot}%{_datadir}/quickshell/dms-greeter
cp -r %{_builddir}/dms-qml/* %{buildroot}%{_datadir}/quickshell/dms-greeter/
install -Dm755 %{_builddir}/dms-qml/Modules/Greetd/assets/dms-greeter %{buildroot}%{_bindir}/dms-greeter
install -Dm644 %{_builddir}/dms-qml/Modules/Greetd/README.md %{buildroot}%{_docdir}/dms-greeter/README.md
install -Dpm0644 %{_builddir}/dms-qml/systemd/tmpfiles-dms-greeter.conf %{buildroot}%{_tmpfilesdir}/dms-greeter.conf
install -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
install -dm755 %{buildroot}%{_sharedstatedir}/greeter
# Remove build and development files
rm -rf %{buildroot}%{_datadir}/quickshell/dms-greeter/.git*
rm -f %{buildroot}%{_datadir}/quickshell/dms-greeter/.gitignore
rm -rf %{buildroot}%{_datadir}/quickshell/dms-greeter/.github
rm -rf %{buildroot}%{_datadir}/quickshell/dms-greeter/distro
%posttrans
if [ -d "%{_sysconfdir}/xdg/quickshell/dms-greeter" ]; then
rmdir "%{_sysconfdir}/xdg/quickshell/dms-greeter" 2>/dev/null || true
rmdir "%{_sysconfdir}/xdg/quickshell" 2>/dev/null || true
rmdir "%{_sysconfdir}/xdg" 2>/dev/null || true
fi
%files
%dir %{_docdir}/dms-greeter
%license %{_docdir}/dms-greeter/LICENSE
%doc %{_docdir}/dms-greeter/README.md
%{_bindir}/dms-greeter
%dir %{_datadir}/quickshell
%{_datadir}/quickshell/dms-greeter/
%{_tmpfilesdir}/%{name}.conf
%pre
# Create greeter user/group if they don't exist
getent group greeter >/dev/null || groupadd -r greeter
getent passwd greeter >/dev/null || \
useradd -r -g greeter -d %{_sharedstatedir}/greeter -s /bin/bash \
-c "System Greeter" greeter
exit 0
%post
# SELinux contexts (no-op on OpenSUSE - semanage/restorecon not present)
if [ -x /usr/sbin/semanage ] && [ -x /usr/sbin/restorecon ]; then
semanage fcontext -a -t bin_t '%{_bindir}/dms-greeter' >/dev/null 2>&1 || true
restorecon %{_bindir}/dms-greeter >/dev/null 2>&1 || true
semanage fcontext -a -t user_home_dir_t '%{_sharedstatedir}/greeter(/.*)?' >/dev/null 2>&1 || true
restorecon -R %{_sharedstatedir}/greeter >/dev/null 2>&1 || true
semanage fcontext -a -t cache_home_t '%{_localstatedir}/cache/dms-greeter(/.*)?' >/dev/null 2>&1 || true
restorecon -R %{_localstatedir}/cache/dms-greeter >/dev/null 2>&1 || true
semanage fcontext -a -t usr_t '%{_datadir}/quickshell/dms-greeter(/.*)?' >/dev/null 2>&1 || true
restorecon -R %{_datadir}/quickshell/dms-greeter >/dev/null 2>&1 || true
restorecon %{_sysconfdir}/pam.d/greetd >/dev/null 2>&1 || true
fi
# Resolve greeter runtime account/group for distro differences
GREETER_USER="greeter"
for candidate in greeter greetd _greeter; do
if getent passwd "$candidate" >/dev/null 2>&1; then
GREETER_USER="$candidate"
break
fi
done
GREETER_GROUP="$GREETER_USER"
if ! getent group "$GREETER_GROUP" >/dev/null 2>&1; then
for candidate in greeter greetd _greeter; do
if getent group "$candidate" >/dev/null 2>&1; then
GREETER_GROUP="$candidate"
break
fi
done
fi
# Ensure proper ownership of greeter directories
chown -R "$GREETER_USER:$GREETER_GROUP" %{_localstatedir}/cache/dms-greeter 2>/dev/null || true
chown -R "$GREETER_USER:$GREETER_GROUP" %{_sharedstatedir}/greeter 2>/dev/null || true
# Verify PAM configuration
PAM_CONFIG="/etc/pam.d/greetd"
write_greetd_pam_config() {
# openSUSE and Debian families usually expose PAM stacks as common-*
if [ -f /etc/pam.d/common-auth ] && [ -f /etc/pam.d/common-account ] && [ -f /etc/pam.d/common-password ] && [ -f /etc/pam.d/common-session ]; then
cat > "$PAM_CONFIG" << 'PAM_EOF'
#%PAM-1.0
auth include common-auth
account required pam_nologin.so
account include common-account
password include common-password
session required pam_loginuid.so
session optional pam_keyinit.so force revoke
session include common-session
PAM_EOF
return
fi
# Fedora/RHEL style system-auth/postlogin stack
if [ -f /etc/pam.d/system-auth ]; then
if [ -f /etc/pam.d/postlogin ]; then
cat > "$PAM_CONFIG" << 'PAM_EOF'
#%PAM-1.0
auth substack system-auth
auth include postlogin
account required pam_nologin.so
account include system-auth
password include system-auth
session required pam_loginuid.so
session optional pam_keyinit.so force revoke
session include system-auth
session include postlogin
PAM_EOF
else
cat > "$PAM_CONFIG" << 'PAM_EOF'
#%PAM-1.0
auth include system-auth
account required pam_nologin.so
account include system-auth
password include system-auth
session required pam_loginuid.so
session optional pam_keyinit.so force revoke
session include system-auth
PAM_EOF
fi
return
fi
# Last-resort conservative fallback
cat > "$PAM_CONFIG" << 'PAM_EOF'
#%PAM-1.0
auth required pam_unix.so nullok
account required pam_unix.so
password required pam_unix.so nullok sha512
session required pam_unix.so
PAM_EOF
}
if [ ! -f "$PAM_CONFIG" ]; then
write_greetd_pam_config
chmod 644 "$PAM_CONFIG"
[ "$1" -eq 1 ] && echo "Created PAM configuration for greetd"
else
NEEDS_PAM_UPDATE=0
if grep -q "common-auth" "$PAM_CONFIG"; then
if [ ! -f /etc/pam.d/common-auth ]; then
NEEDS_PAM_UPDATE=1
fi
elif grep -q "system-auth" "$PAM_CONFIG"; then
if [ ! -f /etc/pam.d/system-auth ]; then
NEEDS_PAM_UPDATE=1
fi
else
NEEDS_PAM_UPDATE=1
fi
if [ "$NEEDS_PAM_UPDATE" -eq 1 ]; then
cp "$PAM_CONFIG" "$PAM_CONFIG.backup-dms-greeter"
write_greetd_pam_config
chmod 644 "$PAM_CONFIG"
[ "$1" -eq 1 ] && echo "Updated PAM configuration (old config backed up to $PAM_CONFIG.backup-dms-greeter)"
fi
fi
# Auto-configure greetd config
GREETD_CONFIG="/etc/greetd/config.toml"
CONFIG_STATUS="Not modified (already configured)"
COMPOSITOR=""
for candidate in niri Hyprland sway; do
if command -v "$candidate" >/dev/null 2>&1; then
case "$candidate" in
Hyprland)
COMPOSITOR="hyprland"
;;
*)
COMPOSITOR="$candidate"
;;
esac
break
fi
done
if [ ! -f "$GREETD_CONFIG" ]; then
mkdir -p /etc/greetd
if [ -n "$COMPOSITOR" ]; then
cat > "$GREETD_CONFIG" << 'GREETD_EOF'
[terminal]
vt = 1
[default_session]
user = "GREETER_USER_PLACEHOLDER"
command = "/usr/bin/dms-greeter --command COMPOSITOR_PLACEHOLDER"
GREETD_EOF
sed -i "s|GREETER_USER_PLACEHOLDER|$GREETER_USER|" "$GREETD_CONFIG"
sed -i "s|COMPOSITOR_PLACEHOLDER|$COMPOSITOR|" "$GREETD_CONFIG"
CONFIG_STATUS="Created new config with $COMPOSITOR "
else
cat > "$GREETD_CONFIG" << 'GREETD_EOF'
[terminal]
vt = 1
[default_session]
user = "GREETER_USER_PLACEHOLDER"
command = "agreety --cmd /bin/login"
GREETD_EOF
sed -i "s|GREETER_USER_PLACEHOLDER|$GREETER_USER|" "$GREETD_CONFIG"
CONFIG_STATUS="Created safe fallback config (no supported compositor detected)"
fi
elif ! grep -q "dms-greeter" "$GREETD_CONFIG"; then
if [ -n "$COMPOSITOR" ]; then
BACKUP_FILE="${GREETD_CONFIG}.backup-$(date +%%Y%%m%%d-%%H%%M%%S)"
cp "$GREETD_CONFIG" "$BACKUP_FILE" 2>/dev/null || true
sed -i "/^\[default_session\]/,/^\[/ s|^command =.*|command = \"/usr/bin/dms-greeter --command $COMPOSITOR\"|" "$GREETD_CONFIG"
sed -i "/^\[default_session\]/,/^\[/ s|^user =.*|user = \"$GREETER_USER\"|" "$GREETD_CONFIG"
CONFIG_STATUS="Updated existing config (backed up) with $COMPOSITOR "
else
CONFIG_STATUS="Skipped dms-greeter command update (no supported compositor detected)"
fi
fi
# Set graphical.target as default
CURRENT_TARGET=$(systemctl get-default 2>/dev/null || echo "unknown")
if [ "$CURRENT_TARGET" != "graphical.target" ]; then
systemctl set-default graphical.target >/dev/null 2>&1 || true
TARGET_STATUS="Set to graphical.target (was: $CURRENT_TARGET) "
else
TARGET_STATUS="Already graphical.target "
fi
if [ "$1" -eq 1 ]; then
cat << 'EOF'
=========================================================================
DMS Greeter Installation Complete!
=========================================================================
Status:
EOF
echo " Greetd config: $CONFIG_STATUS"
echo " Default target: $TARGET_STATUS"
cat << 'EOF'
Greeter user: Created
Greeter directories: /var/cache/dms-greeter, /var/lib/greeter
SELinux contexts: Applied (if applicable)
Next steps:
1. Enable the greeter:
dms greeter enable
2. Sync your theme with the greeter (optional):
dms greeter sync
3. Check your setup:
dms greeter status
Ready to test? Run: sudo systemctl start greetd
Documentation: https://danklinux.com/docs/dankgreeter/
=========================================================================
EOF
fi
%postun
if [ "$1" -eq 0 ] && [ -x /usr/sbin/semanage ]; then
semanage fcontext -d '%{_bindir}/dms-greeter' 2>/dev/null || true
semanage fcontext -d '%{_sharedstatedir}/greeter(/.*)?' 2>/dev/null || true
semanage fcontext -d '%{_localstatedir}/cache/dms-greeter(/.*)?' 2>/dev/null || true
semanage fcontext -d '%{_datadir}/quickshell/dms-greeter(/.*)?' 2>/dev/null || true
fi
%changelog
* CHANGELOG_DATE_PLACEHOLDER AvengeMedia <contact@avengemedia.com> - VERSION_PLACEHOLDER-RELEASE_PLACEHOLDER
- Stable release VERSION_PLACEHOLDER
- Initial OpenSUSE/OBS port from Fedora

View File

@@ -27,6 +27,7 @@ Recommends: danksearch
Recommends: matugen
Recommends: NetworkManager
Recommends: qt6-qtmultimedia
Suggests: cups-pk-helper
Suggests: qt6ct
%description

View File

@@ -11,7 +11,7 @@
OBS_BASE_PROJECT="home:AvengeMedia"
OBS_BASE="$HOME/.cache/osc-checkouts"
ALL_PACKAGES=(dms dms-git)
ALL_PACKAGES=(dms dms-git dms-greeter)
REPOS=("Debian_13" "openSUSE_Tumbleweed" "16.0")
ARCHES=("x86_64" "aarch64")
@@ -41,6 +41,9 @@ for pkg in "${PACKAGES[@]}"; do
dms-git)
PROJECT="$OBS_BASE_PROJECT:dms-git"
;;
dms-greeter)
PROJECT="$OBS_BASE_PROJECT:danklinux"
;;
*)
echo "Error: Unknown package '$pkg'"
continue
@@ -74,11 +77,15 @@ for pkg in "${PACKAGES[@]}"; do
COLOR="\033[0;32m" # Green
SYMBOL="✅"
;;
failed)
failed|broken|broken*)
COLOR="\033[0;31m" # Red
SYMBOL="❌"
FAILED_BUILDS+=("$repo $arch")
;;
blocked)
COLOR="\033[0;33m" # Yellow
SYMBOL="⏸️"
;;
unresolvable)
COLOR="\033[0;33m" # Yellow
SYMBOL="⚠️"

View File

@@ -68,13 +68,14 @@ fi
OBS_BASE_PROJECT="home:AvengeMedia"
OBS_BASE="$HOME/.cache/osc-checkouts"
AVAILABLE_PACKAGES=(dms dms-git)
AVAILABLE_PACKAGES=(dms dms-git dms-greeter)
if [[ -z "$PACKAGE" ]]; then
echo "Available packages:"
echo ""
echo " 1. dms - Stable DMS"
echo " 2. dms-git - Nightly DMS"
echo " 3. dms-greeter - DMS greeter for greetd"
echo " a. all"
echo ""
read -r -p "Select package (1-${#AVAILABLE_PACKAGES[@]}, a): " selection
@@ -141,7 +142,12 @@ check_obs_version_exists() {
return 0
fi
else
echo "⚠️ Could not fetch OBS spec (API may be unavailable), proceeding anyway"
# Empty/invalid response: expected on first upload (no spec on server yet), or actual API failure
if [[ -z "$OBS_SPEC" ]]; then
echo " No existing spec on OBS (first upload?) - proceeding"
else
echo "⚠️ Could not fetch OBS spec (API may be unavailable), proceeding anyway"
fi
return 1
fi
return 1
@@ -168,6 +174,22 @@ update_debian_dms_service() {
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-arm64\.gz|/releases/download/v${base_version}/dms-distropkg-arm64.gz|" "$service_path"
}
update_debian_dms_greeter_service() {
local service_path="$1"
if [[ -z "$service_path" || ! -f "$service_path" ]]; then
return 0
fi
if [[ -z "$CHANGELOG_VERSION" ]]; then
return 0
fi
local base_version
base_version=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
if [[ -z "$base_version" ]]; then
return 0
fi
sed -i "s|/releases/download/v[0-9][^\"]*/dms-qml\.tar\.gz|/releases/download/v${base_version}/dms-qml.tar.gz|" "$service_path"
}
update_opensuse_git_spec() {
local spec_path="$1"
if [[ -z "$spec_path" || ! -f "$spec_path" ]]; then
@@ -248,6 +270,9 @@ dms)
dms-git)
PROJECT="dms-git"
;;
dms-greeter)
PROJECT="danklinux"
;;
*)
echo "Error: Unknown package '$PACKAGE'"
exit 1
@@ -329,9 +354,11 @@ if [[ -d "distro/debian/$PACKAGE/debian" ]]; then
echo " - Applied rebuild suffix: $CHANGELOG_VERSION"
fi
# Keep Debian dms _service in sync with changelog version
# Keep Debian _service in sync with changelog version
if [[ "$PACKAGE" == "dms" ]] && [[ -f "distro/debian/$PACKAGE/_service" ]]; then
update_debian_dms_service "distro/debian/$PACKAGE/_service"
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -f "distro/debian/$PACKAGE/_service" ]]; then
update_debian_dms_greeter_service "distro/debian/$PACKAGE/_service"
fi
# Check if this version already exists in OBS
@@ -341,6 +368,12 @@ if [[ -d "distro/debian/$PACKAGE/debian" ]]; then
if [[ "$PACKAGE" == *"-git" ]]; then
echo "==> Error: This commit is already uploaded to OBS"
echo " The same git commit ($(echo "$CHANGELOG_VERSION" | grep -oP '[a-f0-9]{8}' | tail -1)) already exists on OBS."
if [[ -n "${GITHUB_ACTIONS:-}" ]] || [[ -n "${CI:-}" ]]; then
echo " CI run detected: skipping upload as a no-op (already up to date)."
echo " If you need to force rebuild this same commit, set REBUILD_RELEASE (e.g. 2, 3, ...)."
echo "✓ Exiting gracefully (no changes needed)"
exit 0
fi
echo " To rebuild the same commit, specify a rebuild number:"
echo " ./distro/scripts/obs-upload.sh $PACKAGE 2"
echo " ./distro/scripts/obs-upload.sh $PACKAGE 3"
@@ -379,6 +412,13 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ -f "distro/opensuse/$PACKAGE.spec" ]];
if [[ "$PACKAGE" == *"-git" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
DMS_GREETER_BASE_VERSION=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
DMS_GREETER_RELEASE=$(echo "$CHANGELOG_VERSION" | sed -E 's/.*db([0-9]+)$/\1/' || echo "1")
CHANGELOG_DATE=$(date '+%a %b %d %Y')
sed -i "s/VERSION_PLACEHOLDER/${DMS_GREETER_BASE_VERSION}/g" "$WORK_DIR/$PACKAGE.spec"
sed -i "s/RELEASE_PLACEHOLDER/${DMS_GREETER_RELEASE}/g" "$WORK_DIR/$PACKAGE.spec"
sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" "$WORK_DIR/$PACKAGE.spec"
fi
if [[ -f "$WORK_DIR/.osc/$PACKAGE.spec" ]]; then
@@ -444,6 +484,24 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
fi
fi
# For dms-greeter: download dms-qml.tar.gz from _service URL
if [[ -z "${SOURCE_DIR:-}" ]] && [[ "$PACKAGE" == "dms-greeter" ]] && [[ -f "distro/debian/$PACKAGE/_service" ]]; then
DMS_GREETER_URL=$(grep -A 5 'name="download_url"' "distro/debian/$PACKAGE/_service" | grep "path" | sed 's/.*<param name="path">\(.*\)<\/param>.*/\1/' | head -1)
if [[ -n "$DMS_GREETER_URL" ]]; then
DMS_GREETER_FULL_URL="https://github.com${DMS_GREETER_URL}"
echo " Downloading dms-greeter source from: $DMS_GREETER_FULL_URL"
if wget -q -O "$TEMP_DIR/dms-qml.tar.gz" "$DMS_GREETER_FULL_URL" 2>/dev/null || \
curl -L -f -s -o "$TEMP_DIR/dms-qml.tar.gz" "$DMS_GREETER_FULL_URL" 2>/dev/null; then
cd "$TEMP_DIR"
tar -xzf dms-qml.tar.gz
if [[ -f "Modules/Greetd/assets/dms-greeter" ]]; then
SOURCE_DIR="$TEMP_DIR"
fi
cd "$REPO_ROOT"
fi
fi
fi
if [[ -n "$SOURCE_DIR" && -d "$SOURCE_DIR" ]]; then
SOURCE0=$(grep "^Source0:" "distro/opensuse/$PACKAGE.spec" | awk '{print $2}' | head -1)
@@ -452,6 +510,15 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
cd "$OBS_TARBALL_DIR"
case "$PACKAGE" in
dms-greeter)
EXPECTED_DIR="dms-qml"
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
mkdir -p "$EXPECTED_DIR"
cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/"
tar -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
rm -rf "$EXPECTED_DIR"
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
;;
dms)
DMS_VERSION=$(grep "^Version:" "$REPO_ROOT/distro/opensuse/$PACKAGE.spec" | sed 's/^Version:[[:space:]]*//' | head -1)
EXPECTED_DIR="DankMaterialShell-${DMS_VERSION}"
@@ -584,6 +651,17 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
if [[ -z "$SOURCE_DIR" ]]; then
SOURCE_DIR=$(find . -maxdepth 1 -type d ! -name "." | head -1)
fi
# dms-qml.tar.gz extracts flat (quickshell contents, no top-level dir)
# Create dms-qml wrapper so combined tarball has correct top-level dir (like dms)
if [[ "$PACKAGE" == "dms-greeter" ]] && [[ -f "Modules/Greetd/assets/dms-greeter" ]]; then
mkdir -p dms-qml
for f in *; do
if [[ -e "$f" && "$f" != "dms-qml" && "$f" != "source-archive" ]]; then
mv "$f" dms-qml/ 2>/dev/null || true
fi
done
SOURCE_DIR="dms-qml"
fi
if [[ -z "$SOURCE_DIR" || ! -d "$SOURCE_DIR" ]]; then
echo "Error: Failed to extract source archive or find source directory"
echo "Contents of $TEMP_DIR:"
@@ -660,6 +738,21 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
cd "$OBS_TARBALL_DIR"
case "$PACKAGE" in
dms-greeter)
EXPECTED_DIR="dms-qml"
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
mkdir -p "$EXPECTED_DIR"
cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/"
if [[ "$SOURCE0" == *.tar.xz ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
else
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
fi
rm -rf "$EXPECTED_DIR"
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
;;
dms)
DMS_VERSION=$(grep "^Version:" "$REPO_ROOT/distro/opensuse/$PACKAGE.spec" | sed 's/^Version:[[:space:]]*//' | head -1)
EXPECTED_DIR="DankMaterialShell-${DMS_VERSION}"
@@ -709,10 +802,17 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
echo " - OpenSUSE source tarballs created"
fi
# Copy and update OpenSUSE spec file with the correct version (for -git packages)
# Copy and update OpenSUSE spec file with the correct version
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
if [[ "$PACKAGE" == *"-git" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
DMS_GREETER_BASE_VERSION=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
DMS_GREETER_RELEASE=$(echo "$CHANGELOG_VERSION" | sed -E 's/.*db([0-9]+)$/\1/' || echo "1")
CHANGELOG_DATE=$(date '+%a %b %d %Y')
sed -i "s/VERSION_PLACEHOLDER/${DMS_GREETER_BASE_VERSION}/g" "$WORK_DIR/$PACKAGE.spec"
sed -i "s/RELEASE_PLACEHOLDER/${DMS_GREETER_RELEASE}/g" "$WORK_DIR/$PACKAGE.spec"
sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" "$WORK_DIR/$PACKAGE.spec"
fi
fi
@@ -839,8 +939,48 @@ EOF
echo " - Quilt format detected: creating debian.tar.gz"
tar -czf "$WORK_DIR/debian.tar.gz" -C "distro/debian/$PACKAGE" debian/
# For dms-greeter: create orig tarball so Debian build gets upstream (OBS only passes .dsc Files to Debian)
DSC_FILES_DEBIAN=""
if [[ "$PACKAGE" == "dms-greeter" ]]; then
UPSTREAM_VER=$(echo "$VERSION" | sed 's/-[^-]*$//')
ORIG_TARBALL="${PACKAGE}_${UPSTREAM_VER}.orig.tar.gz"
ORIG_DIR="${PACKAGE}-${UPSTREAM_VER}"
if [[ -f "distro/debian/$PACKAGE/_service" ]] && grep -q "download_url" "distro/debian/$PACKAGE/_service"; then
DG_TEMP=$(mktemp -d)
DMS_GREETER_PATH=$(grep -A 5 'name="download_url"' "distro/debian/$PACKAGE/_service" | grep "path" | sed 's/.*<param name="path">\(.*\)<\/param>.*/\1/' | head -1)
if [[ -n "$DMS_GREETER_PATH" ]]; then
DG_URL="https://github.com${DMS_GREETER_PATH}"
echo " - Downloading dms-greeter source for orig tarball: $DG_URL"
if wget -q -O "$DG_TEMP/dms-qml.tar.gz" "$DG_URL" 2>/dev/null || curl -L -f -s -o "$DG_TEMP/dms-qml.tar.gz" "$DG_URL" 2>/dev/null; then
( cd "$DG_TEMP" && tar --no-same-owner -xzf dms-qml.tar.gz && mkdir -p "$ORIG_DIR" && \
for f in *; do [[ "$f" != "dms-qml.tar.gz" && "$f" != "$ORIG_DIR" ]] && mv "$f" "$ORIG_DIR/"; done )
if [[ -d "$DG_TEMP/$ORIG_DIR/Modules" ]] || [[ -f "$DG_TEMP/$ORIG_DIR/LICENSE" ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$ORIG_TARBALL" -C "$DG_TEMP" "$ORIG_DIR"
ORIG_MD5=$(md5sum "$WORK_DIR/$ORIG_TARBALL" | cut -d' ' -f1)
ORIG_SIZE=$(stat -c%s "$WORK_DIR/$ORIG_TARBALL" 2>/dev/null || stat -f%z "$WORK_DIR/$ORIG_TARBALL" 2>/dev/null)
DSC_FILES_DEBIAN=" $ORIG_MD5 $ORIG_SIZE $ORIG_TARBALL
"
echo " - Created $ORIG_TARBALL for Debian orig"
fi
rm -rf "$DG_TEMP"
fi
fi
fi
fi
DEBIAN_MD5=$(md5sum "$WORK_DIR/debian.tar.gz" | cut -d' ' -f1)
DEBIAN_SIZE=$(stat -c%s "$WORK_DIR/debian.tar.gz" 2>/dev/null || stat -f%z "$WORK_DIR/debian.tar.gz" 2>/dev/null)
echo " - Generating $PACKAGE.dsc for quilt format"
cat >"$WORK_DIR/$PACKAGE.dsc" <<EOF
# debtransform: DEBTRANSFORM-TAR = orig (upstream), DEBTRANSFORM-FILES-TAR = debian archive
DEBTRANSFORM_EXTRA=""
if [[ -n "$DSC_FILES_DEBIAN" ]] && [[ -n "$ORIG_TARBALL" ]]; then
DEBTRANSFORM_EXTRA="DEBTRANSFORM-TAR: $ORIG_TARBALL
DEBTRANSFORM-FILES-TAR: debian.tar.gz
"
fi
cat >"$WORK_DIR/$PACKAGE.dsc" <<DSCEOF
Format: 3.0 (quilt)
Source: $PACKAGE
Binary: $PACKAGE
@@ -848,10 +988,9 @@ Architecture: any
Version: $VERSION
Maintainer: Avenge Media <AvengeMedia.US@gmail.com>
Build-Depends: debhelper-compat (= 13), wget, gzip
DEBTRANSFORM-TAR: debian.tar.gz
Files:
00000000000000000000000000000000 1 debian.tar.gz
EOF
${DEBTRANSFORM_EXTRA}Files:${DSC_FILES_DEBIAN}
$DEBIAN_MD5 $DEBIAN_SIZE debian.tar.gz
DSCEOF
fi
fi
fi
@@ -882,6 +1021,13 @@ if [[ -n "$OBS_FILES" ]]; then
continue
fi
# Keep current orig tarball for dms-greeter (Debian 3.0 quilt needs it)
UPSTREAM_VER_CLEAN=$(echo "$CHANGELOG_VERSION" | sed 's/-[^-]*$//' 2>/dev/null)
if [[ "$PACKAGE" == "dms-greeter" ]] && [[ "$old_file" == "${PACKAGE}_${UPSTREAM_VER_CLEAN}.orig.tar.gz" ]]; then
echo " - Keeping orig tarball: $old_file"
continue
fi
if [[ "$old_file" == "${PACKAGE}-source.tar.gz" ]]; then
echo " - Keeping source tarball: $old_file"
continue

View File

@@ -28,8 +28,17 @@ override_dh_auto_build:
# Launchpad build environment has no internet access
test -d dms-git-repo || (echo "ERROR: dms-git-repo directory not found!" && exit 1)
# Patch go.mod to use Go 1.24 base version (Ubuntu has 1.24.4, project requires 1.24.6)
sed -i 's/^go 1\.24\.[0-9]*/go 1.24/' dms-git-repo/core/go.mod
# Patch go.mod for Launchpad: align go directive w/latest Go toolchain
GO_VERSION=$$(go env GOVERSION | sed -E 's/^go([0-9]+\.[0-9]+).*/\1/'); \
if [ -n "$$GO_VERSION" ]; then \
sed -E -i "s/^go 1\.[0-9]+(\.[0-9]+)?/go $$GO_VERSION/" dms-git-repo/core/go.mod; \
if [ -f dms-git-repo/core/vendor/modules.txt ]; then \
sed -E -i "s/^(## explicit; go )1\.[0-9]+(\.[0-9]+)?$$/\1$$GO_VERSION/" dms-git-repo/core/vendor/modules.txt; \
fi; \
else \
echo "Warning: Could not detect Go version, leaving go.mod go directive unchanged"; \
fi
sed -E -i '/^toolchain go[0-9.]+$$/d' dms-git-repo/core/go.mod
# Build dms-cli from source
# Detect architecture

View File

@@ -13,7 +13,7 @@ Architecture: any
Depends: ${misc:Depends},
greetd,
quickshell-git | quickshell
Recommends: niri | hyprland | sway
Suggests: niri | hyprland | sway
Description: DankMaterialShell greeter for greetd
DankMaterialShell greeter for greetd login manager. A modern, Material Design 3
inspired greeter interface built with Quickshell for Wayland compositors.

16
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1769018530,
"narHash": "sha256-MJ27Cy2NtBEV5tsK+YraYr2g851f3Fl1LpNHDzDX15c=",
"lastModified": 1771369470,
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "88d3861acdd3d2f0e361767018218e51810df8a1",
"rev": "0182a361324364ae3f436a63005877674cf45efb",
"type": "github"
},
"original": {
@@ -23,16 +23,16 @@
]
},
"locked": {
"lastModified": 1766725085,
"narHash": "sha256-O2aMFdDUYJazFrlwL7aSIHbUSEm3ADVZjmf41uBJfHs=",
"lastModified": 1776854048,
"narHash": "sha256-lLbV66V3RMNp1l8/UelmR4YzoJ5ONtgvEtiUMJATH/o=",
"ref": "refs/heads/master",
"rev": "41828c4180fb921df7992a5405f5ff05d2ac2fff",
"revCount": 715,
"rev": "783c953987dc56ff0601abe6845ed96f1d00495a",
"revCount": 806,
"type": "git",
"url": "https://git.outfoxxed.me/quickshell/quickshell"
},
"original": {
"rev": "41828c4180fb921df7992a5405f5ff05d2ac2fff",
"rev": "783c953987dc56ff0601abe6845ed96f1d00495a",
"type": "git",
"url": "https://git.outfoxxed.me/quickshell/quickshell"
}

View File

@@ -4,7 +4,7 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
quickshell = {
url = "git+https://git.outfoxxed.me/quickshell/quickshell?rev=41828c4180fb921df7992a5405f5ff05d2ac2fff";
url = "git+https://git.outfoxxed.me/quickshell/quickshell?rev=783c953987dc56ff0601abe6845ed96f1d00495a";
inputs.nixpkgs.follows = "nixpkgs";
};
};
@@ -17,15 +17,35 @@
...
}:
let
goModVersion =
let
content = builtins.readFile ./core/go.mod;
lines = builtins.filter builtins.isString (builtins.split "\n" content);
goLines = builtins.filter (l: builtins.match "go [0-9]+\\..*" l != null) lines;
matched =
if goLines != [ ] then builtins.match "go ([0-9]+)\\.([0-9]+).*" (builtins.head goLines) else null;
in
if matched != null then
{
major = builtins.elemAt matched 0;
minor = builtins.elemAt matched 1;
}
else
{
major = "1";
minor = "25";
};
goForPkgs = pkgs: pkgs.${"go_${goModVersion.major}_${goModVersion.minor}"};
forEachSystem =
fn:
nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ] (
system: fn system nixpkgs.legacyPackages.${system}
);
buildDmsPkgs = pkgs: {
dms-shell = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
forEachLinuxSystem =
fn:
nixpkgs.lib.genAttrs [ "aarch64-linux" "x86_64-linux" ] (
system: fn system nixpkgs.legacyPackages.${system}
);
mkModuleWithDmsPkgs =
modulePath:
args@{ pkgs, ... }:
@@ -34,6 +54,7 @@
(import modulePath (args // { dmsPkgs = buildDmsPkgs pkgs; }))
];
};
mkQmlImportPath =
pkgs: qmlPkgs:
pkgs.lib.concatStringsSep ":" (map (o: "${o}/${pkgs.qt6.qtbase.qtQmlPrefix}") qmlPkgs);
@@ -50,10 +71,11 @@
qtimageformats
kimageformats
];
in
{
packages = forEachSystem (
system: pkgs:
# Allows downstream modules to provide their own 'pkgs' (with overlays)
# instead of being forced to use the flake's locked nixpkgs.
mkDmsShell =
pkgs:
let
mkDate =
longDate:
@@ -71,10 +93,14 @@
in
"${cleanVersion}${dateSuffix}${revSuffix}";
in
{
dms-shell = pkgs.buildGoModule (
pkgs.lib.makeOverridable (
{
extraQtPackages ? [ ],
}:
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
let
rootSrc = ./.;
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
in
{
inherit version;
@@ -110,8 +136,8 @@
wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs (qmlPkgs pkgs)}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs (qmlPkgs pkgs)}"
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
$out/lib/systemd/user/dms.service
@@ -126,6 +152,9 @@
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
installShellCompletion --cmd dms \
--bash <($out/bin/dms completion bash) \
--fish <($out/bin/dms completion fish) \
@@ -141,10 +170,19 @@
platforms = pkgs.lib.platforms.linux;
};
}
);
)
) { };
buildDmsPkgs = pkgs: {
dms-shell = mkDmsShell pkgs;
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
in
{
packages = forEachSystem (
system: pkgs: {
dms-shell = mkDmsShell pkgs;
quickshell = quickshell.packages.${system}.default;
default = self.packages.${system}.dms-shell;
}
);
@@ -181,7 +219,7 @@
buildInputs =
with pkgs;
[
go_1_25
(goForPkgs pkgs)
gopls
delve
go-tools

View File

@@ -115,4 +115,8 @@ Singleton {
return translations[context][term];
return term;
}
function locale() {
return Qt.locale();
}
}

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