Compare commits

...
151 Commits
Author SHA1 Message Date
purian23 10cee892b4 feat(packaging): dual-series migration publishing & dms migrate dux
- Further banner notices will show once per/shell session in notifications, dms doctor and software updates
- Doc updates to follow

Port 2.0

(cherry picked from commit 095d1c6fd5)
2026-10-30 09:39:55 +00:00
purian23 0299652efb feat(clock): clock: per-widget click action w/overview as optional
Fixes 3993
Port 2.0

(cherry picked from commit d367e7e71b)
2026-10-30 04:12:34 +00:00
purian23 e7707fb5e4 island: optional dash tab bar on the island faces
- bump qml-common
Port 2.0

(cherry picked from commit e777288904)
2026-10-30 03:24:40 +00:00
dux-ci[bot] 03f4dc0aaf nix: sync flake.lock to dux-qml-common submodule 2026-10-30 03:21:16 +00:00
purian23 eb8c50c4b8 dash: settings-style keyboard between the tab row & content
-fix media follows the album accent
- updated keyboard navigation docs
Fixes #3997
Port 2.0

(cherry picked from commit 673ceb261e)
2026-10-30 03:20:53 +00:00
bbedward 9c18799369 ci: set AI review effort to high
(cherry picked from commit f288dbc52e)
2026-10-29 23:12:00 -04:00
purian23 dcd8d81467 media: fix rare binding loop on bento panes switch
(cherry picked from commit 26c4cd1154)
2026-10-29 23:12:00 -04:00
bbedward 366826e8c7 ci: fix pr labeling
(cherry picked from commit 2a9f83ccb2)
2026-10-29 23:11:59 -04:00
bbedward 7e758c935f control center: close the band on a second tile click instead of walking history
fixes #4016
port 2.0

(cherry picked from commit ff62d7618f)
2026-10-29 23:49:10 +00:00
bbedward 1905f2135b i18n: sync
port 2.0

(cherry picked from commit 8ce37501bd)
2026-10-29 18:40:44 +00:00
purian23 974c4d3167 control center: one surface under the edit strip & backdrops/frame sheet fixes
(cherry picked from commit e48c1221e7)
2026-10-29 13:57:12 -04:00
bbedward 3df95c8f4f plugins: route plugin popout clicks through PopoutManager
fixes #4011

(cherry picked from commit 8e8442fa76)
2026-10-29 13:57:12 -04:00
bbedward ad05bdf70b ci: auto label issues
(cherry picked from commit eafb118b1f)
2026-10-29 13:57:12 -04:00
bbedward d22b5c38c9 screenshot: stop re-rotating frames hyprland 0.57 already delivers upright
fixes #4014
port 2.0

(cherry picked from commit a26298d294)
2026-10-29 17:47:09 +00:00
Leonard 5b52ccad7c doctor: match font names the way qt lists them (#3972)
port 2.0

(cherry picked from commit b18fc38693)
2026-10-29 16:59:06 +00:00
StevenKerman 9b762b8ec0 dbar: animate hiding overlay bars on fullscreen in niri (#4000)
port 2.0

(cherry picked from commit 47f7f3d798)
2026-10-29 15:29:09 +00:00
bbedward 2ba4f1355c island: keep blur regions flush with the fill and goth corners
ref #4013
port 2.0

(cherry picked from commit 791537bf7b)
2026-10-29 15:25:43 +00:00
bbedward 08f7acad06 cc, dash: load grid edit chrome only in edit mode
port 2.0

(cherry picked from commit 012237a1ad)
2026-10-29 15:11:02 +00:00
apollo79 c25da363ec add missing theme values, update changed ones (#3978)
port 2.0

(cherry picked from commit c13ffb530a)
2026-10-29 14:36:07 +00:00
Huỳnh Thiện Lộc 74b4175876 feat(battery): scale battery level display (#4012)
port 2.0

(cherry picked from commit d254f40acd)
2026-10-29 14:22:23 +00:00
bbedward 2b5bf15e76 bar: keep ripple and press morph on the pressed clock segment
fixes #4008
port 2.0

(cherry picked from commit 2308934453)
2026-10-29 13:51:38 +00:00
pblcp 2c42fb8492 tray: open overflow item menus at the icon (#4002)
port 2.0

(cherry picked from commit a1c8db2558)
2026-10-29 13:43:20 +00:00
Callum Wong bee384abbd displays: keep vrr on after choosing On on niri (#3998)
port 2.0

(cherry picked from commit 3b63f55779)
2026-10-29 04:27:43 +00:00
Huỳnh Thiện Lộc ee249838f5 fix(screenshot): expose surface bounds for region snap (#3999)
port 2.0

(cherry picked from commit 61528822ba)
2026-10-29 03:09:18 +00:00
xibielemn 3c365533c7 clipboard: bind the first wl_seat only (#3989)
port 2.0

(cherry picked from commit df8b1d85ca)
2026-10-28 21:55:48 +00:00
bbedward 47744d34b7 widgets: drop tabular figures from bar and dash numerics
theme: change light mode backdrop color

port 2.0

(cherry picked from commit e4c4fe44ea)
2026-10-28 21:55:16 +00:00
purian23 c813f65581 island: size the launcher sheet to its results
Port 2.0

(cherry picked from commit 0b174e0702)
2026-10-28 20:34:55 +00:00
purian23 527bb0613e island: match control center sheet and insets to the popout
Port 2.0

(cherry picked from commit e97e41036b)
2026-10-28 20:30:47 +00:00
purian23 41e0f1d434 launcher: fit grid rows in every host and drop the island inset literal
Port 2.0

(cherry picked from commit ff1bccbe7b)
2026-10-28 20:30:22 +00:00
purian23 9be1bc03e9 feat(dash): restore optional settings shortcut in the tab bar
Port 2.0

(cherry picked from commit 2af1c4c1a0)
2026-10-28 19:50:05 +00:00
Curious 57dd939d85 fix(clock): vertically align clock content (#3994)
(cherry picked from commit 197f4b1faa)
2026-10-28 15:11:34 -04:00
bbedward 5fea906c9f network: answer openvpn dynamic challenge prompts with the challenge-response secret
fixes #3952
port 2.0

(cherry picked from commit 19f3e58dcb)
2026-10-28 18:49:45 +00:00
Mario Mohar b528af8469 settings: keep page history when opening keybinds from the launcher tab (#3990)
port 2.0

(cherry picked from commit 18af7856f9)
2026-10-28 18:27:17 +00:00
Mario Mohar 957e87afa5 dock: keep vector spring stable at short animation speeds (#3991)
port 2.0

(cherry picked from commit f5d22b101e)
2026-10-28 18:24:48 +00:00
bbedward 8c5bfac638 theme: restore inverse surface tooltips and index the surface role rows
port 2.0

(cherry picked from commit 46373f8bee)
2026-10-28 18:21:45 +00:00
bbedward c504ebcb95 network/networkmanager: own new wifi profiles per user when modify.system is not granted
port 2.0

(cherry picked from commit 229483272a)
2026-10-28 18:21:43 +00:00
bbedward e36ef50482 settings: derive button group compact mode from its natural width
ref #3984
ref #3982

port 2.0

(cherry picked from commit 2dd237a3af)
2026-10-28 16:25:06 +00:00
bbedward 8aac4c767a cc: stop avatar and app icon reloads from stalling the sheet, disable
edit fill in edit mode

fixes #3975
port 2.0

(cherry picked from commit d2792b8a11)
2026-10-28 15:49:47 +00:00
Huỳnh Thiện Lộc 7a7f67bc24 fix(osd): fill active keep-awake icon (#3986)
port 2.0

(cherry picked from commit af9876d232)
2026-10-28 15:18:11 +00:00
hecate cantus 9fd55f6f5e island: show synced lyrics in the media slot (#3979)
port 2.0

(cherry picked from commit 9d279e7f88)
2026-10-28 14:33:01 +00:00
hecate cantus 4a1fadc349 wellbeing: open the screen time view from the overview card (#3980)
port 2.0

(cherry picked from commit 27ff725a17)
2026-10-28 14:28:26 +00:00
Spyros Psarras 12f8b67d4f brightness: move every device pinned on a screen together (#3939)
port 2.0

(cherry picked from commit 5d53fdb684)
2026-10-28 14:23:33 +00:00
Roni Tarvainen ff1bbe807d Change keep awake icon to local cafe (#3936)
port 2.0

(cherry picked from commit 4a9ab29515)
2026-10-28 14:18:18 +00:00
Huỳnh Thiện Lộc d90ff54ab9 fix(settings-search): resolve placeholders (#3985)
port 2.0

(cherry picked from commit 083b9c55e2)
2026-10-28 14:15:25 +00:00
bbedward 15ddcf2ae1 i18n: sync
port 2.0

(cherry picked from commit a330e9bfa5)
2026-10-28 14:05:13 +00:00
Huỳnh Thiện Lộc 8e10f620c9 feat(theme): add themable tooltip surface colors (#3967)
port 2.0

(cherry picked from commit 8f1a8ad824)
2026-10-28 13:28:05 +00:00
Huỳnh Thiện Lộc e038d76fb6 fix(dock): gate magnification while hidden (#3983)
(cherry picked from commit eac4ae8414)
2026-10-28 09:27:41 -04:00
purian23 7ae25d1d32 settings: reposition appearance card in the interface tab
Port 2.0

(cherry picked from commit 07c899dd0c)
2026-10-28 11:58:47 +00:00
purian23 dba4d5a114 fix(workspaces): resolve slot list binding regression
Port 2.0

(cherry picked from commit 4f0e2c6fd4)
2026-10-28 11:15:30 +00:00
purian23 0d7298b996 feat(theme): introduce dms classic interface style
- Expressive remains in place where Classic drops pills, shape morph, and setting sidebar color badges
- Classic defaults corner style to Fixed and stacks settings groups into continuous cards
- section dividers setting provides a slider to enhance the lines or remove them entirely for a clean look
Port 2.0

(cherry picked from commit 7479d4bb79)
2026-10-28 10:40:29 +00:00
purian23 d29daf8fdd feat(dash): adjustable clock card font weight to match desktop/lockscreen
(cherry picked from commit 59ede533d8)
2026-10-28 01:48:15 -04:00
purian23 43cb8c70b7 ubuntu: fall back to archive Hyprland when the PPA lacks the series
Fixes #3899
Port 2.0

(cherry picked from commit 27a55349bc)
2026-10-28 04:12:39 +00:00
purian23 1efbf723f8 feat(density): text and search fields follow interface density
(cherry picked from commit 170e8f5074)
2026-10-27 21:24:25 -04:00
purian23 d694107f36 feat(density): interface density setting for shell surfaces
- density setting (spacious, comfortable, compact, dense)
- bar, dock, island and frame sizes follow density presets and keep values the user changed
- launcher no longer rebuilds every frame while the action panel animates
- cc toggle rows match the single-height list rows
- ipc settings set runs change hooks and rejects unknown keys and density values
- state layer focus and pressed follow the library's m3 values
- bump dux-qml-common for density menu and file browser metrics

(cherry picked from commit 76025038df)
2026-10-27 20:49:36 -04:00
apollo79 3eab6a52e0 Add translation information and Discord to README (#3944)
(cherry picked from commit 473befd4ec)
2026-10-27 20:49:36 -04:00
apollo79 bb06467edf Workspace switcher widget improvements (#3977)
port 2.0

(cherry picked from commit a88be89444)
2026-10-28 00:47:03 +00:00
hecate cantus 726d9525c0 Fix/3958 lock placement atlas (#3960)
port 2.0

(cherry picked from commit ee6e941459)
2026-10-27 19:20:02 +00:00
bbedward 10008104ce lock: arm fingerprint auth after the compositor confirms the lock
port 2.0

(cherry picked from commit 2804183b7f)
2026-10-27 19:14:58 +00:00
Vladimir 0acaaa4b44 notifications: refresh relative times on the lock screen, in NotificationsTab and in history (#3965)
port 2.0

(cherry picked from commit 3d4ea22e73)
2026-10-27 18:07:35 +00:00
Matt Jiang 7ed525a679 fix(powermenu): show inactive EFI boot entries in reboot-to-OS picker (#3969)
port 2.0

(cherry picked from commit fdebf6d9e6)
2026-10-27 18:00:12 +00:00
bbedward 463df0167f notifications: use only SystemClock to update notification timestamps
port 2.0

(cherry picked from commit 5855b6ff77)
2026-10-27 17:33:58 +00:00
hecate cantus e6f0a5f219 Fix/3958 dock tooltip & app icon perf (#3962)
port 2.0

(cherry picked from commit fa62bf2868)
2026-10-27 17:14:48 +00:00
hecate cantus d2ad778e6f Fix/3958 workspace indicator icon perf (#3961)
port 2.0

(cherry picked from commit b2e1817aad)
2026-10-27 17:10:38 +00:00
bbedward 69fc822ffd cc: fix band expansion of footer items
port 2.0

(cherry picked from commit 2609b49846)
2026-10-27 17:02:33 +00:00
dux-ci[bot] 93d36ac7cd nix: sync flake.lock to dux-qml-common submodule 2026-10-27 16:48:25 +00:00
bbedward 3f75012b7c cc: replace detail pages with bands (1.6 style)
port 2.0

(cherry picked from commit 290360428e)
2026-10-27 16:48:01 +00:00
bbedward 433a84d824 matugen: don't wait on hooks
ref #3901
port 2.0

(cherry picked from commit 86dac60bc2)
2026-10-27 15:09:54 +00:00
bbedward b34a80b78f island: keep the dismiss layer down for notification popups
fixes #3915
port 2.0

(cherry picked from commit 5442f3923b)
2026-10-27 14:29:10 +00:00
bbedward 3dc1aed318 tray: resolve IconThemePath icons from theme subdirectories and the system theme
fixes #3953
port 2.0

(cherry picked from commit 6a15b44298)
2026-10-27 14:03:12 +00:00
bbedward f9c73a77cf settings: treat an unversioned settings file as the current format
fixes #3956
port 2.0

(cherry picked from commit 5cd0b61402)
2026-10-27 13:46:45 +00:00
bbedward a10ee7d3ed search field: spacing adjustments
port 2.0

(cherry picked from commit f56a088e44)
2026-10-27 13:39:36 +00:00
bbedward a2a18ed7db systemd: keep the notifications bus name reserved
port 2.0

(cherry picked from commit 826477e776)
2026-10-27 13:21:55 +00:00
hecate cantus 471318575f wallpaper: use the per-monitor fill mode to decide scrolling (#3957)
port 2.0

(cherry picked from commit 06e0ac182c)
2026-10-27 13:18:00 +00:00
Huỳnh Thiện Lộc 7d337602d2 fix(bar): use active color for workspace text in line style (#3968)
port 2.0

(cherry picked from commit e265e464bb)
2026-10-27 13:13:28 +00:00
bbedward 88fd192b0d launcher: fix grid display
port 2.0

(cherry picked from commit 8827aa0041)
2026-10-27 13:06:35 +00:00
bbedward 523ef916b9 launcher, dash, settings: density and consistency pass
port 2.0

(cherry picked from commit fc7c92f8b2)
2026-10-27 04:03:57 +00:00
bbedward 181e624c0d frame: keep fused chrome blurred across the inset at low alpha
fixes #3954
port 2.0

(cherry picked from commit ca5028411e)
2026-10-27 03:35:34 +00:00
purian23 7ea6f5f7e0 updater: confirm installs on enter & keeps the upgrade log in view when inline
Port 2.0

(cherry picked from commit a92e60ebef)
2026-10-27 03:21:53 +00:00
bbedward 28b9bb246b settings: keep the file snapshot independent of live values so in-place edits persist
fixes #3955
port 2.0

(cherry picked from commit 693d0c8bb7)
2026-10-27 02:59:54 +00:00
purian23 2f371015c4 sysupdate(flatpak): list updates when one needs an uninstalled runtime
Fixes #3950
Port 2.0

(cherry picked from commit a7bd6a8e06)
2026-10-27 02:56:58 +00:00
purian23 3f02a44f5e sysupdate: add a never option to the update check interval
Port 2.0

(cherry picked from commit 28d5d56028)
2026-10-27 02:55:41 +00:00
bbedward ef4b3ae53f popout: commit the emptied input region when the close starts
port 2.0

(cherry picked from commit 4a8fc4d4ba)
2026-10-27 01:01:11 +00:00
bbedward 3f0769f169 cava: stop the capture stream from dragging the pipewire quantum down
ref #810
port 2.0

(cherry picked from commit a3c4d0ab1a)
2026-10-26 23:51:54 +00:00
bbedward 6da523875f bump qml-common, fixes animated image binding loop
port 2.0

(cherry picked from commit d60fa2c86f)
2026-10-26 19:50:59 +00:00
Giacomo Di Clerico 994d5bb150 Bump Go to 1.27.3 (CVE-2026-111943) (#3924)
(cherry picked from commit 486de548e0)
2026-10-26 14:30:38 -04:00
bbedward 3d95e0e8b8 dbus: keep match rules across subscribe socket re-dials
port 2.0

(cherry picked from commit 3aaab84ba3)
2026-10-26 18:30:32 +00:00
bbedward d9e9cddae6 bump qml-common
(cherry picked from commit 6b32cbe40c)
2026-10-26 14:30:29 -04:00
Kadir fdb4f88aec network(networkmanager): refresh wired profiles on settings changes (#3929)
port 2.0

(cherry picked from commit cb20e898cd)
2026-10-26 18:18:40 +00:00
bbedward 2c0998f1dc compositor: prefer live wlr-output scale over the startup randr snapshot
port 2.0

(cherry picked from commit b0fea51132)
2026-10-26 17:59:41 +00:00
alvarosth 5ef5a7391b settings: enforce administrator-managed lock settings (#3897)
port 2.0

(cherry picked from commit 404c9672d7)
2026-10-26 17:41:50 +00:00
Huỳnh Thiện Lộc 4e0985fbbb fix(ddash): preserve tab active state during exit transition (#3928)
(cherry picked from commit c719ffede2)
2026-10-26 13:37:49 -04:00
Marlon Kranz 1979ccf886 matugen: keep user templates when config.toml has no [config] table (#3921)
(cherry picked from commit ce7dcd7439)
2026-10-26 13:37:40 -04:00
Marlon Kranz 2d7b65a8c3 tailscale: translate the peer "last seen" time (#3922)
(cherry picked from commit 0691242c9a)
2026-10-26 13:37:26 -04:00
Marlon Kranz 5a210112aa displays: show why the display profile controls are disabled while Auto is on (#3926)
(cherry picked from commit 0274b00787)
2026-10-26 13:37:18 -04:00
Huỳnh Thiện Lộc 260d4fda73 fix(wallpaper): apply overview blur to built-in backdrops (#3941)
port 2.0

(cherry picked from commit 5b54dafa61)
2026-10-26 15:52:28 +00:00
Huỳnh Thiện Lộc 290d04c511 fix(settings): show bar delete button when multiple bars exist (#3943)
port 2.0

(cherry picked from commit 3c89a1554b)
2026-10-26 15:50:57 +00:00
purian23 6d316539d7 installer: fall back to dankinstall assets until 2.0.1 & update arch package compat
Port 2.0

(cherry picked from commit e689cd2ce7)
2026-10-25 10:19:26 +00:00
purian23 defab4fbc8 rebrand(installer): convert dankinstall to duxinstall
(cherry picked from commit 6d65f304e5)
2026-10-25 04:20:03 -04:00
purian23 d2e191d642 ci: update dux-repo references
(cherry picked from commit 20bcf8559b)
2026-10-25 04:20:03 -04:00
purian23 fe1241bf91 ci: dispatch distro builds from Release & guard shared Void index
(cherry picked from commit 3388f78905)
2026-10-25 04:20:03 -04:00
purian23 6d00fd7295 feat(duxinstall): config support for immutable image-based hosts
- detect ostree/bootc/transactional hosts
- official dms doctor & dms command support
- updated installation instructions on dux labs docs

Port 2.0

(cherry picked from commit 696be97018)
2026-10-25 07:20:54 +00:00
purian23 1c54262b9f dep: bump qml-common
(cherry picked from commit c5b058c83e)
2026-10-24 04:17:25 -04:00
purian23 7622a6cd3c (deps): dux-go desktop fixes & qml-common
(cherry picked from commit 4d49e126ab)
2026-10-24 04:17:25 -04:00
Emile Schup 50ba96a279 battery popout: swap balanced and performance profile icons (#3895)
(cherry picked from commit 52d6a46d89)
2026-10-24 04:17:25 -04:00
bbedward 148c7f6240 docs: point arch and aur badges at dux-shell packages
(cherry picked from commit 2d16cc2f27)
2026-10-24 04:17:24 -04:00
bbedward 53b9bfa2a3 ci: disable background tasks in review job
(cherry picked from commit da79eca3c0)
2026-10-24 04:17:24 -04:00
bbedward 17f32f6c55 ci: cache go modules from core/go.sum, retry downloads
(cherry picked from commit 1fb9f5271a)
2026-10-24 04:17:24 -04:00
purian23 7c2b4228f6 refactor(default apps): rewrite w/value-keyed dropdowns & xdg data/display fixes
Port 2.0

(cherry picked from commit 66382dfa0d)
2026-10-24 07:48:56 +00:00
purian23 c831a9926a fix(clipboard): bind & release the data device when tracking is toggled
Port 2.0

(cherry picked from commit 7e0f110fb8)
2026-10-24 07:41:28 +00:00
bbedward 514c3e3eb8 lock: advertise hidden text from the ime sink
port 2.0

(cherry picked from commit 6f36e49c68)
2026-10-23 18:15:38 +00:00
Guillem CANAL 09449c0b37 idle: retry suspend when a logind sleep inhibitor is released (#3866)
(cherry picked from commit 668dde2a33)
2026-10-23 10:59:13 -04:00
bbedward 6c3a646dc4 bump qml-common
port 2.0

(cherry picked from commit b7be4a344a)
2026-10-23 14:58:15 +00:00
Huỳnh Thiện Lộc a680e1bd39 feat(screenshot): add island snap targets (#3881)
port 2.0

(cherry picked from commit 1e31015acb)
2026-10-23 14:51:30 +00:00
bbedward 36ebe0e3ef fix(apppicker): hide NoDisplay handlers except the shell's own
port 2.0

(cherry picked from commit 71417b7ca7)
2026-10-23 14:06:44 +00:00
bbedward 0102bf6d40 i18n: sync
(cherry picked from commit bf9a51c448)
2026-10-23 09:59:53 -04:00
bbedward dbf07ea644 i18n: sync
(cherry picked from commit 1a46db0517)
2026-10-23 09:59:53 -04:00
bbedward 58d741ec07 cc: allow sliders to shrink to 0.5 height
related #3880
port 2.0

(cherry picked from commit 6bd0e24bd4)
2026-10-23 13:55:41 +00:00
Matt Jiang 2befd45974 powermenu: reboot into firmware setup from the power menu (#3877)
port 2.0

(cherry picked from commit e50bb088b6)
2026-10-23 13:29:48 +00:00
purian23 99eb2a06d0 sysupdate: hide flatpak updates when flatpak is excluded
Port 2.0

(cherry picked from commit efd9324a4d)
2026-10-23 10:28:24 +00:00
purian23 163c4d5a1f refactor(dot): major performance & stability improvements
- Hyprland & Mango users will benefit most while niri gains solid QOL updates
- New icon picker in dot settings
- Dragging, flinging and opening the dot now run on one static surface so they stay smooth on every compositor

Port 2.0

(cherry picked from commit 0cd03e0180)
2026-10-23 05:49:47 -04:00
purian23 d4ecaf82d4 fix(mango): keep layer animations off dms surfaces
- Stops Mango tweening the dot and island when layer_animations is on
Port 2.0

(cherry picked from commit ba7b3620a8)
2026-10-23 08:41:12 +00:00
purian23 ec4e04f06f fix(core): prevent clipboard & wellbeing data loss from concurrent sessions
Port 2.0

(cherry picked from commit c7692f830c)
2026-10-23 05:42:29 +00:00
purian23 5aa2e3f0d4 systemd: start enabled service on restart when idle
Port 2.0

(cherry picked from commit 5a20a8f162)
2026-10-23 04:27:03 +00:00
bbedward 11f99cc13f displays: ceil logical output sizes on niri so neighbours never overlap
fixes #3875
port 2.0

(cherry picked from commit acc2c947e5)
2026-10-23 04:00:30 +00:00
purian23 0ae0b96885 refactor(mango): deprecate pre-mango v18.0 binds & legacy management
(cherry picked from commit d8734bbe1a)
2026-10-22 23:02:56 -04:00
Mario Mohar f0826a953f wallpaper: push the cycling config when the service is created (#3874)
port 2.0

(cherry picked from commit 900e6110d9)
2026-10-22 21:09:21 +00:00
bbedward b5da117206 bar: drop pill blur region when a vertical pill collapses to zero height
fixes #3873
port 2.0

(cherry picked from commit 44d498e362)
2026-10-22 21:01:24 +00:00
alvarosth 430db4e493 Add opt-in administrator lock restrictions (#3869)
(cherry picked from commit 7781c207f1)
2026-10-22 14:57:52 -04:00
bbedward 2a636c7092 shell: restore QT disable decoration pragma
(cherry picked from commit 5b7777785a)
2026-10-22 14:53:15 -04:00
bbedward 8848ad82f1 distro/freebsd: drop elogind references
(cherry picked from commit aeacaf7afc)
2026-10-22 14:53:07 -04:00
Vaibhav Pratap Singh 404d5362e5 battery: pulse only the icon when the bar has no background (#3861)
(cherry picked from commit 0aab0ec09b)
2026-10-22 14:52:58 -04:00
bbedward 4ab57f14b8 plugins: label declared permissions as unenforced
(cherry picked from commit 14b3ddaf83)
2026-10-22 09:50:36 -04:00
bbedward ce81dfc95a plugins: keep manifest paths inside the plugin directory
(cherry picked from commit 6e86b53ccd)
2026-10-22 09:50:29 -04:00
bbedward a5511fe8d7 greeter: remove the embedded legacy greeter (again)
(cherry picked from commit c5d1d4fe46)
2026-10-22 09:50:19 -04:00
purian23 7d930f8f6c distos: compare ubuntu published commit by prefix
(cherry picked from commit 39a58f77ec)
2026-10-22 09:50:12 -04:00
Anton Kesy 11ef76b9d0 docs: use dux project names in README (#3847)
(cherry picked from commit 4f969c8e10)
2026-10-22 09:50:03 -04:00
purian23 e48de4cc2a notifications: keep popup content while it exits after the notification closes
Port 2.0

(cherry picked from commit 47c9d8d89b)
2026-10-22 01:58:36 +00:00
bbedward 1683fceac5 bar: keep far-edge island hosts at sheet size so the bar never jumps on expand
fixes #3855
port 2.0

(cherry picked from commit d8786eaaf7)
2026-10-22 01:57:39 +00:00
bbedward 2a83c5c69b battery: throttle pulse animation to 20fps
ref #3849

(cherry picked from commit 1b59a474dd)
2026-10-21 14:16:02 -04:00
bbedward 1acfae1755 dash: hold panel height while dragging cards unless hung from a top bar
lyrics: ignore floored position samples and shell refresh emits

(cherry picked from commit 28f921fa02)
2026-10-21 13:19:05 -04:00
Adarsh 1e1dbebb70 Theme Antigravity IDE via the VS Code extension (#3836)
(cherry picked from commit 479b1434de)
2026-10-21 13:18:58 -04:00
bbedward 53412d1f22 theme: publish typography for dux apps
(cherry picked from commit 8f2a486006)
2026-10-21 12:42:02 -04:00
bbedward 9ece8f26fa theme: skip matugen runs whose inputs match the last applied run
fixes #3841

(cherry picked from commit afeea489c9)
2026-10-21 11:25:29 -04:00
bbedward fd1295f959 core: only use the simple animation driver for the scrolling wallpaper
fixes #3837

(cherry picked from commit 27a703367f)
2026-10-21 11:25:20 -04:00
bbedward 1e3f16ac9d bar: slide an auto-hidden island by its compact height
fixes #3838

(cherry picked from commit 8e851b3dc4)
2026-10-21 11:25:11 -04:00
bbedward 4fa6f58720 workspaces: keep the pill highlight while hovering its app icons
fixes #3839

(cherry picked from commit 112d05901f)
2026-10-21 11:25:02 -04:00
Marlon Kranz ff45b3b4b2 fix(notifications): close popups when their screen stops showing them (#3844)
(cherry picked from commit b81954454a)
2026-10-21 11:24:49 -04:00
Tomás 12e54e0225 Keep MangoWM workspace apps on their own monitor (#3832)
port 2.0

(cherry picked from commit 203b1369b3)
2026-10-21 11:24:35 -04:00
bbedward f63d8bf4fb niri: only emit displayScales when a scale changed
ref #3842
port 2.0

(cherry picked from commit 90d3c9731c)
2026-10-21 15:18:54 +00:00
Leonard 1e8423992d refactor(ui): match the dock settings page with the bar settings page (#3829)
port 2.0

(cherry picked from commit 5ffc581ecf)
2026-10-21 00:54:58 +00:00
bbedward c4ecb988b4 matugen: neovim template reads the dms config dir"
port 2.0

(cherry picked from commit 3284acab28)
2026-10-20 21:03:37 +00:00
557 changed files with 22517 additions and 15765 deletions

No files matched your search

@@ -93,7 +93,7 @@ Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`, `dashHost` (`
Base provides: `options`, `pluginData`, `getData(key, def)`, `setData(key, value)`. Base provides: `options`, `pluginData`, `getData(key, def)`, `setData(key, value)`.
Optional: `implicitHeight`, `focusTarget`, `restoreFocus()`, `handleKeyEvent(event)` (return true to consume; Ctrl+Tab is reserved), `blocksTabNavigation`, `menuActions`, `signal tabRequested(string id)`, `signal navFocusRequested`. Optional: `implicitHeight`, `focusTarget`, `restoreFocus()`, `handleKeyEvent(event)` (return true to consume; Ctrl+Tab and Ctrl+PgUp/PgDown are reserved), `blocksTabNavigation`, `menuActions`, `signal tabRequested(string id)`, `signal navFocusRequested`.
For tiles inside a tab, use `Card` from `qs.Modules.DankDash.Overview` so colors, radius and tones match the overview. For tiles inside a tab, use `Card` from `qs.Modules.DankDash.Overview` so colors, radius and tones match the overview.
+1 -1
View File
@@ -47,7 +47,7 @@ body:
attributes: attributes:
label: Select your Installation Method label: Select your Installation Method
options: options:
- DankInstaller - Dux Installer
- Distro Packaging - Distro Packaging
- Source - Source
validations: validations:
+1 -1
View File
@@ -54,7 +54,7 @@ body:
attributes: attributes:
label: Select your Installation Method label: Select your Installation Method
options: options:
- DankInstaller - Dux Installer
- Distro Packaging - Distro Packaging
- Source - Source
validations: validations:
+1 -1
View File
@@ -28,4 +28,4 @@
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible - [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean - [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
- [ ] QML changes: ran `make lint-qml` with no new warnings - [ ] QML changes: ran `make lint-qml` with no new warnings
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/duxlabs-dev/dux-docs - [ ] I have opened a corresponding pull request in dux-docs to document any new behaviors: https://github.com/duxlabs-dev/dux-docs
+125 -9
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Shared by dux-shell and dux-repo, which both write dms/current; keep both copies identical.
# ETag guard: publish aborts if another writer replaced x86_64-repodata mid-run.
set -euo pipefail set -euo pipefail
usage() { usage() {
echo "usage: $0 download|publish" >&2 echo "usage: $0 download|is-indexed|index|publish" >&2
exit 2 exit 2
} }
@@ -19,13 +21,54 @@ require_env() {
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
REPODATA_KEY="${R2_PREFIX}/current/x86_64-repodata"
ETAG_STATE="$REPOSITORY_DIR/.repodata-etag"
aws_r2() { aws_r2() {
aws --endpoint-url "$R2_ENDPOINT" "$@" aws --endpoint-url "$R2_ENDPOINT" "$@"
} }
# Print the remote repodata ETag, or "none" when the object does not exist yet.
remote_repodata_etag() {
local out
if out="$(aws_r2 s3api head-object --bucket "$R2_BUCKET" --key "$REPODATA_KEY" \
--query ETag --output text 2>&1)"; then
printf '%s\n' "$out"
elif grep -qiE 'Not Found|404' <<<"$out"; then
echo none
else
printf '%s\n' "$out" >&2
return 1
fi
}
record_repodata_etag() {
remote_repodata_etag > "$ETAG_STATE"
}
# Abort when another publisher replaced the index since we recorded it.
assert_repodata_unchanged() {
local stage="$1" expected actual
if [[ ! -s "$ETAG_STATE" ]]; then
echo "error: $ETAG_STATE is missing; run '$0 download' before publishing" >&2
exit 1
fi
expected="$(<"$ETAG_STATE")"
actual="$(remote_repodata_etag)"
if [[ "$expected" != "$actual" ]]; then
echo "error: ${REPODATA_KEY} changed while this run was building ($stage)" >&2
echo " recorded ETag: $expected" >&2
echo " current ETag: $actual" >&2
echo "another publisher won the race; nothing was overwritten. Re-run to retry." >&2
exit 1
fi
}
download() { download() {
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current" mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
# ETag must be taken before the sync so a mid-sync publish is detected.
record_repodata_etag
aws_r2 s3 sync \ aws_r2 s3 sync \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \ "s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
"$REPOSITORY_DIR/current/" \ "$REPOSITORY_DIR/current/" \
@@ -34,8 +77,62 @@ download() {
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/" cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
} }
# Staged pkgvers count as indexed too.
indexed_pkgvers() {
local repodata="$REPOSITORY_DIR/current/x86_64-repodata" member
[[ -s "$repodata" ]] || return 0
for member in index.plist stage.plist; do
tar -xOf "$repodata" "$member" 2>/dev/null | python3 -c '
import plistlib, sys
data = sys.stdin.buffer.read()
for pkg in (plistlib.loads(data).values() if data.strip() else ()):
print(pkg["pkgver"])
' || true
done
}
# Also catches packages uploaded by a run that died before its repodata PUT.
is_indexed() {
local known file
shopt -s nullglob
known="$(indexed_pkgvers)"
for file in "$REPOSITORY_DIR"/current/*.xbps; do
if ! grep -qxF "$(xbps-uhelper binpkgver "$(basename "$file")")" <<<"$known"; then
echo "$(basename "$file") is not in ${R2_PREFIX}/current/x86_64-repodata"
return 1
fi
done
}
index() {
require_env XBPS_PRIVATE_KEY
local dir="$REPOSITORY_DIR/current" sig
shopt -s nullglob
local pkgs=("$dir"/*.xbps)
if (( ${#pkgs[@]} == 0 )); then
echo "error: no .xbps files to index in $dir" >&2
exit 1
fi
rm -f "$dir"/*.sig
xbps-rindex -a "${pkgs[@]}"
xbps-rindex --remove-obsoletes "$dir"
# Unchanged packages keep their .sig2 byte-for-byte; drop those of removed packages.
for sig in "$dir"/*.sig2; do
[[ -f "${sig%.sig2}" ]] || rm -f "$sig"
done
XBPS_KEY_FILE="$(mktemp)"
trap 'rm -f "$XBPS_KEY_FILE"' EXIT
printf '%s\n' "$XBPS_PRIVATE_KEY" > "$XBPS_KEY_FILE"
xbps-rindex --sign --signedby "AvengeMedia <[email protected]>" --privkey "$XBPS_KEY_FILE" "$dir"
xbps-rindex --sign-pkg --privkey "$XBPS_KEY_FILE" "$dir"/*.xbps
}
build_manifest() { build_manifest() {
local packages='[]' local packages='[]' previous_sources='{}'
local file filename pkgver name version revision sha size local file filename pkgver name version revision sha size
for file in "$REPOSITORY_DIR"/current/*.xbps; do for file in "$REPOSITORY_DIR"/current/*.xbps; do
@@ -58,12 +155,21 @@ build_manifest() {
<<<"$packages")" <<<"$packages")"
done done
# `sources` maps publishing repo -> last published commit; `source_commit` kept for older consumers.
if [[ -s "$REPOSITORY_DIR/previous-current/manifest.json" ]]; then
previous_sources="$(jq -c '.sources // {}' "$REPOSITORY_DIR/previous-current/manifest.json" 2>/dev/null || echo '{}')"
fi
jq -n \ jq -n \
--arg repository "$R2_PREFIX" \ --arg repository "$R2_PREFIX" \
--arg source_repository "$SOURCE_REPOSITORY" \
--arg source_commit "$SOURCE_COMMIT" \ --arg source_commit "$SOURCE_COMMIT" \
--arg published_at "$PUBLISHED_AT" \ --arg published_at "$PUBLISHED_AT" \
--argjson previous_sources "$previous_sources" \
--argjson packages "$packages" \ --argjson packages "$packages" \
'{schema: 1, repository: $repository, source_commit: $source_commit, published_at: $published_at, packages: $packages}' \ '{schema: 1, repository: $repository, source_repository: $source_repository, source_commit: $source_commit,
sources: ($previous_sources + {($source_repository): $source_commit}),
published_at: $published_at, packages: $packages}' \
> "$REPOSITORY_DIR/current/manifest.json" > "$REPOSITORY_DIR/current/manifest.json"
} }
@@ -110,11 +216,16 @@ upload_current() {
--only-show-errors --only-show-errors
done done
aws_r2 s3 cp \ # Orphaned .xbps from an aborted run are re-indexed by the next run.
"$REPOSITORY_DIR/current/x86_64-repodata" \ assert_repodata_unchanged "before uploading repodata"
"s3://${R2_BUCKET}/${R2_PREFIX}/current/x86_64-repodata" \
# Keep the ETag of our own PUT for the final check.
aws_r2 s3api put-object \
--bucket "$R2_BUCKET" \
--key "$REPODATA_KEY" \
--body "$REPOSITORY_DIR/current/x86_64-repodata" \
--cache-control 'no-cache' \ --cache-control 'no-cache' \
--only-show-errors --query ETag --output text > "$ETAG_STATE"
# The manifest is the publication marker and is always uploaded last. # The manifest is the publication marker and is always uploaded last.
aws_r2 s3 cp \ aws_r2 s3 cp \
@@ -123,7 +234,8 @@ upload_current() {
--cache-control 'no-cache' \ --cache-control 'no-cache' \
--only-show-errors --only-show-errors
# Once the new index and marker are live, remove objects no longer referenced. # Remove retired objects only if the live index is still ours.
assert_repodata_unchanged "before removing retired objects"
shopt -s nullglob shopt -s nullglob
for old in "$REPOSITORY_DIR"/previous-current/*; do for old in "$REPOSITORY_DIR"/previous-current/*; do
filename="$(basename "$old")" filename="$(basename "$old")"
@@ -137,8 +249,9 @@ upload_current() {
publish() { publish() {
require_env SOURCE_COMMIT require_env SOURCE_COMMIT
SOURCE_REPOSITORY="${SOURCE_REPOSITORY:-${GITHUB_REPOSITORY:-unknown}}"
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}" PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
export PUBLISHED_AT export PUBLISHED_AT SOURCE_REPOSITORY
shopt -s nullglob shopt -s nullglob
local packages=("$REPOSITORY_DIR"/current/*.xbps) local packages=("$REPOSITORY_DIR"/current/*.xbps)
@@ -157,6 +270,7 @@ publish() {
} }
done done
assert_repodata_unchanged "before publishing"
verify_immutable_packages verify_immutable_packages
build_manifest build_manifest
archive_retired archive_retired
@@ -165,6 +279,8 @@ publish() {
case "${1:-}" in case "${1:-}" in
download) download ;; download) download ;;
is-indexed) is_indexed ;;
index) index ;;
publish) publish ;; publish) publish ;;
*) usage ;; *) usage ;;
esac esac
+3
View File
@@ -71,6 +71,9 @@ jobs:
run: echo "base=$(gh pr view ${{ github.event.issue.number }} --json baseRefName --jq .baseRefName)" >> "$GITHUB_OUTPUT" run: echo "base=$(gh pr view ${{ github.event.issue.number }} --json baseRefName --jq .baseRefName)" >> "$GITHUB_OUTPUT"
- uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507 - uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507
env:
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
CLAUDE_CODE_EFFORT_LEVEL: high
id: claude id: claude
with: with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+2 -2
View File
@@ -63,8 +63,8 @@ jobs:
- name: Build dms (distropkg) - name: Build dms (distropkg)
run: go build -v -tags 'distro_binary withshell' ./cmd/dms run: go build -v -tags 'distro_binary withshell' ./cmd/dms
- name: Build dankinstall - name: Build duxinstall
run: go build -v ./cmd/dankinstall run: go build -v ./cmd/duxinstall
build-freebsd: build-freebsd:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1 -1
View File
@@ -103,5 +103,5 @@ jobs:
{ {
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched" echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
echo "" echo ""
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published." echo "Distro builds (COPR/OBS/PPA/XBPS) are dispatched by the Release workflow once the release is published."
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
+81
View File
@@ -0,0 +1,81 @@
name: PR Labels
# pull_request_target: never check out PR code here, the token has write access
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: pr-labels-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const body = pr.body || "";
const checked = [...body.matchAll(/^\s*-\s*\[[xX]\]\s*(.+)$/gm)].map(m => m[1].trim());
const typeOfChange = {
"Bug fix": "fix",
"New feature": "feature",
"Breaking change": "breaking",
"Refactor / internal cleanup": "internal",
"Documentation": "docs",
};
const labels = new Set(
Object.entries(typeOfChange)
.filter(([item]) => checked.some(line => line.startsWith(item)))
.map(([, label]) => label)
);
const files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo,
pull_number: pr.number,
per_page: 100,
});
const paths = files.map(f => f.filename);
const pathRules = [
["packaging", p => p.startsWith("distro/") || p === "flake.nix" || p === "flake.lock"],
["i18n", p => p.startsWith("quickshell/translations/poexports/")],
["core", p => p.startsWith("core/")],
["ui", p => p.startsWith("quickshell/") && !p.startsWith("quickshell/translations/")],
];
for (const [label, matches] of pathRules) {
if (paths.some(matches)) labels.add(label);
}
const existing = new Set(pr.labels.map(l => l.name));
const missing = [...labels].filter(l => !existing.has(l));
if (missing.length === 0) return core.info("nothing to add");
const areaLabels = {
core: ["Go daemon and CLI under core/", "1d76db"],
ui: ["Quickshell UI under quickshell/", "c5def5"],
};
for (const name of missing.filter(l => l in areaLabels)) {
const [description, color] = areaLabels[name];
try {
await github.rest.issues.getLabel({ ...context.repo, name });
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({ ...context.repo, name, description, color });
}
}
await github.rest.issues.addLabels({
...context.repo,
issue_number: pr.number,
labels: missing,
});
core.info(`added: ${missing.join(", ")}`);
+5
View File
@@ -26,6 +26,11 @@ jobs:
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: core/go.mod go-version-file: core/go.mod
cache-dependency-path: core/go.sum
- name: Download Go modules
working-directory: core
run: for attempt in 1 2 3; do go mod download && exit 0; sleep 15; done; exit 1
- name: run pre-commit hooks - name: run pre-commit hooks
uses: j178/prek-action@v2 uses: j178/prek-action@v2
+1
View File
@@ -3,6 +3,7 @@ name: Prune Void R2 archive
# Keeps the danklinux-void bucket inside the Cloudflare R2 free tier by # Keeps the danklinux-void bucket inside the Cloudflare R2 free tier by
# deleting the oldest archive/<repo>/<timestamp>/ snapshots written by # deleting the oldest archive/<repo>/<timestamp>/ snapshots written by
# publish-void-r2.sh. Live repositories (<repo>/current/) are never touched. # publish-void-r2.sh. Live repositories (<repo>/current/) are never touched.
# <repo> comes from the listing, so new and legacy prefixes need no list.
on: on:
schedule: schedule:
+50 -13
View File
@@ -73,6 +73,7 @@ jobs:
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: core/go.sum
- name: Format check - name: Format check
if: matrix.goos == 'linux' if: matrix.goos == 'linux'
@@ -87,7 +88,7 @@ jobs:
if: matrix.goos == 'linux' if: matrix.goos == 'linux'
run: go test -v ./... run: go test -v ./...
- name: Build dankinstall (${{ env.ASSET }}) - name: Build duxinstall (${{ env.ASSET }})
if: matrix.goos == 'linux' if: matrix.goos == 'linux'
env: env:
GOOS: ${{ matrix.goos }} GOOS: ${{ matrix.goos }}
@@ -95,12 +96,12 @@ jobs:
GOARCH: ${{ matrix.arch }} GOARCH: ${{ matrix.arch }}
run: | run: |
set -eux set -eux
cd cmd/dankinstall cd cmd/duxinstall
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \ go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dankinstall-${{ env.ASSET }} -o ../../duxinstall-${{ env.ASSET }}
cd ../.. cd ../..
gzip -9 -k dankinstall-${{ env.ASSET }} gzip -9 -k duxinstall-${{ env.ASSET }}
sha256sum dankinstall-${{ env.ASSET }}.gz > dankinstall-${{ env.ASSET }}.gz.sha256 sha256sum duxinstall-${{ env.ASSET }}.gz > duxinstall-${{ env.ASSET }}.gz.sha256
- name: Sync embedded shell - name: Sync embedded shell
run: make sync-shell run: make sync-shell
@@ -148,8 +149,8 @@ jobs:
with: with:
name: core-assets-${{ env.ASSET }} name: core-assets-${{ env.ASSET }}
path: | path: |
core/dankinstall-${{ env.ASSET }}.gz core/duxinstall-${{ env.ASSET }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256 core/duxinstall-${{ env.ASSET }}.gz.sha256
core/dms-${{ env.ASSET }}.gz core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256 core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz core/dms-distropkg-${{ env.ASSET }}.gz
@@ -174,8 +175,8 @@ jobs:
with: with:
name: core-assets-${{ env.ASSET }} name: core-assets-${{ env.ASSET }}
path: | path: |
core/dankinstall-${{ env.ASSET }}.gz core/duxinstall-${{ env.ASSET }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256 core/duxinstall-${{ env.ASSET }}.gz.sha256
core/dms-${{ env.ASSET }}.gz core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256 core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz core/dms-distropkg-${{ env.ASSET }}.gz
@@ -247,6 +248,7 @@ jobs:
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: core/go.sum
- name: Download core artifacts - name: Download core artifacts
uses: actions/download-artifact@v5 uses: actions/download-artifact@v5
@@ -300,8 +302,8 @@ jobs:
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems - **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
- **`dms-distropkg-freebsd-amd64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD x86_64 systems - **`dms-distropkg-freebsd-amd64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD x86_64 systems
- **`dms-distropkg-freebsd-arm64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD ARM64 systems - **`dms-distropkg-freebsd-arm64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD ARM64 systems
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems - **`duxinstall-amd64.gz`** - Installer binary for x86_64 systems
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems - **`duxinstall-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging) - **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
- **`dms-qml.tar.gz`** - QML source code only - **`dms-qml.tar.gz`** - QML source code only
- **`dms-source.tar.gz`** - Full repository source with bundled DCommon (for distro packaging) - **`dms-source.tar.gz`** - Full repository source with bundled DCommon (for distro packaging)
@@ -346,8 +348,8 @@ jobs:
fi fi
done done
# Copy dankinstall binaries # Copy duxinstall binaries
cp _core_assets/dankinstall-*.gz* _release_assets/ cp _core_assets/duxinstall-*.gz* _release_assets/
# Copy completions # Copy completions
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
@@ -557,3 +559,38 @@ jobs:
set -euo pipefail set -euo pipefail
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG" gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY" echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
# Build the stable dms package on every distro channel. Runs after the
# release job so every asset the packaging scripts download already exists.
distros:
name: Dispatch ${{ matrix.workflow }}
runs-on: ubuntu-latest
needs: [release]
if: ${{ !contains(inputs.tag, '-') }}
strategy:
fail-fast: false
matrix:
include:
- workflow: run-copr.yml
args: -f package=dms -f version=__VERSION__ -f release=1
- workflow: run-obs.yml
args: -f package=dms
- workflow: run-ppa.yml
args: -f package=dms
- workflow: run-xbps.yml
args: -f version=__TAG__ -f build_dms=true -f build_git=false
steps:
- name: Dispatch packaging workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WORKFLOW: ${{ matrix.workflow }}
ARGS: ${{ matrix.args }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
ARGS="${ARGS//__VERSION__/$VERSION}"
ARGS="${ARGS//__TAG__/$TAG}"
# shellcheck disable=SC2086
gh workflow run "$WORKFLOW" --repo "${{ github.repository }}" $ARGS
echo "dispatched ${WORKFLOW} for ${TAG} (${ARGS})" >> "$GITHUB_STEP_SUMMARY"
+77 -121
View File
@@ -18,185 +18,141 @@ on:
description: 'Release number (e.g., 1, 2, 3 for hotfixes)' description: 'Release number (e.g., 1, 2, 3 for hotfixes)'
required: false required: false
default: '1' default: '1'
owners:
description: 'Copr owners to publish to'
required: false
default: 'all'
type: choice
options:
- all
- duxlabs
- avengemedia
jobs: jobs:
determine-packages: plan:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
packages: ${{ steps.set-packages.outputs.packages }} matrix: ${{ steps.plan.outputs.matrix }}
version: ${{ steps.plan.outputs.version }}
release: ${{ steps.plan.outputs.release }}
steps: steps:
- name: Set package list - name: Plan owners and version
id: set-packages id: plan
env:
OWNERS: ${{ inputs.owners || 'all' }}
PACKAGE: ${{ inputs.package || 'dms' }}
VERSION: ${{ inputs.version }}
RELEASE: ${{ inputs.release || '1' }}
run: | run: |
PACKAGE_INPUT="${{ github.event.inputs.package || 'dms' }}" MATRIX=$(jq -cn --arg owners "$OWNERS" --arg pkg "$PACKAGE" '
echo "packages=[\"$PACKAGE_INPUT\"]" >> $GITHUB_OUTPUT [{owner: "duxlabs", suffix: ""}, {owner: "avengemedia", suffix: "_LEGACY"}]
| map(select($owners == "all" or .owner == $owners) | .package = $pkg)
| {include: .}')
if [ -z "$VERSION" ]; then
VERSION=$(curl -sL "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
fi
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
echo "::error::Could not determine the latest release of ${{ github.repository }}"
exit 1
fi
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release=$RELEASE" >> "$GITHUB_OUTPUT"
echo "Building $PACKAGE $VERSION-$RELEASE for: $(jq -r '[.include[].owner] | join(", ")' <<< "$MATRIX")"
build-and-upload: build-and-upload:
needs: determine-packages name: ${{ matrix.package }} to ${{ matrix.owner }}
needs: plan
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: fail-fast: false
package: ${{ fromJSON(needs.determine-packages.outputs.packages) }} matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
env:
VERSION: ${{ needs.plan.outputs.version }}
RELEASE: ${{ needs.plan.outputs.release }}
PACKAGE: ${{ matrix.package }}
COPR_PROJECT: ${{ matrix.owner }}/dms
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Determine version
id: version
run: |
# Get version from manual input or latest release
if [ -n "${{ github.event.inputs.version }}" ]; then
VERSION="${{ github.event.inputs.version }}"
echo "Using manual version: $VERSION"
else
VERSION=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name' | sed 's/^v//')
echo "Using latest release version: $VERSION"
fi
RELEASE="${{ github.event.inputs.release }}"
if [ -z "$RELEASE" ]; then
RELEASE="1"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "release=$RELEASE" >> $GITHUB_OUTPUT
echo "✅ Building ${{ matrix.package }} version: $VERSION-$RELEASE"
- name: Setup build environment - name: Setup build environment
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y rpm wget curl jq gzip sudo apt-get install -y rpm wget curl jq gzip python3-pip
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
echo "✅ RPM build environment ready"
- name: Download release assets - name: Download release assets
run: | run: |
VERSION="${{ steps.version.outputs.version }}"
cd ~/rpmbuild/SOURCES cd ~/rpmbuild/SOURCES
wget "https://github.com/${{ github.repository }}/releases/download/v${VERSION}/dms-qml.tar.gz" || {
echo "📦 Downloading DMS QML source for v${VERSION}..." echo "::error::Failed to download dms-qml.tar.gz for v${VERSION}"
# Download DMS QML source
wget "https://github.com/duxlabs-dev/dux-shell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}"
exit 1 exit 1
} }
echo "✅ Source downloaded"
echo "Note: dms-cli binary will be downloaded during build based on target architecture"
ls -lh ls -lh
- name: Generate stable spec file - name: Generate stable spec file
run: | run: |
VERSION="${{ steps.version.outputs.version }}" SPEC=~/rpmbuild/SPECS/${PACKAGE}.spec
RELEASE="${{ steps.version.outputs.release }}" cp "distro/fedora/${PACKAGE}.spec" "$SPEC"
CHANGELOG_DATE="$(date '+%a %b %d %Y')" sed -i -e "s/VERSION_PLACEHOLDER/${VERSION}/g" \
PACKAGE="${{ matrix.package }}" -e "s/RELEASE_PLACEHOLDER/${RELEASE}/g" \
-e "s/CHANGELOG_DATE_PLACEHOLDER/$(date '+%a %b %d %Y')/g" "$SPEC"
# Copy spec file from repository head -40 "$SPEC"
cp distro/fedora/${PACKAGE}.spec ~/rpmbuild/SPECS/${PACKAGE}.spec
# Replace placeholders with actual values
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
sed -i "s/RELEASE_PLACEHOLDER/${RELEASE}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
echo "✅ Spec file generated for ${PACKAGE} v${VERSION}-${RELEASE}"
echo ""
echo "=== Spec file preview ==="
head -40 ~/rpmbuild/SPECS/${PACKAGE}.spec
- name: Build SRPM - name: Build SRPM
id: build id: build
run: | run: |
cd ~/rpmbuild/SPECS rpmbuild -bs ~/rpmbuild/SPECS/${PACKAGE}.spec
PACKAGE="${{ matrix.package }}"
echo "🔨 Building SRPM for ${PACKAGE}..."
rpmbuild -bs ${PACKAGE}.spec
SRPM=$(ls ~/rpmbuild/SRPMS/${PACKAGE}-*.src.rpm | tail -n 1) SRPM=$(ls ~/rpmbuild/SRPMS/${PACKAGE}-*.src.rpm | tail -n 1)
SRPM_NAME=$(basename "$SRPM") echo "srpm_path=$SRPM" >> "$GITHUB_OUTPUT"
echo "srpm_name=$(basename "$SRPM")" >> "$GITHUB_OUTPUT"
echo "srpm_path=$SRPM" >> $GITHUB_OUTPUT
echo "srpm_name=$SRPM_NAME" >> $GITHUB_OUTPUT
echo "✅ SRPM built: $SRPM_NAME"
echo ""
echo "=== SRPM Info ==="
rpm -qpi "$SRPM" rpm -qpi "$SRPM"
# Per-owner name: two legs uploading the same artifact name get a 409.
- name: Upload SRPM artifact - name: Upload SRPM artifact
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
with: with:
name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }} name: ${{ matrix.package }}-stable-srpm-${{ needs.plan.outputs.version }}-${{ matrix.owner }}
path: ${{ steps.build.outputs.srpm_path }} path: ${{ steps.build.outputs.srpm_path }}
retention-days: 90 retention-days: 90
- name: Install Copr CLI - name: Install Copr CLI
env:
COPR_LOGIN: ${{ secrets[format('COPR_LOGIN{0}', matrix.suffix)] }}
COPR_TOKEN: ${{ secrets[format('COPR_TOKEN{0}', matrix.suffix)] }}
run: | run: |
sudo apt-get install -y python3-pip : "${COPR_LOGIN:?COPR_LOGIN${{ matrix.suffix }} is not set}" "${COPR_TOKEN:?COPR_TOKEN${{ matrix.suffix }} is not set}"
pip3 install copr-cli pip3 install copr-cli
mkdir -p ~/.config mkdir -p ~/.config
cat > ~/.config/copr << EOF umask 077
[copr-cli] printf '[copr-cli]\nlogin = %s\nusername = %s\ntoken = %s\ncopr_url = https://copr.fedorainfracloud.org\n' \
login = ${{ secrets.COPR_LOGIN }} "$COPR_LOGIN" "${{ matrix.owner }}" "$COPR_TOKEN" > ~/.config/copr
username = avengemedia
token = ${{ secrets.COPR_TOKEN }}
copr_url = https://copr.fedorainfracloud.org
EOF
chmod 600 ~/.config/copr
echo "✅ Copr CLI configured"
- name: Determine Copr project
id: copr_project
run: |
PACKAGE="${{ matrix.package }}"
if [ "$PACKAGE" = "dms" ]; then
COPR_PROJECT="avengemedia/dms"
else
echo "❌ Unknown package: $PACKAGE"
exit 1
fi
echo "copr_project=$COPR_PROJECT" >> $GITHUB_OUTPUT
echo "✅ Copr project: $COPR_PROJECT"
- name: Upload to Copr - name: Upload to Copr
run: | run: |
SRPM="${{ steps.build.outputs.srpm_path }}" SRPM="${{ steps.build.outputs.srpm_path }}"
VERSION="${{ steps.version.outputs.version }}" echo "Uploading $(basename "$SRPM") to ${COPR_PROJECT}"
COPR_PROJECT="${{ steps.copr_project.outputs.copr_project }}"
PACKAGE="${{ matrix.package }}"
echo "🚀 Uploading ${PACKAGE} SRPM to ${COPR_PROJECT}..."
echo " SRPM: $(basename $SRPM)"
echo " Version: $VERSION"
BUILD_OUTPUT=$(copr-cli build "$COPR_PROJECT" "$SRPM" --nowait 2>&1) BUILD_OUTPUT=$(copr-cli build "$COPR_PROJECT" "$SRPM" --nowait 2>&1)
echo "$BUILD_OUTPUT" echo "$BUILD_OUTPUT"
BUILD_ID=$(echo "$BUILD_OUTPUT" | grep -oP 'Build was added to.*\K[0-9]+' || echo "unknown") BUILD_ID=$(echo "$BUILD_OUTPUT" | grep -oP 'Build was added to.*\K[0-9]+' || echo "unknown")
if [ "$BUILD_ID" != "unknown" ]; then if [ "$BUILD_ID" != "unknown" ]; then
echo "✅ Build submitted successfully!" echo "https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/build/$BUILD_ID/"
echo "🔗 https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/build/$BUILD_ID/"
else else
echo "⚠️ Could not extract build ID, but upload may have succeeded" echo "::warning::Could not extract build ID, but upload may have succeeded"
fi fi
- name: Build summary - name: Build summary
if: always() if: always()
run: | run: |
PACKAGE="${{ matrix.package }}" {
COPR_PROJECT="${{ steps.copr_project.outputs.copr_project }}" echo "### ${PACKAGE} stable to ${{ matrix.owner }}"
echo "### 🎉 ${PACKAGE} Stable Build Summary" >> $GITHUB_STEP_SUMMARY echo ""
echo "" >> $GITHUB_STEP_SUMMARY echo "- **Version:** ${VERSION}-${RELEASE}"
echo "- **Package:** ${PACKAGE}" >> $GITHUB_STEP_SUMMARY echo "- **SRPM:** ${{ steps.build.outputs.srpm_name }}"
echo "- **Version:** ${{ steps.version.outputs.version }}-${{ steps.version.outputs.release }}" >> $GITHUB_STEP_SUMMARY echo "- **Project:** https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/"
echo "- **SRPM:** ${{ steps.build.outputs.srpm_name }}" >> $GITHUB_STEP_SUMMARY } >> "$GITHUB_STEP_SUMMARY"
echo "- **Project:** https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Stable release has been built and uploaded to Copr!" >> $GITHUB_STEP_SUMMARY
+86 -88
View File
@@ -16,6 +16,15 @@ on:
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)" description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
required: false required: false
default: "" default: ""
owners:
description: "OBS owners to publish to"
required: false
default: "all"
type: choice
options:
- all
- duxlabs
- avengemedia
schedule: schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) - cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
@@ -27,7 +36,7 @@ jobs:
outputs: outputs:
has_updates: ${{ steps.check.outputs.has_updates }} has_updates: ${{ steps.check.outputs.has_updates }}
packages: ${{ steps.check.outputs.packages }} matrix: ${{ steps.check.outputs.matrix }}
version: ${{ steps.check.outputs.version }} version: ${{ steps.check.outputs.version }}
steps: steps:
@@ -40,13 +49,13 @@ jobs:
- name: Check for updates - name: Check for updates
id: check id: check
env: env:
OBS_USERNAME: ${{ secrets.OBS_USERNAME }} # duxlabs is dispatch-only until its OBS account exists; drop this gate then
OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }} OWNERS: ${{ github.event_name == 'workflow_dispatch' && inputs.owners || 'avengemedia' }}
run: | run: |
# Helper function to check dms-git commit # The public source API needs no credentials, so one job checks every owner
check_dms_git() { check_dms_git() {
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD) local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms-git/dms-git/dms-git.spec" 2>/dev/null || echo "") local OBS_SPEC=$(curl -s "https://api.opensuse.org/public/source/$1:dms-git/dms-git/dms-git.spec" 2>/dev/null || echo "")
local OBS_COMMIT=$(echo "$OBS_SPEC" | grep "^Version:" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "") local OBS_COMMIT=$(echo "$OBS_SPEC" | grep "^Version:" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
if [[ -n "$OBS_COMMIT" && "$CURRENT_COMMIT" == "$OBS_COMMIT" ]]; then if [[ -n "$OBS_COMMIT" && "$CURRENT_COMMIT" == "$OBS_COMMIT" ]]; then
@@ -58,14 +67,13 @@ jobs:
fi fi
} }
# Helper function to check dms stable tag
# Sets LATEST_TAG variable in parent scope if update needed # Sets LATEST_TAG variable in parent scope if update needed
check_dms_stable() { check_dms_stable() {
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") LATEST_TAG=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "") local OBS_SPEC=$(curl -s "https://api.opensuse.org/public/source/$1:dms/dms/dms.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "") local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$OBS_VERSION" ]]; then if [[ -n "$LATEST_TAG" && "${LATEST_TAG#v}" == "$OBS_VERSION" ]]; then
echo "📋 dms: Tag $LATEST_TAG already exists, skipping" echo "📋 dms: Tag $LATEST_TAG already exists, skipping"
return 1 # No update needed return 1 # No update needed
else else
@@ -74,94 +82,85 @@ jobs:
fi fi
} }
# Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}" REBUILD="${{ github.event.inputs.rebuild_release }}"
PKG="${{ github.event.inputs.package }}"
VERSION=""
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then # Returns via OWNER_PACKAGES, and VERSION when a stable tag is due
# Run from tag with no package specified - update stable package plan_owner() {
echo "packages=dms" >> $GITHUB_OUTPUT OWNER_PACKAGES=""
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "$PKG" ]]; then
OWNER_PACKAGES="dms"
VERSION="${GITHUB_REF#refs/tags/}" VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "Triggered by tag: $VERSION (always update)" echo "Triggered by tag: $VERSION (always update)"
elif [[ "${{ github.event_name }}" == "schedule" ]]; then elif [[ "${{ github.event_name }}" == "schedule" ]]; then
# Scheduled run - check dms-git only if check_dms_git "$1"; then
echo "packages=dms-git" >> $GITHUB_OUTPUT OWNER_PACKAGES="dms-git"
if check_dms_git; then
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "has_updates=false" >> $GITHUB_OUTPUT
fi fi
elif [[ -n "${{ github.event.inputs.package }}" ]]; then elif [[ -n "$PKG" ]]; then
# Manual workflow trigger
PKG="${{ github.event.inputs.package }}"
if [[ -n "$REBUILD" ]]; then if [[ -n "$REBUILD" ]]; then
# Rebuild requested - always proceed OWNER_PACKAGES="$PKG"
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)" echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
elif [[ "$PKG" == "all" ]]; then elif [[ "$PKG" == "all" || "$PKG" == "dms" ]]; then
# Check the stable package and build list of those needing updates # "all" only checks the stable package
PACKAGES_TO_UPDATE=() if check_dms_stable "$1"; then
if check_dms_stable; then OWNER_PACKAGES="dms"
PACKAGES_TO_UPDATE+=("dms")
if [[ -n "$LATEST_TAG" ]]; then if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT VERSION="$LATEST_TAG"
fi fi
fi fi
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 elif [[ "$PKG" == "dms-git" ]]; then
if check_dms_git; then if check_dms_git "$1"; then
echo "packages=$PKG" >> $GITHUB_OUTPUT OWNER_PACKAGES="$PKG"
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms" ]]; then
if check_dms_stable; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi fi
else else
# Unknown package - proceed anyway OWNER_PACKAGES="$PKG"
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "Manual trigger: $PKG" echo "Manual trigger: $PKG"
fi fi
else else
# Fallback - proceed OWNER_PACKAGES="all"
echo "packages=all" >> $GITHUB_OUTPUT fi
echo "has_updates=true" >> $GITHUB_OUTPUT }
# Planned per owner: the new projects may lag the legacy ones
INCLUDE='[]'
for T in duxlabs,,home:duxlabs avengemedia,_LEGACY,home:AvengeMedia; do
IFS=, read -r OWNER SUFFIX PROJECT <<< "$T"
[[ "$OWNERS" == all || "$OWNERS" == "$OWNER" ]] || continue
echo "==> $OWNER ($PROJECT)"
plan_owner "$PROJECT"
if [[ -z "$OWNER_PACKAGES" ]]; then
echo "✓ $OWNER: all packages up to date"
continue
fi
echo "✓ $OWNER: packages to update: $OWNER_PACKAGES"
INCLUDE=$(jq -c --arg o "$OWNER" --arg s "$SUFFIX" --arg p "$PROJECT" --arg k "$OWNER_PACKAGES" \
'. + [{owner: $o, suffix: $s, project: $p, packages: $k}]' <<< "$INCLUDE")
done
echo "matrix=$(jq -c '{include: .}' <<< "$INCLUDE")" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$INCLUDE" != "[]" ]]; then
echo "has_updates=true" >> "$GITHUB_OUTPUT"
else
echo "has_updates=false" >> "$GITHUB_OUTPUT"
fi fi
update-obs: update-obs:
name: Upload to OBS name: Upload to OBS (${{ matrix.owner }})
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'
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.check-updates.outputs.matrix) }}
env:
OBS_BASE_PROJECT: ${{ matrix.project }}
steps: steps:
- name: Checkout - name: Checkout
@@ -179,12 +178,12 @@ jobs:
# Use check-updates outputs when available # Use check-updates outputs when available
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
# Use version from check-updates job # Use version from check-updates job
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}" echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}"
elif [[ "${{ github.event_name }}" == "schedule" ]]; then elif [[ "${{ github.event_name }}" == "schedule" ]]; then
# Scheduled run - dms-git only # Scheduled run - dms-git only
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT
echo "Triggered by schedule: updating git package" echo "Triggered by schedule: updating git package"
elif [[ -n "${{ github.event.inputs.package }}" ]]; then elif [[ -n "${{ github.event.inputs.package }}" ]]; then
# Manual workflow dispatch # Manual workflow dispatch
@@ -192,7 +191,7 @@ jobs:
# Determine version for dms stable using the API # Determine version for dms stable using the API
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref # GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") LATEST_TAG=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Using latest release from API: $LATEST_TAG" echo "Using latest release from API: $LATEST_TAG"
@@ -204,14 +203,14 @@ jobs:
# Use filtered packages from check-updates when package="all" and no rebuild/tag specified # Use filtered packages from check-updates when package="all" and no rebuild/tag specified
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT
echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})" echo "Manual trigger: all (filtered to: ${{ matrix.packages }})"
else else
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
echo "Manual trigger: ${{ github.event.inputs.package }}" echo "Manual trigger: ${{ github.event.inputs.package }}"
fi fi
else else
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
fi fi
@@ -308,22 +307,21 @@ jobs:
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: core/go.sum
- name: Install OSC - name: Install OSC
env:
OBS_USERNAME: ${{ secrets[format('OBS_USERNAME{0}', matrix.suffix)] }}
OBS_PASSWORD: ${{ secrets[format('OBS_PASSWORD{0}', matrix.suffix)] }}
run: | run: |
: "${OBS_USERNAME:?OBS_USERNAME${{ matrix.suffix }} is not set}" "${OBS_PASSWORD:?OBS_PASSWORD${{ matrix.suffix }} is not set}"
sudo apt-get update sudo apt-get update
sudo apt-get install -y osc sudo apt-get install -y osc
mkdir -p ~/.config/osc mkdir -p ~/.config/osc
cat > ~/.config/osc/oscrc << EOF umask 077
[general] printf '[general]\napiurl = https://api.opensuse.org\n\n[https://api.opensuse.org]\nuser = %s\npass = %s\n' \
apiurl = https://api.opensuse.org "$OBS_USERNAME" "$OBS_PASSWORD" > ~/.config/osc/oscrc
[https://api.opensuse.org]
user = ${{ secrets.OBS_USERNAME }}
pass = ${{ secrets.OBS_PASSWORD }}
EOF
chmod 600 ~/.config/osc/oscrc
# Cache OBS bundled Go toolchains # Cache OBS bundled Go toolchains
- name: Cache OBS bundled Go toolchains (dms-git) - name: Cache OBS bundled Go toolchains (dms-git)
@@ -404,7 +402,7 @@ jobs:
- name: Summary - name: Summary
if: always() if: always()
run: | run: |
echo "### OBS Package Upload Summary" >> $GITHUB_STEP_SUMMARY echo "### OBS Package Upload Summary (${{ matrix.owner }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
PACKAGES="${{ steps.packages.outputs.packages }}" PACKAGES="${{ steps.packages.outputs.packages }}"
@@ -457,10 +455,10 @@ jobs:
case "$PKG" in case "$PKG" in
dms) dms)
echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/${OBS_BASE_PROJECT}:dms/dms)" >> $GITHUB_STEP_SUMMARY
;; ;;
dms-git) dms-git)
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/${OBS_BASE_PROJECT}:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
;; ;;
esac esac
done done
@@ -476,5 +474,5 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
fi fi
echo "Monitor build progress on [OBS project page](https://build.opensuse.org/project/show/home:AvengeMedia)." >> $GITHUB_STEP_SUMMARY echo "Monitor build progress on [OBS project page](https://build.opensuse.org/project/show/${OBS_BASE_PROJECT})." >> $GITHUB_STEP_SUMMARY
fi fi
+37 -11
View File
@@ -16,6 +16,15 @@ on:
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)" description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
required: false required: false
default: "" default: ""
owners:
description: "PPA owners to publish to"
required: false
default: "all"
type: choice
options:
- all
- duxlabs
- avengemedia
schedule: schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) - cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
@@ -44,6 +53,8 @@ jobs:
- name: Check for updates - name: Check for updates
id: check id: check
env:
OWNERS: ${{ inputs.owners || 'all' }}
run: | run: |
chmod +x distro/scripts/ppa-sync-plan.sh chmod +x distro/scripts/ppa-sync-plan.sh
@@ -59,10 +70,20 @@ jobs:
ARGS+=(--rebuild "$REBUILD_RELEASE") ARGS+=(--rebuild "$REBUILD_RELEASE")
fi fi
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log) # Planned per owner: an empty new PPA needs everything, the old one may need nothing.
TARGETS_JSON='[]'
: > ppa-audit.log
for T in duxlabs: avengemedia:_LEGACY; do
OWNER="${T%%:*}" SUFFIX="${T#*:}"
[[ "$OWNERS" == all || "$OWNERS" == "$OWNER" ]] || continue
echo "~${OWNER}:" >> ppa-audit.log
PLAN=$(PPA_OWNER="$OWNER" distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2>> ppa-audit.log)
TARGETS_JSON=$(jq -c --arg o "$OWNER" --arg s "$SUFFIX" --argjson p "$PLAN" \
'. + [$p[] | {owner: $o, suffix: $s, target: .}]' <<< "$TARGETS_JSON")
done
cat ppa-audit.log cat ppa-audit.log
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")') TARGETS=$(jq -r 'map(.owner + ":" + .target) | join(" ")' <<< "$TARGETS_JSON")
if [[ "$TARGETS_JSON" != "[]" ]]; then if [[ "$TARGETS_JSON" != "[]" ]]; then
echo "has_updates=true" >> "$GITHUB_OUTPUT" echo "has_updates=true" >> "$GITHUB_OUTPUT"
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT" echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
@@ -76,7 +97,7 @@ jobs:
fi fi
upload-ppa: upload-ppa:
name: Upload ${{ matrix.target }} name: Upload ${{ matrix.owner }} ${{ 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'
@@ -84,9 +105,9 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }} include: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
concurrency: concurrency:
group: ppa-dms-${{ matrix.target }} group: ppa-dms-${{ matrix.owner }}-${{ matrix.target }}
cancel-in-progress: false cancel-in-progress: false
steps: steps:
@@ -100,6 +121,7 @@ jobs:
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: core/go.sum
cache: false cache: false
- name: Install build dependencies - name: Install build dependencies
@@ -117,8 +139,9 @@ jobs:
- name: Configure GPG - name: Configure GPG
env: env:
GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_KEY: ${{ secrets[format('GPG_PRIVATE_KEY{0}', matrix.suffix)] }}
run: | run: |
: "${GPG_KEY:?GPG_PRIVATE_KEY${{ matrix.suffix }} is not set}"
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"
@@ -126,9 +149,11 @@ jobs:
- name: Upload target - name: Upload target
env: env:
TARGET: ${{ matrix.target }} TARGET: ${{ matrix.target }}
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }} PPA_OWNER: ${{ matrix.owner }}
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }} LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets[format('LAUNCHPAD_SSH_PRIVATE_KEY{0}', matrix.suffix)] }}
LAUNCHPAD_SSH_LOGIN: ${{ secrets[format('LAUNCHPAD_SSH_LOGIN{0}', matrix.suffix)] }}
run: | run: |
: "${LAUNCHPAD_SSH_PRIVATE_KEY:?LAUNCHPAD_SSH_PRIVATE_KEY${{ matrix.suffix }} is not set}" "${LAUNCHPAD_SSH_LOGIN:?LAUNCHPAD_SSH_LOGIN${{ matrix.suffix }} is not set}"
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET" IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
case "$PACKAGE" in case "$PACKAGE" in
@@ -137,7 +162,7 @@ jobs:
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;; *) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
esac esac
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM" echo "Uploading $PACKAGE to ~$PPA_OWNER/$PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM" bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
- name: Summary - name: Summary
@@ -145,6 +170,7 @@ jobs:
run: | run: |
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY" echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Owner:** ${{ matrix.owner }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY" echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY" echo "- **DMS PPA:** https://launchpad.net/~${{ matrix.owner }}/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY" echo "- **DMS-Git PPA:** https://launchpad.net/~${{ matrix.owner }}/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
+10 -37
View File
@@ -3,9 +3,7 @@ name: Void Linux XBPS Repository
on: on:
schedule: schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) - cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
# release trigger disabled; dispatch manually after a release # Stable builds are dispatched by release.yml after the release assets exist.
# release:
# types: [published]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@@ -45,11 +43,13 @@ jobs:
- name: Check published commit - name: Check published commit
id: check id: check
env: env:
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json MANIFEST_URL: https://void.duxlabs.dev/dms/current/manifest.json
run: | run: |
SHOULD_BUILD=true SHOULD_BUILD=true
if [ "${{ github.event_name }}" = "schedule" ]; then if [ "${{ github.event_name }}" = "schedule" ]; then
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)" # dux-repo's legacy greeter mirror also writes dms/current; read this repo's own entry
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" \
| jq -r --arg repo "${{ github.repository }}" '.sources[$repo] // .source_commit // empty' 2>/dev/null || true)"
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
SHOULD_BUILD=false SHOULD_BUILD=false
echo "${{ github.sha }} is already published; skipping the scheduled build." echo "${{ github.sha }} is already published; skipping the scheduled build."
@@ -159,8 +159,8 @@ jobs:
- name: Configure repositories - name: Configure repositories
run: | run: |
# Append the repository to repos-remote templates so xbps-src translates it automatically # Append the repository to repos-remote templates so xbps-src translates it automatically
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf echo "repository=https://void.duxlabs.dev/dux-repo/current" >> void-packages/etc/xbps.d/repos-remote.conf
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf echo "repository=https://void.duxlabs.dev/dux-repo/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
# Add any existing compiled packages to the build cache directory to avoid rebuilds # Add any existing compiled packages to the build cache directory to avoid rebuilds
if [ -d "r2-repo/current" ]; then if [ -d "r2-repo/current" ]; then
@@ -278,36 +278,9 @@ jobs:
fi fi
- name: Index and sign repository - name: Index and sign repository
run: | env:
cd r2-repo/current XBPS_PRIVATE_KEY: ${{ secrets.XBPS_PRIVATE_KEY }}
run: bash .github/scripts/publish-void-r2.sh index
rm -f *.sig
# Guard: nothing to index if no .xbps files exist
if ! ls *.xbps 1>/dev/null 2>&1; then
echo "⚠️ No .xbps files found to index, skipping."
exit 0
fi
# Regenerate repo index
xbps-rindex -a $(pwd)/*.xbps
xbps-rindex --remove-obsoletes $(pwd)
# Remove signatures left behind by obsolete packages. Signatures for
# unchanged immutable packages are retained byte-for-byte.
for sig in *.sig2; do
[ -e "$sig" ] || break
[ -f "${sig%.sig2}" ] || rm -f "$sig"
done
# Sign repository
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
chmod 600 /tmp/xbps_privkey.pem
xbps-rindex --sign --signedby "AvengeMedia <[email protected]>" --privkey /tmp/xbps_privkey.pem $(pwd)
xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps
rm -f /tmp/xbps_privkey.pem
- name: Publish repository to R2 - name: Publish repository to R2
run: bash .github/scripts/publish-void-r2.sh publish run: bash .github/scripts/publish-void-r2.sh publish
+1 -1
View File
@@ -130,6 +130,6 @@ distro/void/masterdir*/
# Often gets built # Often gets built
core/dms core/dms
core/dankinstall core/duxinstall
core/internal/shellembed/dist core/internal/shellembed/dist
+1 -1
View File
@@ -4,7 +4,7 @@ DMS is an open-source desktop shell for Wayland compositors on linux, built to w
## Repo Structure ## Repo Structure
- core/ - Go. `cmd/dms` runs quickshell, CLI utilities, and the unix socket server the UI depends on. `cmd/dankinstall` is a separate installer TUI. - core/ - Go. `cmd/dms` runs quickshell, CLI utilities, and the unix socket server the UI depends on. `cmd/duxinstall` is a separate installer TUI.
- quickshell/ - the entire UI: Common/ (singletons, Theme, settings), Services/ (system and core access), Modules/ (bar, dock, lock, ...), Modals/, Widgets/ (shell-specific widgets and deprecated Dank* shims), PLUGINS/, translations/ - quickshell/ - the entire UI: Common/ (singletons, Theme, settings), Services/ (system and core access), Modules/ (bar, dock, lock, ...), Modals/, Widgets/ (shell-specific widgets and deprecated Dank* shims), PLUGINS/, translations/
- dux-qml-common/ - submodule of shared QML widgets - dux-qml-common/ - submodule of shared QML widgets
- docs/, distro/ - IPC and theme docs, packaging - docs/, distro/ - IPC and theme docs, packaging
+19 -18
View File
@@ -13,9 +13,10 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![GitHub stars](https://img.shields.io/github/stars/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/duxlabs-dev/dux-shell/stargazers) [![GitHub stars](https://img.shields.io/github/stars/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/duxlabs-dev/dux-shell/stargazers)
[![GitHub License](https://img.shields.io/github/license/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE) [![GitHub License](https://img.shields.io/github/license/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/duxlabs-dev/dux-shell/releases) [![GitHub release](https://img.shields.io/github/v/release/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/duxlabs-dev/dux-shell/releases)
[![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/) [![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dux-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/dux-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dux-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%2Fduxlabs)](https://ko-fi.com/duxlabs) [![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%2Fduxlabs)](https://ko-fi.com/duxlabs)
[![Discord](https://img.shields.io/discord/1387519366651842574?style=for-the-badge&labelColor=101418&color=9ccbfb&label=Discord)](https://discord.gg/ppWTpKmPgT)
</div> </div>
@@ -33,7 +34,7 @@ dux-shell/
│ ├── Widgets/ # Reusable UI controls │ ├── Widgets/ # Reusable UI controls
│ └── Common/ # Shared resources and themes │ └── Common/ # Shared resources and themes
├── core/ # Go backend and CLI ├── core/ # Go backend and CLI
│ ├── cmd/ # dms CLI and dankinstall binaries │ ├── cmd/ # dms CLI and duxinstall binaries
│ ├── internal/ # System integration, IPC, distro support │ ├── internal/ # System integration, IPC, distro support
│ └── pkg/ # Shared packages │ └── pkg/ # Shared packages
├── distro/ # Distribution packaging ├── distro/ # Distribution packaging
@@ -100,7 +101,7 @@ Notification center with grouping, rich text support, and keyboard navigation.
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews. MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
**Session Management** **Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/duxlabs-dev/dux-greeter). Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dux-greeter](https://github.com/duxlabs-dev/dux-greeter).
**Plugin System** **Plugin System**
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps
@@ -138,16 +139,16 @@ dms plugins restore ~/plugins.lock.json
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview) - **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs) - **Support:** [Ko-fi](https://ko-fi.com/duxlabs)
## Dank Projects ## Dux Projects
DMS is one piece of the suite. The rest lives in its own repos: DMS is one piece of the suite. The rest lives in its own repos:
- [dank-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it. - [dux-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it.
- [dankcalendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop. - [dux-calendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop.
- [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon. - [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
- [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher. - [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher.
- [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar. - [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dux-greeter, and dux-calendar.
- [dankgo](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps. - [dux-go](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps.
## Development ## Development
@@ -157,14 +158,16 @@ See component-specific documentation:
- **[core/](core/)** - Go backend, CLI tools, and system integration - **[core/](core/)** - Go backend, CLI tools, and system integration
- **[distro/](distro/)** - Distribution packaging (Fedora, Debian, NixOS) - **[distro/](distro/)** - Distribution packaging (Fedora, Debian, NixOS)
See [CONTRIBUTING.md](CONTRIBUTING.md) for editor setup, the i18n workflow, and Go style guidelines.
### Building from Source ### Building from Source
**Core + Dankinstall:** **Core + Duxinstall:**
```bash ```bash
cd core cd core
make # Build dms CLI make # Build dms CLI
make dankinstall # Build installer make duxinstall # Build installer
``` ```
**Shell:** **Shell:**
@@ -186,14 +189,12 @@ quickshell -p quickshell/
## Contributing ## Contributing
Contributions welcome. Bug fixes, widgets, features, documentation, and plugins all help. Contributions welcome. Bug fixes, widgets, features, translation, documentation and plugins all help.
1. Fork the repository - For code contributions, see [Development](#development) and [CONTRIBUTING.md](CONTRIBUTING.md)
2. Make your changes - Translations are managed through POEditor: [DMS on POEditor](https://poeditor.com/join/project/kyeCPTpmJM)
3. Test thoroughly - For documentation contributions, see [dux-docs](https://github.com/duxlabs-dev/dux-docs)
4. Open a pull request - If you want to discuss designs or need help with contributing, join the [Discord](https://discord.gg/ppWTpKmPgT)
For documentation contributions, see [DankLinux-Docs](https://github.com/duxlabs-dev/dux-docs).
## Credits ## Credits
+1
View File
@@ -6,6 +6,7 @@ Requisite=graphical-session.target
[Service] [Service]
Type=notify Type=notify
BusName=org.freedesktop.Notifications
NotifyAccess=main NotifyAccess=main
Environment=QS_DISABLE_CRASH_HANDLER=1 Environment=QS_DISABLE_CRASH_HANDLER=1
ExecStart=/usr/bin/dms run --session ExecStart=/usr/bin/dms run --session
+11 -11
View File
@@ -1,7 +1,7 @@
BINARY_NAME=dms BINARY_NAME=dms
BINARY_NAME_INSTALL=dankinstall BINARY_NAME_INSTALL=duxinstall
SOURCE_DIR=cmd/dms SOURCE_DIR=cmd/dms
SOURCE_DIR_INSTALL=cmd/dankinstall SOURCE_DIR_INSTALL=cmd/duxinstall
BUILD_DIR=bin BUILD_DIR=bin
SHELL_SRC=../quickshell SHELL_SRC=../quickshell
EMBED_DIR=internal/shellembed/dist EMBED_DIR=internal/shellembed/dist
@@ -32,7 +32,7 @@ else
DIST_ARCHS = $(ARCH) DIST_ARCHS = $(ARCH)
endif endif
.PHONY: all build sync-shell dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help .PHONY: all build sync-shell duxinstall dist clean install install-all install-duxinstall uninstall uninstall-all uninstall-duxinstall install-config uninstall-config test fmt vet deps print-version help
# Default target # Default target
all: build all: build
@@ -58,7 +58,7 @@ build: sync-shell
CGO_ENABLED=0 $(GO) build -tags withshell $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR) CGO_ENABLED=0 $(GO) build -tags withshell $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
dankinstall: duxinstall:
@echo "Building $(BINARY_NAME_INSTALL)..." @echo "Building $(BINARY_NAME_INSTALL)..."
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL) CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
@@ -77,7 +77,7 @@ dist: sync-shell
done done
@echo "Distribution builds complete" @echo "Distribution builds complete"
build-all: build dankinstall build-all: build duxinstall
install: install:
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@@ -91,7 +91,7 @@ install-all:
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Installation complete" @echo "Installation complete"
install-dankinstall: install-duxinstall:
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Installation complete" @echo "Installation complete"
@@ -108,7 +108,7 @@ uninstall-all:
@rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) @rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Uninstall complete" @echo "Uninstall complete"
uninstall-dankinstall: uninstall-duxinstall:
@echo "Uninstalling $(BINARY_NAME_INSTALL) from $(INSTALL_DIR)..." @echo "Uninstalling $(BINARY_NAME_INSTALL) from $(INSTALL_DIR)..."
@rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) @rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Uninstall complete" @echo "Uninstall complete"
@@ -160,16 +160,16 @@ help:
@echo " all - Build the main binary (dms) (default)" @echo " all - Build the main binary (dms) (default)"
@echo " build - Build the main binary (dms) with the embedded UI" @echo " build - Build the main binary (dms) with the embedded UI"
@echo " sync-shell - Copy quickshell/ into the embed dir (runs before tagged builds)" @echo " sync-shell - Copy quickshell/ into the embed dir (runs before tagged builds)"
@echo " dankinstall - Build dankinstall binary" @echo " duxinstall - Build duxinstall binary"
@echo " dist - Build dms for linux/freebsd amd64/arm64 (no update/greeter)" @echo " dist - Build dms for linux/freebsd amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 and/or DIST_OSES=linux to narrow" @echo " Use ARCH=amd64 or ARCH=arm64 and/or DIST_OSES=linux to narrow"
@echo " build-all - Build both binaries" @echo " build-all - Build both binaries"
@echo " install - Install dms to $(INSTALL_DIR)" @echo " install - Install dms to $(INSTALL_DIR)"
@echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)" @echo " install-all - Install both dms and duxinstall to $(INSTALL_DIR)"
@echo " install-dankinstall - Install only dankinstall to $(INSTALL_DIR)" @echo " install-duxinstall - Install only duxinstall to $(INSTALL_DIR)"
@echo " uninstall - Remove dms from $(INSTALL_DIR)" @echo " uninstall - Remove dms from $(INSTALL_DIR)"
@echo " uninstall-all - Remove both binaries from $(INSTALL_DIR)" @echo " uninstall-all - Remove both binaries from $(INSTALL_DIR)"
@echo " uninstall-dankinstall - Remove only dankinstall from $(INSTALL_DIR)" @echo " uninstall-duxinstall - Remove only duxinstall from $(INSTALL_DIR)"
@echo " clean - Clean build artifacts" @echo " clean - Clean build artifacts"
@echo " test - Run tests" @echo " test - Run tests"
@echo " fmt - Format Go code" @echo " fmt - Format Go code"
+7 -7
View File
@@ -9,7 +9,7 @@ Go-based backend for Dux Shell providing system integration, IPC, and installati
**dms CLI** **dms CLI**
Command-line interface and daemon for shell management and system control. Command-line interface and daemon for shell management and system control.
**dankinstall** **duxinstall**
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags. Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
## System Integration ## System Integration
@@ -102,7 +102,7 @@ Requires Go 1.25+
```bash ```bash
make # Build dms CLI make # Build dms CLI
make dankinstall # Build installer make duxinstall # Build installer
make test # Run tests make test # Run tests
``` ```
@@ -142,12 +142,12 @@ go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \
**Module Structure:** **Module Structure:**
- `cmd/` - Binary entrypoints (dms, dankinstall) - `cmd/` - Binary entrypoints (dms, duxinstall)
- `internal/distros/` - Distribution-specific installation logic - `internal/distros/` - Distribution-specific installation logic
- `internal/proto/` - Wayland protocol bindings - `internal/proto/` - Wayland protocol bindings
- `pkg/` - Shared packages - `pkg/` - Shared packages
## Installation via dankinstall ## Installation via duxinstall
**Interactive (TUI):** **Interactive (TUI):**
@@ -186,7 +186,7 @@ Headless mode requires `--yes` to proceed; without it, the installer exits with
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified. Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
Optional packages (`dms-greeter`, `danksearch`, `dankcalendar`) are disabled by default; enable them with their dedicated flags, `--include-deps`, or `--all-features`. Optional packages (`dms-greeter`, `danksearch`, `dankcalendar`) are disabled by default; enable them with their dedicated flags, `--include-deps`, or `--all-features`.
When no flags are provided, `dankinstall` launches the interactive TUI. When no flags are provided, `duxinstall` launches the interactive TUI.
### Headless mode validation rules ### Headless mode validation rules
@@ -198,8 +198,8 @@ Headless mode activates when `--compositor` or `--term` is provided.
### Log file location ### Log file location
`dankinstall` writes logs to `/tmp` by default. `duxinstall` writes logs to `/tmp` by default.
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory. Set the `DUXINSTALL_LOG_DIR` environment variable to override the log directory.
## Supported Distributions ## Supported Distributions
@@ -10,7 +10,7 @@ NC='\033[0m' # No Color
# Get latest version tag # Get latest version tag
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo -e "${GREEN}Building dankinstall ${VERSION}${NC}" echo -e "${GREEN}Building duxinstall ${VERSION}${NC}"
# Create bin directory if it doesn't exist # Create bin directory if it doesn't exist
mkdir -p bin mkdir -p bin
@@ -19,20 +19,20 @@ mkdir -p bin
for ARCH in amd64 arm64; do for ARCH in amd64 arm64; do
echo -e "${BLUE}Building for ${ARCH}...${NC}" echo -e "${BLUE}Building for ${ARCH}...${NC}"
cd cmd/dankinstall cd cmd/duxinstall
GOOS=linux CGO_ENABLED=0 GOARCH=${ARCH} \ GOOS=linux CGO_ENABLED=0 GOARCH=${ARCH} \
go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \ go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \
-o ../../bin/dankinstall-${ARCH} -o ../../bin/duxinstall-${ARCH}
cd ../.. cd ../..
# Compress # Compress
gzip -9 -k -f bin/dankinstall-${ARCH} gzip -9 -k -f bin/duxinstall-${ARCH}
# Generate checksum # Generate checksum
(cd bin && sha256sum dankinstall-${ARCH}.gz > dankinstall-${ARCH}.gz.sha256) (cd bin && sha256sum duxinstall-${ARCH}.gz > duxinstall-${ARCH}.gz.sha256)
echo -e "${GREEN}✓ Built bin/dankinstall-${ARCH}.gz${NC}" echo -e "${GREEN}✓ Built bin/duxinstall-${ARCH}.gz${NC}"
done done
echo -e "${GREEN}Done! Files ready in bin/:${NC}" echo -e "${GREEN}Done! Files ready in bin/:${NC}"
ls -lh bin/dankinstall-* ls -lh bin/duxinstall-*
+1 -10
View File
@@ -1,10 +1 @@
{ {"policy_version":1,"blocked_commands":[],"message":"This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."}
"policy_version": 1,
"blocked_commands": [
"greeter install",
"greeter enable",
"greeter uninstall",
"setup"
],
"message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."
}
+1
View File
@@ -795,6 +795,7 @@ func getCommonCommands() []*cobra.Command {
systemdCmd, systemdCmd,
switchUserCmd, switchUserCmd,
switchCmd, switchCmd,
migrateCmd,
dmenuCmd, dmenuCmd,
}...) }...)
} }
-24
View File
@@ -55,32 +55,8 @@ var resolveIncludeCmd = &cobra.Command{
Run: runResolveInclude, Run: runResolveInclude,
} }
var mangoMigrateMain bool
var mangoMigrateCmd = &cobra.Command{
Use: "mango-migrate",
Short: "Respell Mango config keys for the installed Mango",
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dialect := mangoconf.Detect()
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
for _, path := range changed {
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
}
// Printed before any error: the shell needs the dialect even when a fragment failed.
fmt.Println(dialect)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() { func init() {
configCmd.AddCommand(resolveIncludeCmd) configCmd.AddCommand(resolveIncludeCmd)
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
configCmd.AddCommand(mangoMigrateCmd)
} }
type IncludeResult struct { type IncludeResult struct {
+152 -44
View File
@@ -219,6 +219,7 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkSystemInfo(), checkSystemInfo(),
checkVersions(qsMissingFeatures), checkVersions(qsMissingFeatures),
checkDMSInstallation(), checkDMSInstallation(),
checkPackageRepos(),
checkWindowManagers(), checkWindowManagers(),
qsFeatures, qsFeatures,
checkOptionalDependencies(), checkOptionalDependencies(),
@@ -285,15 +286,15 @@ func checkSystemInfo() []checkResult {
if message == "" { if message == "" {
message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID) message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID)
} }
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) { details := fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.ID, osInfo.VersionID, osInfo.Architecture)
switch {
case osInfo.Immutable:
details += "; image-based (" + osInfo.ImmutableReason + "), duxinstall deploys configuration only"
case distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID):
status = statusWarn status = statusWarn
message += " (version may not be fully supported)" message += " (version may not be fully supported)"
} }
results = append(results, checkResult{ results = append(results, checkResult{catSystem, "Operating System", status, message, details, doctorDocsURL + "#operating-system"})
catSystem, "Operating System", status, message,
fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture),
doctorDocsURL + "#operating-system",
})
} }
arch := runtime.GOARCH arch := runtime.GOARCH
@@ -804,6 +805,36 @@ func checkDMSInstallation() []checkResult {
return results return results
} }
func checkPackageRepos() []checkResult {
osInfo, err := distros.GetOSInfo()
if err != nil {
return nil
}
distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil)
if err != nil {
return nil
}
return packageReposResult(distro)
}
func packageReposResult(distro distros.Distribution) []checkResult {
moves, ok := distros.PlanRepoMoves(distro)
if !ok {
return nil
}
url := doctorDocsURL + "#package-repos"
legacy := distros.LegacyRepoPaths(moves)
if len(legacy) > 0 {
return []checkResult{{catInstallation, "Package Repos", statusWarn, "Legacy repositories, run: " + migrateDuxCommand, strings.Join(legacy, ", "), url}}
}
for _, move := range moves {
if move.NewPresent {
return []checkResult{{catInstallation, "Package Repos", statusOK, "Current", "", url}}
}
}
return []checkResult{{catInstallation, "Package Repos", statusInfo, "No DMS package repositories", "", url}}
}
func checkWindowManagers() []checkResult { func checkWindowManagers() []checkResult {
compositors := []struct { compositors := []struct {
name, versionCmd, versionArg string name, versionCmd, versionArg string
@@ -858,6 +889,14 @@ func checkWindowManagers() []checkResult {
if c.name == "Hyprland" { if c.name == "Hyprland" {
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...) results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
} }
if c.name == "mangowc" && mangoLacksSnakeCase(ver) {
results = append(results, checkResult{
catCompositor, "Mango config format", statusError,
fmt.Sprintf("Mango %s is older than 0.18", ver),
"DMS writes Mango config with the snake_case keys of Mango 0.18 and later, which this release rejects; upgrade Mango",
doctorDocsURL + "#compositor-checks",
})
}
} }
if !foundAny { if !foundAny {
@@ -878,7 +917,7 @@ func checkWindowManagers() []checkResult {
} }
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" { if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
results = append(results, checkMangoConfig(mangoconf.Dir(), mangoconf.Detect())...) results = append(results, checkMangoConfig(mangoconf.Dir())...)
} }
results = append(results, checkCompositorBlurSupport()) results = append(results, checkCompositorBlurSupport())
@@ -886,33 +925,70 @@ func checkWindowManagers() []checkResult {
return results return results
} }
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`) var (
mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
mangoUnknownKeyword = regexp.MustCompile(`Unknown keyword:\s*(\S+)`)
ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
)
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult { // Mango's parser follows source= and always exits 0; only its output tells.
var mangoParseConfig = func(path string) (string, error) {
if _, err := exec.LookPath("mango"); err != nil {
return "", err
}
out, _ := exec.Command("mango", "-p", "-c", path).CombinedOutput()
return string(out), nil
}
// Each "Unknown keyword" line is followed by a location line naming the file.
func mangoUnknownKeywords(output string) []string {
var found []string
seen := map[string]bool{}
key := ""
for line := range strings.SplitSeq(ansiEscape.ReplaceAllString(output, ""), "\n") {
if m := mangoUnknownKeyword.FindStringSubmatch(line); m != nil {
key = m[1]
continue
}
slash := strings.LastIndex(line, "/")
if key == "" || slash < 0 {
continue
}
file, _, _ := strings.Cut(line[slash+1:], ":")
entry := key + " (" + file + ")"
if !seen[entry] {
seen[entry] = true
found = append(found, entry)
}
key = ""
}
return found
}
func checkMangoConfig(mangoDir string) []checkResult {
url := doctorDocsURL + "#compositor-checks" url := doctorDocsURL + "#compositor-checks"
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf")) mainPath := filepath.Join(mangoDir, "config.conf")
paths = append([]string{filepath.Join(mangoDir, "config.conf")}, paths...) var results []checkResult
if output, err := mangoParseConfig(mainPath); err == nil {
if unknown := mangoUnknownKeywords(output); len(unknown) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
"Unknown keys: " + strings.Join(unknown, ", "),
"Mango 0.18 renamed its config keys to snake_case. Run migrate-config-keys.sh from Mango's 0.18.0 release over ~/.config/mango, or rename the keys by hand.", url})
}
}
var stale, overviewBinds []string paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
paths = append([]string{mainPath}, paths...)
var overviewBinds []string
for _, path := range paths { for _, path := range paths {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
continue continue
} }
if dialect.Translate(string(data)) != string(data) {
stale = append(stale, filepath.Base(path))
}
if mangoLegacyOverviewBinds.Match(data) { if mangoLegacyOverviewBinds.Match(data) {
overviewBinds = append(overviewBinds, filepath.Base(path)) overviewBinds = append(overviewBinds, filepath.Base(path))
} }
} }
var results []checkResult
if len(stale) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
fmt.Sprintf("Not %s keys: %s", dialect, strings.Join(stale, ", ")),
"This Mango rejects those lines as unknown keywords. Run `dms config mango-migrate --main` (config.conf is backed up).", url})
}
if len(overviewBinds) > 0 { if len(overviewBinds) > 0 {
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn, results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "), "Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
@@ -967,6 +1043,10 @@ func hyprlandLacksLua(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0 return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
} }
func mangoLacksSnakeCase(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.18.0") < 0
}
func firstSubmatch(re *regexp.Regexp) func(string) string { func firstSubmatch(re *regexp.Regexp) func(string) string {
return func(s string) string { return func(s string) string {
if m := re.FindStringSubmatch(s); len(m) > 1 { if m := re.FindStringSubmatch(s); len(m) > 1 {
@@ -1712,6 +1792,9 @@ var bundledFontRelPaths = map[string][]string{
"notable": { "notable": {
"DCommon/assets/fonts/notable/Notable-Regular.ttf", "DCommon/assets/fonts/notable/Notable-Regular.ttf",
}, },
"gochi hand": {
"DCommon/assets/fonts/gochi-hand/GochiHand-Regular.ttf",
},
} }
func resolveDoctorShellPath() string { func resolveDoctorShellPath() string {
@@ -1746,22 +1829,47 @@ func isBundledDefaultFont(family string) bool {
return ok return ok
} }
func fontInFcList(name, cacheLower string) bool { const fcListQtFormat = "%{family}\t%{style}\t%{foundry}\n"
target := strings.ToLower(strings.TrimSpace(name))
if target == "" { var qtGenericFamilies = map[string]bool{"sans serif": true, "serif": true, "monospace": true}
return false
func qtFontFamilies() (map[string]bool, error) {
output, err := exec.Command("fc-list", "--format", fcListQtFormat).Output()
if err != nil {
return nil, err
} }
for line := range strings.SplitSeq(cacheLower, "\n") { return parseQtFontFamilies(string(output)), nil
for fam := range strings.SplitSeq(strings.TrimSpace(line), ",") {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
} }
func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailable bool, url string) checkResult { // parseQtFontFamilies retrieves, from the provided fcOutput list, a new map in the style of Qt.fontFamilies()
func parseQtFontFamilies(fcOutput string) map[string]bool {
families := map[string]bool{}
for line := range strings.SplitSeq(fcOutput, "\n") {
fields := strings.Split(line, "\t")
if len(fields) != 3 || fields[0] == "" {
continue
}
styleCount := len(strings.Split(fields[1], ","))
for i, fam := range strings.Split(fields[0], ",") {
if i > 0 && i >= styleCount {
break
}
families[strings.ToLower(fam)] = true
// Qt only lists "Family [Foundry]" for multi-foundry families but resolves it either way
if fields[2] != "" {
families[strings.ToLower(fam+" ["+fields[2]+"]")] = true
}
}
}
return families
}
func fontInFcList(name string, families map[string]bool) bool {
target := strings.ToLower(strings.TrimSpace(name))
return qtGenericFamilies[target] || families[target]
}
func checkConfiguredFont(label, family, shellPath string, fcFamilies map[string]bool, fcListAvailable bool, url string) checkResult {
if bundled := findBundledFontFile(shellPath, family); bundled != "" { if bundled := findBundledFontFile(shellPath, family); bundled != "" {
details := "Bundled (Qt FontLoader)" details := "Bundled (Qt FontLoader)"
if doctorVerbose { if doctorVerbose {
@@ -1796,7 +1904,7 @@ func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailab
} }
} }
if fcCache == "" { if len(fcFamilies) == 0 {
return checkResult{ return checkResult{
catFonts, label, statusWarn, catFonts, label, statusWarn,
fmt.Sprintf("'%s' not found", family), fmt.Sprintf("'%s' not found", family),
@@ -1805,7 +1913,7 @@ func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailab
} }
} }
if fontInFcList(family, fcCache) { if fontInFcList(family, fcFamilies) {
return checkResult{catFonts, label, statusOK, family, "Available via fontconfig", url} return checkResult{catFonts, label, statusOK, family, "Available via fontconfig", url}
} }
@@ -1851,18 +1959,18 @@ func checkFonts() []checkResult {
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily) || !isBundledDefaultFont(displayFontFamily) needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily) || !isBundledDefaultFont(displayFontFamily)
fcListAvailable := utils.CommandExists("fc-list") fcListAvailable := utils.CommandExists("fc-list")
fcCache := "" var fcFamilies map[string]bool
if needFontconfig { if needFontconfig {
if !fcListAvailable { if !fcListAvailable {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify custom fonts in fontconfig cache.", url}) results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify custom fonts in fontconfig cache.", url})
} else { } else {
output, err := exec.Command("fc-list", ":", "family").Output() families, err := qtFontFamilies()
if err != nil { if err != nil {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url}) results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
} else { } else {
fcCache = strings.ToLower(string(output)) fcFamilies = families
if len(strings.TrimSpace(fcCache)) == 0 { if len(fcFamilies) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url}) results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
} }
} }
@@ -1870,9 +1978,9 @@ func checkFonts() []checkResult {
} }
results = append(results, results = append(results,
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url), checkConfiguredFont("Normal Font", fontFamily, shellPath, fcFamilies, fcListAvailable, url),
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url), checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcFamilies, fcListAvailable, url),
checkConfiguredFont("Display Font", displayFontFamily, shellPath, fcCache, fcListAvailable, url), checkConfiguredFont("Display Font", displayFontFamily, shellPath, fcFamilies, fcListAvailable, url),
) )
return results return results
+39 -8
View File
@@ -3,26 +3,57 @@ package main
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func TestCheckBundledGoogleSans(t *testing.T) { func TestCheckBundledFonts(t *testing.T) {
for family, relPaths := range bundledFontRelPaths {
t.Run(family, func(t *testing.T) {
shellPath := t.TempDir() shellPath := t.TempDir()
fontPath := filepath.Join(shellPath, "DCommon", "assets", "fonts", "google-sans-flex", "GoogleSansFlex.ttf") if missing := checkConfiguredFont("Font", family, shellPath, nil, false, ""); missing.status != statusWarn {
missing := checkConfiguredFont("UI font", "Google Sans Flex", shellPath, "", false, "")
if missing.status != statusWarn {
t.Fatalf("missing bundled font status = %v, want %v", missing.status, statusWarn) t.Fatalf("missing bundled font status = %v, want %v", missing.status, statusWarn)
} }
fontPath := filepath.Join(shellPath, relPaths[0])
if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil { if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, family := range []string{"Google Sans Flex", " google sans flex "} { for _, name := range []string{family, " " + strings.ToUpper(family) + " "} {
result := checkConfiguredFont("UI font", family, shellPath, "", false, "") if result := checkConfiguredFont("Font", name, shellPath, nil, false, ""); result.status != statusOK {
if result.status != statusOK { t.Fatalf("bundled font %q status = %v, want %v", name, result.status, statusOK)
t.Fatalf("bundled font %q status = %v, want %v", family, result.status, statusOK) }
}
})
}
}
func TestQtFontFamilies(t *testing.T) {
fcOutput := "RobotoMono Nerd Font\tBold\tGOOG\n" +
"RobotoMono Nerd Font\tBold Italic\tpyrs\n" +
"Nimbus Sans\tRegular\tURW \n" +
"Nimbus Sans\tBold\tUKWN\n" +
"Plex,Plex Lt\tLight,Regular\tIBM\n" +
"Mono,Mono NF,Mono NF Light\tLight,Regular\tNONE\n"
families := parseQtFontFamilies(fcOutput)
cases := map[string]bool{
"RobotoMono Nerd Font [GOOG]": true,
"robotomono nerd font [pyrs]": true,
"RobotoMono Nerd Font": true,
"Nimbus Sans [URW ]": true,
"Plex Lt": true,
"Plex [IBM]": true,
"Mono NF": true,
"Mono NF Light": false,
"Monospace": true,
"Missing Font [GOOG]": false,
}
for name, want := range cases {
if got := fontInFcList(name, families); got != want {
t.Errorf("fontInFcList(%q) = %v, want %v", name, got, want)
} }
} }
} }
+28 -8
View File
@@ -8,8 +8,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
) )
func TestQuickshellVersionFailureDetails(t *testing.T) { func TestQuickshellVersionFailureDetails(t *testing.T) {
@@ -385,19 +383,41 @@ func TestCheckXDGMenuFile(t *testing.T) {
} }
} }
func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) { func TestCheckMangoConfigReportsUnknownKeysAndOverviewBinds(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644) _ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "dms", "layout.conf"), []byte("border_px=2\n"), 0o644) // Shape of `mango -p` 0.18.2 output, colours included; the same key twice collapses.
parsed := "\x1b[1m\x1b[31m[ERROR]:\x1b[0m Unknown keyword: \x1b[1m\x1b[31mexec-once\x1b[0m\n" +
"\x1b[1;31m╰─\x1b[1;33m[Index]\x1b[0m \x1b[1;36m" + dir + "/config.conf\x1b[0m:\x1b[1;35m1\x1b[0m\n ╰─exec-once=dms run\n\n" +
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:10\n ╰─overviewgappo=30\n" +
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:12\n"
prev := mangoParseConfig
mangoParseConfig = func(string) (string, error) { return parsed, nil }
t.Cleanup(func() { mangoParseConfig = prev })
if got := checkMangoConfig(dir, mangoconf.Legacy); len(got) != 2 || got[0].message != "Not legacy keys: layout.conf" { got := checkMangoConfig(dir)
t.Fatalf("legacy: %+v", got) if len(got) != 2 || got[0].message != "Unknown keys: exec-once (config.conf), overviewgappo (layout.conf)" || got[1].name != "Mango mouse binds" {
t.Fatalf("got %+v", got)
}
mangoParseConfig = func(string) (string, error) { return "", os.ErrNotExist }
if got := checkMangoConfig(dir); len(got) != 1 {
t.Fatalf("without mango on PATH only the bind check should run, got %+v", got)
}
}
func TestMangoLacksSnakeCase(t *testing.T) {
for ver, want := range map[string]bool{
"0.17.5": true,
"0.18.0": false,
"0.18.2": false,
"": false,
} {
if got := mangoLacksSnakeCase(ver); got != want {
t.Errorf("mangoLacksSnakeCase(%q) = %v, want %v", ver, got, want)
} }
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" {
t.Fatalf("snake: %+v", got)
} }
} }
+10 -8
View File
@@ -83,6 +83,10 @@ func runUpdate() {
log.Fatalf("Error detecting OS: %v", err) log.Fatalf("Error detecting OS: %v", err)
} }
if osInfo.Immutable {
log.Fatalf("%s is an image-based system (%s). dms lives in the read-only image; update it with 'dms system update' or your image tooling (rpm-ostree, bootc, transactional-update).", osInfo.PrettyName, osInfo.ImmutableReason)
}
config, exists := distros.Registry[osInfo.Distribution.ID] config, exists := distros.Registry[osInfo.Distribution.ID]
if !exists { if !exists {
log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID) log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID)
@@ -124,16 +128,14 @@ func updateArchLinux() error {
var packageName string var packageName string
var isAUR bool var isAUR bool
if isArchPackageInstalled("dms-shell") { // AUR name first: pacman -Q matches provides, and dux-shell-git provides dux-shell.
packageName = "dms-shell" if isArchPackageInstalled("dux-shell-git") {
} else if isArchPackageInstalled("dms-shell-git") { packageName = "dux-shell-git"
packageName = "dms-shell-git"
isAUR = true
} else if isArchPackageInstalled("dms-shell-bin") {
packageName = "dms-shell-bin"
isAUR = true isAUR = true
} else if isArchPackageInstalled("dux-shell") {
packageName = "dux-shell"
} else { } else {
fmt.Println("Info: No dms-shell package found.") fmt.Println("Info: No dux-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()
} }
+9 -4
View File
@@ -30,6 +30,10 @@ var greeterCmd = &cobra.Command{
}, },
} }
func greeterRepoURL(config distros.DistroConfig) string {
return config.Constructor(config, nil).GetPackageMapping(0)["dms-greeter"].RepoURL
}
func isLegacyWrapperScript(path string) bool { func isLegacyWrapperScript(path string) bool {
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
@@ -63,13 +67,14 @@ func greeterPackageInstallHint() string {
case distros.FamilySUSE: case distros.FamilySUSE:
return "Install with 'sudo zypper install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#opensuse--derivatives)" return "Install with 'sudo zypper install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#opensuse--derivatives)"
case distros.FamilyUbuntu: case distros.FamilyUbuntu:
return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)" repo := greeterRepoURL(config)
return fmt.Sprintf("Install with 'sudo apt install dms-greeter' (requires %s: sudo add-apt-repository %s)", repo, repo)
case distros.FamilyFedora: case distros.FamilyFedora:
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)" return fmt.Sprintf("Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable %s)", greeterRepoURL(config))
case distros.FamilyArch: case distros.FamilyArch:
return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'" return "Install from AUR with 'paru -S dux-greeter-bin' or 'yay -S dux-greeter-bin'"
case distros.FamilyVoid: case distros.FamilyVoid:
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)" return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDepsRepo + "' | sudo tee /etc/xbps.d/dux.conf)"
default: default:
return "Install the dms-greeter package for your distribution" return "Install the dms-greeter package for your distribution"
} }
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/spf13/cobra"
)
const migrateDuxCommand = "dms migrate dux"
var (
migrateDryRun bool
migrateAssumeYes bool
)
var migrateCmd = &cobra.Command{
Use: "migrate",
Short: "One-time migrations for existing installs",
}
var migrateDuxCmd = &cobra.Command{
Use: "dux",
Short: "Move DMS package repositories to the Dux Labs owner",
Long: `Move the DMS package repositories from the legacy AvengeMedia owner to Dux Labs.
Adds the Dux Labs repository for every legacy one found, then removes the legacy
one. Installed packages are not changed; the next update comes from the new repo.`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
if err := runMigrateDux(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
migrateDuxCmd.Flags().BoolVar(&migrateDryRun, "dry-run", false, "Print the commands without running them")
migrateDuxCmd.Flags().BoolVarP(&migrateAssumeYes, "yes", "y", false, "Do not ask for confirmation")
migrateCmd.AddCommand(migrateDuxCmd)
}
func runMigrateDux() error {
osInfo, err := distros.GetOSInfo()
if err != nil {
return fmt.Errorf("detecting distribution: %w", err)
}
distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil)
if err != nil {
return err
}
moves, ok := distros.PlanRepoMoves(distro)
if !ok {
fmt.Printf("Nothing to migrate on %s.\n", osInfo.PrettyName)
return nil
}
mover := distro.(distros.RepoMover)
commands := mover.RepoMoveCommands(moves)
if len(commands) == 0 {
fmt.Println("Already migrated.")
return nil
}
if osInfo.Immutable {
imageMover, ok := distro.(distros.ImageRepoMover)
if !ok {
return fmt.Errorf("%s is an image-based system; move the repositories with your image tooling", osInfo.PrettyName)
}
printRepoMoves(moves, imageMover.ImageRepoMoveCommands(moves))
fmt.Printf("\n%s is image-based, so run these yourself.\n", osInfo.PrettyName)
return nil
}
printRepoMoves(moves, commands)
if migrateDryRun {
return nil
}
if !migrateAssumeYes && !askYes("\nContinue? [y/N] ") {
return errors.New("cancelled")
}
ctx := context.Background()
if privesc.CheckCached(ctx) != nil {
if err := privesc.Run(ctx, "", "true"); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
}
err = runWithProgressOutput(func(progressChan chan<- distros.InstallProgressMsg) error {
return mover.ApplyRepoMoves(ctx, moves, "", progressChan)
})
if err != nil {
return err
}
fmt.Println("Done. 'dms doctor' shows the result; the shell notice clears on its next start.")
return nil
}
func printRepoMoves(moves []distros.RepoMove, commands []string) {
fmt.Println("Repositories to move:")
for _, move := range moves {
switch {
case move.OldPresent:
fmt.Printf(" %s → %s\n", move.Old, move.New)
case !move.NewPresent && move.NewPath != "":
fmt.Printf(" add %s\n", move.New)
}
}
fmt.Println("\nCommands:")
for _, command := range commands {
fmt.Printf(" %s\n", command)
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"bytes"
"context"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
)
type repoMoverFake struct {
distros.Distribution
moves []distros.RepoMove
}
func (f *repoMoverFake) RepoMoves() []distros.RepoMove { return f.moves }
func (f *repoMoverFake) ApplyRepoMoves(context.Context, []distros.RepoMove, string, chan<- distros.InstallProgressMsg) error {
return nil
}
func (f *repoMoverFake) RepoMoveCommands([]distros.RepoMove) []string { return nil }
type plainDistroFake struct{ distros.Distribution }
func TestRunWithProgressOutputPrintsRepeatedLinesOnce(t *testing.T) {
var out bytes.Buffer
err := runWithProgressOutputTo(&out, func(ch chan<- distros.InstallProgressMsg) error {
ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."}
ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."}
ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."}
return nil
})
if err != nil {
t.Fatal(err)
}
want := "Removing COPR repo...\n Repository successfully removed.\n"
if out.String() != want {
t.Fatalf("got %q, want %q", out.String(), want)
}
}
func TestCheckPackageRepos(t *testing.T) {
legacy := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms", OldPath: "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:dms.repo", OldPresent: true}
legacyDeps := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/danklinux", New: "duxlabs/dux-repo", OldPath: "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:danklinux.repo", OldPresent: true}
current := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms", NewPresent: true}
absent := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms"}
cases := []struct {
name string
moves []distros.RepoMove
status status
message string
}{
{"legacy", []distros.RepoMove{legacy, legacyDeps, current}, statusWarn, "Legacy repositories, run: dms migrate dux"},
{"current", []distros.RepoMove{current, absent}, statusOK, "Current"},
{"none", []distros.RepoMove{absent}, statusInfo, "No DMS package repositories"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
results := packageReposResult(&repoMoverFake{moves: tc.moves})
if len(results) != 1 || results[0].status != tc.status || results[0].message != tc.message {
t.Fatalf("got %+v, want %v %q", results, tc.status, tc.message)
}
if tc.status == statusWarn && results[0].details != legacy.OldPath+", "+legacyDeps.OldPath {
t.Fatalf("details = %q, want the legacy paths on one line", results[0].details)
}
})
}
voidConf := "/etc/xbps.d/custom.conf"
voidMoves := []distros.RepoMove{
{Kind: distros.RepoTypeXBPS, Old: "https://void.danklinux.com/dms/current", New: "https://void.duxlabs.dev/dms/current", OldPath: voidConf, NewPath: voidConf, OldPresent: true},
{Kind: distros.RepoTypeXBPS, Old: "https://void.danklinux.com/danklinux/current", New: "https://void.duxlabs.dev/dux-repo/current", OldPath: voidConf, NewPath: voidConf, OldPresent: true},
}
if results := packageReposResult(&repoMoverFake{moves: voidMoves}); len(results) != 1 || results[0].status != statusWarn || results[0].details != voidConf {
t.Fatalf("got %+v, want one warning naming %s once", results, voidConf)
}
if results := packageReposResult(&plainDistroFake{}); results != nil {
t.Fatalf("printed %+v on a family without repo moves", results)
}
}
+2 -2
View File
@@ -130,7 +130,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "layout.conf", mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig }, hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) }, mangoContent: func(_ string) string { return config.MangoLayoutConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
@@ -138,7 +138,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "colors.conf", mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig }, hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) }, mangoContent: func(_ string) string { return config.MangoColorsConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
+23 -11
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -142,7 +143,7 @@ func runSwitch(channel string, packages []string) error {
if shellChanges { if shellChanges {
fmt.Println("The shell restarts when the switch is done.") fmt.Println("The shell restarts when the switch is done.")
} }
if !switchAssumeYes && !askSwitch("\nContinue? [y/N] ") { if !switchAssumeYes && !askYes("\nContinue? [y/N] ") {
return errors.New("cancelled") return errors.New("cancelled")
} }
@@ -171,7 +172,7 @@ func runSwitch(channel string, packages []string) error {
if snapshotLabel == "beta" && !switchAssumeYes { if snapshotLabel == "beta" && !switchAssumeYes {
if path, ok := backup.LatestSnapshot("stable"); ok { if path, ok := backup.LatestSnapshot("stable"); ok {
fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path) fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path)
if askSwitch("") { if askYes("") {
restore = path restore = path
} }
} }
@@ -185,28 +186,39 @@ func runSwitch(channel string, packages []string) error {
} }
func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error { func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error {
return runWithProgressOutput(func(progressChan chan<- distros.InstallProgressMsg) error {
return swapper.SwapVariants(ctx, swaps, "", progressChan)
})
}
func runWithProgressOutput(run func(chan<- distros.InstallProgressMsg) error) error {
return runWithProgressOutputTo(os.Stdout, run)
}
func runWithProgressOutputTo(out io.Writer, run func(chan<- distros.InstallProgressMsg) error) error {
progressChan := make(chan distros.InstallProgressMsg, 100) progressChan := make(chan distros.InstallProgressMsg, 100)
swapErr := make(chan error, 1) runErr := make(chan error, 1)
go func() { go func() {
defer close(progressChan) defer close(progressChan)
swapErr <- swapper.SwapVariants(ctx, swaps, "", progressChan) runErr <- run(progressChan)
}() }()
// runWithProgress repeats the step on every output line, so only print it when it changes. // runWithProgress repeats the step and the last output line on every tick, so only print changes.
lastStep := "" lastStep, lastLog := "", ""
for msg := range progressChan { for msg := range progressChan {
switch { switch {
case msg.Error != nil: case msg.Error != nil:
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error) fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
case msg.Step != "" && msg.Step != lastStep: case msg.Step != "" && msg.Step != lastStep:
fmt.Println(msg.Step) fmt.Fprintln(out, msg.Step)
lastStep = msg.Step lastStep = msg.Step
} }
if msg.LogOutput != "" { if msg.LogOutput != "" && msg.LogOutput != lastLog {
fmt.Printf(" %s\n", msg.LogOutput) fmt.Fprintf(out, " %s\n", msg.LogOutput)
lastLog = msg.LogOutput
} }
} }
return <-swapErr return <-runErr
} }
// restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the // restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the
@@ -268,7 +280,7 @@ func switchLabel(m distros.PackageMapping) string {
return m.RepoURL + "/" + m.Name return m.RepoURL + "/" + m.Name
} }
func askSwitch(prompt string) bool { func askYes(prompt string) bool {
if !stdinIsTTY() { if !stdinIsTTY() {
return false return false
} }
+3 -87
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"bufio"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -10,6 +9,7 @@ import (
"sync" "sync"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy" "github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc" "github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -97,97 +97,13 @@ func mergePolicyFile(base *immutableCommandPolicy, path string) error {
return nil return nil
} }
func readOSReleaseMap(path string) map[string]string {
values := make(map[string]string)
file, err := os.Open(path)
if err != nil {
return values
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.ToUpper(strings.TrimSpace(parts[0]))
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
values[key] = strings.ToLower(value)
}
return values
}
func hasAnyToken(text string, tokens ...string) bool {
if text == "" {
return false
}
for _, token := range tokens {
if strings.Contains(text, token) {
return true
}
}
return false
}
func detectImmutableSystem() (bool, string) {
if _, err := os.Stat("/run/ostree-booted"); err == nil {
return true, "/run/ostree-booted is present"
}
osRelease := readOSReleaseMap("/etc/os-release")
if len(osRelease) == 0 {
return false, ""
}
id := osRelease["ID"]
idLike := osRelease["ID_LIKE"]
variantID := osRelease["VARIANT_ID"]
name := osRelease["NAME"]
prettyName := osRelease["PRETTY_NAME"]
immutableIDs := map[string]bool{
"bluefin": true,
"bazzite": true,
"silverblue": true,
"kinoite": true,
"sericea": true,
"onyx": true,
"aurora": true,
"fedora-iot": true,
"fedora-coreos": true,
}
if immutableIDs[id] {
return true, "os-release ID=" + id
}
markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"}
if hasAnyToken(variantID, markers...) {
return true, "os-release VARIANT_ID=" + variantID
}
if hasAnyToken(idLike, "ostree", "rpm-ostree") {
return true, "os-release ID_LIKE=" + idLike
}
if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) {
return true, "os-release identifies an atomic/ostree variant"
}
return false, ""
}
func getImmutablePolicy() (*immutableCommandPolicy, error) { func getImmutablePolicy() (*immutableCommandPolicy, error) {
immutablePolicyOnce.Do(func() { immutablePolicyOnce.Do(func() {
detectedImmutable, reason := detectImmutableSystem() detectedImmutable, reason := distros.DetectImmutable()
immutablePolicy = immutableCommandPolicy{ immutablePolicy = immutableCommandPolicy{
ImmutableSystem: detectedImmutable, ImmutableSystem: detectedImmutable,
ImmutableReason: reason, ImmutableReason: reason,
BlockedCommands: []string{"greeter install", "greeter enable", "setup"}, BlockedCommands: nil,
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.", Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
} }
+9 -5
View File
@@ -7,7 +7,9 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy" "github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
) )
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) { // The greeter commands forward to dms-greeter, which applies its own policy;
// a packaged /usr/share/dms/cli-policy.json can still block them here.
func TestDefaultImmutablePolicyBlocksNothing(t *testing.T) {
var policyFile clipolicy.File var policyFile clipolicy.File
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil { if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
t.Fatalf("failed to parse embedded CLI policy: %v", err) t.Fatalf("failed to parse embedded CLI policy: %v", err)
@@ -17,10 +19,12 @@ func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
} }
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands) blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
if !commandBlockedByPolicy("greeter enable", blocked) { for _, command := range []string{"greeter install", "greeter enable", "greeter uninstall", "greeter sync"} {
t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems") if commandBlockedByPolicy(command, blocked) {
t.Fatalf("%s must be available by default on image-based systems", command)
} }
if commandBlockedByPolicy("greeter sync", blocked) { }
t.Fatal("expected greeter sync to remain available on immutable/image-based systems") if commandBlockedByPolicy("setup", blocked) {
t.Fatal("setup only writes to the home directory and must stay available on image-based systems")
} }
} }
+34 -1
View File
@@ -2,17 +2,21 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-go/shellapp" "github.com/duxlabs-dev/dux-go/shellapp"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"github.com/duxlabs-dev/dux-shell/core/internal/config" "github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/gpu" "github.com/duxlabs-dev/dux-shell/core/internal/gpu"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server" "github.com/duxlabs-dev/dux-shell/core/internal/server"
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed" "github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/afero"
) )
var shellApp = shellapp.New(shellapp.Config{ var shellApp = shellapp.New(shellapp.Config{
@@ -83,18 +87,47 @@ func preLaunch() {
ensureFontCache() ensureFontCache()
} }
// The elapsed-timer driver keeps the scrolling wallpaper spring in step above 60 Hz,
// but it makes every other animation jump after a dropped frame (#3837).
func scrollingWallpaperConfigured() bool {
configDir, err := os.UserConfigDir()
if err != nil {
return false
}
data, err := os.ReadFile(filepath.Join(configDir, "dms", "settings.json"))
if err != nil {
return false
}
return wantsSimpleAnimationDriver(data)
}
func wantsSimpleAnimationDriver(settings []byte) bool {
var parsed struct {
WallpaperFillMode string `json:"wallpaperFillMode"`
}
if json.Unmarshal(settings, &parsed) != nil {
return false
}
return parsed.WallpaperFillMode == "Scrolling"
}
func dmsExtraEnv(string) []string { func dmsExtraEnv(string) []string {
var env []string var env []string
if selfPath, err := os.Executable(); err == nil { if selfPath, err := os.Executable(); err == nil {
env = append(env, "DMS_EXECUTABLE="+selfPath) env = append(env, "DMS_EXECUTABLE="+selfPath)
} }
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" { if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" && scrollingWallpaperConfigured() {
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1") env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
} }
if _, set := os.LookupEnv("MALLOC_CONF"); !set { if _, set := os.LookupEnv("MALLOC_CONF"); !set {
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000") env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
} }
env = append(env, gpu.EGLVendorEnv()...) env = append(env, gpu.EGLVendorEnv()...)
policyEnv, err := clipolicy.ShellEnv(afero.NewOsFs())
if err != nil {
log.Errorf("CLI policy unreadable, enforcing every shell switch and ignoring its lock settings: %v", err)
}
env = append(env, policyEnv...)
// systemd user services miss the login shell PATH, so tools in ~/.local/bin // systemd user services miss the login shell PATH, so tools in ~/.local/bin
// (herdr, pip/cargo installs) would be invisible to the shell and its children. // (herdr, pip/cargo installs) would be invisible to the shell and its children.
for _, entry := range utils.EnvWithUserBinPath(nil) { for _, entry := range utils.EnvWithUserBinPath(nil) {
+28 -1
View File
@@ -20,9 +20,16 @@ var runSystemctl = func(args ...string) error {
return err return err
} }
// Assigned in init: shellApp's config refers back to trySystemdRestart.
var shellRunning func() bool
func init() {
shellRunning = func() bool { return len(shellApp.PIDs()) > 0 }
}
func trySystemdRestart() (bool, error) { func trySystemdRestart() (bool, error) {
if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil { if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil {
return false, nil return trySystemdStart()
} }
if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil { if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil {
@@ -30,3 +37,23 @@ func trySystemdRestart() (bool, error) {
} }
return true, nil return true, nil
} }
// With nothing running, restart would spawn an unmanaged daemon that blocks
// the unit once the session target starts it. A running hand-started
// instance is left to the plain restart path.
func trySystemdStart() (bool, error) {
if shellRunning() {
return false, nil
}
if err := runSystemctl("--user", "is-enabled", "--quiet", dmsSystemdUnit); err != nil {
return false, nil
}
if err := runSystemctl("--user", "is-active", "--quiet", "graphical-session.target"); err != nil {
return false, nil
}
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return true, fmt.Errorf("starting %s: %w", dmsSystemdUnit, err)
}
return true, nil
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"errors"
"slices"
"strings"
"testing"
)
func TestTrySystemdRestart(t *testing.T) {
tests := []struct {
name string
running bool
ok []string
wantHandled bool
wantCall string
}{
{"active unit restarts", false, []string{"is-active dms.service", "restart"}, true, "restart dms.service"},
{"enabled inactive unit starts", false, []string{"is-enabled", "is-active graphical-session.target", "start"}, true, "start dms.service"},
{"hand-started instance keeps plain restart", true, []string{"is-enabled", "is-active graphical-session.target", "start"}, false, ""},
{"session target not up", false, []string{"is-enabled"}, false, ""},
{"unit disabled", false, []string{"is-active graphical-session.target"}, false, ""},
{"no systemd", false, nil, false, ""},
}
origSystemctl, origRunning := runSystemctl, shellRunning
t.Cleanup(func() { runSystemctl, shellRunning = origSystemctl, origRunning })
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shellRunning = func() bool { return tt.running }
var calls []string
runSystemctl = func(args ...string) error {
call := strings.Join(slices.DeleteFunc(slices.Clone(args), func(a string) bool {
return strings.HasPrefix(a, "--")
}), " ")
calls = append(calls, call)
for _, prefix := range tt.ok {
if strings.HasPrefix(call, prefix) {
return nil
}
}
return errors.New("exit status 3")
}
handled, err := trySystemdRestart()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if handled != tt.wantHandled {
t.Fatalf("handled = %v, want %v (calls %q)", handled, tt.wantHandled, calls)
}
if tt.wantCall != "" && !slices.Contains(calls, tt.wantCall) {
t.Fatalf("missing %q in calls %q", tt.wantCall, calls)
}
if !tt.wantHandled && slices.ContainsFunc(calls, func(c string) bool { return strings.HasPrefix(c, "start") }) {
t.Fatalf("unexpected start in calls %q", calls)
}
})
}
}
@@ -32,9 +32,9 @@ var (
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "dankinstall", Use: "duxinstall",
Short: "Install Dux Shell and its dependencies", Short: "Install Dux Shell and its dependencies",
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal. Long: `duxinstall sets up Dux Shell with your chosen compositor and terminal.
Without flags, it launches an interactive TUI. Providing either --compositor Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags. or --term activates headless (unattended) mode, which requires both flags.
@@ -42,7 +42,7 @@ or --term activates headless (unattended) mode, which requires both flags.
Headless mode requires cached credentials or a passwordless rule for your Headless mode requires cached credentials or a passwordless rule for your
privilege escalation tool (sudo, doas, or run0).`, privilege escalation tool (sudo, doas, or run0).`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: runDankinstall, RunE: runDuxinstall,
SilenceErrors: true, SilenceErrors: true,
SilenceUsage: true, SilenceUsage: true,
} }
@@ -66,7 +66,7 @@ func init() {
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: duxinstall must not be run as root")
os.Exit(1) os.Exit(1)
} }
@@ -76,7 +76,7 @@ func main() {
} }
} }
func runDankinstall(cmd *cobra.Command, args []string) error { func runDuxinstall(cmd *cobra.Command, args []string) error {
headlessMode := compositor != "" || term != "" headlessMode := compositor != "" || term != ""
if !headlessMode { if !headlessMode {
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/duxlabs-dev/dux-shell/core module github.com/duxlabs-dev/dux-shell/core
go 1.27.1 go 1.27.3
require ( require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0 github.com/Wifx/gonetworkmanager/v2 v2.2.0
@@ -9,7 +9,7 @@ require (
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/dlclark/regexp2/v2 v2.9.1 github.com/dlclark/regexp2/v2 v2.9.1
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500 github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/go-git/go-git/v6 v6.0.0-beta.1 github.com/go-git/go-git/v6 v6.0.0-beta.1
+2 -2
View File
@@ -97,8 +97,8 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ= github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500 h1:y02m7QgoX4rwauY9HePIPGCRDJ/fMirBmgj78IkeUsc= github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a h1:AgR1FUYWhq5JgptZy0dwk1vsuHCXmOjxLFkB6iS/uTc=
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk= github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk=
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok= github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok=
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0= github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
+9 -3
View File
@@ -43,7 +43,7 @@ if [ -z "$LATEST_VERSION" ]; then
exit 1 exit 1
fi fi
printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC" printf "%bInstalling Duxinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC"
# Download and install # Download and install
TEMP_DIR=$(mktemp -d) TEMP_DIR=$(mktemp -d)
@@ -52,8 +52,14 @@ cd "$TEMP_DIR" || exit 1
# Download the gzipped binary and its checksum # Download the gzipped binary and its checksum
printf "%bDownloading installer...%b\n" "$GREEN" "$NC" printf "%bDownloading installer...%b\n" "$GREEN" "$NC"
curl -L "https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz" -o "installer.gz" RELEASE_URL="https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION"
curl -L "https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz.sha256" -o "expected.sha256" # install.duxlabs.dev serves this file from master; releases before 2.0.1 only ship dankinstall-*.gz
ASSET="duxinstall-$ARCH.gz"
if ! curl -fsLI "$RELEASE_URL/$ASSET" >/dev/null; then
ASSET="dankinstall-$ARCH.gz"
fi
curl -fL "$RELEASE_URL/$ASSET" -o "installer.gz"
curl -fL "$RELEASE_URL/$ASSET.sha256" -o "expected.sha256"
# Get the expected checksum # Get the expected checksum
EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256) EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256)
+133 -7
View File
@@ -4,7 +4,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"slices"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -19,6 +22,41 @@ type File struct {
BlockedCommands *[]string `json:"blocked_commands"` BlockedCommands *[]string `json:"blocked_commands"`
Message *string `json:"message"` Message *string `json:"message"`
DisableRegistries *bool `json:"disable_registries"` DisableRegistries *bool `json:"disable_registries"`
DisableLockIPCUnlock *bool `json:"disable_lock_ipc_unlock"`
DisableLogindUnlock *bool `json:"disable_logind_unlock"`
DisableCustomLockPAM *bool `json:"disable_custom_lock_pam"`
DisableUserPlugins *bool `json:"disable_user_plugins"`
DisableSettingsIPCSet *bool `json:"disable_settings_ipc_set"`
LockSettings map[string]json.RawMessage `json:"lock_settings"`
}
// shellSwitches reach the shell as DMS_POLICY_<KEY> in its launch environment.
var shellSwitches = []struct {
key string
value func(*File) *bool
}{
{"disable_lock_ipc_unlock", func(p *File) *bool { return p.DisableLockIPCUnlock }},
{"disable_logind_unlock", func(p *File) *bool { return p.DisableLogindUnlock }},
{"disable_custom_lock_pam", func(p *File) *bool { return p.DisableCustomLockPAM }},
{"disable_user_plugins", func(p *File) *bool { return p.DisableUserPlugins }},
{"disable_settings_ipc_set", func(p *File) *bool { return p.DisableSettingsIPCSet }},
}
// lockSettingKeys are the shell settings lock_settings may manage.
var lockSettingKeys = []string{
"customPowerActionLock",
"lockBeforeSuspend",
"loginctlLockIntegration",
"lockAtStartup",
"acLockTimeout",
"batteryLockTimeout",
"fadeToLockEnabled",
"fadeToLockGracePeriod",
"enableFprint",
"maxFprintTries",
"enableU2f",
"u2fMode",
} }
// LoadFile returns nil without an error when the file does not exist. // LoadFile returns nil without an error when the file does not exist.
@@ -39,18 +77,106 @@ func LoadFile(fs afero.Fs, path string) (*File, error) {
return &policy, nil return &policy, nil
} }
// RegistriesDisabled reports whether the policy turns off the plugin and theme // load returns the policy files that exist, PackagedPath first.
// registries. An explicit value in AdminPath overrides PackagedPath. func load(fs afero.Fs) ([]*File, error) {
func RegistriesDisabled(fs afero.Fs) (bool, error) { var policies []*File
disabled := false
for _, path := range []string{PackagedPath, AdminPath} { for _, path := range []string{PackagedPath, AdminPath} {
policy, err := LoadFile(fs, path) policy, err := LoadFile(fs, path)
if err != nil {
return nil, err
}
if policy != nil {
policies = append(policies, policy)
}
}
return policies, nil
}
// flag reports a boolean key, false unless set. An explicit value in AdminPath
// overrides PackagedPath.
func flag(policies []*File, value func(*File) *bool) bool {
set := false
for _, policy := range policies {
if v := value(policy); v != nil {
set = *v
}
}
return set
}
// RegistriesDisabled reports whether the policy turns off the plugin and theme
// registries. An explicit value in AdminPath overrides PackagedPath.
func RegistriesDisabled(fs afero.Fs) (bool, error) {
policies, err := load(fs)
if err != nil { if err != nil {
return false, err return false, err
} }
if policy != nil && policy.DisableRegistries != nil { return flag(policies, func(p *File) *bool { return p.DisableRegistries }), nil
disabled = *policy.DisableRegistries }
// LogindUnlockDisabled reports whether a logind Unlock must leave a lock in
// place. An explicit value in AdminPath overrides PackagedPath.
func LogindUnlockDisabled(fs afero.Fs) (bool, error) {
policies, err := load(fs)
if err != nil {
return false, err
} }
return flag(policies, func(p *File) *bool { return p.DisableLogindUnlock }), nil
}
// lockSettings merges lock_settings per key, AdminPath over PackagedPath,
// without null values and keys outside lockSettingKeys.
func lockSettings(policies []*File) map[string]json.RawMessage {
settings := make(map[string]json.RawMessage)
for _, policy := range policies {
for key, value := range policy.LockSettings {
if !slices.Contains(lockSettingKeys, key) {
log.Warnf("CLI policy: ignoring lock_settings key %q, which is not a managed lock setting", key)
continue
} }
return disabled, nil if string(value) == "null" {
continue
}
settings[key] = value
}
}
return settings
}
// LockSettings returns the managed lock settings as raw JSON by key. A policy
// file that fails to load manages none, and its error is returned.
func LockSettings(fs afero.Fs) (map[string]json.RawMessage, error) {
policies, err := load(fs)
return lockSettings(policies), err
}
// LockSettingBool returns a managed boolean lock setting, or nil when it is
// not managed or not a boolean, which the shell ignores too.
func LockSettingBool(settings map[string]json.RawMessage, key string) *bool {
var value *bool
if err := json.Unmarshal(settings[key], &value); err != nil {
return nil
}
return value
}
// ShellEnv returns every shell switch as DMS_POLICY_<KEY>=1 or 0 and the
// managed lock settings as DMS_POLICY_LOCK_SETTINGS=<JSON object>, so a value
// inherited by the shell never counts. A policy file that fails to load turns
// every switch on and manages no lock setting, and its error is returned with
// them.
func ShellEnv(fs afero.Fs) ([]string, error) {
policies, err := load(fs)
env := make([]string, 0, len(shellSwitches)+1)
for _, s := range shellSwitches {
value := "0"
if err != nil || flag(policies, s.value) {
value = "1"
}
env = append(env, "DMS_POLICY_"+strings.ToUpper(s.key)+"="+value)
}
// The values come from json.Unmarshal, so they marshal.
settings, _ := json.Marshal(lockSettings(policies))
env = append(env, "DMS_POLICY_LOCK_SETTINGS="+string(settings))
return env, err
} }
+184
View File
@@ -1,6 +1,8 @@
package clipolicy package clipolicy
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -45,3 +47,185 @@ func TestRegistriesDisabled(t *testing.T) {
}) })
} }
} }
func TestLogindUnlockDisabled(t *testing.T) {
tests := []struct {
name string
packaged string
admin string
want bool
wantErr string
}{
{name: "no policy files"},
{name: "admin policy", admin: `{"disable_logind_unlock": true}`, want: true},
{name: "admin policy overrides packaged", packaged: `{"disable_logind_unlock": true}`, admin: `{"disable_logind_unlock": false}`},
{name: "another key", admin: `{"disable_lock_ipc_unlock": true}`},
{name: "malformed admin policy", admin: "{not json", wantErr: "failed to parse " + AdminPath},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
if tt.packaged != "" {
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
}
if tt.admin != "" {
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
}
disabled, err := LogindUnlockDisabled(fs)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, disabled)
})
}
}
var shellSwitchEnv = map[string]string{
"disable_lock_ipc_unlock": "DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK",
"disable_logind_unlock": "DMS_POLICY_DISABLE_LOGIND_UNLOCK",
"disable_custom_lock_pam": "DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM",
"disable_user_plugins": "DMS_POLICY_DISABLE_USER_PLUGINS",
"disable_settings_ipc_set": "DMS_POLICY_DISABLE_SETTINGS_IPC_SET",
}
func TestShellEnv(t *testing.T) {
tests := []struct {
name string
packaged string
admin string
on bool
}{
{name: "no policy files"},
{name: "packaged policy", packaged: `{"KEY": true}`, on: true},
{name: "admin policy", admin: `{"KEY": true}`, on: true},
{name: "admin policy overrides packaged", packaged: `{"KEY": true}`, admin: `{"KEY": false}`},
{name: "admin policy without the key keeps packaged", packaged: `{"KEY": true}`, admin: `{"immutable_system": true}`, on: true},
}
for key, name := range shellSwitchEnv {
for _, tt := range tests {
t.Run(key+"/"+tt.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
if tt.packaged != "" {
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(strings.ReplaceAll(tt.packaged, "KEY", key)), 0o644))
}
if tt.admin != "" {
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(strings.ReplaceAll(tt.admin, "KEY", key)), 0o644))
}
var want []string
for _, other := range shellSwitchEnv {
if other == name && tt.on {
want = append(want, other+"=1")
} else {
want = append(want, other+"=0")
}
}
want = append(want, "DMS_POLICY_LOCK_SETTINGS={}")
env, err := ShellEnv(fs)
require.NoError(t, err)
assert.ElementsMatch(t, want, env)
})
}
}
}
func TestShellEnvMalformedPolicyTurnsEverySwitchOnAndIgnoresLockSettings(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true}}`), 0o644))
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644))
want := []string{"DMS_POLICY_LOCK_SETTINGS={}"}
for _, name := range shellSwitchEnv {
want = append(want, name+"=1")
}
env, err := ShellEnv(fs)
require.ErrorContains(t, err, "failed to parse "+AdminPath)
assert.ElementsMatch(t, want, env)
}
func TestLockSettings(t *testing.T) {
tests := []struct {
name string
packaged string
admin string
want string
wantErr string
}{
{name: "no policy files", want: `{}`},
{name: "no lock settings", admin: `{"disable_registries": true}`, want: `{}`},
{name: "packaged policy", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": ""}}`, want: `{"lockBeforeSuspend": true, "customPowerActionLock": ""}`},
{name: "admin policy overrides packaged per key", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "acLockTimeout": 300}}`, admin: `{"lock_settings": {"lockBeforeSuspend": false}}`, want: `{"lockBeforeSuspend": false, "acLockTimeout": 300}`},
{name: "admin policy without lock settings keeps packaged", packaged: `{"lock_settings": {"enableFprint": false}}`, admin: `{"disable_registries": true}`, want: `{"enableFprint": false}`},
{name: "null leaves a key unset", packaged: `{"lock_settings": {"u2fMode": "and"}}`, admin: `{"lock_settings": {"u2fMode": null, "lockAtStartup": null}}`, want: `{"u2fMode": "and"}`},
{
name: "only lock settings are managed",
admin: `{"lock_settings": {"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and", "lockScreenShowPowerActions": false, "lockPamPath": "/tmp/pam", "unknownSetting": 1}}`,
want: `{"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and"}`,
},
{name: "values are passed as written", admin: `{"lock_settings": {"lockBeforeSuspend": "yes", "acLockTimeout": [1]}}`, want: `{"lockBeforeSuspend": "yes", "acLockTimeout": [1]}`},
{name: "malformed admin policy manages nothing", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: "{not json", want: `{}`, wantErr: "failed to parse " + AdminPath},
{name: "lock settings not an object", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: `{"lock_settings": ["lockBeforeSuspend"]}`, want: `{}`, wantErr: "failed to parse " + AdminPath},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
if tt.packaged != "" {
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
}
if tt.admin != "" {
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
}
settings, err := LockSettings(fs)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
got, err := json.Marshal(settings)
require.NoError(t, err)
assert.JSONEq(t, tt.want, string(got))
})
}
}
func TestShellEnvLockSettings(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": false, "acLockTimeout": 300}}`), 0o644))
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": "", "lockScreenShowWeather": false}}`), 0o644))
env, err := ShellEnv(fs)
require.NoError(t, err)
assert.Contains(t, env, `DMS_POLICY_LOCK_SETTINGS={"acLockTimeout":300,"customPowerActionLock":"","lockBeforeSuspend":true}`)
}
func TestLockSettingBool(t *testing.T) {
on, off := true, false
tests := []struct {
name string
settings string
want *bool
}{
{name: "not managed", settings: `{"lockAtStartup": true}`},
{name: "managed on", settings: `{"lockBeforeSuspend": true}`, want: &on},
{name: "managed off", settings: `{"lockBeforeSuspend": false}`, want: &off},
{name: "null", settings: `{"lockBeforeSuspend": null}`},
{name: "not a boolean", settings: `{"lockBeforeSuspend": "true"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var settings map[string]json.RawMessage
require.NoError(t, json.Unmarshal([]byte(tt.settings), &settings))
assert.Equal(t, tt.want, LockSettingBool(settings, "lockBeforeSuspend"))
})
}
}
+13 -13
View File
@@ -182,7 +182,7 @@ layout {
} }
func TestConfigDeploymentFlow(t *testing.T) { func TestConfigDeploymentFlow(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-test") tempDir, err := os.MkdirTemp("", "duxinstall-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -312,7 +312,7 @@ func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
} }
func TestHyprlandConfigDeployment(t *testing.T) { func TestHyprlandConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-test") tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -324,7 +324,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
cd := NewConfigDeployer(logChan) cd := NewConfigDeployer(logChan)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) { t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty") td, err := os.MkdirTemp("", "duxinstall-hyprland-empty")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
@@ -344,7 +344,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
}) })
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) { t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge") td, err := os.MkdirTemp("", "duxinstall-hyprland-merge")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
@@ -408,7 +408,7 @@ general {
}) })
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) { t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-lua-conf-symlink") td, err := os.MkdirTemp("", "duxinstall-hyprland-lua-conf-symlink")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
@@ -431,7 +431,7 @@ general {
}) })
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) { t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds") td, err := os.MkdirTemp("", "duxinstall-hyprland-refresh-binds")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
@@ -457,7 +457,7 @@ general {
} }
func TestKittyConfigDeployment(t *testing.T) { func TestKittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-kitty-test") tempDir, err := os.MkdirTemp("", "duxinstall-kitty-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -495,7 +495,7 @@ func TestKittyConfigDeployment(t *testing.T) {
} }
func TestAlacrittyConfigDeployment(t *testing.T) { func TestAlacrittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-alacritty-test") tempDir, err := os.MkdirTemp("", "duxinstall-alacritty-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -570,7 +570,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
} }
t.Run("replaceConfigs nil deploys config", func(t *testing.T) { t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test") tempDir, err := os.MkdirTemp("", "duxinstall-replace-nil-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -603,7 +603,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}) })
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) { t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test") tempDir, err := os.MkdirTemp("", "duxinstall-replace-missing-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -636,7 +636,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}) })
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) { t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test") tempDir, err := os.MkdirTemp("", "duxinstall-replace-exists-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -678,7 +678,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}) })
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) { t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test") tempDir, err := os.MkdirTemp("", "duxinstall-replace-true-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -725,7 +725,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}) })
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) { t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test") tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-legacy-skip-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -1,6 +1,6 @@
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf). # Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
# Remove `source=./dms/colors.conf` from config.conf to override manually. # Remove `source=./dms/colors.conf` from config.conf to override manually.
bordercolor = 0x595959ff border_color = 0x595959ff
focuscolor = 0x8ab4f8ff focus_color = 0x8ab4f8ff
urgentcolor = 0xff5555ff urgent_color = 0xff5555ff
@@ -1,8 +1,12 @@
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf). # Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
border_radius=12 border_radius=12
gappih=5 gap_inner_horizontal=5
gappiv=5 gap_inner_vertical=5
gappoh=5 gap_outer_horizontal=5
gappov=5 gap_outer_vertical=5
borderpx=2 border_px=2
# layer_animations would tween every DMS layer move; override with no_animation:0 rules after the source lines.
layer_rule=no_animation:1,layer_name:^(quickshell)$
layer_rule=no_animation:1,layer_name:^dms:.*
+1 -1
View File
@@ -7,7 +7,7 @@ env=XDG_SESSION_TYPE,wayland
# Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs # Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs
# exec= on every config reload, and DMS reloads the config. # exec= on every config reload, and DMS reloads the config.
exec-once=dms run exec_once=dms run
source=./dms/colors.conf source=./dms/colors.conf
source=./dms/layout.conf source=./dms/layout.conf
+10 -12
View File
@@ -41,14 +41,13 @@ func nonEmptyFile(path string) (string, bool) {
} }
var ( var (
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|keymode|key_mode)\s*=`) mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|key_mode)\s*=`)
mangoMonitorLine = regexp.MustCompile(`^\s*(monitorrule|monitor_rule)\s*=`) mangoMonitorLine = regexp.MustCompile(`^\s*monitor_rule\s*=`)
mangoWindowLine = regexp.MustCompile(`^\s*(windowrule|window_rule|windowrule-once|window_rule_once)\s*=`) mangoWindowLine = regexp.MustCompile(`^\s*window_rule(_once)?\s*=`)
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\n(exec-once|exec_once)=dms run\n`) mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\nexec_once=dms run\n`)
) )
var ( var (
mangoDialect = mangoconf.Detect
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
) )
@@ -110,17 +109,16 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
} }
dialect := mangoDialect()
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand) newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) { if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "") newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
} }
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil { if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err) result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error return result, result.Error
} }
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, dialect, existing, timestamp); err != nil { if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error return result, result.Error
} }
@@ -132,7 +130,7 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
// Like niri, fragments the user or the shell already filled are left alone; the // Like niri, fragments the user or the shell already filled are left alone; the
// replaced config.conf hands its binds, monitor and window rules to empty ones. // replaced config.conf hands its binds, monitor and window rules to empty ones.
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string, dialect mangoconf.Dialect, oldMain, timestamp string) error { func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand, oldMain, timestamp string) error {
stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand) stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
bindsPath := filepath.Join(dmsDir, "binds.conf") bindsPath := filepath.Join(dmsDir, "binds.conf")
if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 { if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 {
@@ -142,7 +140,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
} }
cd.log(fmt.Sprintf("Backed up existing binds to %s", backup)) cd.log(fmt.Sprintf("Backed up existing binds to %s", backup))
if cd.replaceBinds { if cd.replaceBinds {
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(stockBinds)), 0o644); err != nil { if err := os.WriteFile(bindsPath, []byte(stockBinds), 0o644); err != nil {
return fmt.Errorf("failed to write binds.conf: %w", err) return fmt.Errorf("failed to write binds.conf: %w", err)
} }
cd.log("Deployed DMS stock binds.conf") cd.log("Deployed DMS stock binds.conf")
@@ -160,7 +158,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
binds = mangoconf.BindsMovedHeader + "\n" + old binds = mangoconf.BindsMovedHeader + "\n" + old
cd.log("Moved existing binds from config.conf to binds.conf") cd.log("Moved existing binds from config.conf to binds.conf")
} }
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(binds)), 0o644); err != nil { if err := os.WriteFile(bindsPath, []byte(binds), 0o644); err != nil {
return fmt.Errorf("failed to write binds.conf: %w", err) return fmt.Errorf("failed to write binds.conf: %w", err)
} }
} }
@@ -182,7 +180,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name)) cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue continue
} }
if err := os.WriteFile(path, []byte(dialect.Translate(cfg.content)), 0o644); err != nil { if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err) return fmt.Errorf("failed to write %s: %w", cfg.name, err)
} }
cd.log(fmt.Sprintf("Deployed %s", cfg.name)) cd.log(fmt.Sprintf("Deployed %s", cfg.name))
+17 -17
View File
@@ -13,12 +13,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func stubMango(t *testing.T, dialect mangoconf.Dialect, target bool, addWants error) *int { func stubMango(t *testing.T, target bool, addWants error) *int {
t.Helper() t.Helper()
prevDialect, prevTarget := mangoDialect, mangoSessionTargetInstalled prevTarget := mangoSessionTargetInstalled
mangoDialect = func() mangoconf.Dialect { return dialect }
mangoSessionTargetInstalled = func() bool { return target } mangoSessionTargetInstalled = func() bool { return target }
t.Cleanup(func() { mangoDialect, mangoSessionTargetInstalled = prevDialect, prevTarget }) t.Cleanup(func() { mangoSessionTargetInstalled = prevTarget })
return stubAddWants(t, addWants) return stubAddWants(t, addWants)
} }
@@ -31,16 +30,16 @@ func readMango(t *testing.T, home, rel string) string {
const oldMangoConfig = `exec-once=waybar const oldMangoConfig = `exec-once=waybar
bind=SUPER,Return,spawn,foot bind=SUPER,Return,spawn,foot
keymode=resize key_mode=resize
bind=NONE,h,resizewin,-10,0 bind=NONE,h,resizewin,-10,0
monitorrule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1 monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
windowrule=appid:pavucontrol,isfloating:1 window_rule=app_id:pavucontrol,is_floating:1
` `
func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) { func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
stubMango(t, mangoconf.Legacy, false, nil) stubMango(t, false, nil)
require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644))
@@ -48,25 +47,26 @@ func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, result.BackupPath) assert.NotEmpty(t, result.BackupPath)
assert.Contains(t, readMango(t, home, "config.conf"), "exec-once=dms run") assert.Contains(t, readMango(t, home, "config.conf"), "exec_once=dms run")
assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf") assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf")
assert.Equal(t, "", readMango(t, home, "dms/input.conf")) assert.Equal(t, "", readMango(t, home, "dms/input.conf"))
binds := readMango(t, home, "dms/binds.conf") binds := readMango(t, home, "dms/binds.conf")
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkeymode=resize\nbind=NONE,h,resizewin,-10,0") assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkey_mode=resize\nbind=NONE,h,resizewin,-10,0")
assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}") assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}")
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "monitor_rule=name:^DP-1$")
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1") assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1")
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "windowrule=appid:pavucontrol,isfloating:1") assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "window_rule=app_id:pavucontrol,is_floating:1")
} }
func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) { func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
for _, replace := range []bool{false, true} { for _, replace := range []bool{false, true} {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
stubMango(t, mangoconf.Legacy, false, nil) stubMango(t, false, nil)
dms := filepath.Join(home, ".config", "mango", "dms") dms := filepath.Join(home, ".config", "mango", "dms")
require.NoError(t, os.MkdirAll(dms, 0o755)) require.NoError(t, os.MkdirAll(dms, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\nkey_mode=resize\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("windowrule=appid:keep,isglobal:1\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("window_rule=app_id:keep,is_global:1\n"), 0o644))
cd := NewConfigDeployer(nil) cd := NewConfigDeployer(nil)
cd.SetReplaceBinds(replace) cd.SetReplaceBinds(replace)
@@ -76,8 +76,8 @@ func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*")) backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*"))
assert.Len(t, backups, 1, "replace=%v", replace) assert.Len(t, backups, 1, "replace=%v", replace)
binds := readMango(t, home, "dms/binds.conf") binds := readMango(t, home, "dms/binds.conf")
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\n", "replace=%v", replace) assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\nkey_mode=resize\n", "replace=%v", replace)
assert.Equal(t, "windowrule=appid:keep,isglobal:1\n", readMango(t, home, "dms/windowrules.conf")) assert.Equal(t, "window_rule=app_id:keep,is_global:1\n", readMango(t, home, "dms/windowrules.conf"), "kept fragments are left alone")
} }
} }
@@ -97,7 +97,7 @@ func TestMangoDeploySystemdAutostart(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
calls := stubMango(t, mangoconf.Snake, tc.target, tc.addWants) calls := stubMango(t, tc.target, tc.addWants)
_, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true) _, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true)
require.NoError(t, err) require.NoError(t, err)
main := readMango(t, home, "config.conf") main := readMango(t, home, "config.conf")
+1 -1
View File
@@ -43,7 +43,7 @@ func TestDisableSessionRemovesEveryWantsLink(t *testing.T) {
func TestEnableSessionMangoWithoutTarget(t *testing.T) { func TestEnableSessionMangoWithoutTarget(t *testing.T) {
t.Setenv("HOME", t.TempDir()) t.Setenv("HOME", t.TempDir())
calls := stubMango(t, mangoconf.Snake, false, nil) calls := stubMango(t, false, nil)
unit, err := EnableSession(deps.WindowManagerMango) unit, err := EnableSession(deps.WindowManagerMango)
require.Error(t, err) require.Error(t, err)
assert.Empty(t, unit) assert.Empty(t, unit)
+31 -35
View File
@@ -128,7 +128,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
func (a *ArchDistribution) detectDanksearch() deps.Dependency { func (a *ArchDistribution) detectDanksearch() deps.Dependency {
dep := a.BaseDistribution.detectDanksearch() dep := a.BaseDistribution.detectDanksearch()
dep.CanToggle = true dep.CanToggle = true
if a.packageInstalled("dsearch-git") { if a.packageInstalled("dux-search-git") {
dep.Variant = deps.VariantGit dep.Variant = deps.VariantGit
} }
return dep return dep
@@ -136,14 +136,14 @@ func (a *ArchDistribution) detectDanksearch() deps.Dependency {
func (a *ArchDistribution) detectDMS() deps.Dependency { func (a *ArchDistribution) detectDMS() deps.Dependency {
dep := a.BaseDistribution.detectDMS() dep := a.BaseDistribution.detectDMS()
if a.packageInstalled("dms-shell-git") { if a.packageInstalled("dux-shell-git") {
dep.Variant = deps.VariantGit dep.Variant = deps.VariantGit
} }
return dep return dep
} }
func (a *ArchDistribution) detectDankCalendar() deps.Dependency { func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dankcalendar-git")) return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dux-calendar-git"))
} }
func (a *ArchDistribution) detectXDGPortal() deps.Dependency { func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
@@ -155,19 +155,17 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
} }
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency { func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin") installed := a.packageInstalled("dux-greeter-git") || a.packageInstalled("dux-greeter-bin")
dep := a.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", installed) dep := a.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", installed)
dep.CanToggle = true dep.CanToggle = true
if a.packageInstalled("greetd-dms-greeter-git") { if a.packageInstalled("dux-greeter-git") {
dep.Variant = deps.VariantGit dep.Variant = deps.VariantGit
} }
return dep return dep
} }
func (a *ArchDistribution) packageInstalled(pkg string) bool { func (a *ArchDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("pacman", "-Q", pkg) return exec.Command("pacman", "-Q", pkg).Run() == nil
err := cmd.Run()
return err == nil
} }
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package // parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
@@ -270,7 +268,7 @@ func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageM
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR} return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR} return PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
} }
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
@@ -286,30 +284,30 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-search-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-search-bin", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-calendar-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-calendar-bin", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-greeter-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-greeter-bin", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
if forceDMSGit || variant == deps.VariantGit { if forceDMSGit || variant == deps.VariantGit {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-shell-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem} return PackageMapping{Name: "dux-shell", Repository: RepoTypeSystem}
} }
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency { func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
@@ -396,7 +394,7 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags) systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") { if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dux-shell") {
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil { if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to preinstall quickshell-git: %w", err) return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
} }
@@ -566,7 +564,7 @@ func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPass
Progress: 0.18, Progress: 0.18,
Step: "Building quickshell-git before system packages...", Step: "Building quickshell-git before system packages...",
IsComplete: false, IsComplete: false,
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict", CommandInfo: "Installing quickshell-git ahead of dux-shell to avoid conflict",
} }
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32) return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
} }
@@ -587,7 +585,8 @@ func (a *ArchDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap
} }
if swap.To.Repository == RepoTypeAUR { if swap.To.Repository == RepoTypeAUR {
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, swap.From.Name, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil { from := swap.From.Name
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, from, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil {
return err return err
} }
continue continue
@@ -608,11 +607,7 @@ 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 := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, packages...)
if slices.Contains(packages, "dms-shell") {
args = append(args, "--assume-installed", "dms-shell-compositor=1")
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages, Phase: PhaseSystemPackages,
@@ -658,7 +653,7 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
} }
} }
// Reorder packages to ensure dms-shell-git dependencies are installed first // Reorder packages to ensure dux-shell-git dependencies are installed first
orderedPackages := a.reorderAURPackages(packages) orderedPackages := a.reorderAURPackages(packages)
baseProgress := 0.67 baseProgress := 0.67
@@ -699,7 +694,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" { if pkg == "dux-shell-git" {
dmsShell = append(dmsShell, pkg) dmsShell = append(dmsShell, pkg)
} else { } else {
isDep := false isDep := false
@@ -735,7 +730,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
return fmt.Errorf("failed to get user home directory: %w", err) return fmt.Errorf("failed to get user home directory: %w", err)
} }
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "aur-builds", pkg) buildDir := filepath.Join(homeDir, ".cache", "duxinstall", "aur-builds", pkg)
// Clean up any existing cache first // Clean up any existing cache first
if err := os.RemoveAll(buildDir); err != nil { if err := os.RemoveAll(buildDir); err != nil {
@@ -781,17 +776,18 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
} }
} }
if pkg == "dms-shell-git" { if pkg == "dux-shell-git" {
srcinfoPath := filepath.Join(packageDir, ".SRCINFO") srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
depsToRemove := []string{ // extra ships dgop only as dux-gop (provides dgop), which makepkg resolves but pacman -Si does not
"depends = quickshell", srcinfoEdits := []string{
"depends = dgop", "/depends = quickshell/d",
`s/^\(\s*depends = \)dgop$/\1dux-gop/`,
} }
for _, dep := range depsToRemove { for _, edit := range srcinfoEdits {
sedCmd := exec.CommandContext(ctx, "sed", "-i", fmt.Sprintf("/%s/d", dep), srcinfoPath) sedCmd := exec.CommandContext(ctx, "sed", "-i", edit, srcinfoPath)
if err := sedCmd.Run(); err != nil { if err := sedCmd.Run(); err != nil {
return fmt.Errorf("failed to remove dependency %s from .SRCINFO for %s: %w", dep, pkg, err) return fmt.Errorf("failed to patch .SRCINFO for %s: %w", pkg, err)
} }
} }
} }
+3 -3
View File
@@ -502,7 +502,7 @@ func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressCha
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: phase, Phase: phase,
Progress: endProgress, Progress: endProgress,
Step: "Installation step complete", Step: stepMessage,
IsComplete: false, IsComplete: false,
LogOutput: lastOutput, LogOutput: lastOutput,
} }
@@ -541,7 +541,7 @@ func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressCha
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: phase, Phase: phase,
Progress: progress, Progress: progress,
Step: "Installing...", Step: stepMessage,
IsComplete: false, IsComplete: false,
LogOutput: lastOutput, LogOutput: lastOutput,
} }
@@ -659,7 +659,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
if err != nil { if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err) return fmt.Errorf("failed to get user home directory: %w", err)
} }
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds") tmpDir := filepath.Join(homeDir, ".cache", "duxinstall", "manual-builds")
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
+71 -18
View File
@@ -159,11 +159,11 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
// DMS packages from OBS with variant support // DMS packages from OBS with variant support
"dms (Dux Shell)": d.getDmsMapping(variants["dms (Dux Shell)"]), "dms (Dux Shell)": d.getDmsMapping(variants["dms (Dux Shell)"]),
"quickshell": d.getQuickshellMapping(variants["quickshell"]), "quickshell": d.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, "home:AvengeMedia:danklinux"), "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, obsDeps),
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: obsDeps},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: obsDeps},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: obsDeps},
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"), "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, obsDeps),
} }
if wm == deps.WindowManagerNiri { if wm == deps.WindowManagerNiri {
@@ -177,14 +177,14 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
func (d *DebianDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { func (d *DebianDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms-git"} return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: obsDMSGit}
} }
return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms"} return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: obsDMS}
} }
func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit { if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
// Debian 13 ships stable quickshell in trixie-backports only. // Debian 13 ships stable quickshell in trixie-backports only.
if d.debianNeedsQuickshellBackports() { if d.debianNeedsQuickshellBackports() {
@@ -244,16 +244,16 @@ func (d *DebianDistribution) ensureQuickshellBackports(ctx context.Context, syst
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
return PackageMapping{Name: "niri", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "niri", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
func (d *DebianDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { func (d *DebianDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -491,6 +491,62 @@ func (d *DebianDistribution) aptInstallArgs(packages []string, minimal bool) []s
return append(args, packages...) return append(args, packages...)
} }
func (d *DebianDistribution) RepoMoves() []RepoMove {
if !obsMigrationReady || d.isPika() {
return nil
}
return obsAptChannel.moves(repoPathExists)
}
func (d *DebianDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if err := d.enableOBSRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil {
return err
}
removes := repoMoveRemoves(moves)
for _, move := range removes {
command := fmt.Sprintf("rm -f %s %s", obsAptListFile(move.Old), obsAptKeyring(move.Old))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.30,
Step: fmt.Sprintf("Removing OBS repo %s...", move.Old),
NeedsSudo: true,
CommandInfo: "sudo " + command,
}
if err := d.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
return fmt.Errorf("failed to remove OBS repo %s: %w", move.Old, err)
}
}
if len(removes) == 0 {
return nil
}
return d.aptUpdate(ctx, sudoPassword, progressChan)
}
func (d *DebianDistribution) RepoMoveCommands(moves []RepoMove) []string {
osInfo, err := GetOSInfo()
if err != nil {
osInfo = &OSInfo{}
}
return debianRepoMoveCommands(moves, debianOBSRepoVersion(osInfo, readDebianAPTSuites()), debianRepoArchitecture(osInfo.Architecture))
}
func debianRepoMoveCommands(moves []RepoMove, dist, arch string) []string {
var commands []string
for _, add := range repoMoveAdds(moves) {
baseURL := obsBaseURL(add.RepoURL, dist)
commands = append(commands,
fmt.Sprintf("curl -fsSL %s/Release.key | gpg --dearmor | sudo tee %s", baseURL, obsAptKeyring(add.RepoURL)),
fmt.Sprintf("echo 'deb [signed-by=%s arch=%s] %s/ /' | sudo tee %s", obsAptKeyring(add.RepoURL), arch, baseURL, obsAptListFile(add.RepoURL)))
}
for _, move := range repoMoveRemoves(moves) {
commands = append(commands, fmt.Sprintf("sudo rm -f %s %s", obsAptListFile(move.Old), obsAptKeyring(move.Old)))
}
if len(commands) > 0 {
commands = append(commands, "sudo apt-get update")
}
return commands
}
func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool) enabledRepos := make(map[string]bool)
@@ -506,20 +562,17 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] { if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
d.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL)) d.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL))
// RepoURL format: "home:AvengeMedia:danklinux" baseURL := obsBaseURL(pkg.RepoURL, obsRepo)
repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/")
repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-")
baseURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s", repoPath, obsRepo)
// Check if repository already exists // Check if repository already exists
listFile := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", repoName) listFile := obsAptListFile(pkg.RepoURL)
if debianOBSListFileMatches(listFile, obsRepo) { if debianOBSListFileMatches(listFile, obsRepo) {
d.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL)) d.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true enabledRepos[pkg.RepoURL] = true
continue continue
} }
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName) keyringPath := obsAptKeyring(pkg.RepoURL)
// Create keyrings directory if it doesn't exist // Create keyrings directory if it doesn't exist
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings") mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
+174 -29
View File
@@ -3,6 +3,7 @@ package distros
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"os/exec" "os/exec"
"strings" "strings"
@@ -23,9 +24,6 @@ func init() {
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan) return NewFedoraDistribution(config, logChan)
}) })
Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan) return NewFedoraDistribution(config, logChan)
}) })
@@ -54,6 +52,8 @@ type FedoraDistribution struct {
*BaseDistribution *BaseDistribution
*ManualPackageInstaller *ManualPackageInstaller
config DistroConfig config DistroConfig
rpmInstalled func(pkg string) bool
} }
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution { func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
@@ -138,6 +138,9 @@ func (f *FedoraDistribution) detectXDGPortal() deps.Dependency {
} }
func (f *FedoraDistribution) packageInstalled(pkg string) bool { func (f *FedoraDistribution) packageInstalled(pkg string) bool {
if f.rpmInstalled != nil {
return f.rpmInstalled(pkg)
}
cmd := exec.Command("rpm", "-q", pkg) cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run() err := cmd.Run()
return err == nil return err == nil
@@ -150,19 +153,31 @@ func (f *FedoraDistribution) InstalledVariant(stable, git PackageMapping) (deps.
if !f.packageInstalled(stable.Name) { if !f.packageInstalled(stable.Name) {
return deps.VariantStable, false return deps.VariantStable, false
} }
if git.Repository == RepoTypeCOPR && fedoraInstalledFromCOPR(stable.Name, git.RepoURL) { if git.Repository == RepoTypeCOPR && installedFromCOPR(stable.Name, git.RepoURL) {
return deps.VariantGit, true return deps.VariantGit, true
} }
return deps.VariantStable, true return deps.VariantStable, true
} }
var installedFromCOPR = fedoraInstalledFromCOPR
// fedoraInstalledFromCOPR tells same-named variants apart (dms, niri), which only differ by COPR. // fedoraInstalledFromCOPR tells same-named variants apart (dms, niri), which only differ by COPR.
func fedoraInstalledFromCOPR(pkg, coprRepo string) bool { func fedoraInstalledFromCOPR(pkg, coprRepo string) bool {
out, err := exec.Command("dnf", "repoquery", "--installed", "--qf", "%{from_repo}", pkg).Output() out, err := exec.Command("dnf", "repoquery", "--installed", "--qf", "%{from_repo}", pkg).Output()
if err != nil {
return rpmLooksLikeGitBuild(pkg)
}
return coprFromRepoMatches(string(out), coprRepo)
}
// niri-git carries "git" in its version; dms-git only differs by its Epoch of 2.
func rpmLooksLikeGitBuild(pkg string) bool {
out, err := exec.Command("rpm", "-q", "--qf", "%{EPOCH} %{VERSION}-%{RELEASE}", pkg).Output()
if err != nil { if err != nil {
return false return false
} }
return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(coprRepo, "/", ":")) epoch, versionRelease, _ := strings.Cut(strings.TrimSpace(string(out)), " ")
return strings.Contains(versionRelease, "git") || (pkg == "dms" && epoch == "2")
} }
func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -172,15 +187,20 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw
for _, swap := range swaps { for _, swap := range swaps {
command := fmt.Sprintf("dnf swap -y %s %s", swap.From.Name, swap.To.Name) command := fmt.Sprintf("dnf swap -y %s %s", swap.From.Name, swap.To.Name)
disabledRepo := "" var disabledRepos []string
if swap.From.Name == swap.To.Name { if swap.From.Name == swap.To.Name {
// Left enabled, the old COPR wins distro-sync whenever its version sorts higher (dms-git has Epoch 2). // Left enabled, the old COPR wins distro-sync whenever its version sorts higher (dms-git has Epoch 2).
// An un-migrated host still has it under the legacy owner, so every alias goes.
if swap.From.Repository == RepoTypeCOPR { if swap.From.Repository == RepoTypeCOPR {
disable := privesc.ExecCommand(ctx, sudoPassword, "dnf copr disable -y "+swap.From.RepoURL) for _, repo := range coprAliases(swap.From.RepoURL) {
if err := f.runWithProgress(disable, progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil { if !repoPathExists(coprRepoFile(repo)) {
return fmt.Errorf("failed to disable COPR repo %s: %w", swap.From.RepoURL, err) continue
}
if err := f.coprRepoOp(ctx, "disable", repo, sudoPassword, progressChan); err != nil {
return err
}
disabledRepos = append(disabledRepos, repo)
} }
disabledRepo = swap.From.RepoURL
} }
command = "dnf distro-sync -y " + swap.To.Name command = "dnf distro-sync -y " + swap.To.Name
} }
@@ -192,11 +212,10 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw
NeedsSudo: true, NeedsSudo: true,
CommandInfo: "sudo " + command, CommandInfo: "sudo " + command,
} }
if err := f.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85); err != nil { if err := f.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85); err != nil {
if disabledRepo != "" { for _, repo := range disabledRepos {
reenable := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y "+disabledRepo) if reErr := privescExec(ctx, sudoPassword, "dnf copr enable -y "+repo).Run(); reErr != nil {
if reErr := reenable.Run(); reErr != nil { f.log(fmt.Sprintf("Warning: could not re-enable COPR repo %s: %v", repo, reErr))
f.log(fmt.Sprintf("Warning: could not re-enable COPR repo %s: %v", disabledRepo, reErr))
} }
} }
return fmt.Errorf("failed to switch %s to %s: %w", swap.From.Name, swap.To.Name, err) return fmt.Errorf("failed to switch %s to %s: %w", swap.From.Name, swap.To.Name, err)
@@ -205,6 +224,60 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw
return nil return nil
} }
func (f *FedoraDistribution) coprRepoOp(ctx context.Context, verb, repo, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
command := fmt.Sprintf("dnf copr %s -y %s", verb, repo)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.30,
Step: fmt.Sprintf("Running dnf copr %s %s...", verb, repo),
NeedsSudo: true,
CommandInfo: "sudo " + command,
}
if err := f.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
return fmt.Errorf("failed to %s COPR repo %s: %w", verb, repo, err)
}
return nil
}
func (f *FedoraDistribution) RepoMoves() []RepoMove {
return coprChannel.moves(repoPathExists)
}
func (f *FedoraDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if err := f.enableCOPRRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil {
return err
}
for _, move := range repoMoveRemoves(moves) {
if err := f.coprRepoOp(ctx, "remove", move.Old, sudoPassword, progressChan); err != nil {
return err
}
}
return nil
}
func (f *FedoraDistribution) RepoMoveCommands(moves []RepoMove) []string {
var commands []string
for _, add := range repoMoveAdds(moves) {
commands = append(commands, "sudo dnf copr enable -y "+add.RepoURL)
}
for _, move := range repoMoveRemoves(moves) {
commands = append(commands, "sudo dnf copr remove -y "+move.Old)
}
return commands
}
// Shown to the user, never run: image-based Fedora has no dnf copr.
func (f *FedoraDistribution) ImageRepoMoveCommands(moves []RepoMove) []string {
var commands []string
for _, add := range repoMoveAdds(moves) {
commands = append(commands, f.coprRepoFileCommand(add.RepoURL))
}
for _, move := range repoMoveRemoves(moves) {
commands = append(commands, "sudo rm -f "+move.OldPath)
}
return commands
}
func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
} }
@@ -213,7 +286,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
packages := map[string]PackageMapping{ packages := map[string]PackageMapping{
// Standard DNF packages // Standard DNF packages
"git": {Name: "git", Repository: RepoTypeSystem}, "git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: coprDeps},
"kitty": {Name: "kitty", Repository: RepoTypeSystem}, "kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, "alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
@@ -221,11 +294,11 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
// COPR packages // COPR packages
"quickshell": f.getQuickshellMapping(variants["quickshell"]), "quickshell": f.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "avengemedia/danklinux"), "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, coprDeps),
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: coprDeps},
"dms (Dux Shell)": f.getDmsMapping(variants["dms (Dux Shell)"]), "dms (Dux Shell)": f.getDmsMapping(variants["dms (Dux Shell)"]),
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, "danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: coprDeps},
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, "avengemedia/danklinux"), "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, coprDeps),
} }
switch wm { switch wm {
@@ -244,10 +317,10 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
} }
if f.isEL() { if f.isEL() {
// EPEL 10 has neither, so the danklinux COPR builds them for the epel-10 chroots. // EPEL 10 has neither, so the deps COPR builds them for the epel-10 chroots.
for _, name := range []string{"alacritty", "xwayland-satellite"} { for _, name := range []string{"alacritty", "xwayland-satellite"} {
if _, ok := packages[name]; ok { if _, ok := packages[name]; ok {
packages[name] = PackageMapping{Name: name, Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"} packages[name] = PackageMapping{Name: name, Repository: RepoTypeCOPR, RepoURL: coprDeps}
} }
} }
} }
@@ -257,16 +330,16 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit { if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"} return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: coprDeps}
} }
return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"} return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: coprDeps}
} }
func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms-git"} return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: coprDMSGit}
} }
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms"} return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: coprDMS}
} }
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
@@ -633,7 +706,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 := privesc.ExecCommand(ctx, sudoPassword, cmd := privescExec(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 {
@@ -647,7 +720,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
// Special handling for niri COPR repo - set priority=1 // Special handling for niri COPR repo - set priority=1
if pkg.RepoURL == "yalter/niri-git" { if pkg.RepoURL == "yalter/niri-git" {
f.log("Setting priority=1 for niri-git COPR repo...") f.log("Setting priority=1 for niri-git COPR repo...")
repoFile := "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo" repoFile := coprRepoFile("yalter/niri-git")
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages, Phase: PhaseSystemPackages,
Progress: 0.22, Progress: 0.22,
@@ -657,7 +730,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 := privesc.ExecCommand(ctx, sudoPassword, priorityCmd := privescExec(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 {
@@ -744,3 +817,75 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st
} }
return nil return nil
} }
// Image-based Fedora has rpm-ostree but no dnf, so the .repo file is fetched directly.
func (f *FedoraDistribution) coprRepoFileCommand(repo string) string {
owner, project, _ := strings.Cut(repo, "/")
chroot := "fedora-$(rpm -E %fedora)"
if f.isEL() {
chroot = "epel-$(rpm -E %rhel)"
}
return fmt.Sprintf("sudo curl -fsSLo %s https://copr.fedorainfracloud.org/coprs/%s/%s/repo/%s/%s-%s-%s.repo",
coprRepoFile(repo), owner, project, chroot, owner, project, chroot)
}
// Shown to the user, never run. Terra-only packages have no rpm-ostree path.
func (f *FedoraDistribution) LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string {
var commands []string
var uninstall []string
skip := maps.Clone(disabledFlags)
if skip == nil {
skip = map[string]bool{}
}
// rpm-ostree refuses to install what is already layered, so a reinstall mark only means a variant switch.
for _, dep := range dependencies {
if !reinstallFlags[dep.Name] || skip[dep.Name] || dep.Status != deps.StatusInstalled {
continue
}
swap, status := PlanVariantSwap(f, wm, dep.Name, dep.Variant)
if status != SwapNeeded {
skip[dep.Name] = true
continue
}
uninstall = append(uninstall, swap.From.Name)
if swap.From.Name == swap.To.Name && swap.From.Repository == RepoTypeCOPR {
var files []string
for _, repo := range coprAliases(swap.From.RepoURL) {
files = append(files, coprRepoFile(repo))
}
commands = append(commands, "sudo rm -f "+strings.Join(files, " "))
}
}
dnfPkgs, coprPkgs, manualPkgs, _ := f.categorizePackages(dependencies, wm, reinstallFlags, skip)
seen := map[string]bool{}
pkgs := append([]string{}, dnfPkgs...)
for _, pkg := range coprPkgs {
pkgs = append(pkgs, pkg.Name)
if pkg.RepoURL == "" || seen[pkg.RepoURL] {
continue
}
seen[pkg.RepoURL] = true
commands = append(commands, f.coprRepoFileCommand(pkg.RepoURL))
if pkg.RepoURL == "yalter/niri-git" {
commands = append(commands, "echo priority=1 | sudo tee -a "+coprRepoFile("yalter/niri-git"))
}
}
for _, dep := range dependencies {
if dep.Status == deps.StatusNeedsUpdate && !disabledFlags[dep.Name] {
commands = append(commands, "sudo rpm-ostree upgrade")
break
}
}
if len(pkgs) > 0 {
install := "sudo rpm-ostree install " + strings.Join(pkgs, " ")
if len(uninstall) > 0 {
install += " --uninstall " + strings.Join(uninstall, " ")
}
commands = append(commands, install)
}
for _, name := range manualPkgs {
commands = append(commands, fmt.Sprintf("# %s has no package for image-based Fedora", name))
}
return append(commands, "systemctl reboot")
}
+2 -2
View File
@@ -9,8 +9,8 @@ import (
func TestFedoraPackageMapping_EnterpriseLinuxUsesCOPRForMissingEPELPackages(t *testing.T) { func TestFedoraPackageMapping_EnterpriseLinuxUsesCOPRForMissingEPELPackages(t *testing.T) {
el := NewFedoraDistribution(Registry["rhel"], nil).GetPackageMapping(deps.WindowManagerNiri) el := NewFedoraDistribution(Registry["rhel"], nil).GetPackageMapping(deps.WindowManagerNiri)
for _, name := range []string{"alacritty", "xwayland-satellite"} { for _, name := range []string{"alacritty", "xwayland-satellite"} {
if el[name].Repository != RepoTypeCOPR || el[name].RepoURL != "avengemedia/danklinux" { if el[name].Repository != RepoTypeCOPR || el[name].RepoURL != "duxlabs/dux-repo" {
t.Errorf("rhel %s = %+v, want avengemedia/danklinux COPR", name, el[name]) t.Errorf("rhel %s = %+v, want duxlabs/dux-repo COPR", name, el[name])
} }
} }
+127
View File
@@ -0,0 +1,127 @@
package distros
import (
"fmt"
"os/exec"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/spf13/afero"
)
// Markers only reached when /run/ostree-booted is absent, such as a chroot.
var immutableIDs = map[string]bool{
"bluefin": true,
"bazzite": true,
"aurora": true,
"secureblue": true,
"zirconium": true,
"fedora-iot": true,
"fedora-coreos": true,
"opensuse-microos": true,
"opensuse-aeon": true,
"steamos": true,
"org.gnome.os": true,
"blendos": true,
"arkane": true,
}
var immutableVariantMarkers = []string{"silverblue", "kinoite", "sericea", "onyx", "atomic", "coreos", "iot"}
var immutableMarkerFiles = []struct {
path string
reason string
}{
{"/run/ostree-booted", "ostree-based system"},
{"/usr/sbin/transactional-update", "transactional-update system"},
{"/usr/bin/abroot", "ABRoot system"},
}
const ZirconiumSwitchCommand = "sudo bootc switch ghcr.io/zirconium-dev/zirconium:latest"
// The commands are shown to the user, never run.
type PackageLayerer interface {
LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string
}
func DetectImmutable() (bool, string) {
fs := afero.NewOsFs()
return detectImmutable(fs, readOSRelease(fs))
}
func detectImmutable(fs afero.Fs, osRelease map[string]string) (bool, string) {
for _, marker := range immutableMarkerFiles {
if exists, _ := afero.Exists(fs, marker.path); exists {
return true, fmt.Sprintf("%s (%s)", marker.reason, marker.path)
}
}
id := strings.ToLower(osRelease["ID"])
if immutableIDs[id] {
return true, "os-release ID=" + id
}
variantID := strings.ToLower(osRelease["VARIANT_ID"])
for _, marker := range immutableVariantMarkers {
if strings.Contains(variantID, marker) {
return true, "os-release VARIANT_ID=" + variantID
}
}
for _, like := range strings.Fields(strings.ToLower(osRelease["ID_LIKE"])) {
if like == "microos" || like == "ostree" {
return true, "os-release ID_LIKE=" + like
}
}
return false, ""
}
func ImmutableGuidance(info *OSInfo) string {
var b strings.Builder
fmt.Fprintf(&b, "%s is an image-based system: %s.\n\n", info.PrettyName, info.ImmutableReason)
b.WriteString("duxinstall has no package mapping for this image, so it cannot tell what is installed or deploy configuration for it. Nothing was changed on this system.\n\n")
b.WriteString("See " + site.Docs + "/getting-started for other options.")
return b.String()
}
func PendingDependencies(dependencies []deps.Dependency, reinstallFlags, disabledFlags map[string]bool) []deps.Dependency {
var pending []deps.Dependency
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pending = append(pending, dep)
}
return pending
}
// detectDMS keys off the clone directory; packaged hosts only have /usr/bin/dms.
func ApplyImmutableDetection(dependencies []deps.Dependency) []deps.Dependency {
if _, err := exec.LookPath("dms"); err != nil {
return dependencies
}
for i := range dependencies {
if dependencies[i].Name != "dms (Dux Shell)" || dependencies[i].Status != deps.StatusMissing {
continue
}
dependencies[i].Status = deps.StatusInstalled
// "dms (git) v2.0.0-5333" or "dms v2.0.0"
out, err := exec.Command("dms", "version").Output()
if err != nil {
continue
}
fields := strings.Fields(string(out))
if len(fields) > 0 {
dependencies[i].Version = fields[len(fields)-1]
}
if strings.Contains(string(out), "(git)") {
dependencies[i].Variant = deps.VariantGit
}
}
return dependencies
}
+218
View File
@@ -0,0 +1,218 @@
package distros
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/spf13/afero"
)
func writeOSRelease(t *testing.T, fs afero.Fs, body string) {
t.Helper()
if err := afero.WriteFile(fs, "/etc/os-release", []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestDetectImmutable(t *testing.T) {
cases := []struct {
name string
files []string
osRelease map[string]string
want bool
}{
{"ostree marker beats plain fedora id", []string{"/run/ostree-booted"}, map[string]string{"ID": "fedora"}, true},
{"silverblue variant without marker", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "silverblue"}, true},
{"sway atomic variant", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "sway-atomic"}, true},
{"workstation fedora", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "workstation"}, false},
{"bazzite id", nil, map[string]string{"ID": "bazzite", "ID_LIKE": "fedora"}, true},
{"microos", []string{"/usr/sbin/transactional-update"}, map[string]string{"ID": "opensuse-microos"}, true},
{"aeon by id_like", nil, map[string]string{"ID": "opensuse-aeon-rc", "ID_LIKE": "suse opensuse microos"}, true},
{"tumbleweed", nil, map[string]string{"ID": "opensuse-tumbleweed", "ID_LIKE": "suse opensuse"}, false},
{"vanilla by abroot", []string{"/usr/bin/abroot"}, map[string]string{"ID": "ubuntu", "ID_LIKE": "debian"}, true},
{"steamos", nil, map[string]string{"ID": "steamos", "ID_LIKE": "arch"}, true},
{"arch", nil, map[string]string{"ID": "arch"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
for _, f := range c.files {
if err := afero.WriteFile(fs, f, nil, 0o644); err != nil {
t.Fatal(err)
}
}
got, reason := detectImmutable(fs, c.osRelease)
if got != c.want {
t.Fatalf("detectImmutable = %v (%q), want %v", got, reason, c.want)
}
if got && reason == "" {
t.Fatal("immutable without a reason")
}
})
}
}
func TestGetOSInfoImmutable(t *testing.T) {
t.Run("unregistered image id is accepted when immutable", func(t *testing.T) {
fs := afero.NewMemMapFs()
writeOSRelease(t, fs, "ID=\"zirconium\"\nID_LIKE=\"zirconium\"\nPRETTY_NAME=\"Zirconium\"\nVERSION_ID=44\n")
if err := afero.WriteFile(fs, "/run/ostree-booted", nil, 0o644); err != nil {
t.Fatal(err)
}
info, err := getOSInfo(fs, "amd64")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !info.Immutable || info.Distribution.ID != "zirconium" || info.VersionID != "44" {
t.Fatalf("got %+v", info)
}
})
t.Run("fedora derivative resolves through id_like", func(t *testing.T) {
fs := afero.NewMemMapFs()
writeOSRelease(t, fs, "ID=bazzite\nID_LIKE=fedora\nPRETTY_NAME=Bazzite\nVERSION_ID=44\n")
info, err := getOSInfo(fs, "amd64")
if err != nil {
t.Fatal(err)
}
if !info.Immutable || info.ID != "bazzite" || info.Distribution.ID != "fedora" {
t.Fatalf("got %+v", info)
}
})
t.Run("unregistered mutable id still errors", func(t *testing.T) {
fs := afero.NewMemMapFs()
writeOSRelease(t, fs, "ID=slackware\nPRETTY_NAME=Slackware\n")
if _, err := getOSInfo(fs, "amd64"); err == nil {
t.Fatal("expected unsupported distribution error")
}
})
t.Run("silverblue resolves to fedora but is flagged", func(t *testing.T) {
fs := afero.NewMemMapFs()
writeOSRelease(t, fs, "ID=fedora\nVARIANT_ID=silverblue\nPRETTY_NAME=\"Fedora Linux 44 (Silverblue)\"\nVERSION_ID=44\n")
info, err := getOSInfo(fs, "amd64")
if err != nil {
t.Fatal(err)
}
if !info.Immutable || info.Distribution.ID != "fedora" || info.VariantID != "silverblue" {
t.Fatalf("got %+v", info)
}
})
t.Run("bluefin is no longer a mutable fedora", func(t *testing.T) {
if IsDistroSupported("bluefin") {
t.Fatal("bluefin must not be registered as an installable distribution")
}
})
}
func TestPendingDependencies(t *testing.T) {
all := []deps.Dependency{
{Name: "dms (Dux Shell)", Status: deps.StatusMissing},
{Name: "niri", Status: deps.StatusInstalled},
{Name: "quickshell", Status: deps.StatusInstalled},
{Name: "danksearch", Status: deps.StatusMissing},
}
got := PendingDependencies(all, map[string]bool{"quickshell": true}, map[string]bool{"danksearch": true})
if len(got) != 2 || got[0].Name != "dms (Dux Shell)" || got[1].Name != "quickshell" {
t.Fatalf("got %+v", got)
}
}
func TestApplyImmutableDetection(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}, {Name: "niri", Status: deps.StatusMissing}})
if got[0].Status != deps.StatusInstalled || got[1].Status != deps.StatusMissing {
t.Fatalf("got %+v", got)
}
}
func TestFedoraLayerCommands(t *testing.T) {
f := NewFedoraDistribution(Registry["fedora"], nil)
dependencies := []deps.Dependency{
{Name: "dms (Dux Shell)", Status: deps.StatusMissing},
{Name: "niri", Status: deps.StatusMissing, Variant: deps.VariantGit},
{Name: "ghostty", Status: deps.StatusInstalled},
{Name: "matugen", Status: deps.StatusMissing},
{Name: "danksearch", Status: deps.StatusMissing},
{Name: "xwayland-satellite", Status: deps.StatusMissing},
}
got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, nil, map[string]bool{"danksearch": true}), "\n")
for _, want := range []string{
"_copr:copr.fedorainfracloud.org:duxlabs:dms.repo https://copr.fedorainfracloud.org/coprs/duxlabs/dms/repo/fedora-$(rpm -E %fedora)/duxlabs-dms-fedora-$(rpm -E %fedora).repo",
"_copr:copr.fedorainfracloud.org:yalter:niri-git.repo",
"echo priority=1 | sudo tee -a",
"sudo rpm-ostree install xwayland-satellite dms niri matugen",
"systemctl reboot",
} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in:\n%s", want, got)
}
}
for _, unwanted := range []string{"ghostty", "danksearch", "rpm-ostree upgrade", "dnf "} {
if strings.Contains(got, unwanted) {
t.Fatalf("unexpected %q in:\n%s", unwanted, got)
}
}
if strings.Count(got, "duxlabs:dux-repo.repo https") != 1 {
t.Fatalf("deps repo must be fetched exactly once:\n%s", got)
}
}
func TestImmutableGuidance(t *testing.T) {
steam := &OSInfo{PrettyName: "SteamOS", Distribution: DistroInfo{ID: "steamos"}, ImmutableReason: "os-release ID=steamos"}
got := ImmutableGuidance(steam)
if !strings.Contains(got, "SteamOS is an image-based system") || !strings.Contains(got, "Nothing was changed") {
t.Fatalf("got %q", got)
}
}
func TestFedoraLayerCommandsSwapVariants(t *testing.T) {
f := NewFedoraDistribution(Registry["fedora"], nil)
installed := map[string]bool{"dms-greeter": true, "dms": true}
f.rpmInstalled = func(pkg string) bool { return installed[pkg] }
orig := installedFromCOPR
installedFromCOPR = func(pkg, repo string) bool { return pkg == "dms" && repo == "duxlabs/dms-git" }
t.Cleanup(func() { installedFromCOPR = orig })
dependencies := []deps.Dependency{
{Name: "dms (Dux Shell)", Status: deps.StatusInstalled, Variant: deps.VariantStable, CanToggle: true},
{Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit, CanToggle: true},
{Name: "danksearch", Status: deps.StatusInstalled},
}
reinstall := map[string]bool{"dms (Dux Shell)": true, "dms-greeter": true, "danksearch": true}
got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, reinstall, nil), "\n")
for _, want := range []string{
"sudo rm -f /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:duxlabs:dms-git.repo /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:dms-git.repo",
"sudo rpm-ostree install dms dms-greeter-git --uninstall dms dms-greeter",
} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in:\n%s", want, got)
}
}
if strings.Contains(got, "danksearch") {
t.Fatalf("reinstall mark without a variant change must not be layered:\n%s", got)
}
}
func TestApplyImmutableDetectionReadsVariant(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\necho 'dms (git) v2.0.0-5333'\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}})
if got[0].Variant != deps.VariantGit || got[0].Version != "v2.0.0-5333" {
t.Fatalf("got %+v", got[0])
}
}
+5 -5
View File
@@ -95,8 +95,8 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
m.log("Installing niri from source...") m.log("Installing niri from source...")
homeDir, _ := os.UserHomeDir() homeDir, _ := os.UserHomeDir()
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build") buildDir := filepath.Join(homeDir, ".cache", "duxinstall", "niri-build")
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp") tmpDir := filepath.Join(homeDir, ".cache", "duxinstall", "tmp")
if err := os.MkdirAll(buildDir, 0o755); err != nil { if err := os.MkdirAll(buildDir, 0o755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err) return fmt.Errorf("failed to create build directory: %w", err)
} }
@@ -173,7 +173,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
return fmt.Errorf("HOME environment variable not set") return fmt.Errorf("HOME environment variable not set")
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
@@ -279,7 +279,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
return fmt.Errorf("HOME environment variable not set") return fmt.Errorf("HOME environment variable not set")
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
@@ -342,7 +342,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
return fmt.Errorf("HOME environment variable not set") return fmt.Errorf("HOME environment variable not set")
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
+80 -26
View File
@@ -132,6 +132,70 @@ func (o *OpenSUSEDistribution) SwapVariants(ctx context.Context, swaps []Variant
return o.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85) return o.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85)
} }
func (o *OpenSUSEDistribution) RepoMoves() []RepoMove {
if !obsMigrationReady {
return nil
}
return obsZypperChannel.moves(repoPathExists)
}
func (o *OpenSUSEDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if err := o.enableOBSRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil {
return err
}
for _, move := range repoMoveRemoves(moves) {
command := "zypper removerepo " + obsZypperAlias(move.Old)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.30,
Step: fmt.Sprintf("Removing OBS repo %s...", move.Old),
NeedsSudo: true,
CommandInfo: "sudo " + command,
}
if err := o.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
return fmt.Errorf("failed to remove OBS repo %s: %w", move.Old, err)
}
}
return nil
}
func (o *OpenSUSEDistribution) RepoMoveCommands(moves []RepoMove) []string {
osInfo, err := GetOSInfo()
if err != nil {
osInfo = &OSInfo{}
}
return opensuseRepoMoveCommands(moves, opensuseOBSDist(osInfo))
}
func opensuseRepoMoveCommands(moves []RepoMove, dist string) []string {
var commands []string
adds := repoMoveAdds(moves)
for _, add := range adds {
commands = append(commands, "sudo zypper addrepo -f "+obsZypperRepoURL(add.RepoURL, dist))
}
if len(adds) > 0 {
commands = append(commands, "sudo zypper --gpg-auto-import-keys refresh")
}
for _, move := range repoMoveRemoves(moves) {
commands = append(commands, "sudo zypper removerepo "+obsZypperAlias(move.Old))
}
return commands
}
func opensuseOBSDist(osInfo *OSInfo) string {
switch osInfo.Distribution.ID {
case "opensuse-leap":
return "openSUSE_Leap_" + osInfo.VersionID
case "opensuse-slowroll":
return "openSUSE_Slowroll"
}
return "openSUSE_Tumbleweed"
}
func obsZypperRepoURL(repo, dist string) string {
return obsBaseURL(repo, dist) + "/" + repo + ".repo"
}
func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency { func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency {
gitInstalled := o.packageInstalled("dms-greeter-git") gitInstalled := o.packageInstalled("dms-greeter-git")
dep := o.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || o.packageInstalled("dms-greeter")) dep := o.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || o.packageInstalled("dms-greeter"))
@@ -154,11 +218,11 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
// DMS packages from OBS // DMS packages from OBS
"dms (Dux Shell)": o.getDmsMapping(variants["dms (Dux Shell)"]), "dms (Dux Shell)": o.getDmsMapping(variants["dms (Dux Shell)"]),
"quickshell": o.getQuickshellMapping(variants["quickshell"]), "quickshell": o.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, "home:AvengeMedia:danklinux"), "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, obsDeps),
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: obsDeps},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: obsDeps},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: obsDeps},
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"), "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, obsDeps),
} }
switch wm { switch wm {
@@ -179,28 +243,28 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
func (o *OpenSUSEDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { func (o *OpenSUSEDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms-git"} return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: obsDMSGit}
} }
return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms"} return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: obsDMS}
} }
func (o *OpenSUSEDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { func (o *OpenSUSEDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit { if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
func (o *OpenSUSEDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { func (o *OpenSUSEDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
return PackageMapping{Name: "niri", Repository: RepoTypeSystem} return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
} }
func (o *OpenSUSEDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { func (o *OpenSUSEDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
} }
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
} }
@@ -490,26 +554,16 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
return fmt.Errorf("failed to get OS info: %w", err) return fmt.Errorf("failed to get OS info: %w", err)
} }
obsDistroVersion := "openSUSE_Tumbleweed" obsDistroVersion := opensuseOBSDist(osInfo)
switch osInfo.Distribution.ID {
case "opensuse-leap":
obsDistroVersion = fmt.Sprintf("openSUSE_Leap_%s", osInfo.VersionID)
case "opensuse-slowroll":
obsDistroVersion = "openSUSE_Slowroll"
}
for _, pkg := range obsPkgs { for _, pkg := range obsPkgs {
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] { if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
o.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL)) o.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL))
// RepoURL format: "home:AvengeMedia:danklinux" repoURL := obsZypperRepoURL(pkg.RepoURL, obsDistroVersion)
repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/")
repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-")
repoURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s/%s.repo",
repoPath, obsDistroVersion, pkg.RepoURL)
checkCmd := exec.CommandContext(ctx, "zypper", "repos", repoName) // zypper stores the repo under the alias from the .repo section header, home_<owner>_<project>.
if checkCmd.Run() == nil { if repoPathExists(obsZypperRepoFile(pkg.RepoURL)) {
o.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL)) o.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true enabledRepos[pkg.RepoURL] = true
continue continue
@@ -703,7 +757,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
return fmt.Errorf("HOME environment variable not set") return fmt.Errorf("HOME environment variable not set")
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
+61 -30
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs" "github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
"github.com/spf13/afero"
) )
// DistroInfo contains basic information about a distribution // DistroInfo contains basic information about a distribution
@@ -19,12 +20,18 @@ type DistroInfo struct {
// OSInfo contains complete OS information // OSInfo contains complete OS information
type OSInfo struct { type OSInfo struct {
// Raw os-release ID; Distribution.ID is the registered distro it resolves to via ID_LIKE (bazzite -> fedora).
ID string
Distribution DistroInfo Distribution DistroInfo
Version string Version string
VersionID string VersionID string
VersionCodename string VersionCodename string
PrettyName string PrettyName string
Architecture string Architecture string
IDLike []string
VariantID string
Immutable bool
ImmutableReason string
} }
// GetOSInfo detects the current OS and returns information about it // GetOSInfo detects the current OS and returns information about it
@@ -37,50 +44,74 @@ func GetOSInfo() (*OSInfo, error) {
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH)) return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
} }
info := &OSInfo{ return getOSInfo(afero.NewOsFs(), runtime.GOARCH)
Architecture: runtime.GOARCH, }
// An unregistered ID is only an error on a mutable system; immutable hosts
// stop at the guidance screen and need no Distribution implementation.
func getOSInfo(fs afero.Fs, arch string) (*OSInfo, error) {
values := readOSRelease(fs)
if len(values) == 0 {
return nil, fmt.Errorf("cannot read /etc/os-release")
} }
file, err := os.Open("/etc/os-release") info := &OSInfo{
Architecture: arch,
Version: values["VERSION"],
VersionID: values["VERSION_ID"],
VersionCodename: values["VERSION_CODENAME"],
PrettyName: values["PRETTY_NAME"],
IDLike: strings.Fields(values["ID_LIKE"]),
VariantID: values["VARIANT_ID"],
}
if info.VersionID == "" {
info.VersionID = values["BUILD_ID"]
}
info.Immutable, info.ImmutableReason = detectImmutable(fs, values)
info.ID = values["ID"]
id := info.ID
config, exists := Registry[id]
if !exists && !info.Immutable {
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", id))
}
if !exists {
for _, like := range info.IDLike {
if likeConfig, ok := Registry[like]; ok {
id, config = like, likeConfig
break
}
}
}
info.Distribution = DistroInfo{ID: id, HexColorCode: config.ColorHex}
return info, nil
}
func readOSRelease(fs afero.Fs) map[string]string {
values := make(map[string]string)
file, err := fs.Open("/etc/os-release")
if err != nil { if err != nil {
return nil, err return values
} }
defer file.Close() defer file.Close()
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() parts := strings.SplitN(scanner.Text(), "=", 2)
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 { if len(parts) != 2 {
continue continue
} }
values[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'")
key := parts[0]
value := strings.Trim(parts[1], "\"'")
switch key {
case "ID":
config, exists := Registry[value]
if !exists {
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value))
} }
info.Distribution = DistroInfo{ return values
ID: value, // Use the actual ID from os-release }
HexColorCode: config.ColorHex,
}
case "VERSION_ID", "BUILD_ID":
info.VersionID = value
case "VERSION":
info.Version = value
case "VERSION_CODENAME":
info.VersionCodename = value
case "PRETTY_NAME":
info.PrettyName = value
}
}
return info, scanner.Err() func HasSystemd() bool {
_, err := os.Stat("/run/systemd/system")
return err == nil
} }
// IsUnsupportedDistro checks if a distribution/version combination is supported // IsUnsupportedDistro checks if a distribution/version combination is supported
+257
View File
@@ -0,0 +1,257 @@
package distros
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
)
const (
coprOwner = "duxlabs"
obsHome = "home:duxlabs"
ppaOwner = "duxlabs"
legacyCoprOwner = "avengemedia"
legacyObsHome = "home:AvengeMedia"
legacyPpaOwner = "avengemedia"
projectDMS = "dms"
projectDMSGit = "dms-git"
projectDeps = "dux-repo"
legacyProjectDeps = "danklinux"
// The 2.0 docs told users to enable duxlabs/dux before the deps slug became dux-repo.
retiredProjectDeps = "dux"
voidHost = site.VoidRepo
legacyVoidHost = "https://void.danklinux.com"
voidConfGlob = "/etc/xbps.d/*.conf"
)
// The duxlabs OBS projects are not published yet, so Debian and openSUSE stay on the legacy ones.
const obsMigrationReady = false
var dmsProjects = []string{projectDMS, projectDMSGit, projectDeps}
func legacyProject(project string) string {
if project == projectDeps {
return legacyProjectDeps
}
return project
}
func coprRepo(project string) string { return coprOwner + "/" + project }
func legacyCoprRepo(project string) string { return legacyCoprOwner + "/" + project }
func ppaRepo(project string) string { return "ppa:" + ppaOwner + "/" + project }
func legacyPpaRepo(project string) string { return "ppa:" + legacyPpaOwner + "/" + project }
func legacyObsRepo(project string) string { return legacyObsHome + ":" + project }
func newObsRepo(project string) string { return obsHome + ":" + project }
// obsRepo is what the mappings install from; the move tables use newObsRepo regardless of the gate.
func obsRepo(project string) string {
if !obsMigrationReady {
return legacyObsRepo(legacyProject(project))
}
return newObsRepo(project)
}
var (
coprDMS, coprDMSGit, coprDeps = coprRepo(projectDMS), coprRepo(projectDMSGit), coprRepo(projectDeps)
obsDMS, obsDMSGit, obsDeps = obsRepo(projectDMS), obsRepo(projectDMSGit), obsRepo(projectDeps)
ppaDMS, ppaDMSGit, ppaDeps = ppaRepo(projectDMS), ppaRepo(projectDMSGit), ppaRepo(projectDeps)
)
// Each must match what the package manager itself derives from the RepoURL.
func coprRepoID(repo string) string {
return "copr:copr.fedorainfracloud.org:" + strings.ReplaceAll(repo, "/", ":")
}
func coprRepoFile(repo string) string { return "/etc/yum.repos.d/_" + coprRepoID(repo) + ".repo" }
func obsAptName(repo string) string { return strings.ReplaceAll(repo, ":", "-") }
func obsAptListFile(repo string) string {
return "/etc/apt/sources.list.d/" + obsAptName(repo) + ".list"
}
func obsAptKeyring(repo string) string { return "/etc/apt/keyrings/" + obsAptName(repo) + ".gpg" }
func obsZypperAlias(repo string) string { return strings.ReplaceAll(repo, ":", "_") }
func obsZypperRepoFile(repo string) string {
return "/etc/zypp/repos.d/" + obsZypperAlias(repo) + ".repo"
}
func obsBaseURL(repo, dist string) string {
return "https://download.opensuse.org/repositories/" + strings.ReplaceAll(repo, ":", ":/") + "/" + dist
}
// add-apt-repository names the file <owner>-ubuntu-<project>-<series>.sources (24.04+) or .list.
func ppaSourcesGlobs(repo string) []string {
owner, project, _ := strings.Cut(strings.TrimPrefix(repo, "ppa:"), "/")
stem := "/etc/apt/sources.list.d/" + owner + "-ubuntu-" + project + "-*"
return []string{stem + ".sources", stem + ".list"}
}
// A current COPR mapping may still be installed from its legacy owner.
func coprAliases(repo string) []string {
owner, project, _ := strings.Cut(repo, "/")
if owner != coprOwner {
return []string{repo}
}
return []string{repo, legacyCoprRepo(legacyProject(project))}
}
func coprRepoIDs(repo string) []string {
var ids []string
for _, alias := range coprAliases(repo) {
ids = append(ids, coprRepoID(alias))
}
return ids
}
func coprFromRepoMatches(fromRepo, repo string) bool {
return slices.Contains(coprRepoIDs(repo), strings.TrimSpace(fromRepo))
}
// Test seams.
var (
repoGlob = func(pattern string) []string {
matches, _ := filepath.Glob(pattern)
return matches
}
repoPathExists = func(pattern string) bool { return len(repoGlob(pattern)) > 0 }
repoReadFile = os.ReadFile
privescExec = privesc.ExecCommand
)
// The series is one word, so the retired dux-* glob must not count the dux-repo-* sibling.
func ppaSourcesExists(pattern string) bool {
return slices.ContainsFunc(repoGlob(pattern), func(path string) bool { return ppaSeriesMatch(pattern, path) })
}
func ppaSeriesMatch(pattern, path string) bool {
stem, ext, _ := strings.Cut(pattern, "*")
series := strings.TrimSuffix(strings.TrimPrefix(path, stem), ext)
return series != "" && !strings.Contains(series, "-")
}
type RepoMove struct {
Kind RepositoryType
Old, New string // RepoURL form, e.g. "avengemedia/danklinux" → "duxlabs/dux-repo"
OldPath, NewPath string // file (or glob) whose presence means the repo is enabled
OldPresent, NewPresent bool
}
type RepoMover interface {
RepoMoves() []RepoMove
ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error
RepoMoveCommands(moves []RepoMove) []string
}
// For image-based hosts, where the package manager cannot run the moves.
type ImageRepoMover interface {
ImageRepoMoveCommands(moves []RepoMove) []string
}
func PlanRepoMoves(d Distribution) ([]RepoMove, bool) {
mover, ok := d.(RepoMover)
if !ok {
return nil, false
}
moves := mover.RepoMoves()
return moves, len(moves) > 0
}
type repoChannel struct {
kind RepositoryType
repo, legacyRepo func(project string) string
paths func(repo string) []string
retiredDeps bool // the channel also published the deps project under retiredProjectDeps
}
func (c repoChannel) legacyRepos(project string) []string {
repos := []string{c.legacyRepo(legacyProject(project))}
if project == projectDeps && c.retiredDeps {
repos = append(repos, c.repo(retiredProjectDeps))
}
return repos
}
func (c repoChannel) moves(exists func(string) bool) []RepoMove {
var moves []RepoMove
for _, project := range dmsProjects {
for _, old := range c.legacyRepos(project) {
move := RepoMove{Kind: c.kind, Old: old, New: c.repo(project)}
move.OldPath, move.OldPresent = firstExisting(exists, c.paths(move.Old))
move.NewPath, move.NewPresent = firstExisting(exists, c.paths(move.New))
moves = append(moves, move)
}
}
return moves
}
func firstExisting(exists func(string) bool, paths []string) (string, bool) {
for _, path := range paths {
if exists(path) {
return path, true
}
}
return paths[0], false
}
var (
coprChannel = repoChannel{RepoTypeCOPR, coprRepo, legacyCoprRepo, func(r string) []string { return []string{coprRepoFile(r)} }, true}
obsAptChannel = repoChannel{RepoTypeOBS, newObsRepo, legacyObsRepo, func(r string) []string { return []string{obsAptListFile(r)} }, false}
obsZypperChannel = repoChannel{RepoTypeOBS, newObsRepo, legacyObsRepo, func(r string) []string { return []string{obsZypperRepoFile(r)} }, false}
ppaChannel = repoChannel{RepoTypePPA, ppaRepo, legacyPpaRepo, ppaSourcesGlobs, true}
)
func (m RepoMove) needsAdd() bool { return m.OldPresent && !m.NewPresent }
func (m RepoMove) needsRemove() bool { return m.OldPresent }
// The enable*Repos helpers only read RepoURL; two legacy rows can share a target.
func repoMoveAdds(moves []RepoMove) []PackageMapping {
var adds []PackageMapping
seen := map[string]bool{}
for _, move := range moves {
if !move.needsAdd() || seen[move.New] {
continue
}
seen[move.New] = true
adds = append(adds, PackageMapping{Repository: move.Kind, RepoURL: move.New})
}
return adds
}
func repoMoveRemoves(moves []RepoMove) []RepoMove {
var removes []RepoMove
for _, move := range moves {
if move.needsRemove() {
removes = append(removes, move)
}
}
return removes
}
func (b *BaseDistribution) aptUpdate(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.36,
Step: "Updating package lists...",
NeedsSudo: true,
CommandInfo: "sudo apt-get update",
}
if err := b.runWithProgress(privescExec(ctx, sudoPassword, "apt-get update"), progressChan, PhaseSystemPackages, 0.36, 0.40); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
return nil
}
func LegacyRepoPaths(moves []RepoMove) []string {
paths := []string{}
for _, move := range repoMoveRemoves(moves) {
if !slices.Contains(paths, move.OldPath) {
paths = append(paths, move.OldPath)
}
}
return paths
}
+424
View File
@@ -0,0 +1,424 @@
package distros
import (
"context"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
)
func TestOBSZypperAlias(t *testing.T) {
if got := obsZypperAlias("home:AvengeMedia:dms"); got != "home_AvengeMedia_dms" {
t.Fatalf("alias = %q, want the section header spelling home_AvengeMedia_dms", got)
}
}
func TestCOPRFromRepoMatches(t *testing.T) {
cases := []struct {
fromRepo, repo string
want bool
}{
{"copr:copr.fedorainfracloud.org:duxlabs:dms-git\n", coprDMSGit, true},
{"copr:copr.fedorainfracloud.org:avengemedia:dms-git", coprDMSGit, true},
{"copr:copr.fedorainfracloud.org:avengemedia:danklinux", coprDeps, true},
{"copr:copr.fedorainfracloud.org:duxlabs:dms", coprDMSGit, false},
{"copr:copr.fedorainfracloud.org:avengemedia:dms", coprDMSGit, false},
{"copr:copr.fedorainfracloud.org:duxlabs:dms-git", coprDMS, false},
{"copr:copr.fedorainfracloud.org:yalter:niri-git", "yalter/niri-git", true},
}
for _, tc := range cases {
if got := coprFromRepoMatches(tc.fromRepo, tc.repo); got != tc.want {
t.Errorf("coprFromRepoMatches(%q, %q) = %v, want %v", tc.fromRepo, tc.repo, got, tc.want)
}
}
}
func existsIn(paths ...string) func(string) bool {
return func(p string) bool { return slices.Contains(paths, p) }
}
func TestPlanRepoMoves(t *testing.T) {
legacyDeps := coprRepoFile("avengemedia/danklinux")
retiredDeps := coprRepoFile("duxlabs/dux")
newDeps := coprRepoFile("duxlabs/dux-repo")
cases := []struct {
name string
exists func(string) bool
wantAdds []string
wantRemoves []string
wantLegacyIdx []string
}{
{"legacy only", existsIn(legacyDeps), []string{"duxlabs/dux-repo"}, []string{"avengemedia/danklinux"}, []string{legacyDeps}},
{"both present", existsIn(legacyDeps, newDeps), nil, []string{"avengemedia/danklinux"}, []string{legacyDeps}},
{"new only", existsIn(newDeps), nil, nil, nil},
{"none", existsIn(), nil, nil, nil},
{"retired dux slug", existsIn(retiredDeps), []string{"duxlabs/dux-repo"}, []string{"duxlabs/dux"}, []string{retiredDeps}},
{"both legacy deps", existsIn(legacyDeps, retiredDeps), []string{"duxlabs/dux-repo"}, []string{"avengemedia/danklinux", "duxlabs/dux"}, []string{legacyDeps, retiredDeps}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
moves := coprChannel.moves(tc.exists)
if len(moves) != 4 {
t.Fatalf("got %d rows, want dms, dms-git and the two legacy deps rows", len(moves))
}
var adds, removes []string
for _, add := range repoMoveAdds(moves) {
adds = append(adds, add.RepoURL)
}
for _, move := range repoMoveRemoves(moves) {
removes = append(removes, move.Old)
}
if !slices.Equal(adds, tc.wantAdds) || !slices.Equal(removes, tc.wantRemoves) {
t.Fatalf("adds %v removes %v, want %v / %v", adds, removes, tc.wantAdds, tc.wantRemoves)
}
legacy := LegacyRepoPaths(moves)
if len(legacy) == 0 && len(tc.wantLegacyIdx) == 0 {
return
}
if !slices.Equal(legacy, tc.wantLegacyIdx) {
t.Fatalf("legacy paths %v, want %v", legacy, tc.wantLegacyIdx)
}
})
}
}
func TestPlanRepoMovesPPAGlob(t *testing.T) {
moves := ppaChannel.moves(func(p string) bool {
return p == "/etc/apt/sources.list.d/avengemedia-ubuntu-dms-*.list"
})
legacy := LegacyRepoPaths(moves)
if len(legacy) != 1 || legacy[0] != "/etc/apt/sources.list.d/avengemedia-ubuntu-dms-*.list" {
t.Fatalf("legacy = %v, want the 22.04 .list glob for dms only", legacy)
}
}
// filepath.Glob over a fake listing, where dux-* also matches the dux-repo-* sibling.
func globIn(files ...string) func(string) []string {
return func(pattern string) []string {
var matches []string
for _, file := range files {
if ok, _ := filepath.Match(pattern, file); ok {
matches = append(matches, file)
}
}
return matches
}
}
func TestUbuntuRepoMovesIgnoreRetiredSiblingGlob(t *testing.T) {
orig := repoGlob
repoGlob = globIn(
"/etc/apt/sources.list.d/duxlabs-ubuntu-dux-repo-resolute.sources",
"/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-resolute.sources")
t.Cleanup(func() { repoGlob = orig })
moves := NewUbuntuDistribution(Registry["ubuntu"], nil).RepoMoves()
want := []string{"/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-*.sources"}
if got := LegacyRepoPaths(moves); !slices.Equal(got, want) {
t.Fatalf("legacy = %v, want %v", got, want)
}
if adds := repoMoveAdds(moves); len(adds) != 0 {
t.Fatalf("planned adds %v while dux-repo is present", adds)
}
}
func fakeXBPSConfs(t *testing.T, files map[string]string) {
t.Helper()
origGlob, origRead := repoGlob, repoReadFile
var paths []string
for path := range files {
paths = append(paths, path)
}
slices.Sort(paths)
repoGlob = globIn(paths...)
repoReadFile = func(path string) ([]byte, error) {
content, ok := files[path]
if !ok {
return nil, os.ErrNotExist
}
return []byte(content), nil
}
t.Cleanup(func() { repoGlob, repoReadFile = origGlob, origRead })
}
func TestVoidRepoMoves(t *testing.T) {
const (
legacyDMS = "repository=https://void.danklinux.com/dms/current\n"
legacyDeps = "repository=https://void.danklinux.com/danklinux/current\n"
newDMS = "repository=https://void.duxlabs.dev/dms/current\n"
newDeps = "repository=https://void.duxlabs.dev/dux-repo/current\n"
)
cases := []struct {
name string
files map[string]string
wantMoves []string
wantLegacy []string
wantNew bool
}{
{
"both legacy",
map[string]string{"/etc/xbps.d/dms.conf": legacyDMS, "/etc/xbps.d/danklinux.conf": "# dms deps\n" + legacyDeps, "/etc/xbps.d/00-repository-main.conf": "repository=https://repo-default.voidlinux.org/current\n"},
[]string{
"https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current",
"https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current",
},
[]string{"/etc/xbps.d/danklinux.conf", "/etc/xbps.d/dms.conf"},
false,
},
{
"one migrated",
map[string]string{"/etc/xbps.d/dms.conf": newDMS, "/etc/xbps.d/dux.conf": legacyDeps},
[]string{"https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current"},
[]string{"/etc/xbps.d/dux.conf"},
true,
},
{
"both lines in one file",
map[string]string{"/etc/xbps.d/custom.conf": legacyDMS + legacyDeps},
[]string{
"https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current",
"https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current",
},
[]string{"/etc/xbps.d/custom.conf"},
false,
},
{"greeter only", map[string]string{"/etc/xbps.d/dms.conf": legacyDMS}, []string{"https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current"}, []string{"/etc/xbps.d/dms.conf"}, false},
{"all migrated", map[string]string{"/etc/xbps.d/dms.conf": newDMS, "/etc/xbps.d/dux.conf": newDeps}, nil, nil, true},
{"none", map[string]string{"/etc/xbps.d/00-repository-main.conf": "repository=https://repo-default.voidlinux.org/current\n"}, nil, nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fakeXBPSConfs(t, tc.files)
v := NewVoidDistribution(Registry["void"], nil)
moves, _ := PlanRepoMoves(v)
var got []string
newPresent := false
for _, move := range moves {
newPresent = newPresent || move.NewPresent
if move.OldPresent {
if move.NewPath != move.OldPath {
t.Fatalf("%s must be edited in place, got NewPath %s", move.OldPath, move.NewPath)
}
got = append(got, move.Old+" -> "+move.New)
}
}
if !slices.Equal(got, tc.wantMoves) {
t.Fatalf("moves %q, want %q", got, tc.wantMoves)
}
if legacy := LegacyRepoPaths(moves); !slices.Equal(legacy, tc.wantLegacy) && len(legacy)+len(tc.wantLegacy) > 0 {
t.Fatalf("legacy paths %v, want %v", legacy, tc.wantLegacy)
}
if newPresent != tc.wantNew {
t.Fatalf("new repo present = %v, want %v", newPresent, tc.wantNew)
}
if commands := v.RepoMoveCommands(moves); (len(commands) == 0) != (len(tc.wantMoves) == 0) {
t.Fatalf("commands %q for moves %q", commands, tc.wantMoves)
}
})
}
}
func TestVoidMappingsUseNewHost(t *testing.T) {
v := NewVoidDistribution(Registry["void"], nil)
for _, variants := range []map[string]deps.PackageVariant{
{"dms (Dux Shell)": deps.VariantStable, "dms-greeter": deps.VariantStable},
{"dms (Dux Shell)": deps.VariantGit, "dms-greeter": deps.VariantGit},
} {
packages := v.GetPackageMappingWithVariants(deps.WindowManagerNiri, variants)
want := map[string]string{
"dms (Dux Shell)": "https://void.duxlabs.dev/dms/current",
"dms-greeter": "https://void.duxlabs.dev/dux-repo/current",
"danksearch": "https://void.duxlabs.dev/dux-repo/current",
"dankcalendar": "https://void.duxlabs.dev/dux-repo/current",
}
for dep, url := range want {
if got := packages[dep].RepoURL; got != url {
t.Errorf("%s (%s) RepoURL = %q, want %q", dep, packages[dep].Name, got, url)
}
}
}
}
func TestRepoMoveCommands(t *testing.T) {
withOrig := func(c repoChannel, present ...string) []RepoMove {
return c.moves(existsIn(present...))
}
t.Run("fedora", func(t *testing.T) {
f := NewFedoraDistribution(Registry["fedora"], nil)
moves := withOrig(coprChannel, coprRepoFile("avengemedia/dms"), coprRepoFile("avengemedia/danklinux"), coprRepoFile("duxlabs/dms"))
want := []string{
"sudo dnf copr enable -y duxlabs/dux-repo",
"sudo dnf copr remove -y avengemedia/dms",
"sudo dnf copr remove -y avengemedia/danklinux",
}
if got := f.RepoMoveCommands(moves); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("fedora image-based", func(t *testing.T) {
f := NewFedoraDistribution(Registry["fedora"], nil)
moves := withOrig(coprChannel, coprRepoFile("avengemedia/danklinux"))
want := []string{
"sudo curl -fsSLo /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:duxlabs:dux-repo.repo https://copr.fedorainfracloud.org/coprs/duxlabs/dux-repo/repo/fedora-$(rpm -E %fedora)/duxlabs-dux-repo-fedora-$(rpm -E %fedora).repo",
"sudo rm -f /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:danklinux.repo",
}
if got := f.ImageRepoMoveCommands(moves); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("fedora retired dux slug", func(t *testing.T) {
f := NewFedoraDistribution(Registry["fedora"], nil)
moves := withOrig(coprChannel, coprRepoFile("duxlabs/dux"))
want := []string{
"sudo dnf copr enable -y duxlabs/dux-repo",
"sudo dnf copr remove -y duxlabs/dux",
}
if got := f.RepoMoveCommands(moves); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("ubuntu", func(t *testing.T) {
u := NewUbuntuDistribution(Registry["ubuntu"], nil)
moves := withOrig(ppaChannel, "/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-*.sources", "/etc/apt/sources.list.d/duxlabs-ubuntu-dux-*.sources")
want := []string{
"sudo add-apt-repository -y ppa:duxlabs/dux-repo",
"sudo add-apt-repository -r -y ppa:avengemedia/danklinux",
"sudo add-apt-repository -r -y ppa:duxlabs/dux",
"sudo apt-get update",
}
if got := u.RepoMoveCommands(moves); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("debian", func(t *testing.T) {
moves := withOrig(obsAptChannel, obsAptListFile("home:AvengeMedia:dms-git"))
want := []string{
"curl -fsSL https://download.opensuse.org/repositories/home:/duxlabs:/dms-git/Debian_13/Release.key | gpg --dearmor | sudo tee /etc/apt/keyrings/home-duxlabs-dms-git.gpg",
"echo 'deb [signed-by=/etc/apt/keyrings/home-duxlabs-dms-git.gpg arch=amd64] https://download.opensuse.org/repositories/home:/duxlabs:/dms-git/Debian_13/ /' | sudo tee /etc/apt/sources.list.d/home-duxlabs-dms-git.list",
"sudo rm -f /etc/apt/sources.list.d/home-AvengeMedia-dms-git.list /etc/apt/keyrings/home-AvengeMedia-dms-git.gpg",
"sudo apt-get update",
}
if got := debianRepoMoveCommands(moves, "Debian_13", "amd64"); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("void", func(t *testing.T) {
fakeXBPSConfs(t, map[string]string{
"/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n",
"/etc/xbps.d/dux.conf": "repository=https://void.danklinux.com/danklinux/current\n",
})
v := NewVoidDistribution(Registry["void"], nil)
want := []string{
"sudo sed -i 's|repository=https://void.danklinux.com/dms/current|repository=https://void.duxlabs.dev/dms/current|' /etc/xbps.d/dms.conf",
"sudo sed -i 's|repository=https://void.danklinux.com/danklinux/current|repository=https://void.duxlabs.dev/dux-repo/current|' /etc/xbps.d/dux.conf",
"sudo xbps-install -S",
}
if got := v.RepoMoveCommands(v.RepoMoves()); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("void greeter only", func(t *testing.T) {
fakeXBPSConfs(t, map[string]string{"/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n"})
v := NewVoidDistribution(Registry["void"], nil)
want := []string{
"sudo sed -i 's|repository=https://void.danklinux.com/dms/current|repository=https://void.duxlabs.dev/dms/current|' /etc/xbps.d/dms.conf",
"sudo sh -c 'echo repository=https://void.duxlabs.dev/dux-repo/current >> /etc/xbps.d/dux.conf'",
"sudo xbps-install -S",
}
if got := v.RepoMoveCommands(v.RepoMoves()); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
})
// Either spelling of the deps line already serves the greeter, so no second dux.conf entry.
for name, deps := range map[string]string{
"legacy": "repository=https://void.danklinux.com/danklinux/current\n",
"new": "repository=https://void.duxlabs.dev/dux-repo/current\n",
} {
t.Run("void deps already "+name, func(t *testing.T) {
fakeXBPSConfs(t, map[string]string{
"/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n",
"/etc/xbps.d/danklinux.conf": deps,
})
v := NewVoidDistribution(Registry["void"], nil)
for _, command := range v.RepoMoveCommands(v.RepoMoves()) {
if strings.Contains(command, "dux.conf") {
t.Fatalf("added a duplicate deps repo: %q", command)
}
}
})
}
t.Run("opensuse", func(t *testing.T) {
moves := withOrig(obsZypperChannel, obsZypperRepoFile("home:AvengeMedia:danklinux"))
want := []string{
"sudo zypper addrepo -f https://download.opensuse.org/repositories/home:/duxlabs:/dux-repo/openSUSE_Tumbleweed/home:duxlabs:dux-repo.repo",
"sudo zypper --gpg-auto-import-keys refresh",
"sudo zypper removerepo home_AvengeMedia_danklinux",
}
if got := opensuseRepoMoveCommands(moves, "openSUSE_Tumbleweed"); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
})
}
// Until OBS publishes, movers must plan nothing even with legacy files present, or doctor and migrate nag.
func TestOBSMigrationGate(t *testing.T) {
if obsMigrationReady {
t.Skip("gate open")
}
orig := repoPathExists
repoPathExists = func(string) bool { return true }
t.Cleanup(func() { repoPathExists = orig })
if moves := NewDebianDistribution(Registry["debian"], nil).RepoMoves(); moves != nil {
t.Fatalf("debian planned %v while the gate is closed", moves)
}
if moves := NewOpenSUSEDistribution(Registry["opensuse-tumbleweed"], nil).RepoMoves(); moves != nil {
t.Fatalf("opensuse planned %v while the gate is closed", moves)
}
if got := NewDebianDistribution(Registry["debian"], nil).GetPackageMapping(0)["dms-greeter"].RepoURL; got != "home:AvengeMedia:danklinux" {
t.Fatalf("debian mapping = %q, must stay on the legacy OBS project while the gate is closed", got)
}
}
func TestSwapVariantsDisablesLegacyCOPR(t *testing.T) {
var commands []string
origExec, origExists := privescExec, repoPathExists
privescExec = func(_ context.Context, _, command string) *exec.Cmd {
commands = append(commands, command)
return exec.Command("true")
}
repoPathExists = existsIn(coprRepoFile("avengemedia/dms"))
t.Cleanup(func() { privescExec, repoPathExists = origExec, origExists })
f := NewFedoraDistribution(Registry["fedora"], nil)
swaps := []VariantSwap{{Dep: "dms (Dux Shell)", From: f.getDmsMapping(0), To: f.getDmsMapping(1)}}
progress := make(chan InstallProgressMsg, 100)
if err := f.SwapVariants(context.Background(), swaps, "", progress); err != nil {
t.Fatal(err)
}
joined := strings.Join(commands, "\n")
if !strings.Contains(joined, "dnf copr disable -y avengemedia/dms") {
t.Fatalf("legacy COPR left enabled on an un-migrated host:\n%s", joined)
}
if strings.Contains(joined, "dnf copr disable -y duxlabs/dms") {
t.Fatalf("disabled a COPR whose file does not exist:\n%s", joined)
}
if !strings.Contains(joined, "dnf copr enable -y duxlabs/dms-git") {
t.Fatalf("new git COPR not enabled:\n%s", joined)
}
}
+107 -16
View File
@@ -2,14 +2,20 @@ package distros
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http"
"os/exec" "os/exec"
"strings" "strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/deps" "github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc" "github.com/duxlabs-dev/dux-shell/core/internal/privesc"
) )
const hyprlandPPA = "ppa:cppiber/hyprland"
func init() { func init() {
Register("ubuntu", "#E95420", FamilyUbuntu, func(config DistroConfig, logChan chan<- string) Distribution { Register("ubuntu", "#E95420", FamilyUbuntu, func(config DistroConfig, logChan chan<- string) Distribution {
return NewUbuntuDistribution(config, logChan) return NewUbuntuDistribution(config, logChan)
@@ -123,6 +129,51 @@ func (u *UbuntuDistribution) SwapVariants(ctx context.Context, swaps []VariantSw
return u.installAPTPackages(ctx, to, sudoPassword, progressChan) return u.installAPTPackages(ctx, to, sudoPassword, progressChan)
} }
func (u *UbuntuDistribution) RepoMoves() []RepoMove {
return ppaChannel.moves(ppaSourcesExists)
}
func (u *UbuntuDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if adds := repoMoveAdds(moves); len(adds) > 0 {
if err := u.enablePPARepos(ctx, adds, sudoPassword, progressChan); err != nil {
return err
}
}
removes := repoMoveRemoves(moves)
for _, move := range removes {
command := "add-apt-repository -r -y " + move.Old
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.30,
Step: fmt.Sprintf("Removing PPA %s...", move.Old),
NeedsSudo: true,
CommandInfo: "sudo " + command,
}
if err := u.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
return fmt.Errorf("failed to remove PPA %s: %w", move.Old, err)
}
}
if len(removes) == 0 {
return nil
}
return u.aptUpdate(ctx, sudoPassword, progressChan)
}
func (u *UbuntuDistribution) RepoMoveCommands(moves []RepoMove) []string {
var commands []string
for _, add := range repoMoveAdds(moves) {
commands = append(commands, "sudo add-apt-repository -y "+add.RepoURL)
}
removes := repoMoveRemoves(moves)
for _, move := range removes {
commands = append(commands, "sudo add-apt-repository -r -y "+move.Old)
}
if len(removes) > 0 {
commands = append(commands, "sudo apt-get update")
}
return commands
}
func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return u.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) return u.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
} }
@@ -139,20 +190,20 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
// DMS packages from PPAs // DMS packages from PPAs
"dms (Dux Shell)": u.getDmsMapping(variants["dms (Dux Shell)"]), "dms (Dux Shell)": u.getDmsMapping(variants["dms (Dux Shell)"]),
"quickshell": u.getQuickshellMapping(variants["quickshell"]), "quickshell": u.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypePPA, "ppa:avengemedia/danklinux"), "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypePPA, ppaDeps),
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: ppaDeps},
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: ppaDeps},
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: ppaDeps},
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, "ppa:avengemedia/danklinux"), "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, ppaDeps),
} }
switch wm { switch wm {
case deps.WindowManagerHyprland: case deps.WindowManagerHyprland:
// Use the cppiber PPA for Hyprland // Use the cppiber PPA for Hyprland
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"} packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: hyprlandPPA}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"} packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: hyprlandPPA}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"} packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypePPA, RepoURL: hyprlandPPA}
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
niriVariant := variants["niri"] niriVariant := variants["niri"]
packages["niri"] = u.getNiriMapping(niriVariant) packages["niri"] = u.getNiriMapping(niriVariant)
@@ -164,30 +215,30 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
func (u *UbuntuDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { func (u *UbuntuDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "dms-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/dms-git"} return PackageMapping{Name: "dms-git", Repository: RepoTypePPA, RepoURL: ppaDMSGit}
} }
return PackageMapping{Name: "dms", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/dms"} return PackageMapping{Name: "dms", Repository: RepoTypePPA, RepoURL: ppaDMS}
} }
func (u *UbuntuDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { func (u *UbuntuDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit { if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} return PackageMapping{Name: "quickshell-git", Repository: RepoTypePPA, RepoURL: ppaDeps}
} }
return PackageMapping{Name: "quickshell", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} return PackageMapping{Name: "quickshell", Repository: RepoTypePPA, RepoURL: ppaDeps}
} }
func (u *UbuntuDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { func (u *UbuntuDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "niri-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} return PackageMapping{Name: "niri-git", Repository: RepoTypePPA, RepoURL: ppaDeps}
} }
return PackageMapping{Name: "niri", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} return PackageMapping{Name: "niri", Repository: RepoTypePPA, RepoURL: ppaDeps}
} }
func (u *UbuntuDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { func (u *UbuntuDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypePPA, RepoURL: ppaDeps}
} }
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypePPA, RepoURL: ppaDeps}
} }
func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
@@ -265,6 +316,7 @@ func (u *UbuntuDistribution) InstallPackages(ctx context.Context, dependencies [
} }
systemPkgs, ppaPkgs, manualPkgs, variantMap := u.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) systemPkgs, ppaPkgs, manualPkgs, variantMap := u.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
systemPkgs, ppaPkgs = u.fallbackUnpublishedPPAs(ctx, systemPkgs, ppaPkgs)
// Phase 2: Enable PPA repositories // Phase 2: Enable PPA repositories
if len(ppaPkgs) > 0 { if len(ppaPkgs) > 0 {
@@ -417,6 +469,45 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st
return names return names
} }
// Use the Ubuntu archive when the Hyprland PPA has no build for this series yet.
func (u *UbuntuDistribution) fallbackUnpublishedPPAs(ctx context.Context, systemPkgs []string, ppaPkgs []PackageMapping) ([]string, []PackageMapping) {
usesHyprlandPPA := false
for _, pkg := range ppaPkgs {
usesHyprlandPPA = usesHyprlandPPA || pkg.RepoURL == hyprlandPPA
}
if !usesHyprlandPPA {
return systemPkgs, ppaPkgs
}
osInfo, err := GetOSInfo()
if err != nil || osInfo.VersionCodename == "" || ppaPublishesSeries(ctx, hyprlandPPA, osInfo.VersionCodename) {
return systemPkgs, ppaPkgs
}
u.log(fmt.Sprintf("%s has no %s build, installing its packages from the Ubuntu archive", hyprlandPPA, osInfo.VersionCodename))
kept := ppaPkgs[:0]
for _, pkg := range ppaPkgs {
if pkg.RepoURL == hyprlandPPA {
systemPkgs = append(systemPkgs, pkg.Name)
continue
}
kept = append(kept, pkg)
}
return systemPkgs, kept
}
// Only a 404 counts as unpublished; network errors keep the PPA.
func ppaPublishesSeries(ctx context.Context, ppa, series string) bool {
owner, name, ok := strings.Cut(strings.TrimPrefix(ppa, "ppa:"), "/")
if !ok {
return true
}
url := fmt.Sprintf("https://ppa.launchpadcontent.net/%s/%s/ubuntu/dists/%s/Release", owner, name, series)
_, err := netfetch.Bytes(ctx, url, netfetch.Options{Timeout: 15 * time.Second})
var statusErr *netfetch.StatusError
return !errors.As(err, &statusErr) || statusErr.Code != http.StatusNotFound
}
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)
+1 -1
View File
@@ -15,7 +15,7 @@ type swapFake struct {
func (f *swapFake) GetPackageMappingWithVariants(_ deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping { func (f *swapFake) GetPackageMappingWithVariants(_ deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
return map[string]PackageMapping{ return map[string]PackageMapping{
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "avengemedia/danklinux"), "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "duxlabs/dux-repo"),
"matugen": {Name: "matugen", Repository: RepoTypeSystem}, "matugen": {Name: "matugen", Repository: RepoTypeSystem},
} }
} }
+106 -8
View File
@@ -10,14 +10,15 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/deps" "github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc" "github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
) )
const ( const (
VoidDMSRepo = site.VoidRepo + "/dms/current" VoidDMSRepo = voidHost + "/" + projectDMS + "/current"
VoidDankLinuxRepo = site.VoidRepo + "/danklinux/current" VoidDepsRepo = voidHost + "/" + projectDeps + "/current"
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64" VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
voidDepsConf = "/etc/xbps.d/dux.conf"
voidRunitSvDir = "/etc/sv" voidRunitSvDir = "/etc/sv"
voidRunitServiceDir = "/var/service" voidRunitServiceDir = "/var/service"
) )
@@ -193,6 +194,103 @@ func (v *VoidDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap
return fmt.Errorf("failed to install %s, restored %s: %w", strings.Join(to, ", "), strings.Join(from, ", "), installErr) return fmt.Errorf("failed to install %s, restored %s: %w", strings.Join(to, ", "), strings.Join(from, ", "), installErr)
} }
func (v *VoidDistribution) RepoMoves() []RepoMove {
var moves []RepoMove
legacy, hasDeps := false, false
for _, path := range repoGlob(voidConfGlob) {
for _, url := range xbpsRepositoryLines(path) {
move, ok := voidRepoMove(path, url)
if !ok {
continue
}
legacy = legacy || move.OldPresent
hasDeps = hasDeps || strings.HasPrefix(move.New, voidHost+"/"+projectDeps+"/")
moves = append(moves, move)
}
}
// Greeter-only hosts got dms-greeter from dms/current, which no longer carries it.
if legacy && !hasDeps {
moves = append(moves, RepoMove{Kind: RepoTypeXBPS, New: VoidDepsRepo, NewPath: voidDepsConf})
}
return moves
}
// Matches on the repository line because older installs named the conf files differently.
func xbpsRepositoryLines(path string) []string {
content, err := repoReadFile(path)
if err != nil {
return nil
}
var urls []string
for line := range strings.Lines(string(content)) {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || strings.TrimSpace(key) != "repository" {
continue
}
urls = append(urls, strings.TrimSpace(value))
}
return urls
}
func voidRepoMove(path, url string) (RepoMove, bool) {
if strings.HasPrefix(url, voidHost+"/") {
return RepoMove{Kind: RepoTypeXBPS, New: url, NewPath: path, NewPresent: true}, true
}
rest, ok := strings.CutPrefix(url, legacyVoidHost+"/")
if !ok {
return RepoMove{}, false
}
if deps, ok := strings.CutPrefix(rest, legacyProjectDeps+"/"); ok {
rest = projectDeps + "/" + deps
}
return RepoMove{Kind: RepoTypeXBPS, Old: url, New: voidHost + "/" + rest, OldPath: path, NewPath: path, OldPresent: true}, true
}
func voidAddsRepo(move RepoMove) bool {
return !move.OldPresent && !move.NewPresent && move.NewPath != ""
}
func voidMoveCommands(moves []RepoMove) []string {
var commands []string
for _, move := range moves {
switch {
case move.OldPresent:
commands = append(commands, fmt.Sprintf("sed -i 's|repository=%s|repository=%s|' %s", move.Old, move.New, move.OldPath))
case voidAddsRepo(move):
// Appends so an unrelated file that happens to share the name is not clobbered.
commands = append(commands, fmt.Sprintf("sh -c 'echo repository=%s >> %s'", move.New, move.NewPath))
}
}
if len(commands) > 0 {
commands = append(commands, "xbps-install -S")
}
return commands
}
func (v *VoidDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
for _, command := range voidMoveCommands(moves) {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.30,
Step: "Moving XBPS repositories...",
NeedsSudo: true,
CommandInfo: "sudo " + command,
}
if err := v.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.40); err != nil {
return fmt.Errorf("failed to run %s: %w", command, err)
}
}
return nil
}
func (v *VoidDistribution) RepoMoveCommands(moves []RepoMove) []string {
var commands []string
for _, command := range voidMoveCommands(moves) {
commands = append(commands, "sudo "+command)
}
return commands
}
func (v *VoidDistribution) packageVersion(pkg string) string { func (v *VoidDistribution) packageVersion(pkg string) string {
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output() output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
if err != nil { if err != nil {
@@ -220,9 +318,9 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem}, "quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
"matugen": {Name: "matugen", Repository: RepoTypeSystem}, "matugen": {Name: "matugen", Repository: RepoTypeSystem},
"dms (Dux Shell)": v.getDmsMapping(variants["dms (Dux Shell)"]), "dms (Dux Shell)": v.getDmsMapping(variants["dms (Dux Shell)"]),
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDMSRepo), "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDepsRepo),
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo}, "danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo},
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo}, "dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo},
} }
switch wm { switch wm {
@@ -521,8 +619,8 @@ func (v *VoidDistribution) xbpsRepoName(repoURL string) string {
switch repoURL { switch repoURL {
case VoidDMSRepo: case VoidDMSRepo:
return "dms" return "dms"
case VoidDankLinuxRepo: case VoidDepsRepo:
return "danklinux" return "dux"
case VoidHyprlandRepo: case VoidHyprlandRepo:
return "hyprland" return "hyprland"
default: default:
+103 -50
View File
@@ -2,6 +2,7 @@ package headless
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -17,6 +18,8 @@ import (
// must explicitly confirm the operation. // must explicitly confirm the operation.
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed") var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
var ErrPackagesNotLayered = fmt.Errorf("packages must be layered into the image before configuration can be deployed")
// validConfigNames maps lowercase CLI input to the deployer key used in // validConfigNames maps lowercase CLI input to the deployer key used in
// replaceConfigs. Keep in sync with the config types checked by // replaceConfigs. Keep in sync with the config types checked by
// shouldReplaceConfig in deployer.go. // shouldReplaceConfig in deployer.go.
@@ -101,11 +104,17 @@ func (r *Runner) Run() error {
return fmt.Errorf("OS detection failed: %w", err) return fmt.Errorf("OS detection failed: %w", err)
} }
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) { switch {
case osInfo.Immutable && !distros.IsDistroSupported(osInfo.Distribution.ID):
return errors.New(distros.ImmutableGuidance(osInfo))
case osInfo.Immutable:
fmt.Fprintf(os.Stdout, "Detected: %s (%s), image-based: %s\n", osInfo.PrettyName, osInfo.Architecture, osInfo.ImmutableReason)
fmt.Fprintln(os.Stdout, "No packages will be installed; only configuration is deployed.")
case distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID):
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID) return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
} default:
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture) fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
}
// 4. Create distribution instance // 4. Create distribution instance
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan) distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
@@ -120,6 +129,9 @@ func (r *Runner) Run() error {
if err != nil { if err != nil {
return fmt.Errorf("dependency detection failed: %w", err) return fmt.Errorf("dependency detection failed: %w", err)
} }
if osInfo.Immutable {
dependencies = distros.ApplyImmutableDetection(dependencies)
}
reinstallItems, err := r.applyGitVariants(dependencies, gitVariantChecker(distro, wm, dependencies)) reinstallItems, err := r.applyGitVariants(dependencies, gitVariantChecker(distro, wm, dependencies))
if err != nil { if err != nil {
@@ -158,14 +170,25 @@ func (r *Runner) Run() error {
marker = " RE " marker = " RE "
status = "(will reinstall)" status = "(will reinstall)"
} }
if osInfo.Immutable && dep.Status != deps.StatusInstalled {
status = "(not installed)"
}
} }
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status) fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
} }
fmt.Fprintln(os.Stdout) fmt.Fprintln(os.Stdout)
if osInfo.Immutable {
if err := r.requireLayeredPackages(distro, dependencies, wm, reinstallItems, disabledItems); err != nil {
return err
}
}
// 6b. Require explicit confirmation unless --yes is set // 6b. Require explicit confirmation unless --yes is set
if !r.cfg.Yes { if !r.cfg.Yes {
if replaceConfigs == nil { if osInfo.Immutable {
fmt.Fprintln(os.Stdout, "All packages are present. Only configurations will be deployed.")
} else if replaceConfigs == nil {
// --replace-configs-all // --replace-configs-all
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.") 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.") fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
@@ -186,57 +209,21 @@ func (r *Runner) Run() error {
return ErrConfirmationRequired return ErrConfirmationRequired
} }
// 7. Authenticate sudo sudoPassword := ""
sudoPassword, err := r.resolveSudoPassword() if !osInfo.Immutable {
sudoPassword, err = r.installPackages(distro, dependencies, wm, reinstallItems, disabledItems)
if err != nil { if err != nil {
return err 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 { useSystemd := distros.HasSystemd()
return fmt.Errorf("package installation failed: %w", err)
}
useSystemd := true // 9. Greeter setup. On images dms-greeter escalates itself, so it is left to the user.
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid { if osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
useSystemd = false fmt.Fprintln(os.Stdout, "Greeter: run 'dms-greeter install --yes' to configure greetd.")
} }
if !osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
// 9. Greeter setup (if dms-greeter was included)
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...") fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
logFunc := func(line string) { logFunc := func(line string) {
r.log(line) r.log(line)
@@ -531,14 +518,14 @@ func (r *Runner) resolveSudoPassword() (string, error) {
return "", fmt.Errorf( return "", fmt.Errorf(
"sudo authentication required but no cached credentials found\n" + "sudo authentication required but no cached credentials found\n" +
"Options:\n" + "Options:\n" +
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" + " 1. Run 'sudo -v' before duxinstall to cache credentials\n" +
" 2. Configure passwordless sudo for your user", " 2. Configure passwordless sudo for your user",
) )
case privesc.ToolDoas: case privesc.ToolDoas:
return "", fmt.Errorf( return "", fmt.Errorf(
"doas authentication required but no cached credentials found\n" + "doas authentication required but no cached credentials found\n" +
"Options:\n" + "Options:\n" +
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" + " 1. Run 'doas true' before duxinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user", " 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
) )
case privesc.ToolRun0: case privesc.ToolRun0:
@@ -569,3 +556,69 @@ func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
} }
return false return false
} }
func (r *Runner) requireLayeredPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) error {
pending := distros.PendingDependencies(dependencies, reinstallItems, disabledItems)
if len(pending) == 0 {
return nil
}
layerer, ok := distro.(distros.PackageLayerer)
if !ok {
return fmt.Errorf("this image has no known way to add packages; install them with the image's own tooling, reboot, and run duxinstall again")
}
fmt.Fprintln(os.Stdout, "Packages must be added to the image first. Run these, reboot, then run duxinstall again:")
fmt.Fprintln(os.Stdout)
for _, cmd := range layerer.LayerCommands(dependencies, wm, reinstallItems, disabledItems) {
fmt.Fprintf(os.Stdout, " %s\n", cmd)
}
fmt.Fprintln(os.Stdout)
r.log("Aborted: packages not layered on image-based host")
return ErrPackagesNotLayered
}
func (r *Runner) installPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) (string, error) {
// 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,
)
}()
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)
}
return sudoPassword, nil
}
+8 -5
View File
@@ -23,15 +23,18 @@ type FileLogger struct {
func NewFileLogger() (*FileLogger, error) { func NewFileLogger() (*FileLogger, error) {
timestamp := time.Now().Unix() timestamp := time.Now().Unix()
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp. logDir := os.Getenv("DUXINSTALL_LOG_DIR")
logDir := os.Getenv("DANKINSTALL_LOG_DIR") if logDir == "" {
// Pre-rename name, still set in existing scripts.
logDir = os.Getenv("DANKINSTALL_LOG_DIR")
}
if logDir == "" { if logDir == "" {
logDir = "/tmp" logDir = "/tmp"
} }
if err := os.MkdirAll(logDir, 0o755); err != nil { if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err) return nil, fmt.Errorf("failed to create log directory: %w", err)
} }
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp)) logPath := filepath.Join(logDir, fmt.Sprintf("duxinstall-%d.log", timestamp))
file, err := os.Create(logPath) file, err := os.Create(logPath)
if err != nil { if err != nil {
@@ -49,7 +52,7 @@ func NewFileLogger() (*FileLogger, error) {
passwordRe: passwordRe, passwordRe: passwordRe,
} }
header := fmt.Sprintf("=== DankInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339)) header := fmt.Sprintf("=== DuxInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339))
logger.writeToFile(header) logger.writeToFile(header)
return logger, nil return logger, nil
@@ -102,7 +105,7 @@ func (l *FileLogger) Close() error {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
footer := fmt.Sprintf("\n=== DankInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339)) footer := fmt.Sprintf("\n=== DuxInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339))
l.writer.WriteString(footer) //nolint:errcheck l.writer.WriteString(footer) //nolint:errcheck
l.writer.Flush() l.writer.Flush()
+19
View File
@@ -0,0 +1,19 @@
// Package mangoconf holds what DMS knows about Mango's config files: where they
// live, the session target, and the markers DMS leaves in the binds fragment.
package mangoconf
import (
"fmt"
"strings"
)
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
const maxValueLen = 255
func CheckLine(line string) error {
_, value, ok := strings.Cut(line, "=")
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
return nil
}
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
}
-216
View File
@@ -1,216 +0,0 @@
// Package mangoconf maps Mango config keys between the run-together names of
// Mango <= 0.17.5 and the snake_case names of upstream 8adc6bc3 (mangowm/mango#1491).
// Neither parser accepts the other's names.
package mangoconf
import (
"bytes"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"sync"
)
type Dialect int
const (
Legacy Dialect = iota
Snake
)
// Upstream migration order: where two legacy names share a snake name, the
// window-rule spelling comes first and wins the reverse lookup.
var renames = [][2]string{
{"windowrule-once", "window_rule_once"},
{"windowrule", "window_rule"},
{"monitorrule", "monitor_rule"},
{"tagrule", "tag_rule"},
{"layerrule", "layer_rule"},
{"devicerule", "device_rule"},
{"source-optional", "source_optional"},
{"exec-once", "exec_once"},
{"keymode", "key_mode"},
{"isnoborder", "no_border"},
{"isnoshadow", "no_shadow"},
{"isnoradius", "no_radius"},
{"isnoanimation", "no_animation"},
{"isnosizehint", "no_size_hint"},
{"noblur", "no_blur"},
{"noshadow", "no_shadow"},
{"noanim", "no_animation"},
{"nofocus", "no_focus"},
{"nofadein", "no_fade_in"},
{"nofadeout", "no_fade_out"},
{"noswallow", "no_swallow"},
{"special_gappih", "special_gap_inner_horizontal"},
{"special_gappiv", "special_gap_inner_vertical"},
{"special_gappoh", "special_gap_outer_horizontal"},
{"special_gappov", "special_gap_outer_vertical"},
{"gappih", "gap_inner_horizontal"},
{"gappiv", "gap_inner_vertical"},
{"gappoh", "gap_outer_horizontal"},
{"gappov", "gap_outer_vertical"},
{"overviewgappi", "overview_gap_inner"},
{"overviewgappo", "overview_gap_outer"},
{"default_mfact", "default_master_factor"},
{"default_nmaster", "default_master_count"},
{"animation_curve_opafadein", "animation_curve_opacity_fade_in"},
{"animation_curve_opafadeout", "animation_curve_opacity_fade_out"},
{"dwindle_hsplit", "dwindle_horizontal_split"},
{"dwindle_vsplit", "dwindle_vertical_split"},
{"fadein_begin_opacity", "fade_in_begin_opacity"},
{"fadeout_begin_opacity", "fade_out_begin_opacity"},
{"focusdir_only_zone_overlap", "focus_direction_only_zone_overlap"},
{"force_fakemaximize", "force_fake_maximize"},
{"idleinhibit_ignore_visible", "idle_inhibit_ignore_visible"},
{"idleinhibit_when_focus", "idle_inhibit_when_focus"},
{"idleinhibit_when_fullscreen", "idle_inhibit_when_fullscreen"},
{"syncobj_enable", "sync_obj_enable"},
{"isfakefullscreen", "is_fake_fullscreen"},
{"isfloating", "is_floating"},
{"isfullscreen", "is_fullscreen"},
{"isunglobal", "is_unmanaged_global"},
{"isglobal", "is_global"},
{"isnamedscratchpad", "is_named_scratchpad"},
{"isopensilent", "is_open_silent"},
{"isoverlay", "is_overlay"},
{"istagsilent", "is_tag_silent"},
{"isterm", "is_term"},
{"appid", "app_id"},
{"bordercolor", "border_color"},
{"borderpx", "border_px"},
{"dropcolor", "drop_color"},
{"focuscolor", "focus_color"},
{"globalcolor", "global_color"},
{"globalkeybinding", "global_key_binding"},
{"maximizescreencolor", "maximized_screen_color"},
{"mfact", "master_factor"},
{"nmaster", "master_count"},
{"numlockon", "numlock_on"},
{"offsetx", "offset_x"},
{"offsety", "offset_y"},
{"overlaycolor", "overlay_color"},
{"rootcolor", "root_color"},
{"scratchpadcolor", "scratchpad_color"},
{"shadowscolor", "shadows_color"},
{"sloppyfocus", "sloppy_focus"},
{"smartgaps", "smart_gaps"},
{"splitcolor", "split_color"},
{"urgentcolor", "urgent_color"},
{"warpcursor", "warp_cursor"},
}
var toSnake, toLegacy = func() (map[string]string, map[string]string) {
fwd := make(map[string]string, len(renames))
rev := make(map[string]string, len(renames))
for _, r := range renames {
fwd[r[0]] = r[1]
if _, ok := rev[r[1]]; !ok {
rev[r[1]] = r[0]
}
}
return fwd, rev
}()
// Normalize returns the snake_case name for a key in either dialect.
func Normalize(key string) string {
if s, ok := toSnake[key]; ok {
return s
}
return key
}
// Key spells a snake_case key in this dialect.
func (d Dialect) Key(snake string) string {
if d == Snake {
return snake
}
if l, ok := toLegacy[snake]; ok {
return l
}
return snake
}
var keyLine = regexp.MustCompile(`^(\s*)([A-Za-z][A-Za-z_-]*)(\s*=\s*)(.*)$`)
// Translate respells every line key, and the field keys of rule lines, in this dialect.
func (d Dialect) Translate(text string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
m := keyLine.FindStringSubmatch(line)
if m == nil {
continue
}
key := Normalize(m[2])
value := m[4]
switch key {
case "window_rule", "window_rule_once", "tag_rule":
value = d.translateFields(value, nil)
case "layer_rule":
value = d.translateFields(value, legacyLayerFields)
}
lines[i] = m[1] + d.Key(key) + m[3] + value
}
return strings.Join(lines, "\n")
}
// Layer rules spell these without the window rules' "is" prefix.
var legacyLayerFields = map[string]string{"no_shadow": "noshadow", "no_animation": "noanim"}
func (d Dialect) translateFields(value string, legacyOverride map[string]string) string {
parts := strings.Split(value, ",")
for i, part := range parts {
k, v, ok := strings.Cut(part, ":")
if !ok {
continue
}
trimmed := strings.TrimSpace(k)
key := d.Key(Normalize(trimmed))
if l, ok := legacyOverride[Normalize(trimmed)]; ok && d == Legacy {
key = l
}
parts[i] = strings.Replace(k, trimmed, key, 1) + ":" + v
}
return strings.Join(parts, ",")
}
// Legacy builds carry exec_once as a symbol name, so it cannot tell the dialects apart.
var snakeMarker = []byte("gap_inner_horizontal")
var (
detectOnce sync.Once
detected Dialect
)
// Detect reads the mango binary: snake_case shipped on master builds still reporting 0.17.5, before the 0.18.0 release.
func Detect() Dialect {
detectOnce.Do(func() {
path, err := exec.LookPath("mango")
if err != nil {
return
}
detected = DetectBinary(path)
})
return detected
}
func DetectBinary(path string) Dialect {
data, err := os.ReadFile(path)
if err != nil || !bytes.Contains(data, snakeMarker) {
return Legacy
}
return Snake
}
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
const maxValueLen = 255
func CheckLine(line string) error {
_, value, ok := strings.Cut(line, "=")
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
return nil
}
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
}
-68
View File
@@ -1,68 +0,0 @@
package mangoconf
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTranslateRespellsKeysBothWays(t *testing.T) {
legacy := "exec-once=dms run\nsource=./dms/colors.conf\ngappih = 5\nbordercolor = 0x595959ff\nwindowrule=appid:foo,isfloating:1,isnoanimation:1\nlayerrule=noanim:1,noshadow:1,layer_name:rofi\n# exec-once=kept\n"
snake := "exec_once=dms run\nsource=./dms/colors.conf\ngap_inner_horizontal = 5\nborder_color = 0x595959ff\nwindow_rule=app_id:foo,is_floating:1,no_animation:1\nlayer_rule=no_animation:1,no_shadow:1,layer_name:rofi\n# exec-once=kept\n"
assert.Equal(t, snake, Snake.Translate(legacy))
assert.Equal(t, legacy, Legacy.Translate(snake))
assert.Equal(t, legacy, Legacy.Translate(legacy))
assert.Equal(t, "tag_rule=id:1,master_factor:0.6,master_count:2\n", Snake.Translate("tagrule=id:1,mfact:0.6,nmaster:2\n"))
}
func TestDetectBinary(t *testing.T) {
dir := t.TempDir()
old := filepath.Join(dir, "old")
cur := filepath.Join(dir, "new")
require.NoError(t, os.WriteFile(old, []byte("\x00run_exec_once\x00gappih\x00"), 0o755))
require.NoError(t, os.WriteFile(cur, []byte("\x00exec_once\x00gap_inner_horizontal\x00"), 0o755))
assert.Equal(t, Legacy, DetectBinary(old))
assert.Equal(t, Snake, DetectBinary(cur))
assert.Equal(t, Legacy, DetectBinary(filepath.Join(dir, "missing")))
}
func TestMigrateLeavesUserConfigUnlessAsked(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
main := filepath.Join(dir, "config.conf")
layout := filepath.Join(dir, "dms", "layout.conf")
input := filepath.Join(dir, "dms", "input.conf")
require.NoError(t, os.WriteFile(main, []byte("exec-once=dms run\n"), 0o644))
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
require.NoError(t, os.WriteFile(input, []byte("numlockon=1\n"), 0o644))
changed, err := Snake.Migrate(dir, false)
require.NoError(t, err)
assert.Equal(t, []string{layout, input}, changed)
data, _ := os.ReadFile(main)
assert.Equal(t, "exec-once=dms run\n", string(data))
data, _ = os.ReadFile(input)
assert.Equal(t, "numlock_on=1\n", string(data))
changed, err = Snake.Migrate(dir, true)
require.NoError(t, err)
assert.Equal(t, []string{main}, changed)
backups, _ := filepath.Glob(main + ".backup.*")
assert.Len(t, backups, 1)
}
func TestMigrateReportsUnwritableFragmentAndContinues(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
binds := filepath.Join(dir, "dms", "binds.conf")
layout := filepath.Join(dir, "dms", "layout.conf")
require.NoError(t, os.WriteFile(binds, []byte("exec-once=foo\n"), 0o444))
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
changed, err := Snake.Migrate(dir, false)
assert.ErrorContains(t, err, "binds.conf")
assert.Equal(t, []string{layout}, changed)
}
-57
View File
@@ -1,57 +0,0 @@
package mangoconf
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// DMS-owned fragments under mango/dms; config.conf is the user's and only
// migrated on request.
var fragments = []string{"binds.conf", "colors.conf", "layout.conf", "cursor.conf", "input.conf", "outputs.conf", "windowrules.conf"}
// Migrate respells the dms fragments, and config.conf (after a backup) when includeMain is set.
// It returns the files rewritten; an unwritable file is reported without stopping the others.
func (d Dialect) Migrate(mangoDir string, includeMain bool) ([]string, error) {
var changed []string
var errs []error
paths := make([]string, 0, len(fragments)+1)
for _, f := range fragments {
paths = append(paths, filepath.Join(mangoDir, "dms", f))
}
if includeMain {
paths = append(paths, filepath.Join(mangoDir, "config.conf"))
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
translated := d.Translate(string(data))
if translated == string(data) {
continue
}
if filepath.Base(path) == "config.conf" {
backup := path + ".backup." + time.Now().Format("2006-01-02_15-04-05")
if err := os.WriteFile(backup, data, 0o644); err != nil {
errs = append(errs, fmt.Errorf("backup %s: %w", path, err))
continue
}
}
if err := os.WriteFile(path, []byte(translated), 0o644); err != nil {
errs = append(errs, fmt.Errorf("write %s: %w", path, err))
continue
}
changed = append(changed, path)
}
return changed, errors.Join(errs...)
}
func (d Dialect) String() string {
if d == Snake {
return "snake"
}
return "legacy"
}
+23 -24
View File
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"math" "math"
"os" "os"
"os/exec" "os/exec"
@@ -20,7 +19,6 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/dank16" "github.com/duxlabs-dev/dux-shell/core/internal/dank16"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
"github.com/lucasb-eyer/go-colorful" "github.com/lucasb-eyer/go-colorful"
@@ -44,7 +42,6 @@ const (
TemplateKindGTK TemplateKindGTK
TemplateKindVSCode TemplateKindVSCode
TemplateKindEmacs TemplateKindEmacs
TemplateKindMango
) )
type TemplateDef struct { type TemplateDef struct {
@@ -63,7 +60,7 @@ var templateRegistry = []TemplateDef{
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true}, {ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"}, {ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"}, {ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE", Kind: TemplateKindMango}, {ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE"},
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"}, {ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"}, {ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"}, {ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"},
@@ -667,7 +664,7 @@ func userConfigSection(opts *Options) string {
if err != nil { if err != nil {
return "[config]\n\n" return "[config]\n\n"
} }
section := extractTOMLSection(string(data), "[config]", "[templates]") section := extractTOMLSection(string(data), "[config]", "\n[templates")
if section == "" { if section == "" {
return "[config]\n\n" return "[config]\n\n"
} }
@@ -725,12 +722,6 @@ output_path = '%s'
for _, editor := range vscodeEditors { for _, editor := range vscodeEditors {
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir) appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
} }
case TemplateKindMango:
configFile := tmpl.ConfigFile
if mangoconf.Detect() == mangoconf.Snake {
configFile = "mangowc-snake.toml"
}
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, configFile)
case TemplateKindEmacs: case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" { if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile) appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
@@ -749,7 +740,7 @@ output_path = '%s'
if opts.RunUserTemplates { if opts.RunUserTemplates {
if data, err := os.ReadFile(userConfigPath); err == nil { if data, err := os.ReadFile(userConfigPath); err == nil {
templatesSection := extractTOMLSection(string(data), "[templates]", "") templatesSection := extractTOMLSection("\n"+string(data), "\n[templates", "\n[config]")
if templatesSection != "" { if templatesSection != "" {
cfgFile.WriteString(templatesSection) cfgFile.WriteString(templatesSection)
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
@@ -965,6 +956,7 @@ var vscodeEditors = []vscodeEditor{
{"cursor", ".cursor"}, {"cursor", ".cursor"},
{"windsurf", ".windsurf"}, {"windsurf", ".windsurf"},
{"positron", ".positron"}, {"positron", ".positron"},
{"antigravity", ".antigravity-ide"},
{"vscode-insiders", ".vscode-insiders"}, {"vscode-insiders", ".vscode-insiders"},
} }
@@ -1154,21 +1146,32 @@ func runMatugen(baseArgs []string, sourceMode string) error {
// matugen's stderr is echoed as before and its tail travels in the error, so a failure // matugen's stderr is echoed as before and its tail travels in the error, so a failure
// reaching the shell through the socket still names the broken template or option. // reaching the shell through the socket still names the broken template or option.
// It goes through a file, not a pipe: with a pipe, Wait blocks until every hook
// child that inherited stderr exits, and a backgrounded post_hook never does (#3901).
func execMatugen(args []string) error { func execMatugen(args []string) error {
stderrFile, err := os.CreateTemp("", "matugen-stderr-*")
if err != nil {
return fmt.Errorf("matugen: %w", err)
}
defer os.Remove(stderrFile.Name())
defer stderrFile.Close()
cmd := exec.Command("matugen", args...) cmd := exec.Command("matugen", args...)
cmd.Env = utils.EnvWithUserBinPath(nil) cmd.Env = utils.EnvWithUserBinPath(nil)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
var stderr bytes.Buffer cmd.Stderr = stderrFile
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) runErr := cmd.Run()
err := cmd.Run()
if err == nil { stderr, _ := os.ReadFile(stderrFile.Name())
os.Stderr.Write(stderr)
if runErr == nil {
return nil return nil
} }
tail := stderrTail(stderr.String(), 6) tail := stderrTail(string(stderr), 6)
if tail == "" { if tail == "" {
return fmt.Errorf("matugen: %w", err) return fmt.Errorf("matugen: %w", runErr)
} }
return fmt.Errorf("matugen: %w\n%s", err, tail) return fmt.Errorf("matugen: %w\n%s", runErr, tail)
} }
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
@@ -1218,11 +1221,7 @@ func writeDryRunConfig(opts *Options) (string, error) {
} }
defer cfgFile.Close() defer cfgFile.Close()
section := userConfigSection(opts) cfgFile.WriteString(userConfigSection(opts))
if idx := strings.Index(section, "\n[templates"); idx != -1 {
section = section[:idx+1]
}
cfgFile.WriteString(section)
cfgFile.WriteString("[templates]\n") cfgFile.WriteString("[templates]\n")
return cfgFile.Name(), nil return cfgFile.Name(), nil
} }
+26 -1
View File
@@ -511,7 +511,7 @@ func TestBuildMergedConfigColorsOnly(t *testing.T) {
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs") assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
} }
func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) { func TestBuildMergedConfigMangoSession(t *testing.T) {
t.Setenv("MANGO_INSTANCE_SIGNATURE", "") t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -555,6 +555,31 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
t.Fatalf("failed to read merged config: %v", err) t.Fatalf("failed to read merged config: %v", err)
} }
assert.NotContains(t, string(output), "[templates.dmsmango]") assert.NotContains(t, string(output), "[templates.dmsmango]")
socket := filepath.Join(tempDir, "mango.sock")
if err := os.WriteFile(socket, nil, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MANGO_INSTANCE_SIGNATURE", socket)
checker := mocks_utils.NewMockAppChecker(t)
checker.EXPECT().AnyCommandExists("mango").Return(true)
opts.AppChecker = checker
cfgFile, err = os.CreateTemp(tempDir, "merged-active-*.toml")
if err != nil {
t.Fatal(err)
}
defer cfgFile.Close()
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
t.Fatal(err)
}
if err := cfgFile.Close(); err != nil {
t.Fatal(err)
}
output, err = os.ReadFile(cfgFile.Name())
if err != nil {
t.Fatal(err)
}
assert.Contains(t, string(output), mangowcConfig)
} }
func TestAppendConfigConfigDirExists(t *testing.T) { func TestAppendConfigConfigDirExists(t *testing.T) {
+26
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"sync"
"github.com/duxlabs-dev/dux-go/wayland/client" "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management" "github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management"
@@ -151,6 +152,31 @@ func GetHyprlandMonitorScale(name string) float64 {
return 0 return 0
} }
// !TODO: remove with the frameInLogicalOrientation version fallback
var hyprlandVersion = sync.OnceValues(func() (int, int) {
output, err := exec.Command("hyprctl", "-j", "version").Output()
if err != nil {
return 0, 0
}
var info struct {
Version string `json:"version"`
}
if err := json.Unmarshal(output, &info); err != nil {
return 0, 0
}
var major, minor int
fmt.Sscanf(info.Version, "%d.%d", &major, &minor)
return major, minor
})
func hyprlandVersionAtLeast(major, minor int) bool {
haveMajor, haveMinor := hyprlandVersion()
if haveMajor != major {
return haveMajor > major
}
return haveMinor >= minor
}
func getHyprlandFocusedMonitor() string { func getHyprlandFocusedMonitor() string {
output, err := exec.Command("hyprctl", "-j", "monitors").Output() output, err := exec.Command("hyprctl", "-j", "monitors").Output()
if err != nil { if err != nil {
+1 -4
View File
@@ -609,16 +609,13 @@ func (r *RegionSelector) preCaptureOutput(output *WaylandOutput, pc *PreCapture,
pc.yInverted = false pc.yInverted = false
} }
if output.transform != TransformNormal { transformed, err := output.untransformFrame(capturedBuf)
invTransform := InverseTransform(output.transform)
transformed, err := capturedBuf.ApplyTransform(invTransform)
if err != nil { if err != nil {
log.Error("apply transform failed", "err", err) log.Error("apply transform failed", "err", err)
} else if transformed != capturedBuf { } else if transformed != capturedBuf {
capturedBuf.Close() capturedBuf.Close()
capturedBuf = transformed capturedBuf = transformed
} }
}
if withCursor { if withCursor {
pc.screenBuf = capturedBuf pc.screenBuf = capturedBuf
+23 -6
View File
@@ -49,6 +49,28 @@ func (o *WaylandOutput) effectiveScale() float64 {
return scale return scale
} }
// Hyprland renders screencopy frames in the output's logical orientation since
// 901881c9f (v0.57.0); wlroots and niri still hand back the native framebuffer.
// !TODO: drop the version fallback once every distro ships hyprland >= 0.57
func (o *WaylandOutput) frameInLogicalOrientation(frameW, frameH int) bool {
switch o.transform {
case TransformNormal:
return true
case Transform90, Transform270, TransformFlipped90, TransformFlipped270:
if o.width != o.height {
return frameW == int(o.height) && frameH == int(o.width)
}
}
return DetectCompositor() == CompositorHyprland && hyprlandVersionAtLeast(0, 57)
}
func (o *WaylandOutput) untransformFrame(buf *ShmBuffer) (*ShmBuffer, error) {
if o.frameInLogicalOrientation(buf.Width, buf.Height) {
return buf, nil
}
return buf.ApplyTransform(InverseTransform(o.transform))
}
func (o *WaylandOutput) bounds() Region { func (o *WaylandOutput) bounds() Region {
x, y, w, h := o.x, o.y, o.width, o.height x, y, w, h := o.x, o.y, o.width, o.height
if DetectCompositor() == CompositorHyprland { if DetectCompositor() == CompositorHyprland {
@@ -677,12 +699,7 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
result.YInverted = false result.YInverted = false
} }
if output.transform == TransformNormal { transformed, err := output.untransformFrame(result.Buffer)
return result, nil
}
invTransform := InverseTransform(output.transform)
transformed, err := result.Buffer.ApplyTransform(invTransform)
if err != nil { if err != nil {
result.Buffer.Close() result.Buffer.Close()
return nil, fmt.Errorf("apply transform: %w", err) return nil, fmt.Errorf("apply transform: %w", err)
@@ -40,10 +40,6 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
handleSubscribe(conn, req, m) handleSubscribe(conn, req, m)
case "clipboard.search": case "clipboard.search":
handleSearch(conn, req, m) handleSearch(conn, req, m)
case "clipboard.getConfig":
handleGetConfig(conn, req, m)
case "clipboard.setConfig":
handleSetConfig(conn, req, m)
case "clipboard.store": case "clipboard.store":
handleStore(conn, req, m) handleStore(conn, req, m)
case "clipboard.pinEntry": case "clipboard.pinEntry":
@@ -320,40 +316,6 @@ func handleSearch(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
conn.Respond(req.ID, m.Search(p)) conn.Respond(req.ID, m.Search(p))
} }
func handleGetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
conn.Respond(req.ID, m.GetConfig())
}
func handleSetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
cfg := m.GetConfig()
if v, ok := req.Get[float64]("maxHistory"); ok {
cfg.MaxHistory = int(v)
}
if v, ok := req.Get[float64]("maxEntrySize"); ok {
cfg.MaxEntrySize = int64(v)
}
if v, ok := req.Get[float64]("autoClearDays"); ok {
cfg.AutoClearDays = int(v)
}
if v, ok := req.Get[bool]("clearAtStartup"); ok {
cfg.ClearAtStartup = v
}
if v, ok := req.Get[bool]("disabled"); ok {
cfg.Disabled = v
}
if v, ok := req.Get[float64]("maxPinned"); ok {
cfg.MaxPinned = int(v)
}
if err := m.SetConfig(cfg); err != nil {
conn.RespondError(req.ID, err.Error())
return
}
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "config updated"})
}
func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
data, err := params.String(req.Params, "data") data, err := params.String(req.Params, "data")
if err != nil { if err != nil {
+166 -62
View File
@@ -2,6 +2,7 @@ package clipboard
import ( import (
"bytes" "bytes"
"cmp"
"encoding/binary" "encoding/binary"
"errors" "errors"
"fmt" "fmt"
@@ -29,6 +30,7 @@ import (
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
berrors "go.etcd.io/bbolt/errors"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client" wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control" "github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
@@ -55,8 +57,14 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
configPath, _ := getConfigPath() configPath, _ := getConfigPath()
db, err := openDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
m := &Manager{ m := &Manager{
config: config, config: config,
db: db,
configPath: configPath, configPath: configPath,
display: display, display: display,
wlCtx: wlCtx, wlCtx: wlCtx,
@@ -69,6 +77,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
if !config.Disabled { if !config.Disabled {
if err := m.setupRegistry(); err != nil { if err := m.setupRegistry(); err != nil {
db.Close()
return nil, err return nil, err
} }
} }
@@ -78,12 +87,6 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
go m.watchConfig() go m.watchConfig()
db, err := openDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
m.db = db
if err := m.migrateHashes(); err != nil { if err := m.migrateHashes(); err != nil {
log.Errorf("Failed to migrate hashes: %v", err) log.Errorf("Failed to migrate hashes: %v", err)
} }
@@ -117,6 +120,9 @@ func openDB(path string) (*bolt.DB, error) {
if err == nil { if err == nil {
return db, nil return db, nil
} }
if errors.Is(err, berrors.ErrTimeout) {
return nil, err
}
log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err) log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err)
@@ -244,6 +250,9 @@ func (m *Manager) setupRegistry() error {
m.dataControlMgr = dataControlMgr m.dataControlMgr = dataControlMgr
log.Info("Bound ext_data_control_manager_v1") log.Info("Bound ext_data_control_manager_v1")
case "wl_seat": case "wl_seat":
if m.seat != nil {
return
}
seat := wlclient.NewSeat(ctx) seat := wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
log.Errorf("Failed to bind wl_seat: %v", err) log.Errorf("Failed to bind wl_seat: %v", err)
@@ -272,17 +281,65 @@ func (m *Manager) setupRegistry() error {
} }
func (m *Manager) setupDataDeviceSync() { func (m *Manager) setupDataDeviceSync() {
if m.dataControlMgr == nil || m.seat == nil { if !m.bindDataDevice() {
return return
} }
ctx := m.display.Context() if err := m.display.Context().Dispatch(); err != nil {
log.Errorf("Failed to dispatch initial events: %v", err)
return
}
log.Info("Data device setup complete")
}
// Runs on the dispatcher goroutine; a disabled manager must hold no device so it receives no offers.
func (m *Manager) syncTracking() {
if m.getConfig().Disabled {
m.releaseDataDevice()
return
}
if m.dataDevice != nil {
return
}
if m.registry == nil {
if err := m.setupRegistry(); err != nil {
log.Errorf("Failed to enable clipboard tracking: %v", err)
return
}
}
if m.bindDataDevice() {
log.Info("Data device setup complete")
}
}
func (m *Manager) releaseDataDevice() {
m.deviceBound.Store(false)
if m.currentOffer != nil {
m.releaseOffer(m.currentOffer)
m.currentOffer = nil
}
if m.dataDevice == nil {
return
}
m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1).Destroy()
m.dataDevice = nil
m.mimeTypes = nil
}
func (m *Manager) bindDataDevice() bool {
if m.dataControlMgr == nil || m.seat == nil {
return false
}
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
dataDevice, err := dataMgr.GetDataDevice(m.seat) dataDevice, err := dataMgr.GetDataDevice(m.seat)
if err != nil { if err != nil {
log.Errorf("Failed to send get_data_device request: %v", err) log.Errorf("Failed to send get_data_device request: %v", err)
return return false
} }
dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
@@ -302,8 +359,12 @@ func (m *Manager) setupDataDeviceSync() {
}) })
dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
// The first event replays the selection that existed before binding.
if !m.initialized { if !m.initialized {
m.initialized = true m.initialized = true
if e.Id != nil {
m.releaseOffer(e.Id)
}
return return
} }
@@ -331,6 +392,11 @@ func (m *Manager) setupDataDeviceSync() {
return return
} }
// An event can land between a runtime disable and the posted release.
if m.getConfig().Disabled {
return
}
m.offerMutex.RLock() m.offerMutex.RLock()
mimes := m.offerMimeTypes[offer] mimes := m.offerMimeTypes[offer]
m.offerMutex.RUnlock() m.offerMutex.RUnlock()
@@ -389,14 +455,10 @@ func (m *Manager) setupDataDeviceSync() {
go m.readAndStore(r, preferredMime, altR, altMime) go m.readAndStore(r, preferredMime, altR, altMime)
}) })
m.initialized = false
m.dataDevice = dataDevice m.dataDevice = dataDevice
m.deviceBound.Store(true)
if err := ctx.Dispatch(); err != nil { return true
log.Errorf("Failed to dispatch initial events: %v", err)
return
}
log.Info("Data device setup complete")
} }
func (m *Manager) releaseOffer(offer any) { func (m *Manager) releaseOffer(offer any) {
@@ -515,6 +577,9 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []by
} }
func (m *Manager) storeEntry(entry Entry) error { func (m *Manager) storeEntry(entry Entry) error {
if m.getConfig().Disabled {
return errTrackingDisabled
}
if m.db == nil { if m.db == nil {
return fmt.Errorf("database not available") return fmt.Errorf("database not available")
} }
@@ -569,21 +634,31 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
} }
func (m *Manager) trimLengthInTx(b *bolt.Bucket) error { func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
if m.config.MaxHistory < 0 { maxHistory := m.getConfig().MaxHistory
if maxHistory < 0 {
return nil return nil
} }
type row struct {
key []byte
entry Entry
}
var rows []row
c := b.Cursor() c := b.Cursor()
var count int for k, v := c.First(); k != nil; k, v = c.Next() {
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned { if err != nil || entry.Pinned {
continue continue
} }
if count < m.config.MaxHistory { rows = append(rows, row{slices.Clone(k), entry})
count++
continue
} }
if err := b.Delete(k); err != nil {
slices.SortFunc(rows, func(a, b row) int {
return compareEntriesNewestFirst(a.entry, b.entry)
})
for _, r := range rows[min(maxHistory, len(rows)):] {
if err := b.Delete(r.key); err != nil {
return err return err
} }
} }
@@ -601,7 +676,7 @@ func encodeEntry(e Entry) ([]byte, error) {
binary.Write(buf, binary.BigEndian, uint32(len(e.Preview))) binary.Write(buf, binary.BigEndian, uint32(len(e.Preview)))
buf.WriteString(e.Preview) buf.WriteString(e.Preview)
binary.Write(buf, binary.BigEndian, int32(e.Size)) binary.Write(buf, binary.BigEndian, int32(e.Size))
binary.Write(buf, binary.BigEndian, e.Timestamp.Unix()) binary.Write(buf, binary.BigEndian, e.Timestamp.UnixNano())
if e.IsImage { if e.IsImage {
buf.WriteByte(1) buf.WriteByte(1)
} else { } else {
@@ -667,7 +742,13 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
var timestamp int64 var timestamp int64
binary.Read(buf, binary.BigEndian, &timestamp) binary.Read(buf, binary.BigEndian, &timestamp)
// Rows written before the switch to nanosecond precision hold seconds;
// no second-resolution stamp reaches 1e12 until the year 33658.
if timestamp < 1e12 {
e.Timestamp = time.Unix(timestamp, 0) e.Timestamp = time.Unix(timestamp, 0)
} else {
e.Timestamp = time.Unix(0, timestamp)
}
var isImage byte var isImage byte
binary.Read(buf, binary.BigEndian, &isImage) binary.Read(buf, binary.BigEndian, &isImage)
@@ -1043,9 +1124,25 @@ func (m *Manager) GetHistory() []Entry {
go m.deleteStaleEntries(stale) go m.deleteStaleEntries(stale)
} }
sortEntriesNewestFirst(history)
return history return history
} }
// sortEntriesNewestFirst orders by timestamp, falling back to id: TouchEntry
// rewrites a row in place to keep its id stable, so bucket key order stops
// tracking recency.
func sortEntriesNewestFirst(entries []Entry) {
slices.SortFunc(entries, compareEntriesNewestFirst)
}
func compareEntriesNewestFirst(a, b Entry) int {
if c := b.Timestamp.Compare(a.Timestamp); c != 0 {
return c
}
return cmp.Compare(b.ID, a.ID)
}
func (m *Manager) deleteStaleEntries(ids []uint64) { func (m *Manager) deleteStaleEntries(ids []uint64) {
if m.db == nil { if m.db == nil {
return return
@@ -1181,9 +1278,24 @@ func (m *Manager) TouchEntry(id uint64) error {
return err return err
} }
entry.Timestamp = time.Now() // Round(0) drops the monotonic reading so the in-memory stamp compares the
// same way as the one decoded back from the bucket.
entry.Timestamp = time.Now().Round(0)
if err := m.storeEntry(*entry); err != nil { // storeEntry would dedupe the row against itself and hand out a fresh
// sequence number, invalidating the id the UI is still holding.
err = m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return fmt.Errorf("clipboard bucket missing")
}
encoded, err := encodeEntry(*entry)
if err != nil {
return err
}
return b.Put(itob(id), encoded)
})
if err != nil {
return err return err
} }
@@ -1336,19 +1448,22 @@ func (m *Manager) reopenDB() {
m.db = db m.db = db
} }
var errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol") var (
errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol")
errTrackingDisabled = errors.New("clipboard tracking disabled")
)
// Both are bound once in NewManager; a disabled manager never binds them but // A disabled manager still serves copy requests; failing here lets callers
// still serves copy requests, so callers must fail before touching history. // such as dms screenshot fall back to their own copy.
func (m *Manager) canTakeSelection() bool { func (m *Manager) canTakeSelection() bool {
return m.dataControlMgr != nil && m.dataDevice != nil return m.deviceBound.Load() && !m.getConfig().Disabled
} }
func (m *Manager) SetClipboard(data []byte, mimeType string) error { func (m *Manager) SetClipboard(data []byte, mimeType string) error {
if !m.canTakeSelection() { if !m.canTakeSelection() {
return errSelectionUnavailable return errSelectionUnavailable
} }
if int64(len(data)) > m.config.MaxEntrySize { if int64(len(data)) > m.getConfig().MaxEntrySize {
return fmt.Errorf("data too large") return fmt.Errorf("data too large")
} }
@@ -1366,7 +1481,7 @@ func (m *Manager) SetClipboardEntry(entry *Entry) error {
if !m.canTakeSelection() { if !m.canTakeSelection() {
return errSelectionUnavailable return errSelectionUnavailable
} }
if int64(len(entry.Data)) > m.config.MaxEntrySize { if int64(len(entry.Data)) > m.getConfig().MaxEntrySize {
return fmt.Errorf("data too large") return fmt.Errorf("data too large")
} }
@@ -1515,16 +1630,7 @@ func (m *Manager) Close() {
m.subMutex.Unlock() m.subMutex.Unlock()
m.releaseCurrentSource() m.releaseCurrentSource()
m.releaseDataDevice()
if m.currentOffer != nil {
m.releaseOffer(m.currentOffer)
m.currentOffer = nil
}
if m.dataDevice != nil {
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
device.Destroy()
}
if m.dataControlMgr != nil { if m.dataControlMgr != nil {
mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
@@ -1724,19 +1830,12 @@ func (m *Manager) Search(params SearchParams) SearchResult {
} }
} }
func (m *Manager) GetConfig() Config {
return m.config
}
func (m *Manager) SetConfig(cfg Config) error { func (m *Manager) SetConfig(cfg Config) error {
m.configMutex.Lock() if err := SaveConfig(cfg); err != nil {
m.config = cfg return err
m.configMutex.Unlock() }
m.applyConfigChange(cfg)
m.updateState() return nil
m.notifySubscribers()
return SaveConfig(cfg)
} }
func (m *Manager) getConfig() Config { func (m *Manager) getConfig() Config {
@@ -1777,7 +1876,12 @@ func (m *Manager) watchConfig() {
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
continue continue
} }
newCfg := LoadConfig() // os.WriteFile truncates first; parsing that empty read would
// briefly reset every setting to its default.
newCfg, err := readConfig()
if err != nil {
continue
}
m.applyConfigChange(newCfg) m.applyConfigChange(newCfg)
case err, ok := <-watcher.Errors: case err, ok := <-watcher.Errors:
if !ok { if !ok {
@@ -1794,11 +1898,9 @@ func (m *Manager) applyConfigChange(newCfg Config) {
m.config = newCfg m.config = newCfg
m.configMutex.Unlock() m.configMutex.Unlock()
switch { if newCfg.Disabled != oldCfg.Disabled {
case newCfg.Disabled && !oldCfg.Disabled: log.Infof("Clipboard tracking disabled=%v", newCfg.Disabled)
log.Info("Clipboard tracking disabled") m.post(m.syncTracking)
case !newCfg.Disabled && oldCfg.Disabled:
log.Info("Clipboard tracking enabled")
} }
m.updateState() m.updateState()
@@ -1809,7 +1911,7 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
cfg := m.getConfig() cfg := m.getConfig()
if cfg.Disabled { if cfg.Disabled {
return fmt.Errorf("clipboard tracking disabled") return errTrackingDisabled
} }
if m.db == nil { if m.db == nil {
@@ -2148,6 +2250,8 @@ func (m *Manager) GetPinnedEntries() []Entry {
log.Errorf("Failed to get pinned entries: %v", err) log.Errorf("Failed to get pinned entries: %v", err)
} }
sortEntriesNewestFirst(pinned)
return pinned return pinned
} }
+109 -2
View File
@@ -3,6 +3,7 @@ package clipboard
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"image" "image"
"image/png" "image/png"
"net" "net"
@@ -18,6 +19,7 @@ import (
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/duxlabs-dev/dux-go/ipc"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control" "github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext" mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext"
"github.com/duxlabs-dev/dux-shell/core/internal/server/models" "github.com/duxlabs-dev/dux-shell/core/internal/server/models"
@@ -51,13 +53,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
mockCtx := mocks_wlcontext.NewMockWaylandContext(t) mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe() mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe()
return &Manager{ m := &Manager{
config: DefaultConfig(), config: DefaultConfig(),
db: db, db: db,
wlCtx: mockCtx, wlCtx: mockCtx,
dataControlMgr: &ext_data_control.ExtDataControlManagerV1{}, dataControlMgr: &ext_data_control.ExtDataControlManagerV1{},
dataDevice: &ext_data_control.ExtDataControlDeviceV1{}, dataDevice: &ext_data_control.ExtDataControlDeviceV1{},
} }
m.deviceBound.Store(true)
return m
} }
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) { func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
@@ -443,7 +447,9 @@ func TestUnpinEntry_KeepsTopUnpinnedDuplicate(t *testing.T) {
history = m.GetHistory() history = m.GetHistory()
require.Len(t, history, 3) require.Len(t, history, 3)
require.Equal(t, topHistoryDuplicate.ID, history[0].ID) // GetHistory sorts by timestamp, so the newer row leads even though its
// id is lower.
require.Equal(t, olderHistoryDuplicate.ID, history[0].ID)
require.NoError(t, m.UnpinEntry(pinnedID)) require.NoError(t, m.UnpinEntry(pinnedID))
history = m.GetHistory() history = m.GetHistory()
@@ -887,6 +893,24 @@ func TestOpenDB_HealsGarbageFile(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestOpenDB_LockedByAnotherProcessKeepsHistory(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
holder, err := openDB(path)
require.NoError(t, err)
m := &Manager{config: DefaultConfig(), db: holder, dbPath: path}
require.NoError(t, m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"}))
_, err = openDB(path)
require.Error(t, err)
require.NoError(t, holder.Close())
reopened, err := openDB(path)
require.NoError(t, err)
defer reopened.Close()
m = &Manager{config: DefaultConfig(), db: reopened, dbPath: path}
assert.Len(t, m.GetHistory(), 1)
}
func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) { func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) {
for _, tt := range corruptDBCases { for _, tt := range corruptDBCases {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -1040,3 +1064,86 @@ func TestCopyFile_DisabledTrackingRefusesCopy(t *testing.T) {
require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable) require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable)
assert.Empty(t, m.GetHistory()) assert.Empty(t, m.GetHistory())
} }
func TestApplyConfigChange_DisableReleasesDataDevice(t *testing.T) {
sock := filepath.Join(t.TempDir(), "wayland-test")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
display, err := wlclient.Connect(sock)
require.NoError(t, err)
t.Cleanup(func() { display.Context().Close() })
m := newTestManagerWithDB(t)
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) { fn() })
m.wlCtx = mockCtx
device := ext_data_control.NewExtDataControlDeviceV1(display.Context())
m.dataDevice = device
cfg := DefaultConfig()
cfg.Disabled = true
m.applyConfigChange(cfg)
assert.True(t, device.IsZombie())
assert.Nil(t, m.dataDevice)
require.ErrorIs(t, m.CopyText("secret"), errSelectionUnavailable)
assert.Empty(t, m.GetHistory())
}
func TestTouchEntry_SurvivesTrimWhenHistoryFull(t *testing.T) {
m := newTestManagerWithDB(t)
m.config.MaxHistory = 3
for i := range 3 {
require.NoError(t, m.storeEntry(Entry{
Data: []byte(fmt.Sprintf("entry-%d", i)),
MimeType: "text/plain",
Timestamp: time.Now(),
}))
}
oldest := m.GetHistory()[2]
require.NoError(t, m.TouchEntry(oldest.ID))
// A new copy trims the bucket back to MaxHistory.
require.NoError(t, m.storeEntry(Entry{
Data: []byte("newcomer"),
MimeType: "text/plain",
Timestamp: time.Now(),
}))
history := m.GetHistory()
require.Len(t, history, 3)
ids := make([]uint64, len(history))
for i, e := range history {
ids[i] = e.ID
}
assert.Contains(t, ids, oldest.ID, "the touched row must survive trimming")
assert.Equal(t, oldest.ID, history[1].ID, "the touched row ranks second, behind the newer copy")
}
func TestGetHistory_SameSecondOrdersByID(t *testing.T) {
m := newTestManagerWithDB(t)
m.config.MaxHistory = 10
stamp := time.Now()
for i := range 3 {
require.NoError(t, m.storeEntry(Entry{
Data: []byte(fmt.Sprintf("same-second-%d", i)),
MimeType: "text/plain",
Timestamp: stamp,
}))
}
// Touching the oldest row rewrites it in place, so the bucket now yields
// it first even though every row shares a timestamp.
oldest := m.GetHistory()[2]
require.NoError(t, m.TouchEntry(oldest.ID))
history := m.GetHistory()
require.Len(t, history, 3)
assert.Equal(t, oldest.ID, history[0].ID, "the touched row must lead: its timestamp is newer")
assert.Greater(t, history[1].ID, history[2].ID, "rows still tied on timestamp break on newest id")
}
+14 -4
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
@@ -44,22 +45,30 @@ func getConfigPath() (string, error) {
} }
func LoadConfig() Config { func LoadConfig() Config {
cfg, err := readConfig()
if err != nil {
return DefaultConfig()
}
return cfg
}
func readConfig() (Config, error) {
cfg := DefaultConfig() cfg := DefaultConfig()
path, err := getConfigPath() path, err := getConfigPath()
if err != nil { if err != nil {
return cfg return cfg, err
} }
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return cfg return cfg, err
} }
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return DefaultConfig() return DefaultConfig(), err
} }
return cfg return cfg, nil
} }
func SaveConfig(cfg Config) error { func SaveConfig(cfg Config) error {
@@ -140,6 +149,7 @@ type Manager struct {
pasteSupported bool pasteSupported bool
initialized bool initialized bool
deviceBound atomic.Bool
alive bool alive bool
stopChan chan struct{} stopChan chan struct{}
+6 -4
View File
@@ -1,6 +1,7 @@
package dbus package dbus
import ( import (
"context"
"fmt" "fmt"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/duxlabs-dev/dux-go/ipc"
@@ -43,7 +44,7 @@ func extractObjectParams(p map[string]any, requirePath bool) (objectParams, erro
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
} }
func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) { func HandleRequest(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
switch req.Method { switch req.Method {
case "dbus.call": case "dbus.call":
handleCall(conn, req, m) handleCall(conn, req, m)
@@ -58,7 +59,7 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID s
case "dbus.listNames": case "dbus.listNames":
handleListNames(conn, req, m) handleListNames(conn, req, m)
case "dbus.subscribe": case "dbus.subscribe":
handleSubscribe(conn, req, m, clientID) handleSubscribe(ctx, conn, req, m, clientID)
case "dbus.unsubscribe": case "dbus.unsubscribe":
handleUnsubscribe(conn, req, m) handleUnsubscribe(conn, req, m)
default: default:
@@ -200,7 +201,7 @@ func handleListNames(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
conn.Respond(req.ID, result) conn.Respond(req.ID, result)
} }
func handleSubscribe(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) { func handleSubscribe(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
if id := params.StringOpt(req.Params, "clientId", ""); id != "" { if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
clientID = id clientID = id
} }
@@ -216,12 +217,13 @@ func handleSubscribe(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID
iface := params.StringOpt(req.Params, "interface", "") iface := params.StringOpt(req.Params, "interface", "")
member := params.StringOpt(req.Params, "member", "") member := params.StringOpt(req.Params, "member", "")
result, err := m.Subscribe(clientID, bus, sender, path, iface, member) result, err := m.Subscribe(conn, clientID, bus, sender, path, iface, member)
if err != nil { if err != nil {
conn.RespondError(req.ID, err.Error()) conn.RespondError(req.ID, err.Error())
return return
} }
m.ReleaseOnClose(ctx, conn)
conn.Respond(req.ID, result) conn.Respond(req.ID, result)
} }
+30 -11
View File
@@ -1,12 +1,14 @@
package dbus package dbus
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"strings" "strings"
"github.com/duxlabs-dev/dux-go/dbusutil" "github.com/duxlabs-dev/dux-go/dbusutil"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -188,7 +190,7 @@ func (m *Manager) ListNames(bus string) (*ListNamesResult, error) {
return &ListNamesResult{Names: names}, nil return &ListNamesResult{Names: names}, nil
} }
func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) { func (m *Manager) Subscribe(owner *ipc.ConnWriter, clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
conn, err := m.getConn(bus) conn, err := m.getConn(bus)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -223,6 +225,7 @@ func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (
Interface: iface, Interface: iface,
Member: member, Member: member,
ClientID: clientID, ClientID: clientID,
Owner: owner,
} }
m.subscriptions.Store(subID, sub) m.subscriptions.Store(subID, sub)
@@ -267,10 +270,23 @@ func (m *Manager) Unsubscribe(subID string) error {
return nil return nil
} }
func (m *Manager) UnsubscribeClient(clientID string) { // Match rules live as long as the request connection that added them, not the
// subscribe socket, which the shell re-dials whenever its service list changes.
func (m *Manager) ReleaseOnClose(ctx context.Context, owner *ipc.ConnWriter) {
if _, loaded := m.owners.LoadOrStore(owner, struct{}{}); loaded {
return
}
go func() {
<-ctx.Done()
m.owners.Delete(owner)
m.unsubscribeOwner(owner)
}()
}
func (m *Manager) unsubscribeOwner(owner *ipc.ConnWriter) {
var toDelete []string var toDelete []string
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool { m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
if sub.ClientID == clientID { if sub.Owner == owner {
toDelete = append(toDelete, subID) toDelete = append(toDelete, subID)
} }
return true return true
@@ -281,20 +297,23 @@ func (m *Manager) UnsubscribeClient(clientID string) {
} }
} }
// A re-dialed subscribe socket can register before the old one tears down, so
// the old release must only drop the channel it created. Channels are never
// closed here because dispatchSignal may be mid-send on another goroutine.
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent { func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
ch := make(chan SignalEvent, 64) ch := make(chan SignalEvent, 64)
existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch) m.subscribersMu.Lock()
if loaded { m.signalSubscribers.Store(clientID, ch)
return existing m.subscribersMu.Unlock()
}
return ch return ch
} }
func (m *Manager) UnsubscribeSignals(clientID string) { func (m *Manager) UnsubscribeSignals(clientID string, ch chan SignalEvent) {
if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok { m.subscribersMu.Lock()
close(ch) defer m.subscribersMu.Unlock()
if current, ok := m.signalSubscribers.Load(clientID); ok && current == ch {
m.signalSubscribers.Delete(clientID)
} }
m.UnsubscribeClient(clientID)
} }
func (m *Manager) processSystemSignals() { func (m *Manager) processSystemSignals() {
+6
View File
@@ -1,6 +1,9 @@
package dbus package dbus
import ( import (
"sync"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-go/syncmap" "github.com/duxlabs-dev/dux-go/syncmap"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -12,7 +15,9 @@ type Manager struct {
sessionSignals chan *dbus.Signal sessionSignals chan *dbus.Signal
subscriptions syncmap.Map[string, *signalSubscription] subscriptions syncmap.Map[string, *signalSubscription]
subscribersMu sync.Mutex
signalSubscribers syncmap.Map[string, chan SignalEvent] signalSubscribers syncmap.Map[string, chan SignalEvent]
owners syncmap.Map[*ipc.ConnWriter, struct{}]
} }
type signalSubscription struct { type signalSubscription struct {
@@ -22,6 +27,7 @@ type signalSubscription struct {
Interface string Interface string
Member string Member string
ClientID string ClientID string
Owner *ipc.ConnWriter
} }
type SignalEvent struct { type SignalEvent struct {
+25 -2
View File
@@ -1,9 +1,15 @@
package loginctl package loginctl
import ( import (
"errors"
"fmt" "fmt"
) )
var (
errLockBeforeSuspendManaged = errors.New("lock before suspend is managed by administrator policy")
errSleepInhibitorManaged = errors.New("the sleep inhibitor is managed by administrator policy")
)
func (m *Manager) Lock() error { func (m *Manager) Lock() error {
if m.sessionObj == nil { if m.sessionObj == nil {
return fmt.Errorf("session object not available") return fmt.Errorf("session object not available")
@@ -85,8 +91,21 @@ func (m *Manager) Terminate() error {
return nil return nil
} }
func (m *Manager) SetLockBeforeSuspend(enabled bool) { // applyLockPolicy starts from the managed settings, before the shell connects.
func (m *Manager) applyLockPolicy() {
if managed := m.lockPolicy.LockBeforeSuspend; managed != nil {
m.lockBeforeSuspend.Store(*managed)
}
enabled, managed := m.lockPolicy.sleepInhibitor()
m.sleepInhibitorEnabled.Store(enabled || !managed)
}
func (m *Manager) SetLockBeforeSuspend(enabled bool) error {
if managed := m.lockPolicy.LockBeforeSuspend; managed != nil && *managed != enabled {
return errLockBeforeSuspendManaged
}
m.lockBeforeSuspend.Store(enabled) m.lockBeforeSuspend.Store(enabled)
return nil
} }
func (m *Manager) markLockerReady() { func (m *Manager) markLockerReady() {
@@ -107,7 +126,10 @@ func (m *Manager) markLockerReady() {
m.prelockedReady.Store(true) m.prelockedReady.Store(true)
} }
func (m *Manager) SetSleepInhibitorEnabled(enabled bool) { func (m *Manager) SetSleepInhibitorEnabled(enabled bool) error {
if managedValue, managed := m.lockPolicy.sleepInhibitor(); managed && managedValue != enabled {
return errSleepInhibitorManaged
}
m.sleepInhibitorEnabled.Store(enabled) m.sleepInhibitorEnabled.Store(enabled)
if enabled { if enabled {
// Re-acquire inhibitor if enabled // Re-acquire inhibitor if enabled
@@ -116,4 +138,5 @@ func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
// Release inhibitor if disabled // Release inhibitor if disabled
m.releaseSleepInhibitor() m.releaseSleepInhibitor()
} }
return nil
} }
+8 -2
View File
@@ -100,7 +100,10 @@ func handleSetLockBeforeSuspend(conn *ipc.ConnWriter, req ipc.Request, manager *
return return
} }
manager.SetLockBeforeSuspend(enabled) if err := manager.SetLockBeforeSuspend(enabled); err != nil {
conn.RespondError(req.ID, err.Error())
return
}
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"}) conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"})
} }
@@ -111,7 +114,10 @@ func handleSetSleepInhibitorEnabled(conn *ipc.ConnWriter, req ipc.Request, manag
return return
} }
manager.SetSleepInhibitorEnabled(enabled) if err := manager.SetSleepInhibitorEnabled(enabled); err != nil {
conn.RespondError(req.ID, err.Error())
return
}
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"}) conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
} }
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"net" "net"
"os"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -424,3 +425,85 @@ func TestHandleRequest(t *testing.T) {
assert.Empty(t, resp.Error) assert.Empty(t, resp.Error)
}) })
} }
func TestHandleSetLockBeforeSuspend(t *testing.T) {
on, off := true, false
tests := []struct {
name string
managed *bool
request bool
want bool
wantErr bool
}{
{name: "unmanaged", request: true, want: true},
{name: "managed, same value", managed: &on, request: true, want: true},
{name: "managed on, request off", managed: &on, request: false, want: true, wantErr: true},
{name: "managed off, request on", managed: &off, request: true, want: false, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &Manager{lockPolicy: LockPolicy{LockBeforeSuspend: tt.managed}}
manager.applyLockPolicy()
mc := newMockNetConn()
conn := ipc.NewConnWriter(mc)
HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setLockBeforeSuspend", Params: map[string]any{"enabled": tt.request}}, manager)
var resp ipc.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
if tt.wantErr {
assert.Equal(t, "lock before suspend is managed by administrator policy", resp.Error)
} else {
assert.Empty(t, resp.Error)
}
assert.Equal(t, tt.want, manager.lockBeforeSuspend.Load())
})
}
}
func TestHandleSetSleepInhibitorEnabled(t *testing.T) {
on, off := true, false
tests := []struct {
name string
policy LockPolicy
request bool
want bool
wantErr bool
}{
{name: "unmanaged", request: false, want: false},
{name: "both managed on, request off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: false, want: true, wantErr: true},
{name: "both managed on, request on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: true, want: true},
{name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}, request: true, want: false, wantErr: true},
{name: "integration managed off", policy: LockPolicy{LoginctlLockIntegration: &off}, request: true, want: false, wantErr: true},
{name: "lock before suspend managed on, integration left to the user", policy: LockPolicy{LockBeforeSuspend: &on}, request: false, want: false},
{name: "integration managed on, lock before suspend left to the user", policy: LockPolicy{LoginctlLockIntegration: &on}, request: false, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &Manager{lockPolicy: tt.policy}
manager.applyLockPolicy()
inhibit, err := os.CreateTemp(t.TempDir(), "inhibit")
require.NoError(t, err)
held := manager.sleepInhibitorEnabled.Load()
if held {
manager.inhibitFile = inhibit
}
mc := newMockNetConn()
conn := ipc.NewConnWriter(mc)
HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setSleepInhibitorEnabled", Params: map[string]any{"enabled": tt.request}}, manager)
var resp ipc.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
if tt.wantErr {
assert.Equal(t, "the sleep inhibitor is managed by administrator policy", resp.Error)
assert.Equal(t, held, manager.inhibitFile != nil, "a refused request leaves the inhibitor as it was")
} else {
assert.Empty(t, resp.Error)
}
assert.Equal(t, tt.want, manager.sleepInhibitorEnabled.Load())
})
}
}
+5 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
func NewManager() (*Manager, error) { func NewManager(keepLockOnUnlock bool, lockPolicy LockPolicy) (*Manager, error) {
conn, err := dbus.SystemBus() conn, err := dbus.SystemBus()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to system bus: %w", err) return nil, fmt.Errorf("failed to connect to system bus: %w", err)
@@ -25,8 +25,11 @@ func NewManager() (*Manager, error) {
conn: conn, conn: conn,
dirty: make(chan struct{}, 1), dirty: make(chan struct{}, 1),
signals: make(chan *dbus.Signal, 256), signals: make(chan *dbus.Signal, 256),
keepLockOnUnlock: keepLockOnUnlock,
lockPolicy: lockPolicy,
} }
m.sleepInhibitorEnabled.Store(true) m.applyLockPolicy()
if err := m.initialize(); err != nil { if err := m.initialize(); err != nil {
return nil, err return nil, err
@@ -126,3 +126,28 @@ func TestStateChangedMeaningfully(t *testing.T) {
}) })
} }
} }
func TestManager_ApplyLockPolicy(t *testing.T) {
on, off := true, false
tests := []struct {
name string
policy LockPolicy
lockBeforeSuspend bool
sleepInhibitorEnabled bool
}{
{name: "unmanaged", sleepInhibitorEnabled: true},
{name: "lock before suspend managed on", policy: LockPolicy{LockBeforeSuspend: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true},
{name: "both managed on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true},
{name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}},
{name: "integration managed off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &off}, lockBeforeSuspend: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &Manager{lockPolicy: tt.policy}
manager.applyLockPolicy()
assert.Equal(t, tt.lockBeforeSuspend, manager.lockBeforeSuspend.Load())
assert.Equal(t, tt.sleepInhibitorEnabled, manager.sleepInhibitorEnabled.Load())
})
}
}
+9
View File
@@ -30,6 +30,14 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
} }
case dbusSessionInterface + ".Unlock": case dbusSessionInterface + ".Unlock":
if m.keepLockOnUnlock {
// Only the locker clears LockedHint, so take both from logind, and
// keep the cached state when logind cannot be read.
if m.sessionObj != nil && m.updateSessionState() == nil && !m.snapshotState().Locked {
m.prelockedReady.Store(false)
}
m.notifySubscribers()
} else {
lockedHint := m.sessionLockedHint() lockedHint := m.sessionLockedHint()
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.Locked = false m.state.Locked = false
@@ -37,6 +45,7 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
m.stateMutex.Unlock() m.stateMutex.Unlock()
m.notifySubscribers() m.notifySubscribers()
m.prelockedReady.Store(false) m.prelockedReady.Store(false)
}
// Cancel the lock timer if it's still running // Cancel the lock timer if it's still running
m.lockTimerMu.Lock() m.lockTimerMu.Lock()
+27 -11
View File
@@ -38,19 +38,22 @@ func TestManager_HandleDBusSignal_Lock(t *testing.T) {
func TestManager_HandleDBusSignal_Unlock(t *testing.T) { func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
lockedHint bool keepLock bool
call *dbus.Call // logind's GetAll answer; nil means no session object
wantLocked bool
wantLockedHint bool
}{ }{
{name: "logind cleared LockedHint", lockedHint: false}, {name: "logind cleared LockedHint", call: lockedHintCall(false)},
{name: "logind still reports LockedHint", lockedHint: true}, {name: "logind still reports LockedHint", call: lockedHintCall(true), wantLockedHint: true},
{name: "logind unreachable", call: &dbus.Call{Err: assert.AnError}},
{name: "policy, LockedHint still set", keepLock: true, call: lockedHintCall(true), wantLocked: true, wantLockedHint: true},
{name: "policy, LockedHint cleared", keepLock: true, call: lockedHintCall(false)},
{name: "policy, logind unreachable", keepLock: true, call: &dbus.Call{Err: assert.AnError}, wantLocked: true, wantLockedHint: true},
{name: "policy, no session object", keepLock: true, wantLocked: true, wantLockedHint: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
sessionObj := mockdbus.NewMockBusObject(t)
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(&dbus.Call{
Body: []any{map[string]dbus.Variant{"LockedHint": dbus.MakeVariant(tt.lockedHint)}},
})
manager := &Manager{ manager := &Manager{
state: &SessionState{ state: &SessionState{
Locked: true, Locked: true,
@@ -58,21 +61,34 @@ func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
}, },
stateMutex: sync.RWMutex{}, stateMutex: sync.RWMutex{},
dirty: make(chan struct{}, 1), dirty: make(chan struct{}, 1),
sessionObj: sessionObj, keepLockOnUnlock: tt.keepLock,
} }
if tt.call != nil {
sessionObj := mockdbus.NewMockBusObject(t)
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(tt.call)
manager.sessionObj = sessionObj
}
manager.markLockerReady()
manager.handleDBusSignal(&dbus.Signal{ manager.handleDBusSignal(&dbus.Signal{
Name: "org.freedesktop.login1.Session.Unlock", Name: "org.freedesktop.login1.Session.Unlock",
}) })
assert.Equal(t, tt.wantLocked, manager.prelockedReady.Load())
manager.stateMutex.RLock() manager.stateMutex.RLock()
defer manager.stateMutex.RUnlock() defer manager.stateMutex.RUnlock()
assert.False(t, manager.state.Locked) assert.Equal(t, tt.wantLocked, manager.state.Locked)
assert.Equal(t, tt.lockedHint, manager.state.LockedHint) assert.Equal(t, tt.wantLockedHint, manager.state.LockedHint)
}) })
} }
} }
func lockedHintCall(lockedHint bool) *dbus.Call {
return &dbus.Call{Body: []any{map[string]dbus.Variant{
"LockedHint": dbus.MakeVariant(lockedHint),
}}}
}
func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) { func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
t.Run("preparing for sleep - true", func(t *testing.T) { t.Run("preparing for sleep - true", func(t *testing.T) {
manager := &Manager{ manager := &Manager{
+22
View File
@@ -69,4 +69,26 @@ type Manager struct {
lockTimer *time.Timer lockTimer *time.Timer
sleepInhibitorEnabled atomic.Bool sleepInhibitorEnabled atomic.Bool
fallbackDelay time.Duration fallbackDelay time.Duration
keepLockOnUnlock bool
lockPolicy LockPolicy
}
// LockPolicy holds the lock settings an administrator policy manages; nil
// leaves a setting to the shell.
type LockPolicy struct {
LockBeforeSuspend *bool
LoginctlLockIntegration *bool
}
// sleepInhibitor returns the inhibitor state the shell asks for,
// loginctlLockIntegration && lockBeforeSuspend, when the managed settings
// decide it on their own.
func (p LockPolicy) sleepInhibitor() (enabled, managed bool) {
switch {
case p.LockBeforeSuspend != nil && !*p.LockBeforeSuspend, p.LoginctlLockIntegration != nil && !*p.LoginctlLockIntegration:
return false, true
case p.LockBeforeSuspend != nil && p.LoginctlLockIntegration != nil:
return true, true
}
return false, false
} }
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -278,7 +279,7 @@ func (a *SecretAgent) GetSecrets(
a.backend.cachedVPNCredsMu.Lock() a.backend.cachedVPNCredsMu.Lock()
cached := a.backend.cachedVPNCreds cached := a.backend.cachedVPNCreds
if cached != nil && cached.ConnectionUUID == connUuid { if cached != nil && cached.ConnectionUUID == connUuid && slices.Contains(fields, "password") {
a.backend.cachedVPNCreds = nil a.backend.cachedVPNCreds = nil
a.backend.cachedVPNCredsMu.Unlock() a.backend.cachedVPNCredsMu.Unlock()
@@ -451,7 +452,7 @@ func (a *SecretAgent) GetSecrets(
VpnService: vpnSvc, VpnService: vpnSvc,
SettingName: settingName, SettingName: settingName,
Fields: fields, Fields: fields,
FieldsInfo: buildFieldsInfo(settingName, fields, vpnSvc), FieldsInfo: buildFieldsInfo(settingName, fields, hints, vpnSvc),
Hints: hints, Hints: hints,
Reason: reason, Reason: reason,
ConnectionId: connId, ConnectionId: connId,
@@ -577,13 +578,13 @@ func (a *SecretAgent) GetSecrets(
default: default:
out[settingName] = sec out[settingName] = sec
} }
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
secrets := make(map[string]string) secrets := make(map[string]string)
for k, v := range reply.Secrets { for k, v := range reply.Secrets {
if k != "username" { if k != "username" && k != "challenge-response" {
secrets[k] = v secrets[k] = v
} }
} }
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || (reply.Save && len(secrets) > 0)) {
a.backend.pendingVPNSaveMu.Lock() a.backend.pendingVPNSaveMu.Lock()
a.backend.pendingVPNSave = &pendingVPNCredentials{ a.backend.pendingVPNSave = &pendingVPNCredentials{
ConnectionPath: string(path), ConnectionPath: string(path),
@@ -835,13 +836,46 @@ func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap)
return hints return hints
} }
return infer8021xFields(conn) return infer8021xFields(conn)
case "vpn", "wireguard": case "vpn":
return parseVPNHints(hints).fields
case "wireguard":
return hints return hints
default: default:
return []string{} return []string{}
} }
} }
const (
vpnHintMessagePrefix = "x-vpn-message:"
vpnHintChallengePrefix = "x-dynamic-challenge:"
vpnHintChallengeEchoPrefix = "x-dynamic-challenge-echo:"
)
type vpnPromptHints struct {
fields []string
echo map[string]bool
}
// NM-openvpn asks for a CRV1 challenge answer as x-dynamic-challenge[-echo]:<secret>
// and NM appends the plugin's prompt text as x-vpn-message:<text>, which the modal shows.
func parseVPNHints(hints []string) vpnPromptHints {
parsed := vpnPromptHints{echo: map[string]bool{}}
for _, hint := range hints {
switch {
case strings.HasPrefix(hint, vpnHintMessagePrefix):
case strings.HasPrefix(hint, vpnHintChallengeEchoPrefix):
field := strings.TrimPrefix(hint, vpnHintChallengeEchoPrefix)
parsed.echo[field] = true
parsed.fields = append(parsed.fields, field)
case strings.HasPrefix(hint, vpnHintChallengePrefix):
parsed.fields = append(parsed.fields, strings.TrimPrefix(hint, vpnHintChallengePrefix))
default:
parsed.fields = append(parsed.fields, hint)
}
}
return parsed
}
func infer8021xFields(conn map[string]nmVariantMap) []string { func infer8021xFields(conn map[string]nmVariantMap) []string {
dot1x, ok := conn["802-1x"] dot1x, ok := conn["802-1x"]
if !ok { if !ok {
@@ -877,7 +911,8 @@ func infer8021xFields(conn map[string]nmVariantMap) []string {
return fields return fields
} }
func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo { func buildFieldsInfo(setting string, fields []string, hints []string, vpnService string) []FieldInfo {
echo := parseVPNHints(hints).echo
result := make([]FieldInfo, 0, len(fields)) result := make([]FieldInfo, 0, len(fields))
for _, f := range fields { for _, f := range fields {
info := FieldInfo{Name: f} info := FieldInfo{Name: f}
@@ -899,6 +934,9 @@ func buildFieldsInfo(setting string, fields []string, vpnService string) []Field
} }
case "vpn": case "vpn":
info.Label, info.IsSecret = vpnFieldMeta(f, vpnService) info.Label, info.IsSecret = vpnFieldMeta(f, vpnService)
if echo[f] {
info.IsSecret = false
}
default: default:
info.Label = f info.Label = f
info.IsSecret = true info.IsSecret = true
@@ -1052,6 +1090,8 @@ func vpnFieldMeta(field, _ string) (label string, isSecret bool) {
return "IPSec Pre-Shared Key", true return "IPSec Pre-Shared Key", true
case "cert-pass": case "cert-pass":
return "Certificate Password", true return "Certificate Password", true
case "challenge-response":
return "Challenge Response", true
case "http-proxy-password": case "http-proxy-password":
return "HTTP Proxy Password", true return "HTTP Proxy Password", true
case "username": case "username":
@@ -308,6 +308,47 @@ func TestSecretAgent_GetSecrets_NoInteractionFlag(t *testing.T) {
assert.Contains(t, err.Error(), "NoSecrets") assert.Contains(t, err.Error(), "NoSecrets")
} }
func TestSecretAgent_GetSecrets_OpenVPNDynamicChallenge(t *testing.T) {
conn := map[string]nmVariantMap{
"connection": {
"id": dbus.MakeVariant("Work VPN"),
"type": dbus.MakeVariant("vpn"),
},
"vpn": {
"service-type": dbus.MakeVariant("org.freedesktop.NetworkManager.openvpn"),
},
}
tests := []struct {
name string
hint string
isSecret bool
}{
{"noecho", "x-dynamic-challenge:challenge-response", true},
{"echo", "x-dynamic-challenge-echo:challenge-response", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{Secrets: map[string]string{"challenge-response": "123456"}},
}
agent := &SecretAgent{prompts: broker}
hints := []string{tt.hint, "x-vpn-message:Enter your OTP"}
out, err := agent.GetSecrets(conn, "/test/path", "vpn", hints, nmSecretAgentFlagAllowInteraction)
assert.Nil(t, err)
req := <-broker.asked
assert.Equal(t, []string{"challenge-response"}, req.Fields)
assert.Equal(t, []FieldInfo{{Name: "challenge-response", Label: "Challenge Response", IsSecret: tt.isSecret}}, req.FieldsInfo)
secrets, _ := out["vpn"]["secrets"].Value().(map[string]string)
assert.Equal(t, map[string]string{"challenge-response": "123456"}, secrets)
})
}
}
func TestBuildWiFiSecretsResponse(t *testing.T) { func TestBuildWiFiSecretsResponse(t *testing.T) {
t.Run("wpa-psk returns psk", func(t *testing.T) { t.Run("wpa-psk returns psk", func(t *testing.T) {
out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"}) out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"})
@@ -258,16 +258,11 @@ func (b *NetworkManagerBackend) listEthernetConnections() ([]WiredConnection, er
return nil, fmt.Errorf("no ethernet device available") return nil, fmt.Errorf("no ethernet device available")
} }
s := b.settings settingsMgr, err := b.networkManagerSettings()
if s == nil {
s, err := gonetworkmanager.NewSettings()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get settings: %w", err) return nil, err
}
b.settings = s
} }
settingsMgr := s.(gonetworkmanager.Settings)
connections, err := settingsMgr.ListConnections() connections, err := settingsMgr.ListConnections()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get connections: %w", err) return nil, fmt.Errorf("failed to get connections: %w", err)
@@ -305,6 +305,9 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
} }
b.updateHotspotState() b.updateHotspotState()
b.listCellularConnections() b.listCellularConnections()
// Device state does not change when an inactive wired profile is
// added or deleted, so the profile list must be refreshed here too.
b.listEthernetConnections()
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
} }
@@ -130,3 +130,57 @@ func TestNetworkManagerBackend_HandleWiFiChange_CoalescesAccessPointBurst(t *tes
return stateChangeCalls.Load() == 1 return stateChangeCalls.Load() == 1
}, 4*wifiNetworksUpdateDelay, 10*time.Millisecond) }, 4*wifiNetworksUpdateDelay, 10*time.Millisecond)
} }
func TestNetworkManagerBackend_HandleDBusSignal_ConnectionRemovedRefreshesWiredConnections(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe()
remaining := mock_gonetworkmanager.NewMockConnection(t)
remaining.EXPECT().GetPath().Return(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/1")).Maybe()
remaining.EXPECT().GetSettings().Return(gonetworkmanager.ConnectionSettings{
"connection": {
"id": "Wired connection 1",
"uuid": "uuid-1",
"type": "802-3-ethernet",
},
}, nil).Maybe()
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{remaining}, nil).Maybe()
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
backend.settings = mockSettings
backend.ethernetDevice = mock_gonetworkmanager.NewMockDevice(t)
backend.stateMutex.Lock()
backend.state.WiredConnections = []WiredConnection{
{ID: "Wired connection 1", UUID: "uuid-1", Type: "802-3-ethernet"},
{ID: "Wired connection 2", UUID: "uuid-2", Type: "802-3-ethernet"},
}
backend.stateMutex.Unlock()
backend.handleDBusSignal(&dbus.Signal{
Name: "org.freedesktop.NetworkManager.Settings.ConnectionRemoved",
Body: []any{dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/2")},
})
backend.stateMutex.RLock()
defer backend.stateMutex.RUnlock()
assert.Len(t, backend.state.WiredConnections, 1)
assert.Equal(t, "uuid-1", backend.state.WiredConnections[0].UUID)
}
func TestNetworkManagerBackend_ListEthernetConnections_InvalidSettingsHandle(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
backend.settings = "not a settings handle"
backend.ethernetDevice = mock_gonetworkmanager.NewMockDevice(t)
assert.NotPanics(t, func() {
_, err = backend.listEthernetConnections()
})
assert.Error(t, err)
}
@@ -3,10 +3,12 @@ package network
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os/user"
"sort" "sort"
"github.com/Wifx/gonetworkmanager/v2" "github.com/Wifx/gonetworkmanager/v2"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/godbus/dbus/v5"
) )
func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) { func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) {
@@ -768,6 +770,9 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
"type": "802-11-wireless", "type": "802-11-wireless",
"autoconnect": true, "autoconnect": true,
} }
if !b.canModifySystemConnections() {
restrictConnectionToCurrentUser(settings["connection"])
}
settings["ipv4"] = map[string]any{"method": "auto"} settings["ipv4"] = map[string]any{"method": "auto"}
settings["ipv6"] = map[string]any{"method": "auto"} settings["ipv6"] = map[string]any{"method": "auto"}
@@ -934,6 +939,39 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
// updateConnectionCredentials applies user-supplied credentials to a saved // updateConnectionCredentials applies user-supplied credentials to a saved
// profile before activation; without this a retype after a password change is // profile before activation; without this a retype after a password change is
// silently ignored in favor of the stored (stale) secret. // silently ignored in favor of the stored (stale) secret.
const nmPermissionModifySystem = dbusNMInterface + ".settings.modify.system"
// GetPermissions never prompts. Short of a silent yes, a shared profile prompts on
// creation and on every secret-agent request (NM re-checks the agent), so own it
// per user like gnome-control-center does.
func (b *NetworkManagerBackend) canModifySystemConnections() bool {
conn := b.dbusConn
if conn == nil {
var err error
conn, err = dbus.ConnectSystemBus()
if err != nil {
return false
}
defer conn.Close()
}
var permissions map[string]string
obj := conn.Object(dbusNMInterface, dbus.ObjectPath(dbusNMPath))
if err := obj.Call(dbusNMInterface+".GetPermissions", 0).Store(&permissions); err != nil {
log.Warnf("[createAndConnectWiFi] GetPermissions failed, creating a user-owned profile: %v", err)
return false
}
return permissions[nmPermissionModifySystem] == "yes"
}
func restrictConnectionToCurrentUser(connMeta map[string]any) {
current, err := user.Current()
if err != nil || current.Username == "" {
return
}
connMeta["permissions"] = []string{"user:" + current.Username + ":"}
}
func updateConnectionCredentials(conn gonetworkmanager.Connection, req ConnectionRequest) error { func updateConnectionCredentials(conn gonetworkmanager.Connection, req ConnectionRequest) error {
settings, err := conn.GetSettings() settings, err := conn.GetSettings()
if err != nil { if err != nil {
Loaded 100 of 557 files, more files were not shown because too many files have changed in this diff. Show more