1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-13 07:42:46 -04:00

Compare commits

...

159 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Full-stack Tailscale integration for DMS control center:

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

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

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

* cleanups

---------

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

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

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

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

* feat(display): fix trailing spaces

* prek run

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

---------

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

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

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

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

Incorporate flatpak as an option to update.

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

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

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

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

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

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

* fix: remove support for builtin filebrowser

needs specific adaptors at FB adhering the trash freedesktop spec

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

* feat: allow for custom file manager command

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

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

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

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

Fixes #2178

* Sync notificationHistoryVisible with shouldBeVisible

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

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

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

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

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

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

* session: dispatch labwc logout via LabwcService

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

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

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

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

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

---------

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

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

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

* go fmt

---------

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

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

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

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

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

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

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

* Update headless mode instructions

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

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

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

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

* Add explanations for headless validating rules and log file location

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

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

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

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

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

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

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

* feat: output log messages to stdout during installation

* Revert dependency-checking functionality due to official fix

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

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

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

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

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

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

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

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

The SystemUpdate widget required two clicks to open its popout.

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

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

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

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

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

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

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

---------

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

* Removed debug warning that was accidentally left in

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

* Prevent login sound from playing in the same session

---------

Co-authored-by: Iris <iris@raidev.eu>
2026-04-06 14:11:00 -04:00
Marcus Ramberg 3f7f6c5d2c core(doctor): show all detected terminals (#2163) 2026-04-06 14:09:15 -04:00
bbedward 0b88055742 clipboard: fix reliability of modal/popout 2026-04-06 10:30:39 -04:00
bbedward 2b0826e397 core: migrate to dms-shell arch package 2026-04-06 10:09:55 -04:00
bbedward 7db04c9660 i18n: use comments instead of context, sync 2026-04-06 09:41:45 -04:00
Al- Amin 14d1e1d985 fix: Add match rule for new version of Gnome Calculator app ID (#2157) 2026-04-06 09:11:26 -04:00
Dimariqe 903ab1e61d fix: add TrayRecoveryService with bidirectional SNI dedup (Go server) (#2137)
* fix: add TrayRecoveryService with bidirectional SNI dedup (Go server)

Add TrayRecoveryService manager that re-registers lost tray icons after
resume from suspend via native DBus scanning in the Go server.

The service resolves every registered SNI item (both well-known names and
:1.xxx connection IDs) to a canonical connection ID, building a unified
registeredConnIDs set before either scan section runs. This prevents
duplicates in both directions:

- If an app registered via well-known name, the connection-ID section
  skips its :1.xxx entry.
- If an app registered via connection ID, the well-known-name section
  skips its well-known name (checked through registeredConnIDs).
- After successfully registering via well-known name, registeredConnIDs
  is updated immediately so the connection-ID section won't probe the
  same app in the same run.

A startup scan (3 s delay) covers the common case where the DMS process
is killed during suspend and restarted by systemd (Type=dbus), so the
loginctl PrepareForSleep watcher alone is not sufficient. The startup
scan is harmless on a normal boot — it finds all items already registered
and exits early.

Go port of quickshell commit 1470aa3.

* fix: 'interface{}' can be replaced by 'any'

* TrayRecoveryService: Remove objPath parameter from registerSNI
2026-04-06 09:03:36 -04:00
Walid Salah 5982655539 Make focused app widget only show focused app on the current screen (#2152) 2026-04-05 10:53:18 -04:00
Aaron Tulino 1021a210cf Change power profile by scrolling battery (#2142)
Scrolling up "increases" the profile (Power Saver -> Balanced -> Performance). Supports touchpad.
2026-04-03 12:04:00 -04:00
bbedward e34edb15bb i18n: sync 2026-04-03 11:56:00 -04:00
dev 61ee5f4336 Update CpuMonitor.qml to reserve enough widget space for the widest number "100" instead of "88" (#2135)
My bar kept shifting around by a few pixels every time I hit 100% cpu usage
2026-04-02 15:26:56 -04:00
Phil Jackson ce2a92ec27 feat: rewind to track start on previous when past 8 seconds (#2136)
* feat: rewind to track start on previous when past 8 seconds

Adds MprisController.previousOrRewind() which rewinds the current track
to position 0 if more than 8 seconds in (with canSeek check), and falls
back to previous() otherwise — matching traditional media player behaviour.

All previous() call sites across Media.qml, MediaPlayerTab.qml,
MediaOverviewCard.qml, LockScreenContent.qml and DMSShellIPC.qml
are updated to use the new shared function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: poll position in MprisController for previousOrRewind accuracy

Without a polling timer, activePlayer.position is never updated in
contexts that don't display a seekbar (e.g. the DankBar widget), causing
the position > 8 check in previousOrRewind() to always see 0 and fall
through to previous() instead of rewinding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:26:36 -04:00
Al- Amin 66ce79b9bf fix:update resizeactive binding to include height to make it work (#2126) 2026-04-01 09:19:01 -04:00
Al- Amin 30dd640314 fix:add window rule for the new version of Gnome Calculator (#2125) 2026-04-01 09:18:56 -04:00
474 changed files with 72977 additions and 37326 deletions
+104
View File
@@ -0,0 +1,104 @@
# Agent Skills
This directory contains agent skills following the [Agent Skills](https://agentskills.io) open standard - a portable, version-controlled format for giving AI agents specialized capabilities.
Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs, scripts, and templates. Agents load skills progressively: metadata at startup, full instructions on activation, and supporting files on demand.
## Available Skills
| Skill | Description |
|-------|-------------|
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for DankMaterialShell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
## Installation
The `.agents/skills/` directory at the project root is the standard location defined by the agentskills.io spec. Many agents discover skills from this path automatically. Some agents use their own directory conventions and need a symlink or copy.
### Claude Code
Claude Code discovers skills from `.claude/skills/` (project-level) or `~/.claude/skills/` (personal). To make skills from `.agents/skills/` available, symlink them into the Claude Code skills directory:
**Project-level** (this repo only):
```bash
mkdir -p .claude/skills
ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
```
**Personal** (all your projects):
```bash
ln -s /path/to/DankMaterialShell/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
```
After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development.
See the [Claude Code skills docs](https://code.claude.com/docs/en/skills) for more on skill configuration, invocation control, and frontmatter options.
### Cursor
Cursor discovers skills from `.cursor/skills/` in the project root:
```bash
mkdir -p .cursor/skills
ln -s ../../.agents/skills/dms-plugin-dev .cursor/skills/dms-plugin-dev
```
See [Cursor skills docs](https://cursor.com/docs/context/skills) for details.
### VS Code (Copilot)
VS Code Copilot discovers skills from `.github/skills/` or `.vscode/skills/`:
```bash
mkdir -p .github/skills
ln -s ../../.agents/skills/dms-plugin-dev .github/skills/dms-plugin-dev
```
See [VS Code skills docs](https://code.visualstudio.com/docs/copilot/customization/agent-skills) for details.
### Gemini CLI
Gemini CLI discovers skills from `.gemini/skills/` in the project root:
```bash
mkdir -p .gemini/skills
ln -s ../../.agents/skills/dms-plugin-dev .gemini/skills/dms-plugin-dev
```
See [Gemini CLI skills docs](https://geminicli.com/docs/cli/skills/) for details.
### OpenAI Codex
Codex discovers skills from `.codex/skills/` in the project root:
```bash
mkdir -p .codex/skills
ln -s ../../.agents/skills/dms-plugin-dev .codex/skills/dms-plugin-dev
```
See [Codex skills docs](https://developers.openai.com/codex/skills/) for details.
### Other Agents
The Agent Skills standard is supported by 30+ tools including Goose, Roo Code, JetBrains Junie, Amp, OpenCode, OpenHands, Kiro, and more. Most discover skills from a dot-directory at the project root (e.g., `.goose/skills/`, `.roo/skills/`). Some read `.agents/skills/` directly.
Check the [Agent Skills client showcase](https://agentskills.io/clients) for setup instructions specific to your agent.
The general pattern is:
```bash
mkdir -p .<agent>/skills
ln -s ../../.agents/skills/dms-plugin-dev .<agent>/skills/dms-plugin-dev
```
## Adding New Skills
To add a new skill to this directory:
1. Create a subdirectory named with lowercase letters, numbers, and hyphens (e.g., `my-new-skill/`)
2. Add a `SKILL.md` file with YAML frontmatter (`name`, `description`) and markdown instructions
3. Optionally add `references/`, `scripts/`, and `assets/` subdirectories
4. Keep `SKILL.md` under 500 lines - move detailed content to reference files
See the [Agent Skills specification](https://agentskills.io/specification) for the full format.
+497
View File
@@ -0,0 +1,497 @@
---
name: dms-plugin-dev
description: >
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
manifest creation, QML component development, settings UI, data persistence, theme
integration, PopoutService usage, and external command execution. Use when the user wants
to create, modify, or debug a DMS plugin, or asks about the DMS plugin API.
compatibility: Designed for Claude Code (or similar products)
metadata:
author: DankMaterialShell
version: "1.0"
domain: qml-desktop-development
framework: DankMaterialShell
languages: qml, javascript
allowed-tools: Bash Read Write Edit
---
# DankMaterialShell Plugin Development
## Overview
DMS plugins extend the desktop shell with custom widgets, background services, launcher
integrations, and desktop widgets. Plugins are QML components discovered from
`~/.config/DankMaterialShell/plugins/`.
**Minimum plugin structure:**
```
~/.config/DankMaterialShell/plugins/YourPlugin/
plugin.json # Required: manifest with metadata
YourComponent.qml # Required: main QML component
YourSettings.qml # Optional: settings UI
*.js # Optional: JavaScript utilities
```
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
**Four plugin types:**
| Type | Purpose | Base Component | Bar pills | CC integration |
|------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
## Step 1: Determine Plugin Type
Choose the type based on what the plugin does:
- **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
optionally integrates with Control Center.
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
changes, notifications, battery level, etc.).
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
desktop layer.
## Step 2: Create the Manifest
Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md](references/plugin-manifest-reference.md) for the full schema.
**Minimal manifest:**
```json
{
"id": "yourPlugin",
"name": "Your Plugin Name",
"description": "Brief description of what your plugin does",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["your-capability"],
"component": "./YourWidget.qml"
}
```
**With settings and permissions:**
```json
{
"id": "yourPlugin",
"name": "Your Plugin Name",
"description": "Brief description",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["your-capability"],
"component": "./YourWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.0",
"permissions": ["settings_read", "settings_write"]
}
```
**Key rules:**
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
- `version` must be semver (e.g., `1.0.0`)
- `component` must start with `./` and end with `.qml`
- `type: "launcher"` requires a `trigger` field
- `settings_write` permission is **required** if the plugin has a settings component
## Step 3: Create the Main Component
### Widget
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: "Hello"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: "Hi"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
rotation: 90
}
}
}
}
```
See [widget-plugin-guide.md](references/widget-plugin-guide.md) for popouts, CC integration, and advanced features.
### Launcher
```qml
import QtQuick
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
function getItems(query) {
const items = [
{ name: "Item One", icon: "material:star", comment: "Description",
action: "toast:Hello!", categories: ["MyPlugin"] }
]
if (!query) return items
const q = query.toLowerCase()
return items.filter(i => i.name.toLowerCase().includes(q))
}
function executeItem(item) {
const [type, ...rest] = item.action.split(":")
const data = rest.join(":")
if (type === "toast") ToastService?.showInfo(data)
else if (type === "copy") Quickshell.execDetached(["dms", "cl", "copy", data])
}
}
```
See [launcher-plugin-guide.md](references/launcher-plugin-guide.md) for triggers, icon types, context menus, and image tiles.
### Desktop
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: 0.85
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
Text {
anchors.centerIn: parent
text: "Desktop Widget"
color: Theme.surfaceText
}
}
}
```
See [desktop-plugin-guide.md](references/desktop-plugin-guide.md) for sizing, persistence, and edit mode.
### Daemon
```qml
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
Connections {
target: SessionData
function onSomeSignal() {
console.log("Event received")
}
}
}
```
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
## Step 4: Add Settings (Optional)
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "yourPlugin"
StringSetting {
settingKey: "apiKey"
label: "API Key"
description: "Your API key"
placeholder: "sk-..."
}
ToggleSetting {
settingKey: "enabled"
label: "Enable Feature"
defaultValue: true
}
SelectionSetting {
settingKey: "interval"
label: "Refresh Interval"
options: [
{ label: "1 min", value: "60" },
{ label: "5 min", value: "300" }
]
defaultValue: "300"
}
}
```
**Available setting components:** StringSetting, ToggleSetting, SelectionSetting, SliderSetting, ColorSetting, ListSetting, ListSettingWithInput.
See [settings-components-reference.md](references/settings-components-reference.md) for full property lists.
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
## Step 5: Use Data Persistence
Three tiers of persistence:
| API | Persisted | Use case |
|-----|-----------|----------|
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
- Global vars sync across all instances (multi-monitor, multiple bar sections)
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
## Step 6: Theme Integration
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
**Essential properties:**
- Colors: `Theme.surfaceContainerHigh`, `Theme.surfaceText`, `Theme.primary`, `Theme.onPrimary`
- Fonts: `Theme.fontSizeSmall` (12), `Theme.fontSizeMedium` (14), `Theme.fontSizeLarge` (16), `Theme.fontSizeXLarge` (20)
- Spacing: `Theme.spacingXS`, `Theme.spacingS`, `Theme.spacingM`, `Theme.spacingL`, `Theme.spacingXL`
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
**Common widgets from `qs.Widgets`:** `StyledText`, `StyledRect`, `DankIcon`, `DankButton`, `DankToggle`, `DankTextField`, `DankSlider`, `DankGridView`, `CachingImage`.
See [theme-reference.md](references/theme-reference.md) for the complete property list.
## Step 7: Add Popout Content (Widgets Only)
Add a popout that opens when the bar pill is clicked:
```qml
PluginComponent {
popoutWidth: 400
popoutHeight: 300
popoutContent: Component {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: "Content here"
color: Theme.surfaceText
}
}
}
}
horizontalBarPill: Component { /* ... */ }
verticalBarPill: Component { /* ... */ }
}
```
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
## Step 8: Control Center Integration (Widgets Only)
Add your widget to the Control Center grid:
```qml
PluginComponent {
ccWidgetIcon: "toggle_on"
ccWidgetPrimaryText: "My Feature"
ccWidgetSecondaryText: isActive ? "On" : "Off"
ccWidgetIsActive: isActive
onCcWidgetToggled: {
isActive = !isActive
pluginService?.savePluginData(pluginId, "active", isActive)
}
// Optional: expandable detail panel (for CompoundPill)
ccDetailContent: Component {
Rectangle {
implicitHeight: 200
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
}
}
}
```
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
## Step 9: External Commands and Clipboard
**Run commands and capture output:**
```qml
import qs.Common
Proc.runCommand(
"myPlugin.fetch",
["curl", "-s", "https://api.example.com/data"],
(stdout, exitCode) => {
if (exitCode === 0) processData(stdout)
},
500 // debounce ms
)
```
**Fire-and-forget (clipboard, notifications):**
```qml
import Quickshell
Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
```
**Long-running processes:** Use the `Process` QML component from `Quickshell.Io` with `StdioCollector`.
**Shell commands with pipes:** `["sh", "-c", "ps aux | grep foo"]`
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
## Step 10: Validate and Test
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
3. Open Settings > Plugins > Scan for Plugins
4. Enable your plugin and add it to the DankBar layout
**Common issues:**
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
- Widget not showing: ensure it's enabled AND added to a DankBar section
- Settings error: verify `settings_write` permission is declared
- Data not persisting: check pluginService injection and permissions
## Common Mistakes
1. **Missing `settings_write` permission** - Settings UI shows error without it
2. **Missing `property var popoutService: null`** - Must declare for injection to work
3. **Missing vertical bar pill** - Widget disappears when bar is on left/right edge
4. **Hardcoded colors** - Use `Theme.*` properties, not hex values
5. **Using `globalThis.clipboard`** - Does not exist; use `Quickshell.execDetached(["dms", "cl", "copy", text])`
6. **Wrong Theme property names** - `Theme.fontSizeS` does not exist, use `Theme.fontSizeSmall`
7. **Wrong import for Quickshell** - Use `import Quickshell` (not `import QtQuick` for execDetached)
8. **Forgetting `categories` in launcher items** - Items won't display without it
9. **Not handling null pluginService** - Always use optional chaining or null checks
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
## Quick Reference: Imports
**Widget / Daemon:**
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
```
**Launcher:**
```qml
import QtQuick
import qs.Services
```
**Desktop:**
```qml
import QtQuick
import qs.Common
```
**For clipboard/exec:** `import Quickshell`
**For processes:** `import Quickshell.Io`
**For networking:** `import Quickshell.Networking`
**For toast notifications:** access `ToastService` from `qs.Services`
## Quick Reference: File Naming
- **Directory name:** PascalCase (e.g., `MyAwesomePlugin/`)
- **Plugin ID:** camelCase (e.g., `myAwesomePlugin`)
- **QML files:** PascalCase (e.g., `MyWidget.qml`, `Settings.qml`)
- **Component paths in manifest:** relative with `./` prefix (e.g., `"./MyWidget.qml"`)
- **JS utility files:** camelCase (e.g., `utils.js`, `apiAdapter.js`)
## Reference Files
Load these on demand for detailed API documentation:
- [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
- [settings-components-reference.md](references/settings-components-reference.md) - All 7 setting components with complete property lists
- [theme-reference.md](references/theme-reference.md) - Theme colors, spacing, fonts, radii, common patterns
- [data-persistence-guide.md](references/data-persistence-guide.md) - pluginData, state API, global variables
- [popout-service-reference.md](references/popout-service-reference.md) - PopoutService API for controlling shell popouts and modals
- [advanced-patterns.md](references/advanced-patterns.md) - Variants, JS utilities, qmldir, IPC, multi-file plugins
@@ -0,0 +1,115 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://danklinux.com/schemas/plugin.json",
"title": "DankMaterialShell Plugin Manifest",
"description": "Schema for DankMaterialShell plugin.json manifest files",
"type": "object",
"required": [
"id",
"name",
"description",
"version",
"author",
"type",
"capabilities",
"component"
],
"properties": {
"id": {
"type": "string",
"description": "Unique plugin identifier (camelCase, no spaces)",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
},
"name": {
"type": "string",
"description": "Human-readable plugin name",
"minLength": 1
},
"description": {
"type": "string",
"description": "Short description of plugin functionality",
"minLength": 1
},
"version": {
"type": "string",
"description": "Semantic version string (e.g., '1.0.0')",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
},
"author": {
"type": "string",
"description": "Plugin creator name or email",
"minLength": 1
},
"type": {
"type": "string",
"description": "Plugin type",
"enum": ["widget", "daemon", "launcher", "desktop"]
},
"capabilities": {
"type": "array",
"description": "Array of plugin capabilities",
"items": {
"type": "string"
},
"minItems": 1
},
"component": {
"type": "string",
"description": "Relative path to main QML component file",
"pattern": "^\\./.*\\.qml$"
},
"trigger": {
"type": "string",
"description": "Trigger string for launcher activation (required for launcher type)"
},
"icon": {
"type": "string",
"description": "Material Design icon name"
},
"settings": {
"type": "string",
"description": "Path to settings component QML file",
"pattern": "^\\./.*\\.qml$"
},
"requires_dms": {
"type": "string",
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
},
"requires": {
"type": "array",
"description": "Array of required system tools/dependencies",
"items": {
"type": "string"
}
},
"permissions": {
"type": "array",
"description": "Required capabilities",
"items": {
"type": "string",
"enum": [
"settings_read",
"settings_write",
"process",
"network"
]
}
}
},
"allOf": [
{
"if": {
"properties": {
"type": {
"const": "launcher"
}
}
},
"then": {
"required": ["trigger"]
}
}
],
"additionalProperties": true
}
@@ -0,0 +1,48 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// TODO: Read configuration from settings
property string configValue: pluginData?.configValue || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
configValue = pluginService.loadPluginData(pluginId, "configValue", "")
}
}
// TODO: Connect to the service events you need
// Connections {
// target: SessionData
// function onWallpaperPathChanged() {
// console.log("[MyDaemon] Wallpaper changed:", SessionData.wallpaperPath)
// handleEvent(SessionData.wallpaperPath)
// }
// }
function handleEvent(data) {
Proc.runCommand(
"myDaemon.handle",
["echo", "Event received:", data],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("[MyDaemon] Output:", stdout)
} else {
console.error("[MyDaemon] Failed:", exitCode)
ToastService?.showInfo("Daemon action failed")
}
}
)
}
Component.onCompleted: {
console.log("[MyDaemon] Started")
}
}
@@ -0,0 +1,16 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDaemon"
StringSetting {
settingKey: "configValue"
label: "Configuration"
description: "Value used by the daemon"
placeholder: "Enter value..."
defaultValue: ""
}
}
@@ -0,0 +1,13 @@
{
"id": "myDaemon",
"name": "My Daemon",
"description": "A background service that reacts to events",
"version": "1.0.0",
"author": "Your Name",
"type": "daemon",
"capabilities": ["background-service"],
"component": "./Daemon.qml",
"icon": "settings",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write", "process"]
}
@@ -0,0 +1,18 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDesktopWidget"
SliderSetting {
settingKey: "opacity"
label: "Opacity"
description: "Widget background opacity"
defaultValue: 85
minimum: 10
maximum: 100
unit: "%"
}
}
@@ -0,0 +1,47 @@
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
// TODO: Load settings reactively
property real bgOpacity: {
if (!pluginService) return 0.85
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
return val / 100
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
bgOpacity = val / 100
}
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: root.bgOpacity
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
// TODO: Add your widget content here
Text {
anchors.centerIn: parent
text: "Desktop Widget"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeLarge
}
}
}
@@ -0,0 +1,13 @@
{
"id": "myDesktopWidget",
"name": "My Desktop Widget",
"description": "A custom desktop widget",
"version": "1.0.0",
"author": "Your Name",
"type": "desktop",
"capabilities": ["desktop-widget"],
"component": "./Widget.qml",
"icon": "widgets",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,59 @@
import QtQuick
import Quickshell
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
// TODO: Define your items
property var allItems: [
{
name: "Example Item",
icon: "material:star",
comment: "An example launcher item",
action: "toast:Hello from my launcher!",
categories: ["MyLauncher"]
}
]
function getItems(query) {
if (!query || query.length === 0) return allItems
var q = query.toLowerCase()
return allItems.filter(function(item) {
return item.name.toLowerCase().includes(q) ||
item.comment.toLowerCase().includes(q)
})
}
function executeItem(item) {
var actionParts = item.action.split(":")
var actionType = actionParts[0]
var actionData = actionParts.slice(1).join(":")
switch (actionType) {
case "toast":
if (typeof ToastService !== "undefined")
ToastService.showInfo(actionData)
break
case "copy":
Quickshell.execDetached(["dms", "cl", "copy", actionData])
if (typeof ToastService !== "undefined")
ToastService.showInfo("Copied to clipboard")
break
default:
console.warn("Unknown action type:", actionType)
}
}
Component.onCompleted: {
if (pluginService) {
trigger = pluginService.loadPluginData("myLauncher", "trigger", "#")
}
}
}
@@ -0,0 +1,23 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myLauncher"
StringSetting {
settingKey: "trigger"
label: "Trigger"
description: "Type this prefix in the launcher to activate the plugin"
placeholder: "#"
defaultValue: "#"
}
ToggleSetting {
settingKey: "noTrigger"
label: "Always Visible"
description: "Show items alongside regular apps without needing a trigger"
defaultValue: false
}
}
@@ -0,0 +1,14 @@
{
"id": "myLauncher",
"name": "My Launcher",
"description": "Custom launcher plugin with searchable items",
"version": "1.0.0",
"author": "Your Name",
"type": "launcher",
"capabilities": ["launcher"],
"component": "./Launcher.qml",
"trigger": "#",
"icon": "search",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,23 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myWidget"
StringSetting {
settingKey: "text"
label: "Display Text"
description: "Text shown in the bar widget"
placeholder: "Hello"
defaultValue: "Hello"
}
ToggleSetting {
settingKey: "showIcon"
label: "Show Icon"
description: "Display an icon next to the text"
defaultValue: true
}
}
@@ -0,0 +1,75 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// TODO: Read settings reactively
property string displayText: pluginData?.text || "Hello"
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
displayText = pluginService.loadPluginData(pluginId, "text", "Hello")
}
}
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
rotation: 90
}
}
}
// TODO: Uncomment and customize popout content
// popoutWidth: 350
// popoutHeight: 300
// popoutContent: Component {
// PopoutComponent {
// headerText: "My Widget"
// showCloseButton: true
//
// Column {
// width: parent.width
// spacing: Theme.spacingM
//
// StyledText {
// text: "Popout content here"
// color: Theme.surfaceText
// }
// }
// }
// }
}
@@ -0,0 +1,13 @@
{
"id": "myWidget",
"name": "My Widget",
"description": "A custom bar widget",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["dankbar-widget"],
"component": "./Widget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,320 @@
# Advanced Patterns
Patterns observed in production DMS plugins that go beyond the basics.
## Plugin Variants
Create multiple widget instances from a single plugin definition. Each variant has its own configuration.
### Manifest
No special manifest changes needed - the variant system is built into PluginComponent.
### Widget with Variant Support
```qml
PluginComponent {
property string variantId: ""
property var variantData: ({})
property string displayText: variantData?.text || "Default"
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
}
}
}
}
```
Widget format in bar config: `pluginId:variantId` (e.g., `exampleVariants:variant_1234567890`)
### Settings with Variant Management
```qml
PluginSettings {
pluginId: "exampleVariants"
// Variant creation UI
DankButton {
text: "Add New Instance"
onClicked: {
var id = "variant_" + Date.now()
root.createVariant(id, { name: "New Instance", text: "Hello" })
}
}
// Per-variant configuration
Repeater {
model: root.variants
delegate: Column {
StringSetting {
settingKey: modelData.id + "_text"
label: modelData.name || modelData.id
}
}
}
}
```
## JavaScript Utility Files
For complex logic, split into `.js` files:
### utils.js
```javascript
.pragma library
function formatDuration(ms) {
if (ms < 60000) return "just now"
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"
return Math.floor(ms / 3600000) + "h ago"
}
function parseResponse(json) {
try {
return JSON.parse(json)
} catch (e) {
return null
}
}
```
### Using in QML
```qml
import "utils.js" as Utils
Item {
StyledText {
text: Utils.formatDuration(Date.now() - timestamp)
}
}
```
The `.pragma library` directive makes the JS file a shared singleton - it is loaded once and shared across all QML instances that import it.
## qmldir for Singleton Services
For plugins with internal singleton services:
### qmldir
```
singleton MyService 1.0 MyService.qml
```
### MyService.qml
```qml
pragma Singleton
import QtQuick
QtObject {
property var cache: ({})
function getData(key) {
return cache[key] || null
}
function setData(key, value) {
cache[key] = value
}
}
```
### Using the singleton
```qml
import "." as Local
Item {
Component.onCompleted: {
Local.MyService.setData("key", "value")
}
}
```
## Inline Component Declarations
Reusable sub-components defined inline:
```qml
Item {
component StatusBadge: Rectangle {
property string label: ""
property color badgeColor: Theme.primary
width: badgeText.implicitWidth + Theme.spacingM * 2
height: 24
radius: 12
color: badgeColor
StyledText {
id: badgeText
anchors.centerIn: parent
text: label
color: Theme.onPrimary
font.pixelSize: Theme.fontSizeSmall
}
}
Row {
spacing: Theme.spacingS
StatusBadge { label: "Running"; badgeColor: Theme.success }
StatusBadge { label: "Stopped"; badgeColor: Theme.error }
}
}
```
## Multi-Provider Adapter Pattern
For plugins supporting multiple backends (AI providers, API services):
### apiAdapters.js
```javascript
.pragma library
function createAdapter(provider) {
switch (provider) {
case "openai": return {
url: "https://api.openai.com/v1/chat/completions",
headers: (key) => ({ "Authorization": "Bearer " + key }),
formatRequest: (messages) => JSON.stringify({ model: "gpt-4", messages: messages }),
parseResponse: (text) => JSON.parse(text).choices[0].message.content
}
case "anthropic": return {
url: "https://api.anthropic.com/v1/messages",
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
formatRequest: (messages) => JSON.stringify({ model: "claude-sonnet-4-20250514", messages: messages }),
parseResponse: (text) => JSON.parse(text).content[0].text
}
default: return null
}
}
```
## IPC Integration
For plugins that respond to keyboard shortcuts or external commands:
```qml
PluginComponent {
Connections {
target: DMSIpc
function onCommandReceived(command, args) {
if (command === "myPlugin.toggle") {
doToggle()
} else if (command === "myPlugin.next") {
goNext()
}
}
}
}
```
External trigger: `dms ipc call myPlugin.toggle`
## Networking with Quickshell.Networking
For API calls using the built-in networking module:
```qml
import Quickshell.Networking
Item {
NetworkRequest {
id: request
url: "https://api.example.com/data"
method: "GET"
onResponseReceived: (response) => {
const data = JSON.parse(response.body)
processData(data)
}
onErrorOccurred: (error) => {
console.error("Network error:", error)
}
}
function fetchData() {
request.send()
}
}
```
## Toast Notifications
Show user feedback:
```qml
import qs.Services
// Info toast
ToastService?.showInfo("Operation completed")
// With title
ToastService?.showInfo("Plugin Name", "Data refreshed successfully")
```
Always use optional chaining since ToastService may not be available in all contexts.
## Clipboard Operations
```qml
import Quickshell
function copyToClipboard(text) {
Quickshell.execDetached(["dms", "cl", "copy", text])
ToastService?.showInfo("Copied to clipboard")
}
```
Do NOT use `globalThis.clipboard`, `navigator.clipboard`, or any browser API - they do not exist in the QML runtime.
## Multi-File Plugin Architecture
Large plugins can be split across multiple files:
```
MyPlugin/
plugin.json
Main.qml # Main widget component
Settings.qml # Settings UI
DetailView.qml # Popout detail view
utils.js # Utility functions
apiAdapter.js # API adapter layer
qmldir # Optional: singleton registrations
```
Import sibling files:
```qml
// In Main.qml
import "." as Local
Item {
Loader {
source: "DetailView.qml"
}
}
```
## Performance Tips
1. Use `Proc.runCommand` with appropriate debounce for external commands
2. Pre-cache images and thumbnails for image-heavy plugins
3. Limit concurrent network requests
4. Use `Timer` with reasonable intervals (don't poll faster than needed)
5. Lazy-load heavy content (use `Loader` for complex popout content)
6. Avoid blocking the UI thread with synchronous operations
@@ -0,0 +1,272 @@
# Daemon Plugin Guide
Daemon plugins are invisible background services that react to events and execute actions. They have no bar pills or desktop presence.
## Base Component
Daemons use `PluginComponent` with no bar pills:
```qml
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// Event-driven logic goes here
}
```
## When to Use Daemons
- Monitor system events (wallpaper changes, battery level, notifications)
- Run periodic background tasks (polling APIs, checking system state)
- Execute scripts in response to events
- Control shell UI via PopoutService based on conditions
## Event-Driven Pattern
Use `Connections` to react to service signals:
```qml
PluginComponent {
property var popoutService: null
Connections {
target: SessionData
function onWallpaperPathChanged() {
console.log("Wallpaper changed to:", SessionData.wallpaperPath)
runScript(SessionData.wallpaperPath)
}
}
Connections {
target: BatteryService
function onPercentageChanged() {
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
popoutService?.openBattery()
}
}
}
}
```
## Available Services
Common services daemons can connect to:
| Service | Signals/Properties | Description |
|---------|-------------------|-------------|
| `SessionData` | `wallpaperPath`, `onWallpaperPathChanged` | Desktop session state |
| `BatteryService` | `percentage`, `isCharging`, `batteryAvailable` | Battery status |
| `NotificationService` | `onNotificationReceived(notification)` | Desktop notifications |
| `PluginService` | `onPluginLoaded`, `onGlobalVarChanged` | Plugin lifecycle |
Import services from `qs.Services`.
## Process Execution
### Simple command with Proc
```qml
import qs.Common
PluginComponent {
function runScript(arg) {
Proc.runCommand(
"myDaemon.script",
["bash", "-c", "echo 'Processing: " + arg + "'"],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("Script output:", stdout)
} else {
ToastService?.showInfo("Script failed: exit " + exitCode)
}
}
)
}
}
```
### Long-running process with Process component
```qml
import Quickshell.Io
PluginComponent {
property string scriptPath: ""
Process {
id: proc
command: ["bash", scriptPath]
running: false
stdout: StdioCollector {
onTextReceived: (text) => {
console.log("stdout:", text)
}
}
stderr: StdioCollector {
onTextReceived: (text) => {
console.error("stderr:", text)
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
ToastService?.showInfo("Process failed: exit " + exitCode)
}
}
}
function startProcess() {
if (scriptPath && !proc.running) {
proc.running = true
}
}
}
```
## Timer-Based Polling
```qml
PluginComponent {
Timer {
interval: 60000 // every minute
running: true
repeat: true
onTriggered: checkStatus()
}
function checkStatus() {
Proc.runCommand(
"myDaemon.check",
["sh", "-c", "systemctl is-active myservice"],
(stdout, exitCode) => {
const active = stdout.trim() === "active"
PluginService.setGlobalVar("myDaemon", "serviceActive", active)
}
)
}
}
```
## Data Persistence
Daemons access PluginService directly (it's injected via PluginComponent):
```qml
PluginComponent {
property string configuredScript: pluginData?.scriptPath || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId) {
configuredScript = pluginService.loadPluginData(pluginId, "scriptPath", "")
}
}
}
}
```
## PopoutService Usage
Daemons can control shell UI via the injected popoutService:
```qml
PluginComponent {
property var popoutService: null
function showAlert() {
popoutService?.openNotificationCenter()
}
function openSettings() {
popoutService?.openSettings()
}
}
```
See [popout-service-reference.md](popout-service-reference.md) for the full API.
## Complete Example
Based on the WallpaperWatcherDaemon:
```qml
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
property string scriptPath: pluginData?.scriptPath || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId) {
scriptPath = pluginService.loadPluginData(pluginId, "scriptPath", "")
}
}
}
Connections {
target: SessionData
function onWallpaperPathChanged() {
if (scriptPath) {
runWallpaperScript(SessionData.wallpaperPath)
}
}
}
function runWallpaperScript(wallpaperPath) {
console.log("[WallpaperWatcher] Running script:", scriptPath, wallpaperPath)
Proc.runCommand(
"wallpaperWatcher.run",
["bash", scriptPath, wallpaperPath],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("[WallpaperWatcher] Script output:", stdout)
} else {
console.error("[WallpaperWatcher] Script failed:", exitCode)
ToastService?.showInfo("Wallpaper script failed")
}
}
)
}
Component.onCompleted: {
console.log("[WallpaperWatcher] Daemon started")
}
}
```
## Manifest Example
```json
{
"id": "wallpaperWatcher",
"name": "Wallpaper Watcher",
"description": "Runs a script when the wallpaper changes",
"version": "1.0.0",
"author": "Developer",
"type": "daemon",
"capabilities": ["wallpaper-automation"],
"component": "./WallpaperWatcher.qml",
"icon": "wallpaper",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write", "process"]
}
```
@@ -0,0 +1,176 @@
# Data Persistence Guide
DMS plugins have three tiers of data persistence, each suited for different use cases.
## Tier 1: Plugin Data (Settings)
Persisted to `settings.json`. Use for user preferences and configuration.
### Saving
```qml
pluginService.savePluginData(pluginId, "key", value)
```
### Loading
```qml
var value = pluginService.loadPluginData(pluginId, "key", defaultValue)
```
### Reactive Access via pluginData
`PluginComponent` has a reactive `pluginData` property that auto-loads from settings:
```qml
PluginComponent {
property string displayText: pluginData?.text || "Default"
property bool showIcon: pluginData?.showIcon !== undefined ? pluginData.showIcon : true
}
```
### Reacting to Settings Changes
When settings are changed (e.g., from the settings UI), react with `Connections`:
```qml
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
showIcon = pluginService.loadPluginData(pluginId, "showIcon", true)
}
}
```
## Tier 2: Plugin State
Persisted to a separate state file. Use for runtime state that should survive restarts but is not user-configurable (history, cache, counters).
### Saving
```qml
pluginService.savePluginState(pluginId, "key", value)
```
### Loading
```qml
var state = pluginService.loadPluginState(pluginId, "key", defaultValue)
```
### Additional Methods
```qml
pluginService.clearPluginState(pluginId)
pluginService.removePluginStateKey(pluginId, "key")
```
### Example: Persistent History
```qml
Item {
property var history: []
Component.onCompleted: {
history = pluginService?.loadPluginState(pluginId, "history", []) || []
}
function addToHistory(entry) {
history.unshift({
text: entry,
timestamp: Date.now()
})
if (history.length > 100) history = history.slice(0, 100)
pluginService?.savePluginState(pluginId, "history", history)
}
function clearHistory() {
history = []
pluginService?.removePluginStateKey(pluginId, "history")
}
}
```
## Tier 3: Global Variables (Runtime Only)
NOT persisted. Shared across all instances of a plugin. Use for cross-instance state synchronization (multi-monitor consistency, multi-instance widgets).
### Using PluginGlobalVar Component
```qml
import qs.Modules.Plugins
PluginComponent {
PluginGlobalVar {
id: globalCounter
varName: "counter"
defaultValue: 0
}
horizontalBarPill: Component {
StyledRect {
// ...
StyledText {
text: "Count: " + globalCounter.value
}
MouseArea {
onClicked: globalCounter.set(globalCounter.value + 1)
}
}
}
}
```
**PluginGlobalVar properties:**
| Property | Type | Description |
|----------|------|-------------|
| `varName` | string | Required: name of the global variable |
| `defaultValue` | any | Optional: default if not set |
| `value` | any | Readonly: current value |
**Methods:**
- `set(newValue)` - update the value (triggers reactivity across all instances)
### Using PluginService API Directly
```qml
import qs.Services
property int counter: PluginService.getGlobalVar("myPlugin", "counter", 0)
Connections {
target: PluginService
function onGlobalVarChanged(pluginId, varName) {
if (pluginId === "myPlugin" && varName === "counter") {
counter = PluginService.getGlobalVar("myPlugin", "counter", 0)
}
}
}
function increment() {
var current = PluginService.getGlobalVar("myPlugin", "counter", 0)
PluginService.setGlobalVar("myPlugin", "counter", current + 1)
}
```
## Decision Matrix
| Need | API | Persisted | Scope |
|------|-----|-----------|-------|
| User preferences (API keys, themes, intervals) | `savePluginData` / `loadPluginData` | Yes (settings.json) | Per plugin |
| Runtime state (history, cache, counters) | `savePluginState` / `loadPluginState` | Yes (state file) | Per plugin |
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
## Important Notes
1. **pluginData is reactive** - bindings update automatically when data changes
2. **Global vars are NOT persistent** - they reset when the shell restarts
3. **State vs Data** - data is for user-facing settings, state is for internal runtime data
4. **Null safety** - always check `pluginService` is not null before calling methods
5. **Signal namespacing** - global var signals include `pluginId` to filter for your plugin
6. **Performance** - global vars are efficient for frequent updates; settings writes are batched
@@ -0,0 +1,240 @@
# Desktop Plugin Guide
Desktop plugins are widgets that appear on the desktop background layer. They support drag-and-drop positioning and resize via corner handles.
## Base Component
Desktop widgets use a plain `Item` with injected properties:
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: 0.85
// Your content here
}
}
```
## Injected Properties
These are set automatically by the DesktopPluginWrapper:
| Property | Type | Description |
|----------|------|-------------|
| `pluginService` | object | PluginService reference for data persistence |
| `pluginId` | string | Plugin's unique identifier |
| `editMode` | bool | `true` when user is dragging/resizing |
| `widgetWidth` | real | Current widget container width |
| `widgetHeight` | real | Current widget container height |
## Optional Properties
Define these on your root item to customize behavior:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `minWidth` | real | 100 | Minimum allowed width during resize |
| `minHeight` | real | 100 | Minimum allowed height during resize |
## Position and Size Persistence
Position (`desktopX`, `desktopY`) and size (`desktopWidth`, `desktopHeight`) are automatically managed by the DesktopPluginWrapper. You do not need to handle persistence for positioning.
## Edit Mode
When `editMode` is true, the user is repositioning or resizing. Use this to:
- Show visual indicators (borders, handles)
- Disable interactive elements to prevent accidental actions
- Display additional controls
```qml
Rectangle {
anchors.fill: parent
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
MouseArea {
anchors.fill: parent
enabled: !root.editMode
onClicked: doSomething()
}
}
```
## Loading and Saving Data
Use the injected `pluginService` for data persistence:
```qml
property string displayMode: {
if (!pluginService) return "default"
return pluginService.loadPluginData(pluginId, "displayMode", "default")
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
root.displayMode = pluginService.loadPluginData(pluginId, "displayMode", "default")
}
}
function saveMode(mode) {
pluginService?.savePluginData(pluginId, "displayMode", mode)
}
```
## Settings Component
Desktop plugin settings use the same `PluginSettings` wrapper as other types:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDesktopWidget"
SliderSetting {
settingKey: "opacity"
label: "Opacity"
description: "Widget background opacity"
defaultValue: 85
minimum: 10
maximum: 100
unit: "%"
}
SelectionSetting {
settingKey: "style"
label: "Display Style"
options: [
{ label: "Compact", value: "compact" },
{ label: "Expanded", value: "expanded" }
]
defaultValue: "compact"
}
}
```
## User Interaction
Desktop widgets support:
1. **Drag** - click and drag anywhere (in edit mode)
2. **Resize** - drag bottom-right corner handle (in edit mode)
3. **Edit mode toggle** - via the desktop edit button
## Complete Example
Based on the ExampleDesktopClock pattern:
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 250
property real widgetHeight: 250
property real minWidth: 150
property real minHeight: 150
property string clockStyle: {
if (!pluginService) return "digital"
return pluginService.loadPluginData(pluginId, "clockStyle", "digital")
}
property real bgOpacity: {
if (!pluginService) return 0.85
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
return val / 100
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
clockStyle = pluginService.loadPluginData(pluginId, "clockStyle", "digital")
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
bgOpacity = val / 100
}
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: root.bgOpacity
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatTime(new Date(), "hh:mm:ss")
color: Theme.surfaceText
font.pixelSize: root.widgetWidth * 0.15
font.weight: Font.Bold
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatDate(new Date(), "ddd, MMM d")
color: Theme.onSurfaceVariant
font.pixelSize: Theme.fontSizeMedium
}
}
}
Timer {
interval: 1000
running: true
repeat: true
onTriggered: root.widgetWidth = root.widgetWidth // force update
}
}
```
## Manifest Example
```json
{
"id": "myDesktopClock",
"name": "Desktop Clock",
"description": "Analog and digital clock for the desktop",
"version": "1.0.0",
"author": "Developer",
"type": "desktop",
"capabilities": ["desktop-widget"],
"component": "./ClockWidget.qml",
"icon": "schedule",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
@@ -0,0 +1,308 @@
# Launcher Plugin Guide
Launcher plugins extend the DMS launcher with custom searchable items and actions. They use trigger-based filtering and integrate directly into the app drawer.
## Base Component
Launchers use a plain `Item` (not PluginComponent):
```qml
import QtQuick
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
function getItems(query) {
// Return array of items
return []
}
function executeItem(item) {
// Handle item selection
}
}
```
## Required Interface
| Member | Type | Description |
|--------|------|-------------|
| `pluginService` | property | Injected PluginService reference (declare as `null`) |
| `trigger` | property | Trigger string for activation |
| `itemsChanged` | signal | Emit when item list changes (triggers UI refresh) |
| `getItems(query)` | function | Return array of items matching query |
| `executeItem(item)` | function | Handle item selection |
## Item Structure
Each item returned by `getItems()`:
```javascript
{
name: "Item Display Name", // Required: shown in launcher
icon: "material:star", // Optional: icon specification
comment: "Description text", // Required: subtitle text
action: "type:data", // Required: action identifier
categories: ["MyPlugin"], // Required: array with plugin category
imageUrl: "https://..." // Optional: image for tile view
}
```
## Icon Types
### 1. Material Design Icons
```javascript
{ icon: "material:lightbulb" }
{ icon: "material:terminal" }
{ icon: "material:translate" }
```
Uses the Material Symbols Rounded font.
### 2. Unicode / Emoji Icons
```javascript
{ icon: "unicode:smile_face" }
```
Rendered at 70-80% of icon size with theming.
### 3. Desktop Theme Icons
```javascript
{ icon: "firefox" }
{ icon: "folder" }
```
Uses the user's installed icon theme.
### 4. No Icon
Omit the `icon` field entirely. The launcher hides the icon area and gives full width to the item name.
## Trigger System
**Custom trigger** (items only appear when trigger is typed):
```json
{ "trigger": "#" }
```
- Type `#` alone: shows all plugin items
- Type `# query`: filters plugin items by query
- The query string (without trigger) is passed to `getItems(query)`
**No trigger** (items always visible alongside regular apps):
```json
{ "trigger": "" }
```
Save empty trigger at runtime:
```qml
Component.onCompleted: {
trigger = pluginService?.loadPluginData(pluginId, "trigger", "#") ?? "#"
}
```
## Action Execution
Parse action strings in `executeItem()`:
```qml
function executeItem(item) {
const actionParts = item.action.split(":")
const actionType = actionParts[0]
const actionData = actionParts.slice(1).join(":")
switch (actionType) {
case "toast":
ToastService?.showInfo(actionData)
break
case "copy":
Quickshell.execDetached(["dms", "cl", "copy", actionData])
ToastService?.showInfo("Copied to clipboard")
break
case "exec":
Quickshell.execDetached(actionData.split(" "))
break
case "url":
Quickshell.execDetached(["xdg-open", actionData])
break
default:
console.warn("Unknown action type:", actionType)
}
}
```
## Search / Filtering
The `query` parameter in `getItems()` contains the user's search text (without the trigger prefix).
```qml
function getItems(query) {
const allItems = [
{ name: "Calculator", icon: "material:calculate",
comment: "Open calculator", action: "exec:gnome-calculator",
categories: ["Tools"] },
{ name: "Terminal", icon: "material:terminal",
comment: "Open terminal", action: "exec:alacritty",
categories: ["Tools"] }
]
if (!query || query.length === 0) return allItems
const q = query.toLowerCase()
return allItems.filter(item =>
item.name.toLowerCase().includes(q) ||
item.comment.toLowerCase().includes(q)
)
}
```
## Context Menu Actions
Add right-click actions to launcher items:
```qml
function getContextMenuActions(item) {
return [
{ name: "Copy", icon: "material:content_copy",
action: "copy:" + item.name },
{ name: "Open in Browser", icon: "material:open_in_new",
action: "url:" + item.url }
]
}
```
Context menu actions use the same `executeItem()` handler.
## Image Tile View
For image-heavy launchers (GIF search, sticker pickers), use tile view:
In `plugin.json`:
```json
{
"viewMode": "tile",
"viewModeEnforced": true
}
```
In items:
```javascript
{
name: "Image Title",
imageUrl: "https://example.com/image.png",
comment: "Description",
action: "copy:https://example.com/image.png",
categories: ["MyPlugin"]
}
```
## State Persistence
For plugins with persistent state (notes, history, favorites):
```qml
property var notes: []
Component.onCompleted: {
const saved = pluginService?.loadPluginState(pluginId, "notes", [])
if (saved) notes = saved
}
function addNote(text) {
notes.push({ text: text, timestamp: Date.now() })
pluginService?.savePluginState(pluginId, "notes", notes)
itemsChanged()
}
```
Use `savePluginState/loadPluginState` for runtime data and `savePluginData/loadPluginData` for user preferences.
## Settings for Trigger Configuration
Provide a PluginSettings component for trigger customization:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myLauncher"
StringSetting {
settingKey: "trigger"
label: "Trigger"
description: "Type this prefix to activate the launcher plugin"
placeholder: "#"
defaultValue: "#"
}
ToggleSetting {
settingKey: "noTrigger"
label: "Always Visible"
description: "Show items alongside regular apps (no trigger needed)"
defaultValue: false
}
}
```
## Complete Example
```qml
import QtQuick
import Quickshell
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "!"
signal itemsChanged()
property var commands: [
{ name: "Lock Screen", icon: "material:lock",
comment: "Lock the session", action: "exec:loginctl lock-session" },
{ name: "Screenshot", icon: "material:screenshot_monitor",
comment: "Take a screenshot", action: "exec:grim" },
{ name: "File Manager", icon: "material:folder",
comment: "Open file manager", action: "exec:nautilus" }
]
function getItems(query) {
if (!query) return commands
const q = query.toLowerCase()
return commands.filter(c =>
c.name.toLowerCase().includes(q) ||
c.comment.toLowerCase().includes(q)
)
}
function executeItem(item) {
const [type, ...rest] = item.action.split(":")
const data = rest.join(":")
if (type === "exec") {
Quickshell.execDetached(data.split(" "))
}
}
Component.onCompleted: {
if (pluginService) {
trigger = pluginService.loadPluginData("quickCommands", "trigger", "!")
}
}
}
```
@@ -0,0 +1,119 @@
# Plugin Manifest Reference (plugin.json)
## Required Fields
| Field | Type | Description | Validation |
|-------|------|-------------|------------|
| `id` | string | Unique plugin identifier | camelCase, pattern `^[a-zA-Z][a-zA-Z0-9]*$` |
| `name` | string | Human-readable name | Non-empty |
| `description` | string | Short description (shown in UI) | Non-empty |
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
| `author` | string | Creator name or email | Non-empty |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop` |
| `capabilities` | array | Plugin capabilities | At least 1 string item |
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
## Conditional Requirements
| Condition | Required Field | Description |
|-----------|---------------|-------------|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
## Optional Fields
| Field | Type | Description |
|-------|------|-------------|
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
| `permissions` | array | Required permissions |
| `trigger` | string | Launcher trigger string (required for launcher type) |
## Permissions
| Permission | Description | Enforced |
|------------|-------------|----------|
| `settings_read` | Read plugin configuration | No (not currently enforced) |
| `settings_write` | Write plugin configuration / use PluginSettings | **Yes** |
| `process` | Execute system commands | No (not currently enforced) |
| `network` | Network access | No (not currently enforced) |
If your plugin has a `settings` component but does not declare `settings_write`, users will see an error instead of the settings UI.
## Capabilities
Capabilities are free-form strings that describe what the plugin does. Common values:
- `dankbar-widget` - general bar widget
- `control-center` - integrates with Control Center
- `monitoring` - system/service monitoring
- `launcher` - launcher search provider
- `desktop-widget` - desktop background widget
- `ai` - AI/LLM integration
- `slideout` - uses slideout panel
## Complete Example
```json
{
"id": "myPlugin",
"name": "My Plugin",
"description": "A sample plugin demonstrating all fields",
"version": "1.0.0",
"author": "Developer Name",
"type": "widget",
"capabilities": ["dankbar-widget", "control-center"],
"component": "./MyWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.18",
"requires": ["curl", "jq"],
"permissions": ["settings_read", "settings_write", "process", "network"]
}
```
## Launcher Example
```json
{
"id": "myLauncher",
"name": "My Launcher",
"description": "Search and execute custom actions",
"version": "1.0.0",
"author": "Developer Name",
"type": "launcher",
"capabilities": ["launcher"],
"component": "./MyLauncher.qml",
"trigger": "#",
"icon": "search",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.18",
"permissions": ["settings_read", "settings_write"]
}
```
## JSON Schema
The complete JSON schema is available at `assets/plugin-schema.json` in this skill. Validate with:
```bash
# Using python
python3 -c "
import json, jsonschema
schema = json.load(open('path/to/plugin-schema.json'))
manifest = json.load(open('plugin.json'))
jsonschema.validate(manifest, schema)
print('Valid!')
"
# Using jq (syntax check only)
jq . plugin.json
```
## Additional Properties
The schema allows additional properties (`"additionalProperties": true`), so plugins can include custom fields. Common custom fields seen in production plugins:
- `viewMode` - launcher display mode (`"tile"` for image grids)
- `viewModeEnforced` - lock launcher to specific view mode (`true`/`false`)
@@ -0,0 +1,120 @@
# PopoutService Reference
The `PopoutService` singleton lets plugins control all DMS popouts and modals. It is automatically injected into widget, daemon, and settings components.
## Setup
Declare the property in your component for injection to work:
```qml
property var popoutService: null
```
Without this declaration, injection fails with: `Cannot assign to non-existent property "popoutService"`
## Popouts (DankPopout-based)
| Component | Open | Close | Toggle |
|-----------|------|-------|--------|
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
## Modals (DankModal-based)
| Modal | Show | Hide | Notes |
|-------|------|------|-------|
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
| Launcher | `openDankLauncherV2()` | `closeDankLauncherV2()` | Also has `toggleDankLauncherV2()` |
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Has `toggleProcessListModal()` |
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network auth |
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
## Slideouts
| Component | Open | Close | Toggle |
|-----------|------|-------|--------|
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
## Usage Examples
### Simple toggle
```qml
MouseArea {
onClicked: popoutService?.toggleControlCenter()
}
```
### Conditional popout
```qml
Connections {
target: BatteryService
function onPercentageChanged() {
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
popoutService?.openBattery()
}
}
}
```
### Context menu with multiple actions
```qml
MouseArea {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) contextMenu.popup()
else popoutService?.toggleControlCenter()
}
}
Menu {
id: contextMenu
MenuItem { text: "Settings"; onClicked: popoutService?.openSettings() }
MenuItem { text: "Notifications"; onClicked: popoutService?.toggleNotificationCenter() }
MenuItem { text: "Power"; onClicked: popoutService?.openPowerMenu() }
}
```
### Position-aware toggle (from bar pill)
Some toggle functions accept position parameters for proper popout placement:
```qml
pillClickAction: (x, y, width, section, screen) => {
popoutService?.toggleControlCenter(x, y, width, section, screen)
}
```
## Best Practices
1. **Always use optional chaining** (`?.`) - the service may not be injected yet
2. **Check feature availability** before opening feature-specific popouts:
```qml
if (BatteryService.batteryAvailable) {
popoutService?.openBattery()
}
```
3. **Lazy loading** - first access may activate lazy loaders; this is normal
4. **Popouts are shared** - avoid opening conflicting popouts simultaneously
5. **User intent** - only trigger popouts from user actions or critical system events
6. **Multi-monitor** - positioned popouts are screen-aware when using position parameters
## Injection Locations
The service is injected at these points:
- `DMSShell.qml` - daemon plugins
- `WidgetHost.qml` - widget plugins in left/right bar sections
- `CenterSection.qml` - center bar widgets
- `PluginsTab.qml` - settings components
@@ -0,0 +1,273 @@
# Settings Components Reference
All plugin settings use the `PluginSettings` wrapper. Setting components auto-save on change and auto-load on creation.
## PluginSettings Wrapper
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "yourPlugin" // Required: must match plugin.json id
// Setting components go here
}
```
**Important:** The plugin must declare `"permissions": ["settings_write"]` in plugin.json for the settings UI to render. Without it, users see an error.
**PluginSettings provides to children:**
- `saveValue(key, value)` - save a setting value
- `loadValue(key, defaultValue)` - load a setting value
- `saveState(key, value)` - save plugin state (separate file)
- `loadState(key, defaultValue)` - load plugin state
- `clearState()` - clear all plugin state
- Variant management functions (for variant plugins)
## StringSetting
Text input field.
```qml
StringSetting {
settingKey: "apiKey" // Required: storage key
label: "API Key" // Required: display label
description: "Your API key" // Optional: help text
placeholder: "sk-..." // Optional: input placeholder
defaultValue: "" // Optional: default (default: "")
}
```
**Layout:** Vertical stack - label, description, input field.
## ToggleSetting
Boolean toggle switch.
```qml
ToggleSetting {
settingKey: "notifications" // Required: storage key
label: "Enable Notifications" // Required: display label
description: "Show alerts" // Optional: help text
defaultValue: true // Optional: default (default: false)
}
```
**Layout:** Horizontal - label/description on left, toggle on right.
## SelectionSetting
Dropdown menu.
```qml
SelectionSetting {
settingKey: "theme" // Required: storage key
label: "Theme" // Required: display label
description: "Color scheme" // Optional: help text
options: [ // Required: array of options
{ label: "Dark", value: "dark" },
{ label: "Light", value: "light" },
{ label: "Auto", value: "auto" }
]
defaultValue: "dark" // Optional: default value
}
```
Options can be `{ label, value }` objects or simple strings. Stores the `value` field, displays the `label` field.
**Layout:** Horizontal - label/description on left, dropdown on right.
**Reacting to changes:**
```qml
SelectionSetting {
settingKey: "updateInterval"
label: "Update Interval"
options: [
{ label: "1 minute", value: "60" },
{ label: "5 minutes", value: "300" }
]
defaultValue: "300"
onValueChanged: (newValue) => {
console.log("Interval changed to:", newValue)
}
}
```
## SliderSetting
Numeric slider with min/max.
```qml
SliderSetting {
settingKey: "opacity" // Required: storage key
label: "Opacity" // Required: display label
description: "Background" // Optional: help text
defaultValue: 85 // Optional: default value
minimum: 0 // Required: min value
maximum: 100 // Required: max value
unit: "%" // Optional: unit label shown after value
leftIcon: "dark_mode" // Optional: Material icon on left
rightIcon: "light_mode" // Optional: Material icon on right
}
```
## ColorSetting
Color picker.
```qml
ColorSetting {
settingKey: "accentColor" // Required: storage key
label: "Accent Color" // Required: display label
description: "Custom accent" // Optional: help text
defaultValue: "#ff5722" // Optional: default hex color
}
```
Displays a color swatch that opens a color picker dialog.
## ListSetting
Manage a list of items with manual add/remove. Use when you need custom UI for adding items.
```qml
ListSetting {
id: itemList
settingKey: "items" // Required: storage key
label: "Saved Items" // Required: display label
description: "Your items" // Optional: help text
defaultValue: [] // Optional: default array
delegate: Component { // Optional: custom item display
StyledRect {
width: parent.width
height: 40
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.surfaceText
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: 60
height: 28
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
radius: Theme.cornerRadius
StyledText {
anchors.centerIn: parent
text: "Remove"
color: Theme.errorText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: removeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: itemList.removeItem(index)
}
}
}
}
}
```
**Methods:**
- `addItem(item)` - add an item to the list
- `removeItem(index)` - remove item at index
## ListSettingWithInput
Complete list management with built-in form. Best for collecting structured data.
```qml
ListSettingWithInput {
settingKey: "locations" // Required: storage key
label: "Locations" // Required: display label
description: "Track zones" // Optional: help text
defaultValue: [] // Optional: default array
fields: [ // Required: field definitions
{
id: "name", // Required: key in saved object
label: "Name", // Required: column header
placeholder: "Home", // Optional: input placeholder
width: 150, // Optional: column width (default: 200)
required: true // Optional: must have value to add
},
{
id: "timezone",
label: "Timezone",
placeholder: "America/New_York",
width: 200,
required: true
}
]
}
```
Automatically generates: column headers, input fields, add button with validation, list display, remove buttons.
## Mixing Custom UI with Settings
You can interleave regular QML elements with setting components:
```qml
PluginSettings {
pluginId: "myPlugin"
StyledText {
width: parent.width
text: "General Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
StringSetting {
settingKey: "name"
label: "Display Name"
}
StyledText {
width: parent.width
text: "Advanced Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
topPadding: Theme.spacingL
}
ToggleSetting {
settingKey: "debug"
label: "Debug Mode"
defaultValue: false
}
}
```
## Default Values
Define sensible defaults in every setting component. The default is used when no saved value exists:
```qml
StringSetting { settingKey: "text"; defaultValue: "Hello" }
ToggleSetting { settingKey: "enabled"; defaultValue: true }
SelectionSetting { settingKey: "mode"; defaultValue: "auto" }
SliderSetting { settingKey: "opacity"; defaultValue: 85 }
ColorSetting { settingKey: "color"; defaultValue: "#ff5722" }
ListSetting { settingKey: "items"; defaultValue: [] }
ListSettingWithInput { settingKey: "data"; defaultValue: [] }
```
@@ -0,0 +1,216 @@
# Theme Property Reference
All theme properties are accessed via the `Theme` singleton from `qs.Common`. Always use these instead of hardcoded values.
## Font Sizes
```qml
Theme.fontSizeSmall // 12px (scaled by SettingsData.fontScale)
Theme.fontSizeMedium // 14px (scaled)
Theme.fontSizeLarge // 16px (scaled)
Theme.fontSizeXLarge // 20px (scaled)
```
## Icon Sizes
```qml
Theme.iconSizeSmall // 16px
Theme.iconSize // 24px (default)
Theme.iconSizeLarge // 32px
```
## Spacing
```qml
Theme.spacingXS // Extra small
Theme.spacingS // Small
Theme.spacingM // Medium
Theme.spacingL // Large
Theme.spacingXL // Extra large
```
## Border Radius
```qml
Theme.cornerRadius // Standard
Theme.cornerRadiusSmall // Smaller
Theme.cornerRadiusLarge // Larger
```
## Surface Colors
```qml
Theme.surface
Theme.surfaceContainerLow
Theme.surfaceContainer
Theme.surfaceContainerHigh
Theme.surfaceContainerHighest
```
## Text Colors
```qml
Theme.onSurface // Primary text on surface
Theme.onSurfaceVariant // Secondary text on surface
Theme.surfaceText // Alias for primary surface text
Theme.surfaceVariantText // Alias for secondary surface text
Theme.outline // Border/divider color
```
## Semantic Colors
```qml
Theme.primary
Theme.onPrimary
Theme.secondary
Theme.onSecondary
Theme.error
Theme.errorHover
Theme.errorText
Theme.warning
Theme.success
```
## Special Functions
```qml
Theme.popupBackground() // Popup background with proper opacity
```
## Common Widget Patterns
### Icon with Text
```qml
import qs.Widgets
Row {
spacing: Theme.spacingS
DankIcon {
name: "icon_name"
color: Theme.onSurface
font.pixelSize: Theme.iconSize
}
StyledText {
text: "Label"
color: Theme.onSurface
font.pixelSize: Theme.fontSizeMedium
}
}
```
### Container with Border
```qml
Rectangle {
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
}
```
### Hover Effect
```qml
Rectangle {
id: container
color: Theme.surfaceContainerHigh
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: container.color = Qt.lighter(Theme.surfaceContainerHigh, 1.1)
onExited: container.color = Theme.surfaceContainerHigh
}
}
```
### Clickable Pill
```qml
StyledRect {
width: content.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: mouseArea.containsMouse
? Qt.lighter(Theme.surfaceContainerHigh, 1.1)
: Theme.surfaceContainerHigh
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
Row {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Label"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
anchors.verticalCenter: parent.verticalCenter
}
}
}
```
## Common Mistakes
**Wrong property names** (these do NOT exist):
```qml
Theme.fontSizeS // Use Theme.fontSizeSmall
Theme.iconSizeS // Use Theme.iconSizeSmall
Theme.spacingSmall // Use Theme.spacingS
Theme.borderRadius // Use Theme.cornerRadius
```
**Hardcoded values** (do NOT do this):
```qml
color: "#1e1e1e" // Use Theme.surfaceContainerHigh
color: "white" // Use Theme.surfaceText
font.pixelSize: 14 // Use Theme.fontSizeMedium
```
## Available Widgets from qs.Widgets
| Widget | Description |
|--------|-------------|
| `StyledText` | Themed text with proper color defaults |
| `StyledRect` | Themed rectangle |
| `DankIcon` | Material Symbols icon renderer |
| `DankNFIcon` | Nerd Font icon renderer |
| `DankButton` | Themed button |
| `DankToggle` | Toggle switch |
| `DankTextField` | Text input field |
| `DankSlider` | Slider control |
| `DankDropdown` | Dropdown menu |
| `DankGridView` | Grid layout view |
| `DankListView` | List layout view |
| `DankFlickable` | Scrollable container |
| `DankTabBar` | Tab bar navigation |
| `DankCollapsibleSection` | Collapsible content section |
| `DankTooltip` | Hover tooltip |
| `DankNumberStepper` | Number +/- control |
| `DankFilterChips` | Filter chip row |
| `CachingImage` | Image with disk cache |
| `NumericText` | Fixed-width numeric display |
## Checking All Properties
```bash
grep "property" Common/Theme.qml
```
@@ -0,0 +1,369 @@
# Widget Plugin Guide
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
## Base Component
Widgets use `PluginComponent` from `qs.Modules.Plugins`.
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
horizontalBarPill: Component { /* ... */ }
verticalBarPill: Component { /* ... */ }
popoutContent: Component { /* ... */ }
popoutWidth: 400
popoutHeight: 300
}
```
## Injected Properties
These are automatically set by the plugin host:
| Property | Type | Description |
|----------|------|-------------|
| `axis` | object | Bar axis info (horizontal/vertical) |
| `section` | string | Bar section: `"left"`, `"center"`, or `"right"` |
| `parentScreen` | object | Screen reference for multi-monitor |
| `widgetThickness` | real | Widget size perpendicular to bar edge |
| `barThickness` | real | Bar thickness parallel to edge |
| `pluginId` | string | This plugin's ID |
| `pluginService` | object | PluginService reference |
| `pluginData` | object | Reactive plugin settings data |
## Bar Pills
Define `horizontalBarPill` (for top/bottom bars) and `verticalBarPill` (for left/right bars).
### Horizontal Bar Pill
```qml
horizontalBarPill: Component {
StyledRect {
width: content.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Row {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Label"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
```
### Vertical Bar Pill
```qml
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: content.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSizeSmall
}
}
}
}
```
**Important:** Always define both pills. If a pill is missing, the widget disappears when the bar is on that orientation's edge.
## Popout Content
Open a popout window when the bar pill is clicked:
```qml
PluginComponent {
popoutWidth: 400
popoutHeight: 300
popoutContent: Component {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: "Content here"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
}
}
```
**PopoutComponent properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
| `closePopout` | function | (injected) | Call to close the popout programmatically. |
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
**Content sizing:** Content children render below the header/details. Calculate available height: `popoutHeight - headerHeight - detailsHeight - spacing`
## Custom Click Actions
Override the default popout behavior:
```qml
PluginComponent {
// Simple no-args handler
pillClickAction: () => {
popoutService?.toggleControlCenter()
}
// With position params (x, y, width, section, screen)
pillClickAction: (x, y, width, section, screen) => {
popoutService?.toggleControlCenter(x, y, width, section, screen)
}
pillRightClickAction: () => {
popoutService?.openSettings()
}
}
```
## Control Center Integration
Add CC properties to show your widget in the Control Center grid:
```qml
PluginComponent {
ccWidgetIcon: "toggle_on"
ccWidgetPrimaryText: "Feature Name"
ccWidgetSecondaryText: isActive ? "Active" : "Off"
ccWidgetIsActive: isActive
onCcWidgetToggled: {
isActive = !isActive
pluginService?.savePluginData(pluginId, "active", isActive)
}
}
```
**CC properties:**
| Property | Type | Description |
|----------|------|-------------|
| `ccWidgetIcon` | string | Material icon name |
| `ccWidgetPrimaryText` | string | Main label |
| `ccWidgetSecondaryText` | string | Subtitle / status text |
| `ccWidgetIsActive` | bool | Active state (changes styling) |
**CC signals:**
| Signal | When fired |
|--------|-----------|
| `ccWidgetToggled()` | Icon area clicked |
| `ccWidgetExpanded()` | Expand area clicked (CompoundPill only) |
**CC sizing rules:**
- 25% width - SmallToggleButton (icon only)
- 50% width - ToggleButton (no detail) or CompoundPill (with ccDetailContent)
- Users can resize in CC edit mode
### Detail Content (CompoundPill)
Add an expandable panel below the CC widget:
```qml
ccDetailContent: Component {
Rectangle {
implicitHeight: 200
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
// Detail UI here
}
}
}
```
## Visibility Control
Conditionally show/hide the bar pill:
```qml
PluginComponent {
visibilityCommand: "pgrep -x myapp"
visibilityInterval: 5000 // check every 5 seconds
}
```
## Popout Namespace
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
```qml
PluginComponent {
layerNamespacePlugin: true
}
```
## Reading Plugin Data
Access saved settings reactively via the injected `pluginData`:
```qml
PluginComponent {
property string displayText: pluginData?.text || "Default"
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId)
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
}
}
}
```
## Complete Example
Based on the ExampleEmojiPlugin pattern:
```qml
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
property var emojis: ["star", "heart", "smile"]
property int currentIndex: 0
Timer {
interval: 2000
running: true
repeat: true
onTriggered: currentIndex = (currentIndex + 1) % emojis.length
}
popoutWidth: 350
popoutHeight: 400
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.emojis[root.currentIndex]
font.pixelSize: Theme.fontSizeLarge
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.emojis[root.currentIndex]
font.pixelSize: Theme.fontSizeMedium
}
}
}
popoutContent: Component {
PopoutComponent {
headerText: "Emoji Picker"
showCloseButton: true
DankGridView {
width: parent.width
height: 300
cellWidth: 50
cellHeight: 50
model: root.emojis
delegate: Rectangle {
width: 48
height: 48
radius: Theme.cornerRadius
color: mouseArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
Text {
anchors.centerIn: parent
text: modelData
font.pixelSize: 24
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Quickshell.execDetached(["dms", "cl", "copy", modelData])
ToastService?.showInfo("Copied " + modelData)
}
}
}
}
}
}
}
```
+22 -2
View File
@@ -1,4 +1,4 @@
name: Check nix flake name: Nix flake and NixOS tests
on: on:
pull_request: pull_request:
@@ -9,6 +9,7 @@ on:
jobs: jobs:
check-flake: check-flake:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 120
steps: steps:
- name: Checkout - name: Checkout
@@ -18,6 +19,25 @@ jobs:
- name: Install Nix - name: Install Nix
uses: cachix/install-nix-action@v31 uses: cachix/install-nix-action@v31
with:
enable_kvm: true
extra_nix_config: |
system-features = nixos-test benchmark big-parallel kvm
- name: Check the flake - name: Check the flake
run: nix flake check run: nix flake check -L
- name: Run NixOS module test
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
- name: Run NixOS service start test
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
- name: Run greeter niri test
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
- name: Run home-manager module test
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
- name: Run niri home-manager module test
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
+10
View File
@@ -367,6 +367,16 @@ jobs:
EOF EOF
chmod 600 ~/.config/osc/oscrc chmod 600 ~/.config/osc/oscrc
# Cache OBS bundled Go toolchains
- name: Cache OBS bundled Go toolchains (dms-git)
if: contains(steps.packages.outputs.packages, 'dms-git')
uses: actions/cache@v4
with:
path: /home/runner/.cache/dms-obs-go-toolchain
key: dms-obs-go-toolchain-${{ runner.os }}-${{ hashFiles('core/go.mod') }}
restore-keys: |
dms-obs-go-toolchain-${{ runner.os }}-
- name: Upload to OBS - name: Upload to OBS
id: upload id: upload
env: env:
+65 -205
View File
@@ -22,12 +22,13 @@ on:
jobs: jobs:
check-updates: check-updates:
name: Check for updates name: Check package/series updates
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
has_updates: ${{ steps.check.outputs.has_updates }} has_updates: ${{ steps.check.outputs.has_updates }}
packages: ${{ steps.check.outputs.packages }} targets: ${{ steps.check.outputs.targets }}
targets_json: ${{ steps.check.outputs.targets_json }}
steps: steps:
- name: Checkout - name: Checkout
@@ -35,125 +36,57 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq curl git
- name: Check for updates - name: Check for updates
id: check id: check
run: | run: |
# Helper function to check dms-git commit chmod +x distro/scripts/ppa-sync-plan.sh
check_dms_git() {
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/dms-git?ws.op=getPublishedSources&source_name=dms-git&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
local PPA_COMMIT=$(echo "$PPA_VERSION" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
if [[ -n "$PPA_COMMIT" && "$CURRENT_COMMIT" == "$PPA_COMMIT" ]]; then
echo "📋 dms-git: Commit $CURRENT_COMMIT already exists, skipping"
return 1 # No update needed
else
echo "📋 dms-git: New commit $CURRENT_COMMIT (PPA has ${PPA_COMMIT:-none})"
return 0 # Update needed
fi
}
# Helper function to check stable package tag
check_stable_package() {
local PKG="$1"
local PPA_NAME="$2"
# Use git ls-remote to find the latest tag, sorted by version (descending)
local LATEST_TAG=$(git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git | head -n1 | awk -F/ '{print $NF}' | sed 's/^v//')
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$PKG&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
local PPA_BASE_VERSION=$(echo "$PPA_VERSION" | sed 's/ppa[0-9]*$//')
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$PPA_BASE_VERSION" ]]; then
echo "📋 $PKG: Tag $LATEST_TAG already exists, skipping"
return 1 # No update needed
else
echo "📋 $PKG: New tag ${LATEST_TAG:-unknown} (PPA has ${PPA_BASE_VERSION:-none})"
return 0 # Update needed
fi
}
# Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}"
if [[ "${{ github.event_name }}" == "schedule" ]]; then if [[ "${{ github.event_name }}" == "schedule" ]]; then
# Scheduled run - check dms-git only PACKAGE="dms-git"
echo "packages=dms-git" >> $GITHUB_OUTPUT
if check_dms_git; then
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
# Manual workflow trigger
PKG="${{ github.event.inputs.package }}"
if [[ -n "$REBUILD" ]]; then
# Rebuild requested - always proceed
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "🔄 Manual rebuild requested: $PKG (ppa$REBUILD)"
elif [[ "$PKG" == "all" ]]; then
# Check each package and build list of those needing updates
PACKAGES_TO_UPDATE=()
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
check_stable_package "dms" "dms" && PACKAGES_TO_UPDATE+=("dms")
check_stable_package "dms-greeter" "danklinux" && PACKAGES_TO_UPDATE+=("dms-greeter")
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}"
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
echo "✓ All packages up to date"
fi
elif [[ "$PKG" == "dms-git" ]]; then
if check_dms_git; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms" ]]; then
if check_stable_package "dms" "dms"; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms-greeter" ]]; then
if check_stable_package "dms-greeter" "danklinux"; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
else
# Unknown package - proceed anyway
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "Manual trigger: $PKG"
fi
else else
# Fallback PACKAGE="${{ github.event.inputs.package }}"
echo "packages=dms" >> $GITHUB_OUTPUT fi
echo "has_updates=true" >> $GITHUB_OUTPUT
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
ARGS=(--package "$PACKAGE" --json)
if [[ -n "$REBUILD_RELEASE" ]]; then
ARGS+=(--rebuild "$REBUILD_RELEASE")
fi
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
cat ppa-audit.log
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
if [[ "$TARGETS_JSON" != "[]" ]]; then
echo "has_updates=true" >> "$GITHUB_OUTPUT"
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
echo "targets_json=$TARGETS_JSON" >> "$GITHUB_OUTPUT"
echo "Package/series targets: $TARGETS"
else
echo "has_updates=false" >> "$GITHUB_OUTPUT"
echo "targets=" >> "$GITHUB_OUTPUT"
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
echo "No package/series uploads needed"
fi fi
upload-ppa: upload-ppa:
name: Upload to PPA name: Upload ${{ matrix.target }}
needs: check-updates needs: check-updates
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: needs.check-updates.outputs.has_updates == 'true' if: needs.check-updates.outputs.has_updates == 'true'
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
concurrency:
group: ppa-dms-${{ matrix.target }}
cancel-in-progress: false
steps: steps:
- name: Checkout - name: Checkout
@@ -177,7 +110,8 @@ jobs:
lftp \ lftp \
build-essential \ build-essential \
fakeroot \ fakeroot \
dpkg-dev dpkg-dev \
openssh-client
- name: Configure GPG - name: Configure GPG
env: env:
@@ -185,106 +119,32 @@ jobs:
run: | run: |
echo "$GPG_KEY" | gpg --import echo "$GPG_KEY" | gpg --import
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2) GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
- name: Determine packages to upload - name: Upload target
id: packages env:
TARGET: ${{ matrix.target }}
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
run: | run: |
# Use packages determined by check-updates job IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
if [[ "${{ github.event_name }}" == "schedule" ]]; then case "$PACKAGE" in
echo "Triggered by schedule: uploading git package" dms) PPA_NAME="dms" ;;
elif [[ -n "${{ github.event.inputs.package }}" ]]; then dms-git) PPA_NAME="dms-git" ;;
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}" dms-greeter) PPA_NAME="danklinux" ;;
fi *) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
esac
- name: Upload to PPA echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
run: | bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
PACKAGES="${{ steps.packages.outputs.packages }}"
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
if [[ -z "$PACKAGES" ]]; then
echo "✓ No packages need uploading. All up to date!"
exit 0
fi
# Export REBUILD_RELEASE so ppa-build.sh can use it
if [[ -n "$REBUILD_RELEASE" ]]; then
export REBUILD_RELEASE
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
fi
# PACKAGES can be space-separated list (e.g., "dms-git dms dms-greeter" from "all" check)
# Loop through each package and upload
for PKG in $PACKAGES; do
# Map package to PPA name
case "$PKG" in
dms)
PPA_NAME="dms"
;;
dms-git)
PPA_NAME="dms-git"
;;
dms-greeter)
PPA_NAME="danklinux"
;;
*)
echo "⚠️ Unknown package: $PKG, skipping"
continue
;;
esac
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Uploading $PKG to PPA $PPA_NAME..."
if [[ -n "$REBUILD_RELEASE" ]]; then
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ppa-upload.sh uploads to questing + resolute when series is omitted
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
echo "::error::Upload failed for $PKG"
exit 1
fi
done
- name: Summary - name: Summary
if: always() if: always()
run: | run: |
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
PACKAGES="${{ steps.packages.outputs.packages }}" echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
if [[ -z "$PACKAGES" ]]; then echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "**Status:** ✅ All packages up to date (no uploads needed)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "All packages are current. Run will complete successfully." >> $GITHUB_STEP_SUMMARY
else
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
for PKG in $PACKAGES; do
case "$PKG" in
dms)
echo "- ✅ **dms** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages)" >> $GITHUB_STEP_SUMMARY
;;
dms-git)
echo "- ✅ **dms-git** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages)" >> $GITHUB_STEP_SUMMARY
;;
dms-greeter)
echo "- ✅ **dms-greeter** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages)" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
fi
+8
View File
@@ -20,3 +20,11 @@ repos:
language: system language: system
files: ^core/.*\.(go|mod|sum)$ files: ^core/.*\.(go|mod|sum)$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: no-console-in-qml
name: no console.* in QML (use Log service)
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
language: system
files: ^quickshell/.*\.qml$
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
+1 -1
View File
@@ -13,7 +13,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers) [![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE) [![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases) [![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases)
[![AUR version](https://img.shields.io/aur/version/dms-shell-bin?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://aur.archlinux.org/packages/dms-shell-bin) [![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/)
[![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git) [![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fdanklinux)](https://ko-fi.com/danklinux) [![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fdanklinux)](https://ko-fi.com/danklinux)
+4 -17
View File
@@ -1,26 +1,13 @@
repos: repos:
- repo: local - repo: https://github.com/golangci/golangci-lint
rev: v2.10.1
hooks: hooks:
- id: golangci-lint-fmt - id: golangci-lint-fmt
name: golangci-lint-fmt
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt
language: system
require_serial: true require_serial: true
types: [go]
pass_filenames: false
- id: golangci-lint-full - id: golangci-lint-full
name: golangci-lint-full
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix
language: system
require_serial: true
types: [go]
pass_filenames: false
- id: golangci-lint-config-verify - id: golangci-lint-config-verify
name: golangci-lint-config-verify - repo: local
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 config verify hooks:
language: system
files: \.golangci\.(?:yml|yaml|toml|json)
pass_filenames: false
- id: go-test - id: go-test
name: go test name: go test
entry: go test ./... entry: go test ./...
+41 -1
View File
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
Command-line interface and daemon for shell management and system control. Command-line interface and daemon for shell management and system control.
**dankinstall** **dankinstall**
Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
## System Integration ## System Integration
@@ -147,10 +147,50 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
## Installation via dankinstall ## Installation via dankinstall
**Interactive (TUI):**
```bash ```bash
curl -fsSL https://install.danklinux.com | sh curl -fsSL https://install.danklinux.com | sh
``` ```
**Headless (unattended):**
Headless mode requires cached sudo credentials. Run `sudo -v` first:
```bash
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y
```
| Flag | Short | Description |
|------|-------|-------------|
| `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
| `--exclude-deps <name,...>` | | Skip specific dependencies |
| `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) |
| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) |
| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts |
Headless mode requires `--yes` to proceed; without it, the installer exits with an error.
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
When no flags are provided, `dankinstall` launches the interactive TUI.
### Headless mode validation rules
Headless mode activates when `--compositor` or `--term` is provided.
- Both `--compositor` and `--term` are required; providing only one results in an error.
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
- Positional arguments are not accepted.
### Log file location
`dankinstall` writes logs to `/tmp` by default.
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory.
## Supported Distributions ## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives) Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
+167 -3
View File
@@ -3,20 +3,152 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui" "github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
) )
var Version = "dev" var Version = "dev"
// Flag variables bound via pflag
var (
compositor string
term string
includeDeps []string
excludeDeps []string
replaceConfigs []string
replaceConfigsAll bool
yes bool
)
var rootCmd = &cobra.Command{
Use: "dankinstall",
Short: "Install DankMaterialShell and its dependencies",
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags.
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
configure passwordless sudo for your user.`,
Args: cobra.NoArgs,
RunE: runDankinstall,
SilenceErrors: true,
SilenceUsage: true,
}
func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
}
func main() { func main() {
if os.Getuid() == 0 { if os.Getuid() == 0 {
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root") fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
os.Exit(1) os.Exit(1)
} }
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runDankinstall(cmd *cobra.Command, args []string) error {
headlessMode := compositor != "" || term != ""
if !headlessMode {
// Reject headless-only flags when running in TUI mode.
headlessOnly := []string{
"include-deps",
"exclude-deps",
"replace-configs",
"replace-configs-all",
"yes",
}
var set []string
for _, name := range headlessOnly {
if cmd.Flags().Changed(name) {
set = append(set, "--"+name)
}
}
if len(set) > 0 {
return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", "))
}
}
if headlessMode {
return runHeadless()
}
return runTUI()
}
func runHeadless() error {
// Validate required flags
if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
}
if term == "" {
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
}
cfg := headless.Config{
Compositor: compositor,
Terminal: term,
IncludeDeps: includeDeps,
ExcludeDeps: excludeDeps,
ReplaceConfigs: replaceConfigs,
ReplaceConfigsAll: replaceConfigsAll,
Yes: yes,
}
runner := headless.NewRunner(cfg)
// Set up file logging
fileLogger, err := log.NewFileLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
}
if fileLogger != nil {
fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath())
fileLogger.StartListening(runner.GetLogChan())
defer func() {
if err := fileLogger.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err)
}
}()
} else {
// Drain the log channel to prevent blocking sends from deadlocking
// downstream components (distros, config deployer) that write to it.
// Use an explicit stop signal because this code does not own the
// runner log channel and cannot assume it will be closed.
defer drainLogChan(runner.GetLogChan())()
}
if err := runner.Run(); err != nil {
if fileLogger != nil {
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath())
}
return err
}
if fileLogger != nil {
fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath())
}
return nil
}
func runTUI() error {
fileLogger, err := log.NewFileLogger() fileLogger, err := log.NewFileLogger()
if err != nil { if err != nil {
fmt.Printf("Warning: Failed to create log file: %v\n", err) fmt.Printf("Warning: Failed to create log file: %v\n", err)
@@ -38,18 +170,50 @@ func main() {
if fileLogger != nil { if fileLogger != nil {
fileLogger.StartListening(model.GetLogChan()) fileLogger.StartListening(model.GetLogChan())
} else {
// Drain the log channel to prevent blocking sends from deadlocking
// downstream components (distros, config deployer) that write to it.
// Use an explicit stop signal because this code does not own the
// model log channel and cannot assume it will be closed.
defer drainLogChan(model.GetLogChan())()
} }
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
if logFilePath != "" { if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", logFilePath) fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath)
} }
os.Exit(1) return fmt.Errorf("error running program: %w", err)
} }
if logFilePath != "" { if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", logFilePath) fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
} }
return nil
}
// drainLogChan starts a goroutine that discards all messages from logCh,
// preventing blocking sends from deadlocking downstream components. It returns
// a cleanup function that signals the goroutine to stop and waits for it to
// exit. Callers should defer the returned function.
func drainLogChan(logCh <-chan string) func() {
drainStop := make(chan struct{})
drainDone := make(chan struct{})
go func() {
defer close(drainDone)
for {
select {
case <-drainStop:
return
case _, ok := <-logCh:
if !ok {
return
}
}
}
}()
return func() {
close(drainStop)
<-drainDone
}
} }
+4 -3
View File
@@ -16,9 +16,10 @@ var authCmd = &cobra.Command{
} }
var authSyncCmd = &cobra.Command{ var authSyncCmd = &cobra.Command{
Use: "sync", Use: "sync",
Short: "Sync DMS authentication configuration", Short: "Sync DMS authentication configuration",
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings", Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
+1
View File
@@ -236,6 +236,7 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
defer ddc.Close() defer ddc.Close()
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil { if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
ddc.WaitPending()
fmt.Printf("Set %s to %d%%\n", deviceID, percent) fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return return
} }
+13
View File
@@ -26,6 +26,17 @@ var runCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon") daemon, _ := cmd.Flags().GetBool("daemon")
session, _ := cmd.Flags().GetBool("session") session, _ := cmd.Flags().GetBool("session")
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
}
}
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
}
}
log.ApplyEnvOverrides()
if daemon { if daemon {
runShellDaemon(session) runShellDaemon(session)
} else { } else {
@@ -526,5 +537,7 @@ func getCommonCommands() []*cobra.Command {
dlCmd, dlCmd,
randrCmd, randrCmd,
blurCmd, blurCmd,
trashCmd,
systemCmd,
} }
} }
+38 -7
View File
@@ -11,6 +11,7 @@ import (
"slices" "slices"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
@@ -82,7 +83,7 @@ func (ds *DoctorStatus) OKCount() int {
} }
var ( var (
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`) quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`) niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`) swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
@@ -90,6 +91,7 @@ var (
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`) wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`) labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`) mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
) )
var doctorCmd = &cobra.Command{ var doctorCmd = &cobra.Command{
@@ -468,6 +470,7 @@ func checkWindowManagers() []checkResult {
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}}, {"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}}, {"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}}, {"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
} }
var results []checkResult var results []checkResult
@@ -500,7 +503,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{ results = append(results, checkResult{
catCompositor, "Compositor", statusError, catCompositor, "Compositor", statusError,
"No supported Wayland compositor found", "No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, or Wayfire", "Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
doctorDocsURL + "#compositor-checks", doctorDocsURL + "#compositor-checks",
}) })
} }
@@ -509,9 +512,24 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"}) results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
} }
results = append(results, checkCompositorBlurSupport())
return results 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 { func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput() output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 { if err != nil && len(output) == 0 {
@@ -535,6 +553,8 @@ func detectRunningWM() string {
return "Hyprland" return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "": case os.Getenv("NIRI_SOCKET") != "":
return "niri" return "niri"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("XDG_CURRENT_DESKTOP") != "": case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP") return os.Getenv("XDG_CURRENT_DESKTOP")
} }
@@ -553,6 +573,7 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
qmlContent := ` qmlContent := `
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Wayland
ShellRoot { ShellRoot {
id: root id: root
@@ -561,6 +582,7 @@ ShellRoot {
property bool idleMonitorAvailable: false property bool idleMonitorAvailable: false
property bool idleInhibitorAvailable: false property bool idleInhibitorAvailable: false
property bool shortcutInhibitorAvailable: false property bool shortcutInhibitorAvailable: false
property bool backgroundBlurAvailable: false
Timer { Timer {
interval: 50 interval: 50
@@ -578,16 +600,18 @@ ShellRoot {
try { try {
var testItem = Qt.createQmlObject( 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 hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "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
) )
root.idleMonitorAvailable = testItem.hasIdleMonitor root.idleMonitorAvailable = testItem.hasIdleMonitor
root.idleInhibitorAvailable = testItem.hasIdleInhibitor root.idleInhibitorAvailable = testItem.hasIdleInhibitor
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
testItem.destroy() testItem.destroy()
} catch (e) {} } catch (e) {}
@@ -596,6 +620,8 @@ ShellRoot {
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE") console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor: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)]) Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
} }
} }
@@ -616,6 +642,7 @@ ShellRoot {
{"IdleMonitor", "Idle detection"}, {"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"}, {"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"}, {"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
} }
var results []checkResult var results []checkResult
@@ -820,10 +847,14 @@ func checkOptionalDependencies() []checkResult {
results = append(results, checkImageFormatPlugins()...) results = append(results, checkImageFormatPlugins()...)
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"} terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 { terminals = slices.DeleteFunc(terminals, func(t string) bool {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL}) return !utils.CommandExists(t)
})
if len(terminals) > 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
} else { } 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() networkResult, err := network.DetectNetworkStack()
+27 -8
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"bufio" "bufio"
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -15,6 +16,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "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/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -109,16 +111,37 @@ func updateArchLinux() error {
} }
var packageName string var packageName string
if isArchPackageInstalled("dms-shell-bin") { var isAUR bool
packageName = "dms-shell-bin" if isArchPackageInstalled("dms-shell") {
packageName = "dms-shell"
} else if isArchPackageInstalled("dms-shell-git") { } else if isArchPackageInstalled("dms-shell-git") {
packageName = "dms-shell-git" packageName = "dms-shell-git"
isAUR = true
} else if isArchPackageInstalled("dms-shell-bin") {
packageName = "dms-shell-bin"
isAUR = true
} else { } 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...") fmt.Println("Info: Falling back to git-based update method...")
return updateOtherDistros() 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 helper string
var updateCmd *exec.Cmd var updateCmd *exec.Cmd
@@ -454,11 +477,7 @@ func updateDMSBinary() error {
fmt.Printf("Installing to %s...\n", currentPath) fmt.Printf("Installing to %s...\n", currentPath)
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath) if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
replaceCmd.Stdin = os.Stdin
replaceCmd.Stdout = os.Stdout
replaceCmd.Stderr = os.Stderr
if err := replaceCmd.Run(); err != nil {
return fmt.Errorf("failed to replace binary: %w", err) return fmt.Errorf("failed to replace binary: %w", err)
} }
+26 -51
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
"context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -14,6 +15,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/text/cases" "golang.org/x/text/cases"
@@ -35,7 +37,7 @@ var greeterInstallCmd = &cobra.Command{
Use: "install", Use: "install",
Short: "Install and configure DMS greeter", Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface", Long: "Install greetd and configure it to use DMS as the greeter interface",
PreRunE: requireMutableSystemCommand, PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -57,9 +59,10 @@ var greeterInstallCmd = &cobra.Command{
} }
var greeterSyncCmd = &cobra.Command{ var greeterSyncCmd = &cobra.Command{
Use: "sync", Use: "sync",
Short: "Sync DMS theme and settings with greeter", Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth") auth, _ := cmd.Flags().GetBool("auth")
@@ -88,7 +91,7 @@ var greeterEnableCmd = &cobra.Command{
Use: "enable", Use: "enable",
Short: "Enable DMS greeter in greetd config", Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter", Long: "Configure greetd to use DMS as the greeter",
PreRunE: requireMutableSystemCommand, PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -124,7 +127,7 @@ var greeterUninstallCmd = &cobra.Command{
Use: "uninstall", Use: "uninstall",
Short: "Remove DMS greeter configuration and restore previous display manager", Short: "Remove DMS greeter configuration and restore previous display manager",
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state", Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
PreRunE: requireMutableSystemCommand, PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -306,10 +309,7 @@ func uninstallGreeter(nonInteractive bool) error {
} }
fmt.Println("\nDisabling greetd...") fmt.Println("\nDisabling greetd...")
disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd") if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
disableCmd.Stdout = os.Stdout
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err) fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
} else { } else {
fmt.Println(" ✓ greetd disabled") fmt.Println(" ✓ greetd disabled")
@@ -375,10 +375,10 @@ func restorePreDMSGreetdConfig(sudoPassword string) error {
} }
tmp.Close() tmp.Close()
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to restore %s: %w", candidate, err) return fmt.Errorf("failed to restore %s: %w", candidate, err)
} }
if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath); err != nil {
return err return err
} }
fmt.Printf(" ✓ Restored greetd config from %s\n", candidate) fmt.Printf(" ✓ Restored greetd config from %s\n", candidate)
@@ -406,21 +406,14 @@ command = "agreety --cmd /bin/bash"
} }
tmp.Close() tmp.Close()
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to write fallback greetd config: %w", err) return fmt.Errorf("failed to write fallback greetd config: %w", err)
} }
_ = runSudoCommand(sudoPassword, "chmod", "644", configPath) _ = privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath)
fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)") fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)")
return nil return nil
} }
func runSudoCommand(_ string, command string, args ...string) error {
cmd := exec.Command("sudo", append([]string{command}, args...)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// suggestDisplayManagerRestore scans for installed DMs and re-enables one // suggestDisplayManagerRestore scans for installed DMs and re-enables one
func suggestDisplayManagerRestore(nonInteractive bool) { func suggestDisplayManagerRestore(nonInteractive bool) {
knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
@@ -439,10 +432,7 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
enableDM := func(dm string) { enableDM := func(dm string) {
fmt.Printf(" Enabling %s...\n", dm) fmt.Printf(" Enabling %s...\n", dm)
cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm) if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err) fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
} else { } else {
fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm) fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm)
@@ -641,10 +631,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if response != "n" && response != "no" { if response != "n" && response != "no" {
fmt.Printf("\nAdding user to %s group...\n", greeterGroup) fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username) if err := privesc.Run(context.Background(), "", "usermod", "-aG", greeterGroup, currentUser.Username); err != nil {
addUserCmd.Stdout = os.Stdout
addUserCmd.Stderr = os.Stderr
if err := addUserCmd.Run(); err != nil {
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err) return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
} }
fmt.Printf("✓ User added to %s group\n", greeterGroup) fmt.Printf("✓ User added to %s group\n", greeterGroup)
@@ -869,22 +856,19 @@ func disableDisplayManager(dmName string) (bool, error) {
actionTaken := false actionTaken := false
if state.NeedsDisable { if state.NeedsDisable {
var disableCmd *exec.Cmd var action, actionVerb string
var actionVerb string switch state.EnabledState {
case "static":
if state.EnabledState == "static" {
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName) fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
disableCmd = exec.Command("sudo", "systemctl", "mask", dmName) action = "mask"
actionVerb = "masked" actionVerb = "masked"
} else { default:
fmt.Printf(" Disabling %s...\n", dmName) fmt.Printf(" Disabling %s...\n", dmName)
disableCmd = exec.Command("sudo", "systemctl", "disable", dmName) action = "disable"
actionVerb = "disabled" actionVerb = "disabled"
} }
disableCmd.Stdout = os.Stdout if err := privesc.Run(context.Background(), "", "systemctl", action, dmName); err != nil {
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err) return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
} }
@@ -925,10 +909,7 @@ func ensureGreetdEnabled() error {
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" { if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
fmt.Println(" Unmasking greetd...") fmt.Println(" Unmasking greetd...")
unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd") if err := privesc.Run(context.Background(), "", "systemctl", "unmask", "greetd"); err != nil {
unmaskCmd.Stdout = os.Stdout
unmaskCmd.Stderr = os.Stderr
if err := unmaskCmd.Run(); err != nil {
return fmt.Errorf("failed to unmask greetd: %w", err) return fmt.Errorf("failed to unmask greetd: %w", err)
} }
fmt.Println(" ✓ Unmasked greetd") fmt.Println(" ✓ Unmasked greetd")
@@ -940,10 +921,7 @@ func ensureGreetdEnabled() error {
fmt.Println(" Enabling greetd service...") fmt.Println(" Enabling greetd service...")
} }
enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd") if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", "greetd"); err != nil {
enableCmd.Stdout = os.Stdout
enableCmd.Stderr = os.Stderr
if err := enableCmd.Run(); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err) return fmt.Errorf("failed to enable greetd: %w", err)
} }
@@ -973,10 +951,7 @@ func ensureGraphicalTarget() error {
currentTargetStr := strings.TrimSpace(string(currentTarget)) currentTargetStr := strings.TrimSpace(string(currentTarget))
if currentTargetStr != "graphical.target" { if currentTargetStr != "graphical.target" {
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr) fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target") if err := privesc.Run(context.Background(), "", "systemctl", "set-default", "graphical.target"); err != nil {
setDefaultCmd.Stdout = os.Stdout
setDefaultCmd.Stderr = os.Stderr
if err := setDefaultCmd.Run(); err != nil {
fmt.Println("⚠ Warning: Failed to set graphical.target as default") fmt.Println("⚠ Warning: Failed to set graphical.target as default")
fmt.Println(" Greeter may not start on boot. Run manually:") fmt.Println(" Greeter may not start on boot. Run manually:")
fmt.Println(" sudo systemctl set-default graphical.target") fmt.Println(" sudo systemctl set-default graphical.target")
+36 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -11,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -19,7 +21,7 @@ var setupCmd = &cobra.Command{
Use: "setup", Use: "setup",
Short: "Deploy DMS configurations", Short: "Deploy DMS configurations",
Long: "Deploy compositor and terminal configurations with interactive prompts", Long: "Deploy compositor and terminal configurations with interactive prompts",
PersistentPreRunE: requireMutableSystemCommand, PersistentPreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if err := runSetup(); err != nil { if err := runSetup(); err != nil {
log.Fatalf("Error during setup: %v", err) log.Fatalf("Error during setup: %v", err)
@@ -267,6 +269,8 @@ func runSetupDmsConfig(name string) error {
func runSetup() error { func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===") fmt.Println("=== DMS Configuration Setup ===")
ensureInputGroup()
wm, wmSelected := promptCompositor() wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal() terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd() useSystemd := promptSystemd()
@@ -340,6 +344,37 @@ func runSetup() error {
return nil 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) { func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:") fmt.Println("Select compositor:")
fmt.Println("1) Niri") fmt.Println("1) Niri")
+365
View File
@@ -0,0 +1,365 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
var systemCmd = &cobra.Command{
Use: "system",
Short: "System operations",
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
}
var systemUpdateCmd = &cobra.Command{
Use: "update",
Short: "Apply or list system updates",
Long: `Apply or list system updates across detected package managers.
Default behavior is to apply available updates after prompting for confirmation.
Use --check to list updates without applying.
Examples:
dms system update --check # list available updates
dms system update # apply updates (interactive prompt)
dms system update --noconfirm # apply updates without prompting
dms system update --dry # simulate without changing anything
dms system update --no-flatpak --noconfirm # apply system updates only
dms system update --interval 3600 # set the server poll interval to 1h`,
Run: runSystemUpdate,
}
var (
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
)
func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
systemCmd.AddCommand(systemUpdateCmd)
}
func runSystemUpdate(cmd *cobra.Command, args []string) {
switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS)
case sysUpdateCheck:
runSystemUpdateCheck()
default:
runSystemUpdateApply()
}
}
func selectBackends(ctx context.Context) []sysupdate.Backend {
sel := sysupdate.Select(ctx)
backends := sel.All()
if !sysUpdateNoFlatpak {
return backends
}
out := backends[:0]
for _, b := range backends {
if b.Repo() == sysupdate.RepoFlatpak {
continue
}
out = append(out, b)
}
return out
}
func runSystemUpdateCheck() {
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer cancel()
backends := selectBackends(ctx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
stopSpin := startSpinner("Checking for updates… ")
allPkgs, firstErr := collectUpdates(ctx, backends)
stopSpin()
allPkgs = filterUpdateTargets(allPkgs)
if sysUpdateJSON {
out, _ := json.MarshalIndent(map[string]any{
"backends": backendResults(backends, allPkgs),
"packages": allPkgs,
"error": errOrEmpty(firstErr),
"count": len(allPkgs),
}, "", " ")
fmt.Println(string(out))
return
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(allPkgs))
if firstErr != nil {
fmt.Printf("Error: %v\n", firstErr)
}
if len(allPkgs) == 0 {
return
}
fmt.Println()
for _, p := range allPkgs {
printPackage(p)
}
}
type backendResult struct {
ID string `json:"id"`
Display string `json:"displayName"`
Packages []sysupdate.Package `json:"packages"`
}
func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult {
results := make([]backendResult, 0, len(backends))
for _, b := range backends {
var backendPkgs []sysupdate.Package
for _, p := range pkgs {
if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) {
backendPkgs = append(backendPkgs, p)
}
}
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs})
}
return results
}
func runSystemUpdateApply() {
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer checkCancel()
backends := selectBackends(checkCtx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
stopSpin := startSpinner("Checking for updates…")
pkgs, firstErr := collectUpdates(checkCtx, backends)
stopSpin()
pkgs = filterUpdateTargets(pkgs)
if firstErr != nil {
fmt.Printf("Warning: %v\n\n", firstErr)
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(pkgs))
if len(pkgs) == 0 {
fmt.Println("Nothing to upgrade.")
return
}
fmt.Println()
for _, p := range pkgs {
printPackage(p)
}
fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
fmt.Println("Aborted.")
return
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
opts := sysupdate.UpgradeOptions{
Targets: pkgs,
IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR,
DryRun: sysUpdateDry,
UseSudo: true,
}
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
onLine := func(line string) { fmt.Println(line) }
ran := false
for _, b := range backends {
if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) {
continue
}
ran = true
fmt.Printf("\n== %s ==\n", b.DisplayName())
if err := b.Upgrade(ctx, opts, onLine); err != nil {
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
}
}
if !ran {
fmt.Println("Nothing to upgrade.")
return
}
if sysUpdateDry {
fmt.Println("\nDry run complete (no changes applied).")
return
}
fmt.Println("\nUpgrade complete.")
}
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
var all []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
all = append(all, pkgs...)
}
return all, firstErr
}
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR {
return pkgs
}
out := pkgs[:0]
for _, p := range pkgs {
if p.Repo == sysupdate.RepoAUR {
continue
}
out = append(out, p)
}
return out
}
func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "sysupdate.setInterval",
Params: map[string]any{"seconds": float64(seconds)},
})
if err != nil {
log.Fatalf("Failed: %v (is dms server running?)", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
fmt.Printf("Interval set to %d seconds.\n", seconds)
}
func promptYesNo(prompt string) bool {
if !stdinIsTTY() {
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
}
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "n", "no":
return false
default:
return true
}
}
func printBackends(backends []sysupdate.Backend) {
if len(backends) == 0 {
return
}
names := make([]string, 0, len(backends))
for _, b := range backends {
names = append(names, b.DisplayName())
}
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
}
func stdinIsTTY() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
func stdoutIsTTY() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
// startSpinner prints an animated spinner to stdout for progress indication
func startSpinner(msg string) func() {
if !stdoutIsTTY() {
return func() {}
}
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
done := make(chan struct{})
go func() {
for i := 0; ; i++ {
select {
case <-done:
fmt.Print("\r\033[K")
return
case <-time.After(80 * time.Millisecond):
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
}
}
}()
return func() { close(done) }
}
var (
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
)
func printPackage(p sysupdate.Package) {
if !stdoutIsTTY() {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
return
}
fmt.Printf(" %s %s %s %s %s\n",
styleRepo.Render("["+string(p.Repo)+"]"),
styleName.Render(p.Name),
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
styleArrow.Render("->"),
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
)
}
func errOrEmpty(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func defaultIfEmpty(s, def string) string {
if s == "" {
return def
}
return s
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra"
)
var trashCmd = &cobra.Command{
Use: "trash",
Short: "Manage the user's trash (XDG Trash spec 1.0)",
}
var trashPutCmd = &cobra.Command{
Use: "put <path...>",
Short: "Move files or directories into the trash",
Args: cobra.MinimumNArgs(1),
Run: runTrashPut,
}
var trashListCmd = &cobra.Command{
Use: "list",
Short: "List trashed items across all known trash directories",
Run: runTrashList,
}
var trashCountCmd = &cobra.Command{
Use: "count",
Short: "Print the total number of trashed items",
Run: runTrashCount,
}
var trashEmptyCmd = &cobra.Command{
Use: "empty",
Short: "Permanently delete every trashed item",
Run: runTrashEmpty,
}
var trashRestoreCmd = &cobra.Command{
Use: "restore <name>",
Short: "Restore a trashed item to its original location",
Args: cobra.ExactArgs(1),
Run: runTrashRestore,
}
var (
trashJSONOutput bool
trashRestoreDir string
)
func init() {
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
}
func runTrashPut(cmd *cobra.Command, args []string) {
var failed int
for _, p := range args {
if _, err := trash.Put(p); err != nil {
log.Errorf("trash %s: %v", p, err)
failed++
continue
}
fmt.Println(p)
}
if failed > 0 {
os.Exit(1)
}
}
func runTrashList(cmd *cobra.Command, args []string) {
entries, err := trash.List()
if err != nil {
log.Fatalf("list trash: %v", err)
}
if trashJSONOutput {
if entries == nil {
entries = []trash.Entry{}
}
out, _ := json.MarshalIndent(entries, "", " ")
fmt.Println(string(out))
return
}
if len(entries) == 0 {
fmt.Println("Trash is empty")
return
}
for _, e := range entries {
marker := "F"
if e.IsDir {
marker = "D"
}
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
}
}
func runTrashCount(cmd *cobra.Command, args []string) {
n, err := trash.Count()
if err != nil {
log.Fatalf("count trash: %v", err)
}
fmt.Println(n)
}
func runTrashEmpty(cmd *cobra.Command, args []string) {
if err := trash.Empty(); err != nil {
log.Fatalf("empty trash: %v", err)
}
}
func runTrashRestore(cmd *cobra.Command, args []string) {
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
log.Fatalf("restore: %v", err)
}
}
+14
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -269,3 +270,16 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath) return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
} }
// preRunPrivileged combines the immutable-system check with a privesc tool
// selection prompt (shown only when multiple tools are available and the
// $DMS_PRIVESC env var isn't set).
func preRunPrivileged(cmd *cobra.Command, args []string) error {
if err := requireMutableSystemCommand(cmd, args); err != nil {
return err
}
if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil {
return err
}
return nil
}
+6 -1
View File
@@ -5,6 +5,7 @@ package main
import ( import (
"os" "os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
) )
@@ -14,6 +15,8 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
@@ -30,7 +33,9 @@ func init() {
} }
func main() { 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.") log.Fatal("This program should not be run as root. Exiting.")
} }
+6 -1
View File
@@ -5,6 +5,7 @@ package main
import ( import (
"os" "os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
) )
@@ -14,6 +15,8 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
@@ -27,7 +30,9 @@ func init() {
} }
func main() { 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.") log.Fatal("This program should not be run as root. Exiting.")
} }
+14 -3
View File
@@ -80,6 +80,16 @@ func getRuntimeDir() string {
return os.TempDir() return os.TempDir()
} }
func appendLogEnv(env []string) []string {
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
env = append(env, "DMS_LOG_LEVEL="+v)
}
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
env = append(env, "DMS_LOG_FILE="+v)
}
return env
}
func hasSystemdRun() bool { func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run") _, err := exec.LookPath("systemd-run")
return err == nil return err == nil
@@ -192,9 +202,6 @@ func runShellInteractive(session bool) {
} }
} }
// ! TODO - remove when QS 0.3 is up and we can use the pragma
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
if isSessionManaged && hasSystemdRun() { if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope") cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
} }
@@ -216,6 +223,8 @@ func runShellInteractive(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
} }
cmd.Env = appendLogEnv(cmd.Env)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
@@ -459,6 +468,8 @@ func runShellDaemon(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
} }
cmd.Env = appendLogEnv(cmd.Env)
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0) devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil { if err != nil {
log.Fatalf("Error opening /dev/null: %v", err) log.Fatalf("Error opening /dev/null: %v", err)
+16
View File
@@ -7,6 +7,22 @@ import (
"strings" "strings"
) )
// isReadOnlyCommand returns true if the CLI args indicate a command that is
// safe to run as root (e.g. shell completion, help).
func isReadOnlyCommand(args []string) bool {
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "-") {
continue
}
switch arg {
case "completion", "help", "__complete", "system":
return true
}
return false
}
return false
}
func isArchPackageInstalled(packageName string) bool { func isArchPackageInstalled(packageName string) bool {
cmd := exec.Command("pacman", "-Q", packageName) cmd := exec.Command("pacman", "-Q", packageName)
err := cmd.Run() err := cmd.Run()
+45 -29
View File
@@ -1,84 +1,100 @@
module github.com/AvengeMedia/DankMaterialShell/core module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.0 go 1.26.1
toolchain go1.26.1
require ( require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0 github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.23.1 github.com/alecthomas/chroma/v2 v2.24.1
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/log v1.0.0
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.10.1
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
github.com/pilebones/go-udev v0.9.1 github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0 github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.7.16 github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/image v0.36.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
tailscale.com v1.96.5
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/akutz/memconn v0.1.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect github.com/fogleman/gg v1.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/mdlayher/netlink v1.11.1 // indirect
github.com/mdlayher/socket v0.6.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/crypto v0.48.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f github.com/go-git/go-git/v6 v6.0.0-alpha.2
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.41.0 golang.org/x/sys v0.43.0
golang.org/x/text v0.34.0 golang.org/x/text v0.36.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
+103 -55
View File
@@ -1,14 +1,18 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U= github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg= github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
@@ -24,54 +28,71 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs= github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc= github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII= github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw= github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s= github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU= github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -84,12 +105,16 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU= github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -101,14 +126,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -117,12 +148,13 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8= github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -146,6 +178,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk= github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
@@ -155,37 +193,47 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
+118 -83
View File
@@ -1,7 +1,6 @@
package clipboard package clipboard
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -13,66 +12,142 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" 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 { func Copy(data []byte, mimeType string) error {
return CopyReader(bytes.NewReader(data), mimeType, false, false) return copyForkCached(data, mimeType, false)
} }
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error { func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground { if foreground {
return copyServeWithWriter(func(writer io.Writer) error { return serveClipboard(data, mimeType, pasteOnce)
total := 0
for total < len(data) {
n, err := writer.Write(data[total:])
total += n
if err != nil {
return err
}
}
if total != len(data) {
return io.ErrShortWrite
}
return nil
}, mimeType, pasteOnce)
} }
return CopyReader(bytes.NewReader(data), mimeType, foreground, pasteOnce) return copyForkCached(data, mimeType, pasteOnce)
} }
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error { func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if !foreground { if foreground {
return copyFork(data, mimeType, pasteOnce) buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
} }
return copyServeReader(data, mimeType, pasteOnce) return copyFork(data, mimeType, pasteOnce)
} }
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error { func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
args := []string{os.Args[0], "cl", "copy", "--foreground"} cmd := exec.Command(os.Args[0])
if pasteOnce {
args = append(args, "--paste-once")
}
args = append(args, "--type", mimeType)
cmd := exec.Command(args[0], args[1:]...)
cmd.Stderr = nil cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
cmd.Env = append(os.Environ(), "DMS_CLIP_FORKED=1") 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() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
return fmt.Errorf("stdout pipe: %w", err) return fmt.Errorf("stdout pipe: %w", err)
} }
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
cacheFile, err := createClipboardCacheFile()
if err != nil {
return fmt.Errorf("create cache file: %w", err)
}
cachePath := cacheFile.Name()
if _, err := cacheFile.Write(data); err != nil {
cacheFile.Close()
os.Remove(cachePath)
return fmt.Errorf("write cache file: %w", err)
}
if err := cacheFile.Close(); err != nil {
os.Remove(cachePath)
return fmt.Errorf("close cache file: %w", err)
}
cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath)
cmd.Stdin = nil
if err := waitReady(cmd); err != nil {
os.Remove(cachePath)
return err
}
return nil
}
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
cmd := newForkCmd(mimeType, pasteOnce)
switch src := data.(type) { switch src := data.(type) {
case *os.File: case *os.File:
cmd.Stdin = src cmd.Stdin = src
if err := cmd.Start(); err != nil { return waitReady(cmd)
return fmt.Errorf("start: %w", err)
}
default: default:
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
return fmt.Errorf("stdin pipe: %w", err) 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 { if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err) return fmt.Errorf("start: %w", err)
} }
@@ -83,50 +158,22 @@ func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
if err := stdin.Close(); err != nil { if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err) return fmt.Errorf("close stdin: %w", err)
} }
}
var buf [1]byte var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil { if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err) return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
} }
return nil
} }
func signalReady() { func signalReady() {
if os.Getenv("DMS_CLIP_FORKED") == "" { if os.Getenv(envServe) == "" {
return return
} }
os.Stdout.Write([]byte{1}) os.Stdout.Write([]byte{1})
} }
func copyServeReader(data io.Reader, mimeType string, pasteOnce bool) error {
cachedData, err := createClipboardCacheFile()
if err != nil {
return fmt.Errorf("create clipboard cache file: %w", err)
}
defer os.Remove(cachedData.Name())
if _, err := io.Copy(cachedData, data); err != nil {
return fmt.Errorf("cache clipboard data: %w", err)
}
if err := cachedData.Close(); err != nil {
return fmt.Errorf("close temp cache file: %w", err)
}
return copyServeWithWriter(func(writer io.Writer) error {
cachedFile, err := os.Open(cachedData.Name())
if err != nil {
return fmt.Errorf("open temp cache file: %w", err)
}
defer cachedFile.Close()
if _, err := io.Copy(writer, cachedFile); err != nil {
return fmt.Errorf("write clipboard data: %w", err)
}
return nil
}, mimeType, pasteOnce)
}
func createClipboardCacheFile() (*os.File, error) { func createClipboardCacheFile() (*os.File, error) {
preferredDirs := []string{} preferredDirs := []string{}
@@ -147,7 +194,7 @@ func createClipboardCacheFile() (*os.File, error) {
return os.CreateTemp("", "dms-clipboard-*") return os.CreateTemp("", "dms-clipboard-*")
} }
func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOnce bool) error { func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
display, err := wlclient.Connect("") display, err := wlclient.Connect("")
if err != nil { if err != nil {
return fmt.Errorf("wayland connect: %w", err) return fmt.Errorf("wayland connect: %w", err)
@@ -189,12 +236,10 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
if bindErr != nil { if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr) return fmt.Errorf("registry bind: %w", bindErr)
} }
if dataControlMgr == nil { if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1") return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
} }
defer dataControlMgr.Destroy() defer dataControlMgr.Destroy()
if seat == nil { if seat == nil {
return fmt.Errorf("no seat available") return fmt.Errorf("no seat available")
} }
@@ -233,18 +278,12 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
cancelled := make(chan struct{}) cancelled := make(chan struct{})
pasted := make(chan struct{}, 1) pasted := make(chan struct{}, 1)
sendErr := make(chan error, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { 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") file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close() defer file.Close()
if err := writeTo(file); err != nil { _, _ = file.Write(data)
select {
case sendErr <- err:
default:
}
}
select { select {
case pasted <- struct{}{}: case pasted <- struct{}{}:
default: default:
@@ -266,8 +305,6 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
select { select {
case <-cancelled: case <-cancelled:
return nil return nil
case err := <-sendErr:
return err
case <-pasted: case <-pasted:
if pasteOnce { if pasteOnce {
return nil return nil
@@ -521,12 +558,10 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
if bindErr != nil { if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr) return fmt.Errorf("registry bind: %w", bindErr)
} }
if dataControlMgr == nil { if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1") return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
} }
defer dataControlMgr.Destroy() defer dataControlMgr.Destroy()
if seat == nil { if seat == nil {
return fmt.Errorf("no seat available") return fmt.Errorf("no seat available")
} }
@@ -554,12 +589,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
pasted := make(chan struct{}, 1) pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { 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") file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close() defer file.Close()
if data, ok := offerMap[e.MimeType]; ok { if data, ok := offerMap[e.MimeType]; ok {
file.Write(data) _, _ = file.Write(data)
} }
select { select {
+53 -50
View File
@@ -39,11 +39,10 @@ type LayerSurface struct {
wlSurface *client.Surface wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1 layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport viewport *wp_viewporter.WpViewport
wlPool *client.ShmPool wlPools [2]*client.ShmPool
wlBuffer *client.Buffer wlBuffers [2]*client.Buffer
bufferBusy bool slotBusy [2]bool
oldPool *client.ShmPool needsRedraw bool
oldBuffer *client.Buffer
scopyBuffer *client.Buffer scopyBuffer *client.Buffer
configured bool configured bool
hidden bool hidden bool
@@ -136,6 +135,7 @@ func (p *Picker) Run() (*Color, error) {
break break
} }
p.flushRedraws()
p.checkDone() p.checkDone()
} }
@@ -164,6 +164,15 @@ func (p *Picker) checkDone() {
} }
} }
func (p *Picker) flushRedraws() {
for _, ls := range p.surfaces {
if !ls.needsRedraw {
continue
}
p.redrawSurface(ls)
}
}
func (p *Picker) connect() error { func (p *Picker) connect() error {
display, err := client.Connect("") display, err := client.Connect("")
if err != nil { if err != nil {
@@ -507,47 +516,45 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
} }
func (p *Picker) redrawSurface(ls *LayerSurface) { func (p *Picker) redrawSurface(ls *LayerSurface) {
slot := ls.state.FrontIndex()
if ls.slotBusy[slot] {
ls.needsRedraw = true
return
}
var renderBuf *ShmBuffer var renderBuf *ShmBuffer
if ls.hidden { switch {
case ls.hidden:
renderBuf = ls.state.RedrawScreenOnly() renderBuf = ls.state.RedrawScreenOnly()
} else { default:
renderBuf = ls.state.Redraw() renderBuf = ls.state.Redraw()
} }
if renderBuf == nil { if renderBuf == nil {
return return
} }
if ls.oldBuffer != nil { ls.needsRedraw = false
ls.oldBuffer.Destroy()
ls.oldBuffer = nil if ls.wlPools[slot] == nil {
} pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
if ls.oldPool != nil { if err != nil {
ls.oldPool.Destroy() return
ls.oldPool = nil }
ls.wlPools[slot] = pool
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
if err != nil {
return
}
ls.wlBuffers[slot] = wlBuffer
s := slot
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
ls.slotBusy[s] = false
})
} }
ls.oldPool = ls.wlPool ls.slotBusy[slot] = true
ls.oldBuffer = ls.wlBuffer
ls.wlPool = nil
ls.wlBuffer = nil
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
if err != nil {
return
}
ls.wlPool = pool
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
if err != nil {
return
}
ls.wlBuffer = wlBuffer
lsRef := ls
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
lsRef.bufferBusy = false
})
ls.bufferBusy = true
logicalW, logicalH := ls.state.LogicalSize() logicalW, logicalH := ls.state.LogicalSize()
if logicalW == 0 || logicalH == 0 { if logicalW == 0 || logicalH == 0 {
@@ -566,7 +573,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
} }
_ = ls.wlSurface.SetBufferScale(bufferScale) _ = ls.wlSurface.SetBufferScale(bufferScale)
} }
_ = ls.wlSurface.Attach(wlBuffer, 0, 0) _ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0)
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH)) _ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
_ = ls.wlSurface.Commit() _ = ls.wlSurface.Commit()
@@ -634,7 +641,7 @@ func (p *Picker) setupPointerHandlers() {
} }
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY) p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
p.redrawSurface(p.activeSurface) p.activeSurface.needsRedraw = true
}) })
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) { p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
@@ -655,7 +662,7 @@ func (p *Picker) setupPointerHandlers() {
return return
} }
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY) p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
p.redrawSurface(p.activeSurface) p.activeSurface.needsRedraw = true
}) })
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) { p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
@@ -679,17 +686,13 @@ func (p *Picker) cleanup() {
if ls.scopyBuffer != nil { if ls.scopyBuffer != nil {
ls.scopyBuffer.Destroy() ls.scopyBuffer.Destroy()
} }
if ls.oldBuffer != nil { for i := range ls.wlBuffers {
ls.oldBuffer.Destroy() if ls.wlBuffers[i] != nil {
} ls.wlBuffers[i].Destroy()
if ls.oldPool != nil { }
ls.oldPool.Destroy() if ls.wlPools[i] != nil {
} ls.wlPools[i].Destroy()
if ls.wlBuffer != nil { }
ls.wlBuffer.Destroy()
}
if ls.wlPool != nil {
ls.wlPool.Destroy()
} }
if ls.viewport != nil { if ls.viewport != nil {
ls.viewport.Destroy() ls.viewport.Destroy()
+6
View File
@@ -274,6 +274,12 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
return s.renderBufs[s.front] return s.renderBufs[s.front]
} }
func (s *SurfaceState) FrontIndex() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.front
}
func (s *SurfaceState) SwapBuffers() { func (s *SurfaceState) SwapBuffers() {
s.mu.Lock() s.mu.Lock()
s.front ^= 1 s.front ^= 1
+20 -1
View File
@@ -62,12 +62,31 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) { func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
var results []DeploymentResult var results []DeploymentResult
// Primary config file paths used to detect fresh installs.
configPrimaryPaths := map[string]string{
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
}
shouldReplaceConfig := func(configType string) bool { shouldReplaceConfig := func(configType string) bool {
if replaceConfigs == nil { if replaceConfigs == nil {
return true return true
} }
replace, exists := replaceConfigs[configType] replace, exists := replaceConfigs[configType]
return !exists || replace if !exists || replace {
return true
}
// Config is explicitly set to "don't replace" — but still deploy
// if the config file doesn't exist yet (fresh install scenario).
if primaryPath, ok := configPrimaryPaths[configType]; ok {
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
return true
}
}
return false
} }
switch wm { switch wm {
+166
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -624,3 +625,168 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
assert.Contains(t, string(newContent), "decorations = \"None\"") assert.Contains(t, string(newContent), "decorations = \"None\"")
}) })
} }
func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
allFalse := map[string]bool{
"Niri": false,
"Hyprland": false,
"Ghostty": false,
"Kitty": false,
"Alacritty": false,
}
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
nil, // replaceConfigs
nil, // reinstallItems
)
require.NoError(t, err)
// With replaceConfigs=nil, all configs should be deployed
hasDeployed := false
for _, r := range results {
if r.Deployed {
hasDeployed = true
break
}
}
assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil")
})
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
allFalse, // replaceConfigs — all false
nil, // reinstallItems
)
require.NoError(t, err)
// Config files don't exist on disk, so they should still be deployed
hasDeployed := false
for _, r := range results {
if r.Deployed {
hasDeployed = true
break
}
}
assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false")
})
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
// Create the Ghostty primary config file so shouldReplaceConfig returns false
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
require.NoError(t, err)
// Also create the Niri primary config file
niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl")
err = os.MkdirAll(filepath.Dir(niriPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644)
require.NoError(t, err)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
allFalse, // replaceConfigs — all false
nil, // reinstallItems
)
require.NoError(t, err)
// Both Niri and Ghostty config files exist, so with all false they should be skipped
for _, r := range results {
assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType)
}
})
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
// Create the Ghostty primary config file
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
require.NoError(t, err)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
replaceConfigs := map[string]bool{
"Niri": false,
"Hyprland": false,
"Ghostty": true, // explicitly true
"Kitty": false,
"Alacritty": false,
}
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
deps.WindowManagerNiri,
deps.TerminalGhostty,
nil, // installedDeps
replaceConfigs, // Ghostty=true, rest=false
nil, // reinstallItems
)
require.NoError(t, err)
// Ghostty should be deployed because replaceConfigs["Ghostty"]=true
foundGhostty := false
for _, r := range results {
if r.ConfigType == "Ghostty" && r.Deployed {
foundGhostty = true
}
}
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
})
}
@@ -137,7 +137,7 @@ bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout === # === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit 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 === # === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow bindmd = SUPER, mouse:272, Move window, movewindow
+1 -4
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 ^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$ 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 ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$ windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$ windowrule = float on, match:class ^(blueman-manager)$
@@ -106,10 +107,6 @@ windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$ windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$ windowrule = float on, match:class ^(zoom)$
# DMS windows floating by default
# ! Hyprland doesn't size these windows correctly so disabling by default here
# windowrule = float on, match:class ^(org.quickshell)$
layerrule = no_anim on, match:namespace ^(quickshell)$ layerrule = no_anim on, match:namespace ^(quickshell)$
layerrule = no_anim on, match:namespace ^dms:.* layerrule = no_anim on, match:namespace ^dms:.*
+1 -6
View File
@@ -224,6 +224,7 @@ window-rule {
open-floating false open-floating false
} }
window-rule { window-rule {
match app-id=r#"^org\.gnome\.Calculator$"#
match app-id=r#"^gnome-calculator$"# match app-id=r#"^gnome-calculator$"#
match app-id=r#"^galculator$"# match app-id=r#"^galculator$"#
match app-id=r#"^blueman-manager$"# match app-id=r#"^blueman-manager$"#
@@ -249,12 +250,6 @@ window-rule {
match app-id="zoom" match app-id="zoom"
open-floating true open-floating true
} }
// Open dms windows as floating by default
window-rule {
match app-id=r#"org.quickshell$"#
match app-id=r#"com.danklinux.dms$"#
open-floating true
}
debug { debug {
honor-xdg-activation-with-invalid-serial honor-xdg-activation-with-invalid-serial
} }
+72 -56
View File
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -207,8 +208,7 @@ func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) Pac
if forceQuickshellGit || variant == deps.VariantGit { if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR} return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
} }
// ! TODO - for now we're only forcing quickshell-git on ARCH, as other distros use DL repos which pin a newer quickshell return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
@@ -242,11 +242,7 @@ func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMap
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
} }
if a.packageInstalled("dms-shell-bin") { return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency { func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
@@ -296,7 +292,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
LogOutput: "Installing base-devel development tools", 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 { if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
return fmt.Errorf("failed to install base-devel: %w", err) return fmt.Errorf("failed to install base-devel: %w", err)
} }
@@ -328,6 +324,19 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
}
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
}
if slices.Contains(systemPkgs, "quickshell") && a.packageInstalled("quickshell-git") {
if err := a.removeQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to remove quickshell-git: %w", err)
}
}
// Phase 3: System Packages // Phase 3: System Packages
if len(systemPkgs) > 0 { if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
@@ -445,6 +454,51 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
return systemPkgs, aurPkgs, manualPkgs, variantMap return systemPkgs, aurPkgs, manualPkgs, variantMap
} }
func (a *ArchDistribution) removeQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.33,
Step: "Removing quickshell-git...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell-git",
LogOutput: "Removing quickshell-git so stable quickshell can be installed",
}
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell-git")
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.33, 0.35)
}
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if a.packageInstalled("quickshell-git") {
return nil
}
if a.packageInstalled("quickshell") {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.15,
Step: "Removing stable quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
}
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
return fmt.Errorf("failed to remove stable quickshell: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.18,
Step: "Building quickshell-git before system packages...",
IsComplete: false,
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
}
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
}
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 { if len(packages) == 0 {
return nil return nil
@@ -453,6 +507,9 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", "))) a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
args := []string{"pacman", "-S", "--needed", "--noconfirm"} 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...) args = append(args, packages...)
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
@@ -464,7 +521,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), 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) return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
} }
@@ -540,7 +597,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
var dmsShell []string var dmsShell []string
for _, pkg := range packages { for _, pkg := range packages {
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" { if pkg == "dms-shell-git" {
dmsShell = append(dmsShell, pkg) dmsShell = append(dmsShell, pkg)
} else { } else {
isDep := false isDep := false
@@ -621,7 +678,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
} }
} }
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" { if pkg == "dms-shell-git" {
srcinfoPath := filepath.Join(packageDir, ".SRCINFO") srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
depsToRemove := []string{ depsToRemove := []string{
"depends = quickshell", "depends = quickshell",
@@ -644,15 +701,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
} }
srcinfoPath = filepath.Join(packageDir, ".SRCINFO") srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
if pkg == "dms-shell-bin" { {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.35*(endProgress-startProgress),
Step: fmt.Sprintf("Skipping dependency installation for %s (manually managed)...", pkg),
IsComplete: false,
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
}
} else {
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages, Phase: PhaseAURPackages,
Progress: startProgress + 0.3*(endProgress-startProgress), Progress: startProgress + 0.3*(endProgress-startProgress),
@@ -739,42 +788,9 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
CommandInfo: "sudo pacman -U built-package", CommandInfo: "sudo pacman -U built-package",
} }
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
var files []string var files []string
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" { matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
// For DMS split packages, install base package files = matches
pattern := filepath.Join(packageDir, fmt.Sprintf("%s-%s*.pkg.tar*", pkg, "*"))
matches, err := filepath.Glob(pattern)
if err == nil {
for _, match := range matches {
basename := filepath.Base(match)
// Always include base package
if !strings.Contains(basename, "hyprland") && !strings.Contains(basename, "niri") {
files = append(files, match)
}
}
}
// Also update compositor-specific packages if they're installed
if strings.HasSuffix(pkg, "-git") {
if a.packageInstalled("dms-shell-hyprland-git") {
hyprlandPattern := filepath.Join(packageDir, "dms-shell-hyprland-git-*.pkg.tar*")
if hyprlandMatches, err := filepath.Glob(hyprlandPattern); err == nil && len(hyprlandMatches) > 0 {
files = append(files, hyprlandMatches[0])
}
}
if a.packageInstalled("dms-shell-niri-git") {
niriPattern := filepath.Join(packageDir, "dms-shell-niri-git-*.pkg.tar*")
if niriMatches, err := filepath.Glob(niriPattern); err == nil && len(niriMatches) > 0 {
files = append(files, niriMatches[0])
}
}
}
} else {
// For other packages, install all built packages
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
files = matches
}
if len(files) == 0 { if len(files) == 0 {
return fmt.Errorf("no package files found after building %s", pkg) return fmt.Errorf("no package files found after building %s", pkg)
@@ -783,7 +799,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
installArgs := []string{"pacman", "-U", "--noconfirm"} installArgs := []string{"pacman", "-U", "--noconfirm"}
installArgs = append(installArgs, files...) installArgs = append(installArgs, files...)
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " ")) installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
fileNames := make([]string, len(files)) fileNames := make([]string, len(files))
for i, f := range files { for i, f := range files {
+3 -23
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
) )
@@ -55,27 +56,6 @@ func (b *BaseDistribution) logError(message string, err error) {
b.log(errorMsg) 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 { func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
status := deps.StatusMissing status := deps.StatusMissing
if b.commandExists(name) { if b.commandExists(name) {
@@ -252,7 +232,7 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency {
} }
versionStr := string(output) versionStr := string(output)
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`) versionRegex := regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
matches := versionRegex.FindStringSubmatch(versionStr) matches := versionRegex.FindStringSubmatch(versionStr)
if len(matches) < 2 { if len(matches) < 2 {
@@ -710,7 +690,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
} }
// Install to /usr/local/bin // Install to /usr/local/bin
installCmd := ExecSudoCommand(ctx, sudoPassword, installCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath)) fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install DMS binary: %w", err) return fmt.Errorf("failed to install DMS binary: %w", err)
+12 -11
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -182,7 +183,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists", 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 { if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err) return fmt.Errorf("failed to update package lists: %w", err)
} }
@@ -199,7 +200,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential") checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil { 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 { if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err) return fmt.Errorf("failed to install build-essential: %w", err)
} }
@@ -215,7 +216,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Installing additional development tools", LogOutput: "Installing additional development tools",
} }
devToolsCmd := ExecSudoCommand(ctx, sudoPassword, devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev") "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 { if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err) return fmt.Errorf("failed to install development tools: %w", err)
@@ -441,7 +442,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName) keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
// Create keyrings directory if it doesn't exist // 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 { if err := mkdirCmd.Run(); err != nil {
d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err)) d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err))
} }
@@ -455,7 +456,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
} }
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath) 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 { 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) return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
} }
@@ -471,7 +472,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile), 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)) fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { 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) return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err)
@@ -491,7 +492,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: "sudo apt-get update", 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 { 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) return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err)
} }
@@ -537,7 +538,7 @@ func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress) return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress)
} }
@@ -625,7 +626,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"} args := []string{"apt-get", "install", "-y"}
args = append(args, depList...) 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) return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
} }
@@ -643,7 +644,7 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup", 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 { if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err) return fmt.Errorf("failed to install rustup: %w", err)
} }
@@ -682,7 +683,7 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go", 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) return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
} }
+5 -4
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -254,7 +255,7 @@ func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
args := []string{"dnf", "install", "-y"} args := []string{"dnf", "install", "-y"}
args = append(args, missingPkgs...) args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
f.logError("failed to install prerequisites", err) f.logError("failed to install prerequisites", err)
@@ -437,7 +438,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL), 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)) fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@@ -461,7 +462,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile), 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)) fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
priorityOutput, err := priorityCmd.CombinedOutput() priorityOutput, err := priorityCmd.CombinedOutput()
if err != nil { if err != nil {
@@ -537,7 +538,7 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
} }
+15 -14
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
var GentooGlobalUseFlags = []string{ var GentooGlobalUseFlags = []string{
@@ -201,9 +202,9 @@ func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword
var cmd *exec.Cmd var cmd *exec.Cmd
if hasUse { 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 { } 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() output, err := cmd.CombinedOutput()
@@ -281,7 +282,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Syncing Portage tree with emerge --sync", 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() syncOutput, syncErr := syncCmd.CombinedOutput()
if syncErr != nil { if syncErr != nil {
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput))) 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 := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...) args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
g.logError("failed to install prerequisites", err) 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, " ")), 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) return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
} }
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error { func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
packageUseDir := "/etc/portage/package.use" packageUseDir := "/etc/portage/package.use"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", packageUseDir)) fmt.Sprintf("mkdir -p %s", packageUseDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil { if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output))) 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 { if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName)) g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(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)) fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
if output, err := replaceCmd.CombinedOutput(); err != nil { if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output))) 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)) fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
output, err := appendCmd.CombinedOutput() output, err := appendCmd.CombinedOutput()
@@ -557,7 +558,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
} }
// Enable GURU repository // Enable GURU repository
enableCmd := ExecSudoCommand(ctx, sudoPassword, enableCmd := privesc.ExecCommand(ctx, sudoPassword,
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code") "eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
output, err := enableCmd.CombinedOutput() output, err := enableCmd.CombinedOutput()
@@ -589,7 +590,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
LogOutput: "Syncing GURU repository", LogOutput: "Syncing GURU repository",
} }
syncCmd := ExecSudoCommand(ctx, sudoPassword, syncCmd := privesc.ExecCommand(ctx, sudoPassword,
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code") "emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput() syncOutput, syncErr := syncCmd.CombinedOutput()
@@ -622,7 +623,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
acceptKeywordsDir := "/etc/portage/package.accept_keywords" acceptKeywordsDir := "/etc/portage/package.accept_keywords"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir)) fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil { if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output))) 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 { if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName)) g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(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)) fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
if output, err := replaceCmd.CombinedOutput(); err != nil { if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output))) 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)) fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
output, err := appendCmd.CombinedOutput() output, err := appendCmd.CombinedOutput()
@@ -695,6 +696,6 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), 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) return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
} }
+10 -18
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
// ManualPackageInstaller provides methods for installing packages from source // 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", CommandInfo: "sudo make install",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install") installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
m.logError("failed to install dgop", err) 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", CommandInfo: "dpkg -i niri.deb",
} }
installDebCmd := ExecSudoCommand(ctx, sudoPassword, installDebCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir)) fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
output, err := installDebCmd.CombinedOutput() output, err := installDebCmd.CombinedOutput()
@@ -324,7 +325,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
CommandInfo: "sudo cmake --install build", CommandInfo: "sudo cmake --install build",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build") installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err) 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", CommandInfo: "sudo make install",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install") installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Hyprland: %w", err) 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/", 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)) fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err) 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), CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
} }
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err) return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
} }
// Make it executable if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make matugen executable: %w", err) 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), CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
} }
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err) return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
} }
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath) if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err) return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
} }
+8 -7
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -250,7 +251,7 @@ func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPas
args := []string{"zypper", "install", "-y"} args := []string{"zypper", "install", "-y"}
args = append(args, missingPkgs...) args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
o.logError("failed to install prerequisites", err) o.logError("failed to install prerequisites", err)
@@ -486,7 +487,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL), CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("zypper addrepo -f %s", repoURL)) fmt.Sprintf("zypper addrepo -f %s", repoURL))
if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { 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)) o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err))
@@ -507,7 +508,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: "sudo zypper --gpg-auto-import-keys refresh", 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 { if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to refresh repositories: %w", err) return fmt.Errorf("failed to refresh repositories: %w", err)
} }
@@ -588,7 +589,7 @@ func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sud
} }
for _, alias := range aliases { for _, alias := range aliases {
cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", escapeSingleQuotes(alias))) cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", privesc.EscapeSingleQuotes(alias)))
repoOutput, err := cmd.CombinedOutput() repoOutput, err := cmd.CombinedOutput()
if err != nil { if err != nil {
o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput)))) o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
@@ -646,7 +647,7 @@ func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packag
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
} }
@@ -774,7 +775,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
CommandInfo: "sudo cmake --install build", CommandInfo: "sudo cmake --install build",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build") installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err) return fmt.Errorf("failed to install quickshell: %w", err)
@@ -798,7 +799,7 @@ func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword str
CommandInfo: "sudo zypper install rustup", 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 { if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err) return fmt.Errorf("failed to install rustup: %w", err)
} }
+13 -12
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -177,7 +178,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists", 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 { if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err) return fmt.Errorf("failed to update package lists: %w", err)
} }
@@ -195,7 +196,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential") checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil { if err := checkCmd.Run(); err != nil {
// Not installed, install it // 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 { if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err) return fmt.Errorf("failed to install build-essential: %w", err)
} }
@@ -211,7 +212,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Installing additional development tools", 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") "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 { if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err) return fmt.Errorf("failed to install development tools: %w", err)
@@ -398,7 +399,7 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool) enabledRepos := make(map[string]bool)
installPPACmd := ExecSudoCommand(ctx, sudoPassword, installPPACmd := privesc.ExecCommand(ctx, sudoPassword,
"apt-get install -y software-properties-common") "apt-get install -y software-properties-common")
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil { if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
return fmt.Errorf("failed to install software-properties-common: %w", err) return fmt.Errorf("failed to install software-properties-common: %w", err)
@@ -416,7 +417,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL), 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)) fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { 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) u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
@@ -437,7 +438,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: "sudo apt-get update", 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 { 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) return fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
} }
@@ -504,7 +505,7 @@ func (u *UbuntuDistribution) installAPTGroups(ctx context.Context, packages []st
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
} }
@@ -591,7 +592,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"} args := []string{"apt-get", "install", "-y"}
args = append(args, depList...) 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) return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
} }
@@ -609,7 +610,7 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup", 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 { if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err) return fmt.Errorf("failed to install rustup: %w", err)
} }
@@ -649,7 +650,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports", 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") "add-apt-repository -y ppa:longsleep/golang-backports")
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil { if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
return fmt.Errorf("failed to add Go PPA: %w", err) return fmt.Errorf("failed to add Go PPA: %w", err)
@@ -664,7 +665,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get update", 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 { 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) return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
} }
@@ -678,7 +679,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go", 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) return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
} }
+80 -172
View File
@@ -17,6 +17,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/sblinch/kdl-go" "github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document" "github.com/sblinch/kdl-go/document"
@@ -327,56 +328,17 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
switch config.Family { switch config.Family {
case distros.FamilyArch: case distros.FamilyArch:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm greetd")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"pacman -S --needed --noconfirm greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd")
}
case distros.FamilyFedora: case distros.FamilyFedora:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y greetd")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"dnf install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd")
}
case distros.FamilySUSE: case distros.FamilySUSE:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y greetd")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, case distros.FamilyUbuntu, distros.FamilyDebian:
"zypper install -y greetd") installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd")
}
case distros.FamilyUbuntu:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
}
case distros.FamilyDebian:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
}
case distros.FamilyGentoo: case distros.FamilyGentoo:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"emerge --ask n sys-apps/greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-apps/greetd")
}
case distros.FamilyNix: case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix") return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
default: default:
return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family) return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family)
} }
@@ -437,56 +399,56 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug)) logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug))
if _, err := exec.LookPath("gpg"); err != nil { if _, err := exec.LookPath("gpg"); err != nil {
logFunc("Installing gnupg for OBS repository key import...") logFunc("Installing gnupg for OBS repository key import...")
installGPGCmd := exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "gnupg") installGPGCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y gnupg")
installGPGCmd.Stdout = os.Stdout installGPGCmd.Stdout = os.Stdout
installGPGCmd.Stderr = os.Stderr installGPGCmd.Stderr = os.Stderr
if err := installGPGCmd.Run(); err != nil { if err := installGPGCmd.Run(); err != nil {
logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err)) logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err))
} }
} }
mkdirCmd := exec.CommandContext(ctx, "sudo", "mkdir", "-p", "/etc/apt/keyrings") mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
mkdirCmd.Stdout = os.Stdout mkdirCmd.Stdout = os.Stdout
mkdirCmd.Stderr = os.Stderr mkdirCmd.Stderr = os.Stderr
mkdirCmd.Run() mkdirCmd.Run()
addKeyCmd := exec.CommandContext(ctx, "bash", "-c", addKeyCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf(`curl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg`, keyURL)) fmt.Sprintf(`bash -c "curl -fsSL %s | gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg"`, keyURL))
addKeyCmd.Stdout = os.Stdout addKeyCmd.Stdout = os.Stdout
addKeyCmd.Stderr = os.Stderr addKeyCmd.Stderr = os.Stderr
addKeyCmd.Run() addKeyCmd.Run()
addRepoCmd := exec.CommandContext(ctx, "bash", "-c", addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf(`echo '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list`, repoLine)) fmt.Sprintf(`bash -c "echo '%s' > /etc/apt/sources.list.d/danklinux.list"`, repoLine))
addRepoCmd.Stdout = os.Stdout addRepoCmd.Stdout = os.Stdout
addRepoCmd.Stderr = os.Stderr addRepoCmd.Stderr = os.Stderr
addRepoCmd.Run() addRepoCmd.Run()
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
case distros.FamilySUSE: case distros.FamilySUSE:
repoURL := getOpenSUSEOBSRepoURL(osInfo) repoURL := getOpenSUSEOBSRepoURL(osInfo)
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL) failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL)
logFunc("Adding DankLinux OBS repository...") logFunc("Adding DankLinux OBS repository...")
addRepoCmd := exec.CommandContext(ctx, "sudo", "zypper", "addrepo", repoURL) addRepoCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper addrepo %s", repoURL))
addRepoCmd.Stdout = os.Stdout addRepoCmd.Stdout = os.Stdout
addRepoCmd.Stderr = os.Stderr addRepoCmd.Stderr = os.Stderr
addRepoCmd.Run() addRepoCmd.Run()
exec.CommandContext(ctx, "sudo", "zypper", "refresh").Run() privesc.ExecCommand(ctx, sudoPassword, "zypper refresh").Run()
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "dms-greeter") installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y dms-greeter")
case distros.FamilyUbuntu: case distros.FamilyUbuntu:
failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter" failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter"
logFunc("Enabling PPA ppa:avengemedia/danklinux...") logFunc("Enabling PPA ppa:avengemedia/danklinux...")
ppacmd := exec.CommandContext(ctx, "sudo", "add-apt-repository", "-y", "ppa:avengemedia/danklinux") ppacmd := privesc.ExecCommand(ctx, sudoPassword, "add-apt-repository -y ppa:avengemedia/danklinux")
ppacmd.Stdout = os.Stdout ppacmd.Stdout = os.Stdout
ppacmd.Stderr = os.Stderr ppacmd.Stderr = os.Stderr
ppacmd.Run() ppacmd.Run()
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
case distros.FamilyFedora: case distros.FamilyFedora:
failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter" failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter"
logFunc("Enabling COPR avengemedia/danklinux...") logFunc("Enabling COPR avengemedia/danklinux...")
coprcmd := exec.CommandContext(ctx, "sudo", "dnf", "copr", "enable", "-y", "avengemedia/danklinux") coprcmd := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y avengemedia/danklinux")
coprcmd.Stdout = os.Stdout coprcmd.Stdout = os.Stdout
coprcmd.Stderr = os.Stderr coprcmd.Stderr = os.Stderr
coprcmd.Run() coprcmd.Run()
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "dms-greeter") installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y dms-greeter")
case distros.FamilyArch: case distros.FamilyArch:
aurHelper := "" aurHelper := ""
for _, helper := range []string{"paru", "yay"} { for _, helper := range []string{"paru", "yay"} {
@@ -539,25 +501,25 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() { if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() {
action = "Updated" action = "Updated"
} }
if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil {
return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err) return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err)
} }
logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst)) logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst))
if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "+x", wrapperDst); err != nil {
return fmt.Errorf("failed to make wrapper executable: %w", err) return fmt.Errorf("failed to make wrapper executable: %w", err)
} }
osInfo, err := distros.GetOSInfo() osInfo, err := distros.GetOSInfo()
if err == nil { if err == nil {
if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) { if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) {
if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err))
} else { } else {
logFunc("✓ Set SELinux fcontext for dms-greeter") logFunc("✓ Set SELinux fcontext for dms-greeter")
} }
if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-v", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err))
} else { } else {
logFunc("✓ Restored SELinux context for dms-greeter") logFunc("✓ Restored SELinux context for dms-greeter")
@@ -583,7 +545,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return fmt.Errorf("failed to stat cache directory: %w", err) return fmt.Errorf("failed to stat cache directory: %w", err)
} }
if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", cacheDir); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
created = true created = true
@@ -595,17 +557,17 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
daemonUser := DetectGreeterUser() daemonUser := DetectGreeterUser()
preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group) preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group)
owner := preferredOwner owner := preferredOwner
if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, cacheDir); err != nil {
// Some setups may not have a matching daemon user at this moment; fall back // Some setups may not have a matching daemon user at this moment; fall back
// to root:<group> while still allowing group-writable greeter runtime access. // to root:<group> while still allowing group-writable greeter runtime access.
fallbackOwner := fmt.Sprintf("root:%s", group) fallbackOwner := fmt.Sprintf("root:%s", group)
if fallbackErr := runSudoCmd(sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil { if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil {
return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr) return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr)
} }
owner = fallbackOwner owner = fallbackOwner
} }
if err := runSudoCmd(sudoPassword, "chmod", "2770", cacheDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", cacheDir); err != nil {
return fmt.Errorf("failed to set cache directory permissions: %w", err) return fmt.Errorf("failed to set cache directory permissions: %w", err)
} }
@@ -616,13 +578,13 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
filepath.Join(cacheDir, ".cache"), filepath.Join(cacheDir, ".cache"),
} }
for _, dir := range runtimeDirs { for _, dir := range runtimeDirs {
if err := runSudoCmd(sudoPassword, "mkdir", "-p", dir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", dir); err != nil {
return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err) return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err)
} }
if err := runSudoCmd(sudoPassword, "chown", owner, dir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, dir); err != nil {
return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err) return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err)
} }
if err := runSudoCmd(sudoPassword, "chmod", "2770", dir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", dir); err != nil {
return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err) return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err)
} }
} }
@@ -634,7 +596,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
} }
if isSELinuxEnforcing() && utils.CommandExists("restorecon") { if isSELinuxEnforcing() && utils.CommandExists("restorecon") {
if err := runSudoCmd(sudoPassword, "restorecon", "-Rv", cacheDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-Rv", cacheDir); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err))
} }
} }
@@ -659,13 +621,13 @@ func ensureGreeterMemoryCompatLink(logFunc func(string), sudoPassword, legacyPat
info, err := os.Lstat(legacyPath) info, err := os.Lstat(legacyPath)
if err == nil && info.Mode().IsRegular() { if err == nil && info.Mode().IsRegular() {
if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) { if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) {
if copyErr := runSudoCmd(sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil { if copyErr := privesc.Run(context.Background(), sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr)) logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr))
} }
} }
} }
if err := runSudoCmd(sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil {
return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err) return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err)
} }
@@ -692,7 +654,7 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
return nil return nil
} }
if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil {
return fmt.Errorf("failed to create /etc/apparmor.d: %w", err) return fmt.Errorf("failed to create /etc/apparmor.d: %w", err)
} }
@@ -709,15 +671,15 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
} }
tmp.Close() tmp.Close()
if err := runSudoCmd(sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil {
return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err) return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err)
} }
if err := runSudoCmd(sudoPassword, "chmod", "644", appArmorProfileDest); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", appArmorProfileDest); err != nil {
return fmt.Errorf("failed to set AppArmor profile permissions: %w", err) return fmt.Errorf("failed to set AppArmor profile permissions: %w", err)
} }
if utils.CommandExists("apparmor_parser") { if utils.CommandExists("apparmor_parser") {
if err := runSudoCmd(sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil {
logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err)) logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err))
logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest) logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest)
} else { } else {
@@ -745,9 +707,9 @@ func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
} }
if utils.CommandExists("apparmor_parser") { if utils.CommandExists("apparmor_parser") {
_ = runSudoCmd(sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest) _ = privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest)
} }
if err := runSudoCmd(sudoPassword, "rm", "-f", appArmorProfileDest); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", appArmorProfileDest); err != nil {
return fmt.Errorf("failed to remove AppArmor profile: %w", err) return fmt.Errorf("failed to remove AppArmor profile: %w", err)
} }
logFunc(" ✓ Removed DMS AppArmor profile") logFunc(" ✓ Removed DMS AppArmor profile")
@@ -777,50 +739,17 @@ func EnsureACLInstalled(logFunc func(string), sudoPassword string) error {
switch config.Family { switch config.Family {
case distros.FamilyArch: case distros.FamilyArch:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "acl")
}
case distros.FamilyFedora: case distros.FamilyFedora:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y acl")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "dnf install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "acl")
}
case distros.FamilySUSE: case distros.FamilySUSE:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y acl")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "zypper install -y acl") case distros.FamilyUbuntu, distros.FamilyDebian:
} else { installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y acl")
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "acl")
}
case distros.FamilyUbuntu:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl")
}
case distros.FamilyDebian:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl")
}
case distros.FamilyGentoo: case distros.FamilyGentoo:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-fs/acl")
}
case distros.FamilyNix: case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix") return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix")
default: default:
return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family) return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family)
} }
@@ -877,7 +806,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
} }
// Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora). // Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora).
if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err))
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path)) logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path))
continue continue
@@ -934,7 +863,7 @@ func RemediateStaleACLs(logFunc func(string), sudoPassword string) {
continue continue
} }
for _, user := range existingUsers { for _, user := range existingUsers {
_ = runSudoCmd(sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir) _ = privesc.Run(context.Background(), sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir)
cleaned = true cleaned = true
} }
} }
@@ -974,7 +903,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
// Create the group if it doesn't exist yet (e.g. before greetd package is installed). // Create the group if it doesn't exist yet (e.g. before greetd package is installed).
if !utils.HasGroup(group) { if !utils.HasGroup(group) {
if err := runSudoCmd(sudoPassword, "groupadd", "-r", group); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "groupadd", "-r", group); err != nil {
return fmt.Errorf("failed to create %s group: %w", group, err) return fmt.Errorf("failed to create %s group: %w", group, err)
} }
logFunc(fmt.Sprintf("✓ Created system group %s", group)) logFunc(fmt.Sprintf("✓ Created system group %s", group))
@@ -985,7 +914,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
if err == nil && strings.Contains(string(groupsOutput), group) { if err == nil && strings.Contains(string(groupsOutput), group) {
logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group)) logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group))
} else { } else {
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, currentUser); err != nil {
return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err) return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err)
} }
logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group)) logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group))
@@ -1000,7 +929,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
if strings.Contains(string(daemonGroupsOutput), group) { if strings.Contains(string(daemonGroupsOutput), group) {
logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group)) logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group))
} else { } else {
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, daemonUser); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, daemonUser); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err)) logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err))
} else { } else {
logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group)) logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group))
@@ -1030,12 +959,12 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
} }
} }
if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chgrp", "-R", group, dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
continue continue
} }
if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err))
continue continue
} }
@@ -1247,8 +1176,8 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
} }
target := filepath.Join(cacheDir, "colors.json") target := filepath.Join(cacheDir, "colors.json")
_ = runSudoCmd(sudoPassword, "rm", "-f", target) _ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", target)
if err := runSudoCmd(sudoPassword, "ln", "-sf", source, target); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", source, target); err != nil {
return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err) return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err)
} }
@@ -1300,9 +1229,9 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
} }
} }
_ = runSudoCmd(sudoPassword, "rm", "-f", link.target) _ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", link.target)
if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", link.source, link.target); err != nil {
return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err) return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err)
} }
@@ -1340,13 +1269,13 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error { func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error {
destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
if state.ResolvedGreeterWallpaperPath == "" { if state.ResolvedGreeterWallpaperPath == "" {
if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil {
return fmt.Errorf("failed to clear override file %s: %w", destPath, err) return fmt.Errorf("failed to clear override file %s: %w", destPath, err)
} }
logFunc("✓ Cleared greeter wallpaper override") logFunc("✓ Cleared greeter wallpaper override")
return nil return nil
} }
if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil {
return fmt.Errorf("failed to remove old override file %s: %w", destPath, err) return fmt.Errorf("failed to remove old override file %s: %w", destPath, err)
} }
src := state.ResolvedGreeterWallpaperPath src := state.ResolvedGreeterWallpaperPath
@@ -1357,17 +1286,17 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas
if st.IsDir() { if st.IsDir() {
return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src) return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src)
} }
if err := runSudoCmd(sudoPassword, "cp", src, destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", src, destPath); err != nil {
return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err) return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err)
} }
greeterGroup := DetectGreeterGroup() greeterGroup := DetectGreeterGroup()
daemonUser := DetectGreeterUser() daemonUser := DetectGreeterUser()
if err := runSudoCmd(sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil {
if fallbackErr := runSudoCmd(sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil { if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil {
return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err) return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err)
} }
} }
if err := runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", destPath); err != nil {
return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err) return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err)
} }
logFunc("✓ Synced greeter wallpaper override") logFunc("✓ Synced greeter wallpaper override")
@@ -1422,13 +1351,13 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
greeterDir := "/etc/greetd/niri" greeterDir := "/etc/greetd/niri"
greeterGroup := DetectGreeterGroup() greeterGroup := DetectGreeterGroup()
if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", greeterDir); err != nil {
return fmt.Errorf("failed to create greetd niri directory: %w", err) return fmt.Errorf("failed to create greetd niri directory: %w", err)
} }
if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil {
return fmt.Errorf("failed to set greetd niri directory ownership: %w", err) return fmt.Errorf("failed to set greetd niri directory ownership: %w", err)
} }
if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "755", greeterDir); err != nil {
return fmt.Errorf("failed to set greetd niri directory permissions: %w", err) return fmt.Errorf("failed to set greetd niri directory permissions: %w", err)
} }
@@ -1450,7 +1379,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil { if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup %s: %w", dmsPath, err) return fmt.Errorf("failed to backup %s: %w", dmsPath, err)
} }
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
return fmt.Errorf("failed to install greetd niri dms config: %w", err) return fmt.Errorf("failed to install greetd niri dms config: %w", err)
} }
@@ -1473,7 +1402,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil { if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup %s: %w", mainPath, err) return fmt.Errorf("failed to backup %s: %w", mainPath, err)
} }
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil {
return fmt.Errorf("failed to install greetd niri main config: %w", err) return fmt.Errorf("failed to install greetd niri main config: %w", err)
} }
@@ -1549,7 +1478,7 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi
return fmt.Errorf("failed to close temp greetd config: %w", err) return fmt.Errorf("failed to close temp greetd config: %w", err)
} }
if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to update greetd config: %w", err) return fmt.Errorf("failed to update greetd config: %w", err)
} }
@@ -1565,10 +1494,10 @@ func backupFileIfExists(sudoPassword string, path string, suffix string) error {
} }
backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405")) backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405"))
if err := runSudoCmd(sudoPassword, "cp", path, backupPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", path, backupPath); err != nil {
return err return err
} }
return runSudoCmd(sudoPassword, "chmod", "644", backupPath) return privesc.Run(context.Background(), sudoPassword, "chmod", "644", backupPath)
} }
func (s *niriGreeterSync) processFile(filePath string) error { func (s *niriGreeterSync) processFile(filePath string) error {
@@ -1804,11 +1733,11 @@ vt = 1
return fmt.Errorf("failed to close temp greetd config: %w", err) return fmt.Errorf("failed to close temp greetd config: %w", err)
} }
if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err) return fmt.Errorf("failed to create /etc/greetd: %w", err)
} }
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err) return fmt.Errorf("failed to install greetd config: %w", err)
} }
@@ -1912,27 +1841,6 @@ func getOpenSUSEOBSRepoURL(osInfo *distros.OSInfo) string {
return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug) return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug)
} }
func runSudoCmd(sudoPassword string, command string, args ...string) error {
var cmd *exec.Cmd
if sudoPassword != "" {
fullArgs := append([]string{command}, args...)
quotedArgs := make([]string, len(fullArgs))
for i, arg := range fullArgs {
quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
cmdStr := strings.Join(quotedArgs, " ")
cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr)
} else {
cmd = exec.Command("sudo", append([]string{command}, args...)...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func checkSystemdEnabled(service string) (string, error) { func checkSystemdEnabled(service string) (string, error) {
cmd := exec.Command("systemctl", "is-enabled", service) cmd := exec.Command("systemctl", "is-enabled", service)
output, _ := cmd.Output() output, _ := cmd.Output()
@@ -1949,7 +1857,7 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
switch state { switch state {
case "enabled", "enabled-runtime", "static", "indirect", "alias": case "enabled", "enabled-runtime", "static", "indirect", "alias":
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm)) logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
if err := runSudoCmd(sudoPassword, "systemctl", "disable", dm); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "disable", dm); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
} else { } else {
logFunc(fmt.Sprintf("✓ Disabled %s", dm)) logFunc(fmt.Sprintf("✓ Disabled %s", dm))
@@ -1970,13 +1878,13 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
} }
if state == "masked" || state == "masked-runtime" { if state == "masked" || state == "masked-runtime" {
logFunc(" Unmasking greetd...") logFunc(" Unmasking greetd...")
if err := runSudoCmd(sudoPassword, "systemctl", "unmask", "greetd"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "unmask", "greetd"); err != nil {
return fmt.Errorf("failed to unmask greetd: %w", err) return fmt.Errorf("failed to unmask greetd: %w", err)
} }
logFunc(" ✓ Unmasked greetd") logFunc(" ✓ Unmasked greetd")
} }
logFunc(" Enabling greetd service (--force)...") logFunc(" Enabling greetd service (--force)...")
if err := runSudoCmd(sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err) return fmt.Errorf("failed to enable greetd: %w", err)
} }
logFunc("✓ greetd enabled") logFunc("✓ greetd enabled")
@@ -1996,7 +1904,7 @@ func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
return nil return nil
} }
logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current)) logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current))
if err := runSudoCmd(sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil {
return fmt.Errorf("failed to set graphical target: %w", err) return fmt.Errorf("failed to set graphical target: %w", err)
} }
logFunc("✓ Default target set to graphical.target") logFunc("✓ Default target set to graphical.target")
+439
View File
@@ -0,0 +1,439 @@
package headless
import (
"context"
"fmt"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
// ErrConfirmationRequired is returned when --yes is not set and the user
// must explicitly confirm the operation.
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
// validConfigNames maps lowercase CLI input to the deployer key used in
// replaceConfigs. Keep in sync with the config types checked by
// shouldReplaceConfig in deployer.go.
var validConfigNames = map[string]string{
"niri": "Niri",
"hyprland": "Hyprland",
"ghostty": "Ghostty",
"kitty": "Kitty",
"alacritty": "Alacritty",
}
// orderedConfigNames defines the canonical order for config names in output.
// Must be kept in sync with validConfigNames.
var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"}
// Config holds all CLI parameters for unattended installation.
type Config struct {
Compositor string // "niri" or "hyprland"
Terminal string // "ghostty", "kitty", or "alacritty"
IncludeDeps []string
ExcludeDeps []string
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
ReplaceConfigsAll bool // deploy/replace all configurations
Yes bool
}
// Runner orchestrates unattended (headless) installation.
type Runner struct {
cfg Config
logChan chan string
}
// NewRunner creates a new headless runner.
func NewRunner(cfg Config) *Runner {
return &Runner{
cfg: cfg,
logChan: make(chan string, 1000),
}
}
// GetLogChan returns the log channel for file logging.
func (r *Runner) GetLogChan() <-chan string {
return r.logChan
}
// Run executes the full unattended installation flow.
func (r *Runner) Run() error {
r.log("Starting headless installation")
// 1. Parse compositor and terminal selections
wm, err := r.parseWindowManager()
if err != nil {
return err
}
terminal, err := r.parseTerminal()
if err != nil {
return err
}
// 2. Build replace-configs map
replaceConfigs, err := r.buildReplaceConfigs()
if err != nil {
return err
}
// 3. Detect OS
r.log("Detecting operating system...")
osInfo, err := distros.GetOSInfo()
if err != nil {
return fmt.Errorf("OS detection failed: %w", err)
}
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
}
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
// 4. Create distribution instance
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
if err != nil {
return fmt.Errorf("failed to initialize distribution: %w", err)
}
// 5. Detect dependencies
r.log("Detecting dependencies...")
fmt.Fprintln(os.Stdout, "Detecting dependencies...")
dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
if err != nil {
return fmt.Errorf("dependency detection failed: %w", err)
}
// 5. Apply include/exclude filters and build the disabled-items map.
// Headless mode does not currently collect any explicit reinstall selections,
// so keep reinstallItems nil instead of constructing an always-empty map.
disabledItems, err := r.buildDisabledItems(dependencies)
if err != nil {
return err
}
var reinstallItems map[string]bool
// Print dependency summary
fmt.Fprintln(os.Stdout, "\nDependencies:")
for _, dep := range dependencies {
marker := " "
status := ""
if disabledItems[dep.Name] {
marker = " SKIP "
status = "(disabled)"
} else {
switch dep.Status {
case deps.StatusInstalled:
marker = " OK "
status = "(installed)"
case deps.StatusMissing:
marker = " NEW "
status = "(will install)"
case deps.StatusNeedsUpdate:
marker = " UPD "
status = "(will update)"
case deps.StatusNeedsReinstall:
marker = " RE "
status = "(will reinstall)"
}
}
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
}
fmt.Fprintln(os.Stdout)
// 6b. Require explicit confirmation unless --yes is set
if !r.cfg.Yes {
if replaceConfigs == nil {
// --replace-configs-all
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
} else if r.anyConfigEnabled(replaceConfigs) {
var names []string
for _, cliName := range orderedConfigNames {
deployerKey := validConfigNames[cliName]
if replaceConfigs[deployerKey] {
names = append(names, deployerKey)
}
}
fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", "))
} else {
fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.")
}
fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.")
r.log("Aborted: --yes not set")
return ErrConfirmationRequired
}
// 7. Authenticate sudo
sudoPassword, err := r.resolveSudoPassword()
if err != nil {
return err
}
// 8. Install packages
fmt.Fprintln(os.Stdout, "Installing packages...")
r.log("Starting package installation")
progressChan := make(chan distros.InstallProgressMsg, 100)
installErr := make(chan error, 1)
go func() {
defer close(progressChan)
installErr <- distro.InstallPackages(
context.Background(),
dependencies,
wm,
sudoPassword,
reinstallItems,
disabledItems,
false, // skipGlobalUseFlags
progressChan,
)
}()
// Consume progress messages and print them
for msg := range progressChan {
if msg.Error != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
} else if msg.Step != "" {
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
}
if msg.LogOutput != "" {
r.log(msg.LogOutput)
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
}
}
if err := <-installErr; err != nil {
return fmt.Errorf("package installation failed: %w", err)
}
// 9. Greeter setup (if dms-greeter was included)
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
compositorName := "niri"
if wm == deps.WindowManagerHyprland {
compositorName = "Hyprland"
}
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
logFunc := func(line string) {
r.log(line)
fmt.Fprintf(os.Stdout, " greeter: %s\n", line)
}
if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil {
// Non-fatal, matching TUI behavior
fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err)
}
}
// 10. Deploy configurations
fmt.Fprintln(os.Stdout, "Deploying configurations...")
r.log("Starting configuration deployment")
deployer := config.NewConfigDeployer(r.logChan)
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
context.Background(),
wm,
terminal,
dependencies,
replaceConfigs,
reinstallItems,
)
if err != nil {
return fmt.Errorf("configuration deployment failed: %w", err)
}
for _, result := range results {
if result.Deployed {
msg := fmt.Sprintf(" Deployed: %s", result.ConfigType)
if result.BackupPath != "" {
msg += fmt.Sprintf(" (backup: %s)", result.BackupPath)
}
fmt.Fprintln(os.Stdout, msg)
}
if result.Error != nil {
fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error)
}
}
fmt.Fprintln(os.Stdout, "\nInstallation complete!")
r.log("Headless installation completed successfully")
return nil
}
// buildDisabledItems computes the set of dependencies that should be skipped
// during installation, applying the --include-deps and --exclude-deps filters.
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
disabledItems := make(map[string]bool)
// dms-greeter is opt-in (disabled by default), matching TUI behavior
for i := range dependencies {
if dependencies[i].Name == "dms-greeter" {
disabledItems["dms-greeter"] = true
break
}
}
// Process --include-deps (enable items that are disabled by default)
for _, name := range r.cfg.IncludeDeps {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !r.depExists(dependencies, name) {
return nil, fmt.Errorf("--include-deps: unknown dependency %q", name)
}
delete(disabledItems, name)
}
// Process --exclude-deps (disable items)
for _, name := range r.cfg.ExcludeDeps {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !r.depExists(dependencies, name) {
return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name)
}
// Don't allow excluding DMS itself
if name == "dms (DankMaterialShell)" {
return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name)
}
disabledItems[name] = true
}
return disabledItems, nil
}
// buildReplaceConfigs converts the --replace-configs / --replace-configs-all
// flags into the map[string]bool consumed by the config deployer.
//
// Returns:
// - nil when --replace-configs-all is set (deployer treats nil as "replace all")
// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk)
// - a map with requested configs true, all others false for --replace-configs
// - an error when both flags are set or an invalid config name is given
func (r *Runner) buildReplaceConfigs() (map[string]bool, error) {
hasSpecific := len(r.cfg.ReplaceConfigs) > 0
if hasSpecific && r.cfg.ReplaceConfigsAll {
return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive")
}
if r.cfg.ReplaceConfigsAll {
return nil, nil
}
// Build a map with all known configs explicitly set to false
result := make(map[string]bool, len(validConfigNames))
for _, cliName := range orderedConfigNames {
result[validConfigNames[cliName]] = false
}
// Enable only the requested configs
for _, name := range r.cfg.ReplaceConfigs {
name = strings.TrimSpace(name)
if name == "" {
continue
}
deployerKey, ok := validConfigNames[strings.ToLower(name)]
if !ok {
return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name)
}
result[deployerKey] = true
}
return result, nil
}
func (r *Runner) log(message string) {
select {
case r.logChan <- message:
default:
}
}
func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
switch strings.ToLower(r.cfg.Compositor) {
case "niri":
return deps.WindowManagerNiri, nil
case "hyprland":
return deps.WindowManagerHyprland, nil
default:
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
}
}
func (r *Runner) parseTerminal() (deps.Terminal, error) {
switch strings.ToLower(r.cfg.Terminal) {
case "ghostty":
return deps.TerminalGhostty, nil
case "kitty":
return deps.TerminalKitty, nil
case "alacritty":
return deps.TerminalAlacritty, nil
default:
return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal)
}
}
func (r *Runner) resolveSudoPassword() (string, error) {
tool, err := privesc.Detect()
if err != nil {
return "", err
}
if err := privesc.CheckCached(context.Background()); err == nil {
r.log(fmt.Sprintf("%s cache is valid, no password needed", tool.Name()))
fmt.Fprintf(os.Stdout, "%s: using cached credentials\n", tool.Name())
return "", nil
}
switch tool {
case privesc.ToolSudo:
return "", fmt.Errorf(
"sudo authentication required but no cached credentials found\n" +
"Options:\n" +
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
" 2. Configure passwordless sudo for your user",
)
case privesc.ToolDoas:
return "", fmt.Errorf(
"doas authentication required but no cached credentials found\n" +
"Options:\n" +
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
)
case privesc.ToolRun0:
return "", fmt.Errorf(
"run0 authentication required but no cached credentials found\n" +
"Configure a polkit rule granting your user passwordless privilege\n" +
"(see `man polkit` for details on rules in /etc/polkit-1/rules.d/)",
)
default:
return "", fmt.Errorf("unsupported privilege tool: %s", tool)
}
}
func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
for _, v := range m {
if v {
return true
}
}
return false
}
func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
for _, dep := range dependencies {
if dep.Name == name {
return true
}
}
return false
}
+459
View File
@@ -0,0 +1,459 @@
package headless
import (
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func TestParseWindowManager(t *testing.T) {
tests := []struct {
name string
input string
want deps.WindowManager
wantErr bool
}{
{"niri lowercase", "niri", deps.WindowManagerNiri, false},
{"niri mixed case", "Niri", deps.WindowManagerNiri, false},
{"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false},
{"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false},
{"invalid", "sway", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{Compositor: tt.input})
got, err := r.parseWindowManager()
if (err != nil) != tt.wantErr {
t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseWindowManager() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseTerminal(t *testing.T) {
tests := []struct {
name string
input string
want deps.Terminal
wantErr bool
}{
{"ghostty lowercase", "ghostty", deps.TerminalGhostty, false},
{"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false},
{"kitty lowercase", "kitty", deps.TerminalKitty, false},
{"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false},
{"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false},
{"invalid", "wezterm", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{Terminal: tt.input})
got, err := r.parseTerminal()
if (err != nil) != tt.wantErr {
t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseTerminal() = %v, want %v", got, tt.want)
}
})
}
}
func TestDepExists(t *testing.T) {
dependencies := []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
{Name: "ghostty", Status: deps.StatusMissing},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
{Name: "dms-greeter", Status: deps.StatusMissing},
}
tests := []struct {
name string
dep string
want bool
}{
{"existing dep", "niri", true},
{"existing dep with special chars", "dms (DankMaterialShell)", true},
{"existing optional dep", "dms-greeter", true},
{"non-existing dep", "firefox", false},
{"empty name", "", false},
}
r := NewRunner(Config{})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := r.depExists(dependencies, tt.dep); got != tt.want {
t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want)
}
})
}
}
func TestNewRunner(t *testing.T) {
cfg := Config{
Compositor: "niri",
Terminal: "ghostty",
IncludeDeps: []string{"dms-greeter"},
ExcludeDeps: []string{"some-pkg"},
Yes: true,
}
r := NewRunner(cfg)
if r == nil {
t.Fatal("NewRunner returned nil")
}
if r.cfg.Compositor != "niri" {
t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri")
}
if r.cfg.Terminal != "ghostty" {
t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty")
}
if !r.cfg.Yes {
t.Error("cfg.Yes = false, want true")
}
if r.logChan == nil {
t.Error("logChan is nil")
}
}
func TestGetLogChan(t *testing.T) {
r := NewRunner(Config{})
ch := r.GetLogChan()
if ch == nil {
t.Fatal("GetLogChan returned nil")
}
// Verify the channel is readable by sending a message
go func() {
r.logChan <- "test message"
}()
msg := <-ch
if msg != "test message" {
t.Errorf("received %q, want %q", msg, "test message")
}
}
func TestLog(t *testing.T) {
r := NewRunner(Config{})
// log should not block even if channel is full
for i := 0; i < 1100; i++ {
r.log("message")
}
// If we reach here without hanging, the non-blocking send works
}
func TestRunRequiresYes(t *testing.T) {
// Verify that ErrConfirmationRequired is a distinct sentinel error
if ErrConfirmationRequired == nil {
t.Fatal("ErrConfirmationRequired should not be nil")
}
expected := "confirmation required: pass --yes to proceed"
if ErrConfirmationRequired.Error() != expected {
t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected)
}
}
func TestConfigYesStoredCorrectly(t *testing.T) {
// Yes=false (default) should be stored
rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false})
if rNo.cfg.Yes {
t.Error("cfg.Yes = true, want false")
}
// Yes=true should be stored
rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true})
if !rYes.cfg.Yes {
t.Error("cfg.Yes = false, want true")
}
}
func TestValidConfigNamesCompleteness(t *testing.T) {
// orderedConfigNames and validConfigNames must stay in sync.
if len(orderedConfigNames) != len(validConfigNames) {
t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d",
len(orderedConfigNames), len(validConfigNames))
}
// Every entry in orderedConfigNames must exist in validConfigNames.
for _, name := range orderedConfigNames {
if _, ok := validConfigNames[name]; !ok {
t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name)
}
}
// validConfigNames must have no extra keys not in orderedConfigNames.
ordered := make(map[string]bool, len(orderedConfigNames))
for _, name := range orderedConfigNames {
ordered[name] = true
}
for key := range validConfigNames {
if !ordered[key] {
t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key)
}
}
}
func TestBuildReplaceConfigs(t *testing.T) {
allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"}
tests := []struct {
name string
replaceConfigs []string
replaceAll bool
wantNil bool // expect nil (replace all)
wantEnabled []string // deployer keys that should be true
wantErr bool
}{
{
name: "neither flag set",
wantNil: false,
wantEnabled: nil, // all should be false
},
{
name: "replace-configs-all",
replaceAll: true,
wantNil: true,
},
{
name: "specific configs",
replaceConfigs: []string{"niri", "ghostty"},
wantNil: false,
wantEnabled: []string{"Niri", "Ghostty"},
},
{
name: "both flags set",
replaceConfigs: []string{"niri"},
replaceAll: true,
wantErr: true,
},
{
name: "invalid config name",
replaceConfigs: []string{"foo"},
wantErr: true,
},
{
name: "case insensitive",
replaceConfigs: []string{"NIRI", "Ghostty"},
wantNil: false,
wantEnabled: []string{"Niri", "Ghostty"},
},
{
name: "single config",
replaceConfigs: []string{"kitty"},
wantNil: false,
wantEnabled: []string{"Kitty"},
},
{
name: "whitespace entry",
replaceConfigs: []string{" ", "niri"},
wantNil: false,
wantEnabled: []string{"Niri"},
},
{
name: "duplicate entry",
replaceConfigs: []string{"niri", "niri"},
wantNil: false,
wantEnabled: []string{"Niri"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{
ReplaceConfigs: tt.replaceConfigs,
ReplaceConfigsAll: tt.replaceAll,
})
got, err := r.buildReplaceConfigs()
if (err != nil) != tt.wantErr {
t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if tt.wantNil {
if got != nil {
t.Fatalf("buildReplaceConfigs() = %v, want nil", got)
}
return
}
if got == nil {
t.Fatal("buildReplaceConfigs() = nil, want non-nil map")
}
// All known deployer keys must be present
for _, key := range allDeployerKeys {
if _, exists := got[key]; !exists {
t.Errorf("missing deployer key %q in result map", key)
}
}
// Build enabled set for easy lookup
enabledSet := make(map[string]bool)
for _, k := range tt.wantEnabled {
enabledSet[k] = true
}
for _, key := range allDeployerKeys {
want := enabledSet[key]
if got[key] != want {
t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want)
}
}
})
}
}
func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
r := NewRunner(Config{
Compositor: "niri",
Terminal: "ghostty",
ReplaceConfigs: []string{"niri", "ghostty"},
ReplaceConfigsAll: false,
})
if len(r.cfg.ReplaceConfigs) != 2 {
t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs))
}
if r.cfg.ReplaceConfigsAll {
t.Error("ReplaceConfigsAll = true, want false")
}
r2 := NewRunner(Config{
Compositor: "niri",
Terminal: "ghostty",
ReplaceConfigsAll: true,
})
if !r2.cfg.ReplaceConfigsAll {
t.Error("ReplaceConfigsAll = false, want true")
}
if len(r2.cfg.ReplaceConfigs) != 0 {
t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs))
}
}
func TestBuildDisabledItems(t *testing.T) {
dependencies := []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
{Name: "ghostty", Status: deps.StatusMissing},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
{Name: "dms-greeter", Status: deps.StatusMissing},
{Name: "waybar", Status: deps.StatusMissing},
}
tests := []struct {
name string
includeDeps []string
excludeDeps []string
deps []deps.Dependency // nil means use the shared fixture
wantErr bool
errContains string // substring expected in error message
wantDisabled []string // dep names that should be in disabledItems
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
}{
{
name: "no flags set, dms-greeter disabled by default",
wantDisabled: []string{"dms-greeter"},
wantEnabled: []string{"niri", "ghostty", "waybar"},
},
{
name: "include dms-greeter enables it",
includeDeps: []string{"dms-greeter"},
wantEnabled: []string{"dms-greeter"},
},
{
name: "exclude a regular dep",
excludeDeps: []string{"waybar"},
wantDisabled: []string{"dms-greeter", "waybar"},
},
{
name: "include unknown dep returns error",
includeDeps: []string{"nonexistent"},
wantErr: true,
errContains: "--include-deps",
},
{
name: "exclude unknown dep returns error",
excludeDeps: []string{"nonexistent"},
wantErr: true,
errContains: "--exclude-deps",
},
{
name: "exclude DMS itself is forbidden",
excludeDeps: []string{"dms (DankMaterialShell)"},
wantErr: true,
errContains: "cannot exclude required package",
},
{
name: "include and exclude same dep",
includeDeps: []string{"dms-greeter"},
excludeDeps: []string{"dms-greeter"},
wantDisabled: []string{"dms-greeter"},
},
{
name: "whitespace entries are skipped",
includeDeps: []string{" ", "dms-greeter"},
wantEnabled: []string{"dms-greeter"},
},
{
name: "no dms-greeter in deps, nothing disabled by default",
deps: []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled},
},
wantEnabled: []string{"niri"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{
IncludeDeps: tt.includeDeps,
ExcludeDeps: tt.excludeDeps,
})
d := tt.deps
if d == nil {
d = dependencies
}
got, err := r.buildDisabledItems(d)
if (err != nil) != tt.wantErr {
t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
return
}
if got == nil {
t.Fatal("buildDisabledItems() returned nil map, want non-nil")
}
// Check expected disabled items
for _, name := range tt.wantDisabled {
if !got[name] {
t.Errorf("expected %q to be disabled, but it is not", name)
}
}
// Check expected enabled items (should not be in the map or be false)
for _, name := range tt.wantEnabled {
if got[name] {
t.Errorf("expected %q to NOT be disabled, but it is", name)
}
}
// If wantDisabled is empty, the map should have length 0
if len(tt.wantDisabled) == 0 && len(got) != 0 {
t.Errorf("expected empty disabledItems map, got %v", got)
}
})
}
}
+1 -2
View File
@@ -10,7 +10,6 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document" "github.com/sblinch/kdl-go/document"
) )
@@ -292,7 +291,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
parser := NewNiriParser(filepath.Dir(overridePath)) parser := NewNiriParser(filepath.Dir(overridePath))
parser.currentSource = overridePath parser.currentSource = overridePath
doc, err := kdl.Parse(strings.NewReader(string(data))) doc, err := parseKDL(data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -50,6 +50,103 @@ type NiriParser struct {
conflictingConfigs map[string]*NiriKeyBinding 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 { func NewNiriParser(configDir string) *NiriParser {
return &NiriParser{ return &NiriParser{
configDir: configDir, configDir: configDir,
@@ -91,7 +188,7 @@ func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSec
return return
} }
doc, err := kdl.Parse(strings.NewReader(string(data))) doc, err := parseKDL(data)
if err != nil { if err != nil {
return 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err) return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
} }
@@ -3,9 +3,74 @@ package providers
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"slices"
"testing" "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) { func TestNiriParseKeyCombo(t *testing.T) {
tests := []struct { tests := []struct {
combo string combo string
+11 -1
View File
@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"fmt" "fmt"
"os" "os"
"path/filepath"
"regexp" "regexp"
"sync" "sync"
"time" "time"
@@ -21,7 +22,16 @@ type FileLogger struct {
func NewFileLogger() (*FileLogger, error) { func NewFileLogger() (*FileLogger, error) {
timestamp := time.Now().Unix() timestamp := time.Now().Unix()
logPath := fmt.Sprintf("/tmp/dankinstall-%d.log", timestamp)
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp.
logDir := os.Getenv("DANKINSTALL_LOG_DIR")
if logDir == "" {
logDir = "/tmp"
}
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
}
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp))
file, err := os.Create(logPath) file, err := os.Create(logPath)
if err != nil { if err != nil {
+98 -1
View File
@@ -1,12 +1,16 @@
package log package log
import ( import (
"io"
"os" "os"
"regexp"
"strings" "strings"
"sync" "sync"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
cblog "github.com/charmbracelet/log" cblog "github.com/charmbracelet/log"
"github.com/mattn/go-isatty"
"github.com/muesli/termenv"
) )
// Logger embeds the Charm Logger and adds Printf/Fatalf // Logger embeds the Charm Logger and adds Printf/Fatalf
@@ -21,8 +25,26 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
var ( var (
logger *Logger logger *Logger
initLogger sync.Once initLogger sync.Once
logMu sync.Mutex
logFile *os.File
logStderr io.Writer = os.Stderr
ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
) )
// ansiStripWriter strips ANSI escape sequences before forwarding to w. Used
// for the file sink so colored stderr stays colored while the file stays plain.
type ansiStripWriter struct{ w io.Writer }
func (a *ansiStripWriter) Write(p []byte) (int, error) {
stripped := ansiRe.ReplaceAll(p, nil)
if _, err := a.w.Write(stripped); err != nil {
return 0, err
}
return len(p), nil
}
func parseLogLevel(level string) cblog.Level { func parseLogLevel(level string) cblog.Level {
switch strings.ToLower(level) { switch strings.ToLower(level) {
case "debug": case "debug":
@@ -86,7 +108,7 @@ func GetLogger() *Logger {
SetString(" DEBUG"). SetString(" DEBUG").
Foreground(lipgloss.Color("4")) Foreground(lipgloss.Color("4"))
base := cblog.New(os.Stderr) base := cblog.New(logStderr)
base.SetStyles(styles) base.SetStyles(styles)
base.SetReportTimestamp(false) base.SetReportTimestamp(false)
@@ -98,10 +120,85 @@ func GetLogger() *Logger {
base.SetPrefix(" go") base.SetPrefix(" go")
logger = &Logger{base} logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path)
}
}) })
return logger return logger
} }
// SetLevel updates the active log level. Accepts the same strings as
// DMS_LOG_LEVEL. Unknown values default to info.
func SetLevel(level string) {
GetLogger().SetLevel(parseLogLevel(level))
}
// SetLogFile makes the logger append to path in addition to stderr. Passing an
// empty string detaches the file sink. Atomic per-line writes (≤PIPE_BUF) on
// O_APPEND keep concurrent Go and QML writers from corrupting each other.
//
// Color handling: charmbracelet/log auto-detects color support from its
// io.Writer, and io.MultiWriter doesn't pass that through, so we force the ANSI
// profile when stderr is a TTY and route the file through ansiStripWriter so
// the file stays plain while stderr keeps its colors.
func SetLogFile(path string) error {
logMu.Lock()
defer logMu.Unlock()
if logFile != nil {
logFile.Close()
logFile = nil
}
l := GetLogger()
if path == "" {
l.SetOutput(logStderr)
applyColorProfile(l, logStderr)
return nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
if err != nil {
return err
}
logFile = f
out := io.MultiWriter(logStderr, &ansiStripWriter{w: f})
l.SetOutput(out)
applyColorProfile(l, logStderr)
return nil
}
// applyColorProfile forces the renderer's color profile to match what stderr
// would produce on its own, undoing the auto-downgrade triggered by wrapping
// stderr in a non-TTY writer (e.g. io.MultiWriter).
func applyColorProfile(l *Logger, stderr io.Writer) {
f, ok := stderr.(*os.File)
if !ok {
l.SetColorProfile(termenv.Ascii)
return
}
if isatty.IsTerminal(f.Fd()) {
l.SetColorProfile(termenv.ANSI)
return
}
l.SetColorProfile(termenv.Ascii)
}
// ApplyEnvOverrides re-reads DMS_LOG_LEVEL and DMS_LOG_FILE and reconfigures
// the singleton. Safe to call after CLI flags have rewritten the environment.
func ApplyEnvOverrides() {
GetLogger()
if level := os.Getenv("DMS_LOG_LEVEL"); level != "" {
SetLevel(level)
}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
if err := SetLogFile(path); err != nil {
Warnf("Failed to open log file %q: %v", path, err)
}
}
}
// * Convenience wrappers // * Convenience wrappers
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) } func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
+1
View File
@@ -60,6 +60,7 @@ var templateRegistry = []TemplateDef{
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"}, {ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"}, {ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"}, {ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"}, {ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal}, {ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal}, {ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
+13 -31
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
const ( const (
@@ -80,16 +81,18 @@ type lockscreenPamResolver struct {
func defaultSyncDeps() syncDeps { func defaultSyncDeps() syncDeps {
return syncDeps{ return syncDeps{
pamDir: "/etc/pam.d", pamDir: "/etc/pam.d",
greetdPath: GreetdPamPath, greetdPath: GreetdPamPath,
dankshellPath: DankshellPamPath, dankshellPath: DankshellPamPath,
dankshellU2fPath: DankshellU2FPamPath, dankshellU2fPath: DankshellU2FPamPath,
isNixOS: IsNixOS, isNixOS: IsNixOS,
readFile: os.ReadFile, readFile: os.ReadFile,
stat: os.Stat, stat: os.Stat,
createTemp: os.CreateTemp, createTemp: os.CreateTemp,
removeFile: os.Remove, removeFile: os.Remove,
runSudoCmd: runSudoCmd, runSudoCmd: func(password, command string, args ...string) error {
return privesc.Run(context.Background(), password, append([]string{command}, args...)...)
},
pamModuleExists: pamModuleExists, pamModuleExists: pamModuleExists,
fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser, fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser,
} }
@@ -869,24 +872,3 @@ func fingerprintAuthAvailableForUser(username string) bool {
} }
return hasEnrolledFingerprintOutput(string(out)) return hasEnrolledFingerprintOutput(string(out))
} }
func runSudoCmd(sudoPassword string, command string, args ...string) error {
var cmd *exec.Cmd
if sudoPassword != "" {
fullArgs := append([]string{command}, args...)
quotedArgs := make([]string, len(fullArgs))
for i, arg := range fullArgs {
quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
cmdStr := strings.Join(quotedArgs, " ")
cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr)
} else {
cmd = exec.Command("sudo", append([]string{command}, args...)...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+385
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
}
+2 -34
View File
@@ -9,10 +9,8 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
@@ -105,7 +103,7 @@ func GetOutputDir() string {
return dir return dir
} }
if xdgPics := getXDGPicturesDir(); xdgPics != "" { if xdgPics := utils.XDGPicturesDir(); xdgPics != "" {
screenshotDir := filepath.Join(xdgPics, "Screenshots") screenshotDir := filepath.Join(xdgPics, "Screenshots")
if err := os.MkdirAll(screenshotDir, 0o755); err == nil { if err := os.MkdirAll(screenshotDir, 0o755); err == nil {
return screenshotDir return screenshotDir
@@ -113,42 +111,12 @@ func GetOutputDir() string {
return xdgPics return xdgPics
} }
if home := os.Getenv("HOME"); home != "" { if home, err := os.UserHomeDir(); err == nil {
return home return home
} }
return "." return "."
} }
func getXDGPicturesDir() string {
userConfigDir, err := os.UserConfigDir()
if err != nil {
log.Error("failed to get user config dir", "err", err)
return ""
}
userDirsFile := filepath.Join(userConfigDir, "user-dirs.dirs")
data, err := os.ReadFile(userDirsFile)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
if len(line) == 0 || line[0] == '#' {
continue
}
const prefix = "XDG_PICTURES_DIR="
if !strings.HasPrefix(line, prefix) {
continue
}
path := strings.Trim(line[len(prefix):], "\"")
expanded, err := utils.ExpandPath(path)
if err != nil {
return ""
}
return expanded
}
return ""
}
func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error { func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error {
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888)) return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
} }
+6 -5
View File
@@ -64,12 +64,13 @@ func SendNotification(result NotifyResult) {
summary := "Screenshot captured" summary := "Screenshot captured"
body := "" body := ""
if result.Clipboard && result.FilePath != "" { switch {
body = fmt.Sprintf("Copied to clipboard\n%s", filepath.Base(result.FilePath)) case result.FilePath != "" && result.Clipboard:
} else if result.Clipboard { body = fmt.Sprintf("%s\nCopied to clipboard", filepath.Base(result.FilePath))
body = "Copied to clipboard" case result.FilePath != "":
} else if result.FilePath != "" {
body = filepath.Base(result.FilePath) body = filepath.Base(result.FilePath)
case result.Clipboard:
body = "Copied to clipboard"
} }
obj := conn.Object(notifyDest, notifyPath) obj := conn.Object(notifyDest, notifyPath)
+41 -24
View File
@@ -215,31 +215,34 @@ func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential
callback: callback, callback: callback,
} }
if timer, exists := b.debounceTimers[id]; exists { if existing, exists := b.debounceTimers[id]; exists {
timer.Reset(200 * time.Millisecond) if existing.Stop() {
} else { b.debounceWg.Done()
b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() { }
b.debounceMutex.Lock()
pending, exists := b.debouncePending[id]
if exists {
delete(b.debouncePending, id)
}
b.debounceMutex.Unlock()
if !exists {
return
}
err := b.setBrightnessImmediateWithExponent(id, pending.percent)
if err != nil {
log.Debugf("Failed to set brightness for %s: %v", id, err)
}
if pending.callback != nil {
pending.callback()
}
})
} }
b.debounceWg.Add(1)
b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
defer b.debounceWg.Done()
b.debounceMutex.Lock()
pending, hasPending := b.debouncePending[id]
delete(b.debouncePending, id)
delete(b.debounceTimers, id)
b.debounceMutex.Unlock()
if !hasPending {
return
}
if err := b.setBrightnessImmediateWithExponent(id, pending.percent); err != nil {
log.Debugf("Failed to set brightness for %s: %v", id, err)
}
if pending.callback != nil {
pending.callback()
}
})
b.debounceMutex.Unlock() b.debounceMutex.Unlock()
return nil return nil
@@ -490,5 +493,19 @@ func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int {
return percent 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() { func (b *DDCBackend) Close() {
} }
+1
View File
@@ -84,6 +84,7 @@ type DDCBackend struct {
debounceMutex sync.Mutex debounceMutex sync.Mutex
debounceTimers map[string]*time.Timer debounceTimers map[string]*time.Timer
debouncePending map[string]ddcPendingSet debouncePending map[string]ddcPendingSet
debounceWg sync.WaitGroup
} }
type ddcPendingSet struct { type ddcPendingSet struct {
+70 -34
View File
@@ -212,9 +212,10 @@ func (m *Manager) setupDataDeviceSync() {
} }
var offer any var offer any
if e.Id != nil { switch {
case e.Id != nil:
offer = e.Id offer = e.Id
} else if e.OfferId != 0 { case e.OfferId != 0:
m.offerMutex.RLock() m.offerMutex.RLock()
offer = m.offerRegistry[e.OfferId] offer = m.offerRegistry[e.OfferId]
m.offerMutex.RUnlock() m.offerMutex.RUnlock()
@@ -224,10 +225,6 @@ func (m *Manager) setupDataDeviceSync() {
wasOwner := m.isOwner wasOwner := m.isOwner
m.ownerLock.Unlock() m.ownerLock.Unlock()
if offer == nil {
return
}
if wasOwner { if wasOwner {
return return
} }
@@ -236,9 +233,11 @@ func (m *Manager) setupDataDeviceSync() {
m.currentOffer = offer m.currentOffer = offer
if prevOffer != nil && prevOffer != offer { if prevOffer != nil && prevOffer != offer {
m.offerMutex.Lock() m.releaseOffer(prevOffer)
delete(m.offerMimeTypes, prevOffer) }
m.offerMutex.Unlock()
if offer == nil {
return
} }
m.offerMutex.RLock() m.offerMutex.RLock()
@@ -292,6 +291,33 @@ func (m *Manager) setupDataDeviceSync() {
log.Info("Data device setup complete") 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) { func (m *Manager) readAndStore(r *os.File, mimeType string) {
defer r.Close() defer r.Close()
@@ -395,7 +421,7 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
if extractHash(v) != hash { if extractHash(v) != hash {
continue continue
} }
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned { if err == nil && entry.Pinned {
continue continue
} }
@@ -413,7 +439,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
c := b.Cursor() c := b.Cursor()
var count int var count int
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned { if err == nil && entry.Pinned {
continue continue
} }
@@ -456,6 +482,14 @@ func encodeEntry(e Entry) ([]byte, error) {
} }
func decodeEntry(data []byte) (Entry, 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) buf := bytes.NewReader(data)
var e Entry var e Entry
@@ -463,8 +497,15 @@ func decodeEntry(data []byte) (Entry, error) {
var dataLen uint32 var dataLen uint32
binary.Read(buf, binary.BigEndian, &dataLen) binary.Read(buf, binary.BigEndian, &dataLen)
e.Data = make([]byte, dataLen) switch {
buf.Read(e.Data) 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 var mimeLen uint32
binary.Read(buf, binary.BigEndian, &mimeLen) binary.Read(buf, binary.BigEndian, &mimeLen)
@@ -668,14 +709,9 @@ func sizeStr(size int) string {
func (m *Manager) updateState() { func (m *Manager) updateState() {
history := m.GetHistory() history := m.GetHistory()
for i := range history {
history[i].Data = nil
}
var current *Entry var current *Entry
if len(history) > 0 { if len(history) > 0 {
c := history[0] c := history[0]
c.Data = nil
current = &c current = &c
} }
@@ -750,7 +786,7 @@ func (m *Manager) GetHistory() []Entry {
c := b.Cursor() c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err != nil { if err != nil {
continue continue
} }
@@ -935,7 +971,7 @@ func (m *Manager) ClearHistory() {
var toDelete [][]byte var toDelete [][]byte
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err != nil || !entry.Pinned { if err != nil || !entry.Pinned {
toDelete = append(toDelete, k) toDelete = append(toDelete, k)
} }
@@ -958,7 +994,7 @@ func (m *Manager) ClearHistory() {
if b != nil { if b != nil {
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, _ := decodeEntry(v) entry, _ := decodeEntryMeta(v)
if entry.Pinned { if entry.Pinned {
pinnedCount++ pinnedCount++
} }
@@ -1066,6 +1102,7 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
m.ownerLock.Unlock() m.ownerLock.Unlock()
}) })
m.releaseCurrentSource()
m.currentSource = source m.currentSource = source
m.sourceMutex.Lock() m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType} m.sourceMimeTypes = []string{mimeType}
@@ -1145,9 +1182,11 @@ func (m *Manager) Close() {
m.subscribers = make(map[string]chan State) m.subscribers = make(map[string]chan State)
m.subMutex.Unlock() m.subMutex.Unlock()
if m.currentSource != nil { m.releaseCurrentSource()
source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
source.Destroy() if m.currentOffer != nil {
m.releaseOffer(m.currentOffer)
m.currentOffer = nil
} }
if m.dataDevice != nil { if m.dataDevice != nil {
@@ -1191,11 +1230,10 @@ func (m *Manager) clearOldEntries(days int) error {
var toDelete [][]byte var toDelete [][]byte
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err != nil { if err != nil {
continue continue
} }
// Skip pinned entries
if entry.Pinned { if entry.Pinned {
continue continue
} }
@@ -1310,7 +1348,7 @@ func (m *Manager) Search(params SearchParams) SearchResult {
c := b.Cursor() c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err != nil { if err != nil {
continue continue
} }
@@ -1335,7 +1373,6 @@ func (m *Manager) Search(params SearchParams) SearchResult {
continue continue
} }
entry.Data = nil
all = append(all, entry) all = append(all, entry)
} }
return nil return nil
@@ -1510,7 +1547,7 @@ func (m *Manager) PinEntry(id uint64) error {
} }
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err != nil || !entry.Pinned { if err != nil || !entry.Pinned {
continue continue
} }
@@ -1528,7 +1565,6 @@ func (m *Manager) PinEntry(id uint64) error {
return nil return nil
} }
// Check pinned count
cfg := m.getConfig() cfg := m.getConfig()
pinnedCount := 0 pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error { if err := m.db.View(func(tx *bolt.Tx) error {
@@ -1538,7 +1574,7 @@ func (m *Manager) PinEntry(id uint64) error {
} }
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned { if err == nil && entry.Pinned {
pinnedCount++ pinnedCount++
} }
@@ -1629,12 +1665,11 @@ func (m *Manager) GetPinnedEntries() []Entry {
c := b.Cursor() c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err != nil { if err != nil {
continue continue
} }
if entry.Pinned { if entry.Pinned {
entry.Data = nil
pinned = append(pinned, entry) pinned = append(pinned, entry)
} }
} }
@@ -1660,7 +1695,7 @@ func (m *Manager) GetPinnedCount() int {
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v) entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned { if err == nil && entry.Pinned {
count++ count++
} }
@@ -1779,6 +1814,7 @@ func (m *Manager) CopyFile(filePath string) error {
m.ownerLock.Unlock() m.ownerLock.Unlock()
}) })
m.releaseCurrentSource()
m.currentSource = source m.currentSource = source
m.ownerLock.Lock() m.ownerLock.Lock()
+1 -1
View File
@@ -391,7 +391,7 @@ func (m *Manager) Close() {
func InitializeManager() (*Manager, error) { func InitializeManager() (*Manager, error) {
if os.Getuid() != 0 && !hasInputGroupAccess() { 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() return NewManager()
@@ -104,7 +104,7 @@ func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface
return false return false
} }
if reply != dbus.RequestNameReplyPrimaryOwner { 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 return false
} }
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil { if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {
+1 -6
View File
@@ -35,12 +35,7 @@ type SessionState struct {
type EventType string type EventType string
const ( const (
EventStateChanged EventType = "state_changed" EventStateChanged EventType = "state_changed"
EventLock EventType = "lock"
EventUnlock EventType = "unlock"
EventPrepareForSleep EventType = "prepare_for_sleep"
EventIdleHintChanged EventType = "idle_hint_changed"
EventLockedHintChanged EventType = "locked_hint_changed"
) )
type SessionEvent struct { type SessionEvent struct {
+2 -7
View File
@@ -8,11 +8,6 @@ import (
func TestEventType_Constants(t *testing.T) { func TestEventType_Constants(t *testing.T) {
assert.Equal(t, EventType("state_changed"), EventStateChanged) assert.Equal(t, EventType("state_changed"), EventStateChanged)
assert.Equal(t, EventType("lock"), EventLock)
assert.Equal(t, EventType("unlock"), EventUnlock)
assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep)
assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged)
assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged)
} }
func TestSessionState_Struct(t *testing.T) { func TestSessionState_Struct(t *testing.T) {
@@ -40,11 +35,11 @@ func TestSessionEvent_Struct(t *testing.T) {
} }
event := SessionEvent{ event := SessionEvent{
Type: EventLock, Type: EventStateChanged,
Data: state, Data: state,
} }
assert.Equal(t, EventLock, event.Type) assert.Equal(t, EventStateChanged, event.Type)
assert.Equal(t, "1", event.Data.SessionID) assert.Equal(t, "1", event.Data.SessionID)
assert.True(t, event.Data.Locked) assert.True(t, event.Data.Locked)
} }
@@ -158,18 +158,26 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo
channel := frequencyToChannel(freq) 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{ network := WiFiNetwork{
SSID: ssid, SSID: ssid,
BSSID: bssid, BSSID: bssid,
Signal: strength, Signal: strength,
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: ssid == currentSSID && bssid == currentBSSID, Connected: isConnected,
Saved: savedSSIDs[ssid], Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid], Autoconnect: autoconnectMap[ssid],
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: maxBitrate / 1000, Rate: rate,
Channel: channel, Channel: channel,
} }
@@ -514,19 +522,27 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
channel := frequencyToChannel(freq) 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{ network := WiFiNetwork{
SSID: ssid, SSID: ssid,
BSSID: bssid, BSSID: bssid,
Signal: strength, Signal: strength,
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: ssid == currentSSID, Connected: isConnected,
Saved: savedSSIDs[ssid], Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid], Autoconnect: autoconnectMap[ssid],
Hidden: hiddenSSIDs[ssid], Hidden: hiddenSSIDs[ssid],
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: maxBitrate / 1000, Rate: rate,
Channel: channel, Channel: channel,
} }
@@ -1062,19 +1078,27 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
channel := frequencyToChannel(freq) 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{ network := WiFiNetwork{
SSID: apSSID, SSID: apSSID,
BSSID: apBSSID, BSSID: apBSSID,
Signal: strength, Signal: strength,
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: connected && apSSID == ssid, Connected: isConnected,
Saved: savedSSIDs[apSSID], Saved: savedSSIDs[apSSID],
Autoconnect: autoconnectMap[apSSID], Autoconnect: autoconnectMap[apSSID],
Hidden: hiddenSSIDs[apSSID], Hidden: hiddenSSIDs[apSSID],
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: maxBitrate / 1000, Rate: rate,
Channel: channel, Channel: channel,
Device: name, Device: name,
} }
+20
View File
@@ -20,6 +20,8 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -109,6 +111,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "tailscale.") {
if tailscaleManager == nil {
models.RespondError(conn, req.ID, "Tailscale not available")
return
}
tailscale.HandleRequest(conn, req, tailscaleManager)
return
}
if strings.HasPrefix(req.Method, "dwl.") { if strings.HasPrefix(req.Method, "dwl.") {
if dwlManager == nil { if dwlManager == nil {
models.RespondError(conn, req.ID, "dwl manager not initialized") models.RespondError(conn, req.ID, "dwl manager not initialized")
@@ -202,6 +213,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "sysupdate.") {
if sysUpdateManager == nil {
models.RespondError(conn, req.ID, "sysupdate manager not initialized")
return
}
sysupdate.HandleRequest(conn, req, sysUpdateManager)
return
}
switch req.Method { switch req.Method {
case "ping": case "ping":
models.Respond(conn, req.ID, "pong") models.Respond(conn, req.ID, "pong")
+144
View File
@@ -30,7 +30,10 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -63,6 +66,7 @@ var waylandManager *wayland.Manager
var bluezManager *bluez.Manager var bluezManager *bluez.Manager
var appPickerManager *apppicker.Manager var appPickerManager *apppicker.Manager
var cupsManager *cups.Manager var cupsManager *cups.Manager
var tailscaleManager *tailscale.Manager
var dwlManager *dwl.Manager var dwlManager *dwl.Manager
var extWorkspaceManager *extworkspace.Manager var extWorkspaceManager *extworkspace.Manager
var brightnessManager *brightness.Manager var brightnessManager *brightness.Manager
@@ -72,7 +76,9 @@ var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager var themeModeManager *thememode.Manager
var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager var locationManager *location.Manager
var sysUpdateManager *sysupdate.Manager
var geoClientInstance geolocation.Client var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client" const dbusClientID = "dms-dbus-client"
@@ -394,6 +400,18 @@ func InitializeThemeModeManager() error {
return nil return nil
} }
func InitializeTrayRecoveryManager() error {
manager, err := trayrecovery.NewManager()
if err != nil {
return err
}
trayRecoveryManager = manager
log.Info("TrayRecovery manager initialized")
return nil
}
func InitializeLocationManager(geoClient geolocation.Client) error { func InitializeLocationManager(geoClient geolocation.Client) error {
manager, err := location.NewManager(geoClient) manager, err := location.NewManager(geoClient)
if err != nil { if err != nil {
@@ -407,6 +425,19 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
return nil return nil
} }
func InitializeSysUpdateManager() error {
manager, err := sysupdate.NewManager()
if err != nil {
log.Warnf("Failed to initialize sysupdate manager: %v", err)
return err
}
sysUpdateManager = manager
log.Info("Sysupdate manager initialized")
return nil
}
func handleConnection(conn net.Conn) { func handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
@@ -460,6 +491,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "cups") caps = append(caps, "cups")
} }
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
caps = append(caps, "tailscale")
}
if dwlManager != nil { if dwlManager != nil {
caps = append(caps, "dwl") caps = append(caps, "dwl")
} }
@@ -492,6 +527,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return Capabilities{Capabilities: caps} return Capabilities{Capabilities: caps}
} }
@@ -526,6 +565,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "cups") caps = append(caps, "cups")
} }
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
caps = append(caps, "tailscale")
}
if dwlManager != nil { if dwlManager != nil {
caps = append(caps, "dwl") caps = append(caps, "dwl")
} }
@@ -562,6 +605,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return ServerInfo{ return ServerInfo{
APIVersion: APIVersion, APIVersion: APIVersion,
CLIVersion: CLIVersion, CLIVersion: CLIVersion,
@@ -1002,6 +1049,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
} }
} }
if shouldSubscribe("tailscale") && tailscaleManager != nil && tailscaleManager.IsAvailable() {
wg.Add(1)
tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale")
go func() {
defer wg.Done()
defer tailscaleManager.Unsubscribe(clientID + "-tailscale")
initialState := tailscaleManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "tailscale", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-tailscaleChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "tailscale", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("dwl") && dwlManager != nil { if shouldSubscribe("dwl") && dwlManager != nil {
wg.Add(1) wg.Add(1)
dwlChan := dwlManager.Subscribe(clientID + "-dwl") dwlChan := dwlManager.Subscribe(clientID + "-dwl")
@@ -1229,6 +1308,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}() }()
} }
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
wg.Add(1)
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
go func() {
defer wg.Done()
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
initialState := sysUpdateManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-sysupdateChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("dbus") && dbusManager != nil { if shouldSubscribe("dbus") && dbusManager != nil {
wg.Add(1) wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClientID) dbusChan := dbusManager.SubscribeSignals(dbusClientID)
@@ -1325,20 +1436,37 @@ func cleanupManagers() {
if themeModeManager != nil { if themeModeManager != nil {
themeModeManager.Close() themeModeManager.Close()
} }
if trayRecoveryManager != nil {
trayRecoveryManager.Close()
}
if wlContext != nil { if wlContext != nil {
wlContext.Close() wlContext.Close()
} }
if locationManager != nil { if locationManager != nil {
locationManager.Close() locationManager.Close()
} }
if sysUpdateManager != nil {
sysUpdateManager.Close()
}
if geoClientInstance != nil { if geoClientInstance != nil {
geoClientInstance.Close() geoClientInstance.Close()
} }
if tailscaleManager != nil {
tailscaleManager.Close()
}
} }
func Start(printDocs bool) error { func Start(printDocs bool) error {
cleanupStaleSockets() cleanupStaleSockets()
// Tailscale manager always starts — reconnects internally via WatchIPNBus.
// The capability is only advertised once tailscaled is reachable; the
// callback wakes capability subscribers so QML clients see it transition.
tailscaleManager = tailscale.NewManager("")
tailscaleManager.SetAvailabilityCallback(func(bool) {
notifyCapabilityChange()
})
socketPath := GetSocketPath() socketPath := GetSocketPath()
os.Remove(socketPath) os.Remove(socketPath)
@@ -1610,6 +1738,18 @@ func Start(printDocs bool) error {
}() }()
} }
go func() {
<-loginctlReady
if loginctlManager == nil {
return
}
if err := InitializeTrayRecoveryManager(); err != nil {
log.Warnf("TrayRecovery manager unavailable: %v", err)
} else {
trayRecoveryManager.WatchLoginctl(loginctlManager)
}
}()
go func() { go func() {
geoClient := geolocation.NewClient() geoClient := geolocation.NewClient()
geoClientInstance = geoClient geoClientInstance = geoClient
@@ -1704,6 +1844,10 @@ func Start(printDocs bool) error {
} }
}() }()
if err := InitializeSysUpdateManager(); err != nil {
log.Warnf("Sysupdate manager unavailable: %v", err)
}
log.Info("") log.Info("")
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities) log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
+96
View File
@@ -0,0 +1,96 @@
package sysupdate
import (
"context"
"os/exec"
"sync"
)
type Backend interface {
ID() string
DisplayName() string
Repo() RepoKind
IsAvailable(ctx context.Context) bool
NeedsAuth() bool
RunsInTerminal() bool
CheckUpdates(ctx context.Context) ([]Package, error)
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
}
type Selection struct {
System Backend
Overlay []Backend
}
func (s Selection) All() []Backend {
if s.System == nil {
return s.Overlay
}
out := make([]Backend, 0, 1+len(s.Overlay))
out = append(out, s.System)
out = append(out, s.Overlay...)
return out
}
func (s Selection) Info() []BackendInfo {
all := s.All()
out := make([]BackendInfo, 0, len(all))
for _, b := range all {
out = append(out, BackendInfo{
ID: b.ID(),
DisplayName: b.DisplayName(),
Repo: b.Repo(),
NeedsAuth: b.NeedsAuth(),
RunsInTerminal: b.RunsInTerminal(),
})
}
return out
}
var (
registryMu sync.RWMutex
systemCandidates []func() Backend
overlayCandidate []func() Backend
)
func RegisterSystemBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
systemCandidates = append(systemCandidates, factory)
}
func RegisterOverlayBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
overlayCandidate = append(overlayCandidate, factory)
}
func Select(ctx context.Context) Selection {
registryMu.RLock()
sys := append([]func() Backend(nil), systemCandidates...)
ov := append([]func() Backend(nil), overlayCandidate...)
registryMu.RUnlock()
var sel Selection
for _, factory := range sys {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.System = b
break
}
for _, factory := range ov {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.Overlay = append(sel.Overlay, b)
}
return sel
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
@@ -0,0 +1,81 @@
package sysupdate
import (
"context"
"os/exec"
"regexp"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &aptBackend{} })
}
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
type aptBackend struct{}
func (aptBackend) ID() string { return "apt" }
func (aptBackend) DisplayName() string { return "APT" }
func (aptBackend) Repo() RepoKind { return RepoSystem }
func (aptBackend) NeedsAuth() bool { return true }
func (aptBackend) RunsInTerminal() bool { return false }
func (aptBackend) IsAvailable(_ context.Context) bool {
return commandExists("apt") || commandExists("apt-get")
}
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
out, err := cmd.Output()
if err != nil {
return nil, err
}
return parseAptUpgradable(string(out)), nil
}
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
bin := "apt-get"
if !commandExists(bin) {
bin = "apt"
}
if opts.DryRun {
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
OnLine: onLine,
})
}
if !BackendHasTargets(aptBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
return Run(ctx, aptUpgradeArgv(bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func aptUpgradeArgv(bin string, opts UpgradeOptions) []string {
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
}
func parseAptUpgradable(text string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := aptUpgradableLine.FindStringSubmatch(line)
if m == nil {
continue
}
pkgs = append(pkgs, Package{
Name: m[1],
Repo: RepoSystem,
Backend: "apt",
FromVersion: m[3],
ToVersion: m[2],
})
}
return pkgs
}
@@ -0,0 +1,72 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseAptUpgradable(t *testing.T) {
tests := []struct {
name string
input string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "header line only",
input: `Listing... Done
`,
want: nil,
},
{
name: "single upgradable",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
{
name: "multiple architectures and suites",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "package name with hyphens, dots, plus signs",
input: `Listing... Done
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
want: []Package{
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
},
},
{
name: "non-matching lines ignored",
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseAptUpgradable(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
}
})
}
}
@@ -0,0 +1,136 @@
package sysupdate
import (
"context"
"errors"
"os/exec"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
}
type dnfBackend struct {
bin string
}
func (b dnfBackend) ID() string { return b.bin }
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
func (b dnfBackend) NeedsAuth() bool { return true }
func (b dnfBackend) RunsInTerminal() bool { return false }
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
if !commandExists(b.bin) {
return false
}
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
return false
}
return true
}
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := dnfListUpgrades(ctx, b.bin)
if err != nil {
return nil, err
}
installed := rpmInstalledVersions(ctx)
return parseDnfList(out, b.bin, installed), nil
}
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
}
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
return Run(ctx, dnfUpgradeArgv(b.bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y")
}
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
argv := dnfCheckUpdatesArgv(bin)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.CombinedOutput()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
return string(out), nil
}
return "", err
}
func dnfCheckUpdatesArgv(bin string) []string {
subcommand := "check-update"
if bin == "dnf5" {
subcommand = "check-upgrade"
}
return []string{bin, subcommand, "--refresh", "--quiet"}
}
func rpmInstalledVersions(ctx context.Context) map[string]string {
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
if err != nil {
return nil
}
m := make(map[string]string)
for line := range strings.SplitSeq(string(out), "\n") {
name, ver, ok := strings.Cut(line, "\t")
if !ok {
continue
}
m[name] = ver
}
return m
}
func parseDnfList(text, backendID string, installed map[string]string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
nameArch := fields[0]
version := fields[1]
dot := strings.LastIndex(nameArch, ".")
if dot <= 0 {
continue
}
if !looksLikeRpmVersion(version) {
continue
}
name := nameArch[:dot]
pkgs = append(pkgs, Package{
Name: nameArch,
Repo: RepoSystem,
Backend: backendID,
FromVersion: installed[name],
ToVersion: version,
})
}
return pkgs
}
func looksLikeRpmVersion(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r >= '0' && r <= '9' {
return true
}
}
return false
}
@@ -0,0 +1,114 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseDnfList(t *testing.T) {
tests := []struct {
name string
input string
backendID string
installed map[string]string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "single package with installed cross-ref",
input: "bash.x86_64 5.2.40-1.fc41 updates",
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "noarch package and missing installed entry",
input: `bash.x86_64 5.2.40-1.fc41 updates
fonts-misc.noarch 1.0.5-2.fc41 updates`,
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
},
},
{
name: "skips header rows",
input: `Available
Upgrades
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "skips lines with too few fields",
input: "incomplete",
backendID: "dnf",
want: nil,
},
{
name: "skips dnf5 banner / column header lines",
input: `Updates available
Last metadata expiration check: 0:01:23 ago on Tue Apr 29 14:00:00 2026.
Package Version Repository Size
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "skips dnf warning lines while keeping package rows",
input: `Failed to expire repository cache in path "/home/user/.cache/libdnf5/updates": cannot open file
example-driver.x86_64 2:9.8.7-1.fc99 updates
example-tool.noarch 1.2.3^45.gitabcdef-1.fc99 copr`,
backendID: "dnf5",
installed: map[string]string{
"example-driver": "2:9.8.6-1.fc99",
"example-tool": "1.2.2^44.gitabcdef-1.fc99",
},
want: []Package{
{Name: "example-driver.x86_64", Repo: RepoSystem, Backend: "dnf5", FromVersion: "2:9.8.6-1.fc99", ToVersion: "2:9.8.7-1.fc99"},
{Name: "example-tool.noarch", Repo: RepoSystem, Backend: "dnf5", FromVersion: "1.2.2^44.gitabcdef-1.fc99", ToVersion: "1.2.3^45.gitabcdef-1.fc99"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDnfList(tt.input, tt.backendID, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
}
})
}
}
func TestDnfCheckUpdatesArgv(t *testing.T) {
tests := []struct {
bin string
want []string
}{
{bin: "dnf5", want: []string{"dnf5", "check-upgrade", "--refresh", "--quiet"}},
{bin: "dnf", want: []string{"dnf", "check-update", "--refresh", "--quiet"}},
}
for _, tt := range tests {
t.Run(tt.bin, func(t *testing.T) {
got := dnfCheckUpdatesArgv(tt.bin)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("dnfCheckUpdatesArgv(%q) = %#v, want %#v", tt.bin, got, tt.want)
}
})
}
}
@@ -0,0 +1,172 @@
package sysupdate
import (
"context"
"errors"
"os/exec"
"strings"
)
func init() {
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
}
type flatpakBackend struct{}
func (flatpakBackend) ID() string { return "flatpak" }
func (flatpakBackend) DisplayName() string { return "Flatpak" }
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
func (flatpakBackend) NeedsAuth() bool { return false }
func (flatpakBackend) RunsInTerminal() bool { return false }
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
// Run `flatpak update`
cmd := exec.CommandContext(ctx, "flatpak", "update")
cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts
out, err := cmd.Output()
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(out) > 0 {
} else if len(out) == 0 {
return nil, err
}
}
installed := flatpakInstalled(ctx)
return parseFlatpakUpdateOutput(string(out), installed), nil
}
type flatpakInstalledEntry struct {
version string
branch string
}
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
m := flatpakListInstalled(ctx, false)
if m == nil {
m = make(map[string]flatpakInstalledEntry)
}
for k, v := range flatpakListInstalled(ctx, true) {
if _, exists := m[k]; !exists {
m[k] = v
}
}
return m
}
func flatpakListInstalled(ctx context.Context, system bool) map[string]flatpakInstalledEntry {
args := []string{"flatpak", "list", "--columns=application,version,branch"}
if system {
args = append(args, "--system")
}
out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
if err != nil {
return nil
}
m := make(map[string]flatpakInstalledEntry)
for line := range strings.SplitSeq(string(out), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
entry := flatpakInstalledEntry{}
if len(fields) > 1 {
entry.version = fields[1]
}
if len(fields) > 2 {
entry.branch = fields[2]
}
key := appID
if entry.branch != "" {
key = appID + "//" + entry.branch
}
m[key] = entry
}
return m
}
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine})
}
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
}
func flatpakUpgradeArgv() []string {
return []string{"flatpak", "update", "-y", "--noninteractive"}
}
func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package {
var pkgs []Package
seen := make(map[string]bool)
for line := range strings.SplitSeq(text, "\n") {
p := parseFlatpakUpdateRow(strings.TrimRight(line, "\r"), installed)
if p == nil || seen[p.Ref] {
continue
}
seen[p.Ref] = true
pkgs = append(pkgs, *p)
}
return pkgs
}
func parseFlatpakUpdateRow(line string, installed map[string]flatpakInstalledEntry) *Package {
// Row format: " N.\t<name>\t<appID>\t<branch>\t<op>\t<remote>\t<size>"
fields := strings.Split(line, "\t")
if len(fields) < 5 {
return nil
}
// First field must look like " N." (optional whitespace, digits, period)
rowField := strings.TrimSpace(fields[0])
if len(rowField) < 2 || rowField[len(rowField)-1] != '.' {
return nil
}
for _, c := range rowField[:len(rowField)-1] {
if c < '0' || c > '9' {
return nil
}
}
appID := strings.TrimSpace(fields[2])
branch := strings.TrimSpace(fields[3])
op := strings.TrimSpace(fields[4])
if appID == "" || op == "" {
return nil
}
switch op {
case "i", "u", "r": // install, update, reinstall
default:
return nil
}
ref := appID
if branch != "" {
ref = appID + "//" + branch
}
name := strings.TrimSpace(fields[1])
if name == "" {
name = appID
}
var from string
if op != "i" {
if inst, ok := installed[ref]; ok {
from = inst.version
}
}
return &Package{
Name: name,
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: from,
Ref: ref,
}
}
@@ -0,0 +1,107 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseFlatpakUpdateOutput(t *testing.T) {
realOutput := "Looking for updates…\n\n\n 1.\t \torg.gtk.Gtk3theme.adw-gtk3-dark\t3.22\ti\tflathub\t< 131.4 kB\n\nProceed with these changes to the system installation? [Y/n]: n\n"
tests := []struct {
name string
input string
installed map[string]flatpakInstalledEntry
want []Package
}{
{
name: "empty output",
input: "",
want: nil,
},
{
name: "nothing to do",
input: "Looking for updates…\n\nNothing to do.\n",
want: nil,
},
{
name: "real flatpak update output — new install",
input: realOutput,
want: []Package{
{
Name: "org.gtk.Gtk3theme.adw-gtk3-dark",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
Ref: "org.gtk.Gtk3theme.adw-gtk3-dark//3.22",
},
},
},
{
name: "update with installed version",
input: "Looking for updates…\n\n 1.\tSlack\tcom.slack.Slack\tstable\tu\tflathub\t< 5.2 MB\n\nProceed? [Y/n]: n\n",
installed: map[string]flatpakInstalledEntry{
"com.slack.Slack//stable": {version: "4.40.0"},
},
want: []Package{
{
Name: "Slack",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "4.40.0",
Ref: "com.slack.Slack//stable",
},
},
},
{
name: "reinstall op included",
input: " 1.\t\torg.freedesktop.Platform\t25.08\tr\tflathub\t< 100 MB\n",
want: []Package{
{
Name: "org.freedesktop.Platform",
Repo: RepoFlatpak,
Backend: "flatpak",
Ref: "org.freedesktop.Platform//25.08",
},
},
},
{
name: "unknown op excluded",
input: " 1.\t\torg.freedesktop.Platform\t25.08\te\tflathub\t0\n",
want: nil,
},
{
name: "deduplicates same ref",
input: " 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n 2.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n",
want: []Package{
{
Name: "com.example.App",
Repo: RepoFlatpak,
Backend: "flatpak",
Ref: "com.example.App//stable",
},
},
},
{
name: "non-table lines ignored",
input: "Looking for updates…\nSome warning line\nID\tBranch\tOp\n 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\nProceed? [Y/n]: n\n",
want: []Package{
{
Name: "com.example.App",
Repo: RepoFlatpak,
Backend: "flatpak",
Ref: "com.example.App//stable",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFlatpakUpdateOutput(tt.input, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseFlatpakUpdateOutput() = %#v\nwant %#v", got, tt.want)
}
})
}
}
@@ -0,0 +1,253 @@
package sysupdate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func init() {
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
}
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
type pacmanBackend struct{}
func (pacmanBackend) ID() string { return "pacman" }
func (pacmanBackend) DisplayName() string { return "Pacman" }
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
func (pacmanBackend) NeedsAuth() bool { return true }
func (pacmanBackend) RunsInTerminal() bool { return false }
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
return parseArchUpdates(out, b.ID(), RepoSystem), nil
}
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
}
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
return Run(ctx, pacmanUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
}
type archHelperBackend struct {
id string
}
func (b archHelperBackend) ID() string { return b.id }
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
func (b archHelperBackend) NeedsAuth() bool { return true }
func (b archHelperBackend) RunsInTerminal() bool {
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
}
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
func (b archHelperBackend) DisplayName() string {
switch b.id {
case "paru":
return "Paru (AUR)"
case "yay":
return "Yay (AUR)"
default:
return b.id
}
}
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
repoOut, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
if err != nil {
return nil, err
}
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
return pkgs, nil
}
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
}
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...)
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ")
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
}
func archHelperUpgradeArgv(id string, includeAUR bool) []string {
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
if !includeAUR {
argv = append(argv, "--repo")
}
return argv
}
func pacmanRepoUpdates(ctx context.Context) (string, error) {
if commandExists("checkupdates") {
return capturePermissive(ctx, "checkupdates")
}
if commandExists("fakeroot") {
out, err := pacmanCheckViaFakeroot(ctx)
if err == nil {
return out, nil
}
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
}
return capturePermissive(ctx, "pacman", "-Qu")
}
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
dir, err := pacmanPrivateDB()
if err != nil {
return "", err
}
if err := seedPacmanDB(dir); err != nil {
return "", fmt.Errorf("seed sync db: %w", err)
}
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
if out, err := refresh.CombinedOutput(); err != nil {
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
}
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
}
func seedPacmanDB(dir string) error {
syncDir := filepath.Join(dir, "sync")
if err := os.MkdirAll(syncDir, 0o755); err != nil {
return err
}
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
if err != nil {
return err
}
for _, src := range dbs {
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
return err
}
}
localLink := filepath.Join(dir, "local")
if fi, err := os.Lstat(localLink); err == nil {
if fi.Mode()&os.ModeSymlink == 0 {
if err := os.RemoveAll(localLink); err != nil {
return err
}
} else {
return nil
}
}
return os.Symlink("/var/lib/pacman/local", localLink)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func pacmanPrivateDB() (string, error) {
tmp := os.Getenv("TMPDIR")
if tmp == "" {
tmp = "/tmp"
}
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 1, 2:
return string(out), nil
}
}
return "", err
}
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := archUpdateLine.FindStringSubmatch(line)
if m == nil {
continue
}
p := Package{
Name: m[1],
Repo: repo,
Backend: backendID,
FromVersion: m[2],
ToVersion: m[3],
}
if repo == RepoAUR {
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}
@@ -0,0 +1,114 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseArchUpdates(t *testing.T) {
tests := []struct {
name string
input string
backendID string
repo RepoKind
want []Package
}{
{
name: "empty",
input: "",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "whitespace only",
input: " \n\n \n",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "single repo update",
input: "bat 0.26.0-1 -> 0.26.1-2",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "multiple updates with epoch versions",
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
linux 6.18.0-1 -> 6.18.1-1
mesa 26.4.0-1 -> 26.4.1-1`,
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
},
},
{
name: "AUR update with changelog url",
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
backendID: "paru",
repo: RepoAUR,
want: []Package{
{
Name: "google-chrome",
Repo: RepoAUR,
Backend: "paru",
FromVersion: "147.0.7727.116-1",
ToVersion: "147.0.7727.137-1",
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
},
},
},
{
name: "git package latest-commit marker",
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
backendID: "yay",
repo: RepoAUR,
want: []Package{
{
Name: "niri-git",
Repo: RepoAUR,
Backend: "yay",
FromVersion: "26.04.r5.ga85b922-1",
ToVersion: "latest-commit",
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
},
},
},
{
name: "skips lines that don't match arrow format",
input: `bat 0.26.0-1 -> 0.26.1-2
this is not an update line
foo`,
backendID: "pacman",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "extra whitespace tolerated",
input: " bat 0.26.0-1 -> 0.26.1-2 ",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}
@@ -0,0 +1,132 @@
package sysupdate
import (
"context"
"encoding/json"
"errors"
"os/exec"
)
const ostreeExitUpdateAvailable = 77
func init() {
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
}
type rpmOstreeBackend struct{}
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
func (rpmOstreeBackend) NeedsAuth() bool { return true }
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
if !commandExists("rpm-ostree") {
return false
}
return ostreeBooted(ctx)
}
type ostreeStatus struct {
Deployments []ostreeDeployment `json:"deployments"`
CachedUpdate *ostreeCached `json:"cached-update"`
}
type ostreeDeployment struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Booted bool `json:"booted"`
}
type ostreeCached struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Checksum string `json:"checksum"`
}
func ostreeBooted(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
out, err := cmd.Output()
if err != nil {
return false
}
var s ostreeStatus
if err := json.Unmarshal(out, &s); err != nil {
return false
}
return len(s.Deployments) > 0
}
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
if err := cmd.Run(); err != nil {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
return nil, err
}
}
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
if err != nil {
return nil, err
}
return parseRpmOstreeStatus(statusOut)
}
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
var s ostreeStatus
if err := json.Unmarshal(statusOut, &s); err != nil {
return nil, err
}
if s.CachedUpdate == nil {
return nil, nil
}
booted := bootedDeployment(s.Deployments)
from := ""
if booted != nil {
from = booted.Version
}
if from == s.CachedUpdate.Version {
return nil, nil
}
name := s.CachedUpdate.Origin
if name == "" {
name = "system"
}
return []Package{{
Name: name,
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: from,
ToVersion: s.CachedUpdate.Version,
}}, nil
}
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
for i := range deps {
if deps[i].Booted {
return &deps[i]
}
}
return nil
}
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if !BackendHasTargets(rpmOstreeBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
return Run(ctx, rpmOstreeUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func rpmOstreeUpgradeArgv(opts UpgradeOptions) []string {
argv := []string{"rpm-ostree", "upgrade"}
if opts.DryRun {
argv = append(argv, "--check")
}
return argv
}
@@ -0,0 +1,104 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseRpmOstreeStatus(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "no cached update",
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
want: nil,
},
{
name: "cached update available, booted version differs",
input: `{
"deployments": [
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
],
"cached-update": {
"origin": "fedora:fedora/x86_64/silverblue",
"version": "39.20240115.0",
"checksum": "abc123"
}
}`,
want: []Package{
{
Name: "fedora:fedora/x86_64/silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "39.20240101.0",
ToVersion: "39.20240115.0",
},
},
},
{
name: "cached update equals booted version (no real update)",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": true}],
"cached-update": {"origin": "x", "version": "39.20240101.0"}
}`,
want: nil,
},
{
name: "no booted deployment falls back to empty from",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": false}],
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
}`,
want: []Package{
{
Name: "fedora:silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "",
ToVersion: "39.20240115.0",
},
},
},
{
name: "missing origin defaults to system",
input: `{
"deployments": [{"version": "1.0", "booted": true}],
"cached-update": {"version": "1.1"}
}`,
want: []Package{
{
Name: "system",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "1.0",
ToVersion: "1.1",
},
},
},
{
name: "malformed JSON",
input: `{not json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseRpmOstreeStatus([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
}
})
}
}
@@ -0,0 +1,85 @@
package sysupdate
import (
"context"
"encoding/xml"
"errors"
"os/exec"
)
func init() {
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
}
type zypperBackend struct{}
func (zypperBackend) ID() string { return "zypper" }
func (zypperBackend) DisplayName() string { return "Zypper" }
func (zypperBackend) Repo() RepoKind { return RepoSystem }
func (zypperBackend) NeedsAuth() bool { return true }
func (zypperBackend) RunsInTerminal() bool { return false }
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
type zypperUpdateList struct {
XMLName xml.Name `xml:"stream"`
Updates []zypperUpdate `xml:"update-list>update"`
}
type zypperUpdate struct {
Name string `xml:"name,attr"`
Edition string `xml:"edition,attr"`
EditionOld string `xml:"edition-old,attr"`
Kind string `xml:"kind,attr"`
}
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
out, err := cmd.Output()
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 100, 101, 102, 103:
err = nil
}
}
if err != nil {
return nil, err
}
}
return parseZypperXML(out)
}
func parseZypperXML(out []byte) ([]Package, error) {
var list zypperUpdateList
if err := xml.Unmarshal(out, &list); err != nil {
return nil, err
}
pkgs := make([]Package, 0, len(list.Updates))
for _, u := range list.Updates {
if u.Kind != "" && u.Kind != "package" {
continue
}
pkgs = append(pkgs, Package{
Name: u.Name,
Repo: RepoSystem,
Backend: "zypper",
FromVersion: u.EditionOld,
ToVersion: u.Edition,
})
}
return pkgs, nil
}
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
}
if !BackendHasTargets(zypperBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
return Run(ctx, zypperUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func zypperUpgradeArgv(opts UpgradeOptions) []string {
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
}

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