Compare commits

...
165 Commits
Author SHA1 Message Date
bbedward 7cf13f150a fix: make installer not rely on github API
fixes #3949
port 2.0
2026-10-30 13:38:19 -04:00
Huỳnh Thiện Lộc 16fc39cc16 feat(osd): add keyboard layout OSD (#4034)
port 2.0
2026-10-30 12:50:44 -04:00
bbedward 7060c8eeb7 plugins: multi-select type chips in browser, dash browse button
port 2.0
2026-10-30 12:48:34 -04:00
bbedward 87312075dd lyrics: distrust the player position after a track change until the probe settles
ref #4025
port 2.0
2026-10-30 10:50:50 -04:00
bbedward 7ad1b7ef1d plugins: work around x/crypto rejecting ssh-mldsa44-ed25519 known_hosts entries
fixes #4029
port 2.0
2026-10-30 10:35:55 -04:00
bbedward a0b74b2acf core: bump qml-common
ref #4031
port 2.0
2026-10-30 10:06:15 -04:00
purian23 4927ef5b9b hyprland: drop legacy conf support and close 0.57 gaps
Port 2.0
2026-10-30 09:22:04 -04:00
Mario Mohar 46a12d9152 island: show mic_off in the privacy group when the mic is muted (#4027)
port 2.0
2026-10-30 09:15:32 -04:00
Mario Mohar caabd9228b settings: build week day names from local dates (#4026)
port 2.0
2026-10-30 09:15:12 -04:00
Leonard f65e934e1b fix #3863 and allow user to disable / enable audio cards (#4019)
port 2.0
2026-10-30 09:13:53 -04:00
Huỳnh Thiện Lộc bca7513825 feat(widget-editor): resize square with shift (#4030)
port 2.0
2026-10-30 09:09:00 -04:00
Huỳnh Thiện Lộc fd137f0c81 fix(widget-editor): show color picker above editor (#4021)
port 2.0
2026-10-30 09:08:38 -04:00
purian23 6f68c94bc3 dash: add live property to the user info card
Port 2.0
2026-10-30 09:02:57 -04:00
purian23 40a129c9d6 distros: dux packaging rebrand sweep
Port 2.0
2026-10-30 07:32:31 -04:00
purian23 095d1c6fd5 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
2026-10-30 05:39:22 -04:00
purian23 d367e7e71b feat(clock): clock: per-widget click action w/overview as optional
Fixes 3993
Port 2.0
2026-10-30 00:12:12 -04:00
purian23 e777288904 island: optional dash tab bar on the island faces
- bump qml-common
Port 2.0
2026-10-29 23:24:23 -04:00
dux-ci[bot] 1038bc70d0 nix: sync flake.lock to dux-qml-common submodule 2026-10-30 03:20:48 +00:00
purian23 673ceb261e dash: settings-style keyboard between the tab row & content
-fix media follows the album accent
- updated keyboard navigation docs
Fixes #3997
Port 2.0
2026-10-29 23:20:25 -04:00
bbedward f288dbc52e ci: set AI review effort to high 2026-10-29 23:53:03 +00:00
bbedward ff62d7618f control center: close the band on a second tile click instead of walking history
fixes #4016
port 2.0
2026-10-29 19:48:52 -04:00
purian23 26c4cd1154 media: fix rare binding loop on bento panes switch 2026-10-29 18:13:35 -04:00
bbedward 8ce37501bd i18n: sync
port 2.0
2026-10-29 14:40:19 -04:00
purian23 e48c1221e7 control center: one surface under the edit strip & backdrops/frame sheet fixes 2026-10-29 13:55:16 -04:00
bbedward a26298d294 screenshot: stop re-rotating frames hyprland 0.57 already delivers upright
fixes #4014
port 2.0
2026-10-29 13:46:50 -04:00
Leonard b18fc38693 doctor: match font names the way qt lists them (#3972)
port 2.0
2026-10-29 12:58:28 -04:00
bbedward 8e8442fa76 plugins: route plugin popout clicks through PopoutManager
fixes #4011
2026-10-29 11:39:02 -04:00
StevenKerman 47f7f3d798 dbar: animate hiding overlay bars on fullscreen in niri (#4000)
port 2.0
2026-10-29 11:28:50 -04:00
bbedward 791537bf7b island: keep blur regions flush with the fill and goth corners
ref #4013
port 2.0
2026-10-29 11:25:05 -04:00
bbedward 012237a1ad cc, dash: load grid edit chrome only in edit mode
port 2.0
2026-10-29 11:10:38 -04:00
apollo79 c13ffb530a add missing theme values, update changed ones (#3978)
port 2.0
2026-10-29 10:35:48 -04:00
Huỳnh Thiện Lộc d254f40acd feat(battery): scale battery level display (#4012)
port 2.0
2026-10-29 10:22:04 -04:00
bbedward 2a9f83ccb2 ci: fix pr labeling 2026-10-29 10:16:17 -04:00
bbedward 2308934453 bar: keep ripple and press morph on the pressed clock segment
fixes #4008
port 2.0
2026-10-29 09:51:19 -04:00
pblcp a1c8db2558 tray: open overflow item menus at the icon (#4002)
port 2.0
2026-10-29 09:43:02 -04:00
bbedward eafb118b1f ci: auto label issues 2026-10-29 09:30:54 -04:00
Callum Wong 3b63f55779 displays: keep vrr on after choosing On on niri (#3998)
port 2.0
2026-10-29 00:27:25 -04:00
Huỳnh Thiện Lộc 61528822ba fix(screenshot): expose surface bounds for region snap (#3999)
port 2.0
2026-10-28 23:09:01 -04:00
xibielemn df8b1d85ca clipboard: bind the first wl_seat only (#3989)
port 2.0
2026-10-28 17:55:25 -04:00
bbedward e4c4fe44ea widgets: drop tabular figures from bar and dash numerics
theme: change light mode backdrop color

port 2.0
2026-10-28 17:54:49 -04:00
purian23 0b174e0702 island: size the launcher sheet to its results
Port 2.0
2026-10-28 16:34:24 -04:00
purian23 e97e41036b island: match control center sheet and insets to the popout
Port 2.0
2026-10-28 16:30:20 -04:00
purian23 ff1bccbe7b launcher: fit grid rows in every host and drop the island inset literal
Port 2.0
2026-10-28 16:29:50 -04:00
purian23 2af1c4c1a0 feat(dash): restore optional settings shortcut in the tab bar
Port 2.0
2026-10-28 15:48:53 -04:00
Curious 197f4b1faa fix(clock): vertically align clock content (#3994) 2026-10-28 15:10:03 -04:00
bbedward 19f3e58dcb network: answer openvpn dynamic challenge prompts with the challenge-response secret
fixes #3952
port 2.0
2026-10-28 14:49:14 -04:00
Mario Mohar 18af7856f9 settings: keep page history when opening keybinds from the launcher tab (#3990)
port 2.0
2026-10-28 14:25:34 -04:00
Mario Mohar f5d22b101e dock: keep vector spring stable at short animation speeds (#3991)
port 2.0
2026-10-28 14:24:29 -04:00
bbedward 46373f8bee theme: restore inverse surface tooltips and index the surface role rows
port 2.0
2026-10-28 14:21:19 -04:00
bbedward 229483272a network/networkmanager: own new wifi profiles per user when modify.system is not granted
port 2.0
2026-10-28 14:10:28 -04:00
bbedward 2dd237a3af settings: derive button group compact mode from its natural width
ref #3984
ref #3982

port 2.0
2026-10-28 12:24:07 -04:00
bbedward d2792b8a11 cc: stop avatar and app icon reloads from stalling the sheet, disable
edit fill in edit mode

fixes #3975
port 2.0
2026-10-28 11:48:41 -04:00
Huỳnh Thiện Lộc af9876d232 fix(osd): fill active keep-awake icon (#3986)
port 2.0
2026-10-28 11:17:51 -04:00
hecate cantus 9d279e7f88 island: show synced lyrics in the media slot (#3979)
port 2.0
2026-10-28 10:32:44 -04:00
hecate cantus 27ff725a17 wellbeing: open the screen time view from the overview card (#3980)
port 2.0
2026-10-28 10:28:06 -04:00
Spyros Psarras 5d53fdb684 brightness: move every device pinned on a screen together (#3939)
port 2.0
2026-10-28 10:23:15 -04:00
Roni Tarvainen 4a9ab29515 Change keep awake icon to local cafe (#3936)
port 2.0
2026-10-28 10:17:58 -04:00
Huỳnh Thiện Lộc 083b9c55e2 fix(settings-search): resolve placeholders (#3985)
port 2.0
2026-10-28 10:14:45 -04:00
bbedward a330e9bfa5 i18n: sync
port 2.0
2026-10-28 10:04:46 -04:00
Huỳnh Thiện Lộc 8f1a8ad824 feat(theme): add themable tooltip surface colors (#3967)
port 2.0
2026-10-28 09:27:47 -04:00
Huỳnh Thiện Lộc eac4ae8414 fix(dock): gate magnification while hidden (#3983) 2026-10-28 08:30:57 -04:00
purian23 07c899dd0c settings: reposition appearance card in the interface tab
Port 2.0
2026-10-28 07:58:22 -04:00
purian23 4f0e2c6fd4 fix(workspaces): resolve slot list binding regression
Port 2.0
2026-10-28 07:15:07 -04:00
purian23 7479d4bb79 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
2026-10-28 06:38:43 -04:00
purian23 59ede533d8 feat(dash): adjustable clock card font weight to match desktop/lockscreen 2026-10-28 01:45:34 -04:00
purian23 27a55349bc ubuntu: fall back to archive Hyprland when the PPA lacks the series
Fixes #3899
Port 2.0
2026-10-28 00:12:05 -04:00
purian23 170e8f5074 feat(density): text and search fields follow interface density 2026-10-27 21:23:08 -04:00
apollo79 a88be89444 Workspace switcher widget improvements (#3977)
port 2.0
2026-10-27 20:46:46 -04:00
purian23 76025038df 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
2026-10-27 20:46:01 -04:00
hecate cantus ee6e941459 Fix/3958 lock placement atlas (#3960)
port 2.0
2026-10-27 15:19:19 -04:00
bbedward 2804183b7f lock: arm fingerprint auth after the compositor confirms the lock
port 2.0
2026-10-27 15:14:27 -04:00
Vladimir 3d4ea22e73 notifications: refresh relative times on the lock screen, in NotificationsTab and in history (#3965)
port 2.0
2026-10-27 14:07:14 -04:00
Matt Jiang fdebf6d9e6 fix(powermenu): show inactive EFI boot entries in reboot-to-OS picker (#3969)
port 2.0
2026-10-27 13:59:56 -04:00
bbedward 5855b6ff77 notifications: use only SystemClock to update notification timestamps
port 2.0
2026-10-27 13:32:33 -04:00
hecate cantus fa62bf2868 Fix/3958 dock tooltip & app icon perf (#3962)
port 2.0
2026-10-27 13:14:29 -04:00
hecate cantus b2e1817aad Fix/3958 workspace indicator icon perf (#3961)
port 2.0
2026-10-27 13:09:28 -04:00
bbedward 2609b49846 cc: fix band expansion of footer items
port 2.0
2026-10-27 13:02:12 -04:00
dux-ci[bot] 853b399973 nix: sync flake.lock to dux-qml-common submodule 2026-10-27 16:47:58 +00:00
bbedward 290360428e cc: replace detail pages with bands (1.6 style)
port 2.0
2026-10-27 12:47:09 -04:00
bbedward 86dac60bc2 matugen: don't wait on hooks
ref #3901
port 2.0
2026-10-27 11:08:27 -04:00
bbedward 5442f3923b island: keep the dismiss layer down for notification popups
fixes #3915
port 2.0
2026-10-27 10:28:45 -04:00
bbedward 6a15b44298 tray: resolve IconThemePath icons from theme subdirectories and the system theme
fixes #3953
port 2.0
2026-10-27 10:02:45 -04:00
bbedward 5cd0b61402 settings: treat an unversioned settings file as the current format
fixes #3956
port 2.0
2026-10-27 09:46:20 -04:00
bbedward f56a088e44 search field: spacing adjustments
port 2.0
2026-10-27 09:39:03 -04:00
bbedward 826477e776 systemd: keep the notifications bus name reserved
port 2.0
2026-10-27 09:21:37 -04:00
hecate cantus 06e0ac182c wallpaper: use the per-monitor fill mode to decide scrolling (#3957)
port 2.0
2026-10-27 09:17:43 -04:00
Huỳnh Thiện Lộc e265e464bb fix(bar): use active color for workspace text in line style (#3968)
port 2.0
2026-10-27 09:13:10 -04:00
bbedward 8827aa0041 launcher: fix grid display
port 2.0
2026-10-27 09:05:56 -04:00
bbedward fc7c92f8b2 launcher, dash, settings: density and consistency pass
port 2.0
2026-10-27 00:03:32 -04:00
bbedward ca5028411e frame: keep fused chrome blurred across the inset at low alpha
fixes #3954
port 2.0
2026-10-26 23:35:18 -04:00
purian23 a92e60ebef updater: confirm installs on enter & keeps the upgrade log in view when inline
Port 2.0
2026-10-26 23:21:29 -04:00
bbedward 693d0c8bb7 settings: keep the file snapshot independent of live values so in-place edits persist
fixes #3955
port 2.0
2026-10-26 22:59:26 -04:00
purian23 a7bd6a8e06 sysupdate(flatpak): list updates when one needs an uninstalled runtime
Fixes #3950
Port 2.0
2026-10-26 22:56:23 -04:00
purian23 28d5d56028 sysupdate: add a never option to the update check interval
Port 2.0
2026-10-26 22:52:02 -04:00
bbedward 4a8fc4d4ba popout: commit the emptied input region when the close starts
port 2.0
2026-10-26 21:00:48 -04:00
bbedward a3c4d0ab1a cava: stop the capture stream from dragging the pipewire quantum down
ref #810
port 2.0
2026-10-26 19:51:30 -04:00
bbedward d60fa2c86f bump qml-common, fixes animated image binding loop
port 2.0
2026-10-26 15:50:28 -04:00
bbedward 3aaab84ba3 dbus: keep match rules across subscribe socket re-dials
port 2.0
2026-10-26 14:30:11 -04:00
Kadir cb20e898cd network(networkmanager): refresh wired profiles on settings changes (#3929)
port 2.0
2026-10-26 14:18:20 -04:00
Giacomo Di Clerico 486de548e0 Bump Go to 1.27.3 (CVE-2026-111943) (#3924) 2026-10-26 14:16:04 -04:00
bbedward 6b32cbe40c bump qml-common 2026-10-26 14:07:37 -04:00
bbedward b0fea51132 compositor: prefer live wlr-output scale over the startup randr snapshot
port 2.0
2026-10-26 13:59:22 -04:00
alvarosth 404c9672d7 settings: enforce administrator-managed lock settings (#3897)
port 2.0
2026-10-26 13:41:13 -04:00
Huỳnh Thiện Lộc 5b54dafa61 fix(wallpaper): apply overview blur to built-in backdrops (#3941)
port 2.0
2026-10-26 11:52:11 -04:00
Huỳnh Thiện Lộc 3c89a1554b fix(settings): show bar delete button when multiple bars exist (#3943)
port 2.0
2026-10-26 11:50:32 -04:00
apollo79 473befd4ec Add translation information and Discord to README (#3944) 2026-10-26 11:50:07 -04:00
Marlon Kranz ce7dcd7439 matugen: keep user templates when config.toml has no [config] table (#3921) 2026-10-25 20:58:06 -04:00
Marlon Kranz 0691242c9a tailscale: translate the peer "last seen" time (#3922) 2026-10-25 20:44:43 -04:00
Marlon Kranz 0274b00787 displays: show why the display profile controls are disabled while Auto is on (#3926) 2026-10-25 20:43:15 -04:00
Huỳnh Thiện Lộc c719ffede2 fix(ddash): preserve tab active state during exit transition (#3928) 2026-10-25 20:42:31 -04:00
purian23 e689cd2ce7 installer: fall back to dankinstall assets until 2.0.1 & update arch package compat
Port 2.0
2026-10-25 06:19:02 -04:00
purian23 6d65f304e5 rebrand(installer): convert dankinstall to duxinstall 2026-10-25 04:18:01 -04:00
purian23 696be97018 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
2026-10-25 03:19:41 -04:00
purian23 20bcf8559b ci: update dux-repo references 2026-10-24 16:35:18 -04:00
purian23 3388f78905 ci: dispatch distro builds from Release & guard shared Void index 2026-10-24 15:50:01 -04:00
purian23 c5b058c83e dep: bump qml-common 2026-10-24 04:13:27 -04:00
purian23 66382dfa0d refactor(default apps): rewrite w/value-keyed dropdowns & xdg data/display fixes
Port 2.0
2026-10-24 03:48:34 -04:00
purian23 4d49e126ab (deps): dux-go desktop fixes & qml-common 2026-10-24 03:42:55 -04:00
purian23 7e0f110fb8 fix(clipboard): bind & release the data device when tracking is toggled
Port 2.0
2026-10-24 03:39:08 -04:00
Emile Schup 52d6a46d89 battery popout: swap balanced and performance profile icons (#3895) 2026-10-23 23:45:53 -04:00
bbedward 6f36e49c68 lock: advertise hidden text from the ime sink
port 2.0
2026-10-23 14:15:20 -04:00
bbedward 2d16cc2f27 docs: point arch and aur badges at dux-shell packages 2026-10-23 13:40:03 -04:00
bbedward b7be4a344a bump qml-common
port 2.0
2026-10-23 10:57:59 -04:00
Huỳnh Thiện Lộc 1e31015acb feat(screenshot): add island snap targets (#3881)
port 2.0
2026-10-23 10:51:13 -04:00
bbedward 71417b7ca7 fix(apppicker): hide NoDisplay handlers except the shell's own
port 2.0
2026-10-23 10:06:12 -04:00
bbedward 6bd0e24bd4 cc: allow sliders to shrink to 0.5 height
related #3880
port 2.0
2026-10-23 09:55:05 -04:00
Matt Jiang e50bb088b6 powermenu: reboot into firmware setup from the power menu (#3877)
port 2.0
2026-10-23 09:29:31 -04:00
purian23 efd9324a4d sysupdate: hide flatpak updates when flatpak is excluded
Port 2.0
2026-10-23 06:28:01 -04:00
purian23 0cd03e0180 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
2026-10-23 05:38:46 -04:00
purian23 ba7b3620a8 fix(mango): keep layer animations off dms surfaces
- Stops Mango tweening the dot and island when layer_animations is on
Port 2.0
2026-10-23 04:40:54 -04:00
purian23 c7692f830c fix(core): prevent clipboard & wellbeing data loss from concurrent sessions
Port 2.0
2026-10-23 01:42:07 -04:00
purian23 5a20a8f162 systemd: start enabled service on restart when idle
Port 2.0
2026-10-23 00:26:01 -04:00
bbedward acc2c947e5 displays: ceil logical output sizes on niri so neighbours never overlap
fixes #3875
port 2.0
2026-10-23 00:00:13 -04:00
purian23 d8734bbe1a refactor(mango): deprecate pre-mango v18.0 binds & legacy management 2026-10-22 22:47:35 -04:00
Guillem CANAL 668dde2a33 idle: retry suspend when a logind sleep inhibitor is released (#3866) 2026-10-22 17:12:36 -04:00
Mario Mohar 900e6110d9 wallpaper: push the cycling config when the service is created (#3874)
port 2.0
2026-10-22 17:09:02 -04:00
bbedward 44d498e362 bar: drop pill blur region when a vertical pill collapses to zero height
fixes #3873
port 2.0
2026-10-22 17:00:53 -04:00
bbedward bf9a51c448 i18n: sync 2026-10-22 15:41:11 -04:00
bbedward 1a46db0517 i18n: sync 2026-10-22 15:13:00 -04:00
alvarosth 7781c207f1 Add opt-in administrator lock restrictions (#3869) 2026-10-22 14:57:29 -04:00
bbedward 5b7777785a shell: restore QT disable decoration pragma 2026-10-22 14:51:36 -04:00
Vaibhav Pratap Singh 0aab0ec09b battery: pulse only the icon when the bar has no background (#3861) 2026-10-22 12:13:48 -04:00
bbedward da79eca3c0 ci: disable background tasks in review job 2026-10-22 11:43:54 -04:00
bbedward aeacaf7afc distro/freebsd: drop elogind references 2026-10-22 10:38:15 -04:00
bbedward 14b3ddaf83 plugins: label declared permissions as unenforced 2026-10-22 09:44:13 -04:00
bbedward 6e86b53ccd plugins: keep manifest paths inside the plugin directory 2026-10-22 09:44:08 -04:00
bbedward c5d1d4fe46 greeter: remove the embedded legacy greeter (again) 2026-10-22 09:37:07 -04:00
purian23 39a58f77ec distos: compare ubuntu published commit by prefix 2026-10-22 06:06:38 -04:00
purian23 47c9d8d89b notifications: keep popup content while it exits after the notification closes
Port 2.0
2026-10-21 21:58:13 -04:00
bbedward d8786eaaf7 bar: keep far-edge island hosts at sheet size so the bar never jumps on expand
fixes #3855
port 2.0
2026-10-21 21:57:20 -04:00
Anton Kesy 4f969c8e10 docs: use dux project names in README (#3847) 2026-10-21 15:50:06 -04:00
bbedward 1b59a474dd battery: throttle pulse animation to 20fps
ref #3849
2026-10-21 13:59:43 -04:00
bbedward 28f921fa02 dash: hold panel height while dragging cards unless hung from a top bar
lyrics: ignore floored position samples and shell refresh emits
2026-10-21 13:15:53 -04:00
bbedward 8f2a486006 theme: publish typography for dux apps 2026-10-21 12:26:05 -04:00
bbedward 27a703367f core: only use the simple animation driver for the scrolling wallpaper
fixes #3837
2026-10-21 11:18:18 -04:00
bbedward 8e851b3dc4 bar: slide an auto-hidden island by its compact height
fixes #3838
2026-10-21 11:05:57 -04:00
bbedward 112d05901f workspaces: keep the pill highlight while hovering its app icons
fixes #3839
2026-10-21 10:39:40 -04:00
bbedward afeea489c9 theme: skip matugen runs whose inputs match the last applied run
fixes #3841
2026-10-21 10:34:22 -04:00
bbedward 90d3c9731c niri: only emit displayScales when a scale changed
ref #3842
port 2.0
2026-10-21 10:14:52 -04:00
Marlon Kranz b81954454a fix(notifications): close popups when their screen stops showing them (#3844) 2026-10-21 08:02:49 -04:00
Adarsh 479b1434de Theme Antigravity IDE via the VS Code extension (#3836) 2026-10-21 03:57:45 -04:00
Tomás 203b1369b3 Keep MangoWM workspace apps on their own monitor (#3832)
port 2.0
2026-10-20 20:54:58 -04:00
Leonard 5ffc581ecf refactor(ui): match the dock settings page with the bar settings page (#3829)
port 2.0
2026-10-20 20:54:40 -04:00
bbedward 3284acab28 matugen: neovim template reads the dms config dir"
port 2.0
2026-10-20 17:03:12 -04:00
bbedward 1fb9f5271a ci: cache go modules from core/go.sum, retry downloads 2026-10-20 15:24:59 -04:00
619 changed files with 24957 additions and 18375 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,
}...) }...)
} }
+3 -80
View File
@@ -55,39 +55,13 @@ 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 {
Exists bool `json:"exists"` Exists bool `json:"exists"`
Included bool `json:"included"` Included bool `json:"included"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
func runResolveInclude(cmd *cobra.Command, args []string) { func runResolveInclude(cmd *cobra.Command, args []string) {
@@ -132,64 +106,13 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
} }
mainLua := filepath.Join(configDir, "hyprland.lua") mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil { if _, err := os.Stat(mainLua); err != nil {
result.ConfigFormat = "lua" return result, nil
}
result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool)) result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool))
return result, nil return result, nil
} }
// 0.55/0.56 installs still on hyprland.conf: read-only until migrated.
mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err != nil {
return result, nil
}
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
result.Included = hyprlandFindIncludeHyprlang(mainConf, targetAbs, make(map[string]bool))
return result, nil
}
func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "source") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
continue
}
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1]))
if err != nil {
continue
}
if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) {
return true
}
}
return false
}
func checkNiriInclude(filename string) (IncludeResult, error) { func checkNiriInclude(filename string) (IncludeResult, error) {
configDir := filepath.Join(utils.XDGConfigHome(), "niri") configDir := filepath.Join(utils.XDGConfigHome(), "niri")
+6 -21
View File
@@ -43,32 +43,17 @@ func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
} }
} }
func TestCheckHyprlandIncludeFormat(t *testing.T) { func TestCheckHyprlandIncludeIgnoresLegacyConf(t *testing.T) {
tests := []struct {
name string
files map[string]string
wantFormat string
wantReadOnly bool
wantIncluded bool
}{
{"hyprlang sourced", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n"}, "hyprlang", true, true},
{"hyprlang not sourced", map[string]string{"hyprland.conf": "bind = SUPER, Q, killactive\n"}, "hyprlang", true, false},
{"lua wins over leftover conf", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n", "hyprland.lua": "\n"}, "lua", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
xdg := t.TempDir() xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg) t.Setenv("XDG_CONFIG_HOME", xdg)
for name, content := range tt.files { writeConfigFile(t, filepath.Join(xdg, "hypr", "hyprland.conf"), "source = ./dms/colors.conf\n")
writeConfigFile(t, filepath.Join(xdg, "hypr", name), content) writeConfigFile(t, filepath.Join(xdg, "hypr", "dms", "colors.conf"), "")
}
got, err := checkHyprlandInclude("colors.conf") got, err := checkHyprlandInclude("colors.conf")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got.ConfigFormat != tt.wantFormat || got.ReadOnly != tt.wantReadOnly || got.Included != tt.wantIncluded { if got.Included {
t.Fatalf("got %+v, want format %q readOnly %v included %v", got, tt.wantFormat, tt.wantReadOnly, tt.wantIncluded) t.Fatalf("got %+v; a hyprland.conf source must not count once Hyprland reads only Lua", got)
}
})
} }
} }
+312 -68
View File
@@ -12,6 +12,7 @@ import (
"regexp" "regexp"
"runtime" "runtime"
"slices" "slices"
"strconv"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -215,11 +216,13 @@ func runDoctor(cmd *cobra.Command, args []string) {
qsFeatures, qsMissingFeatures := checkQuickshellFeatures() qsFeatures, qsMissingFeatures := checkQuickshellFeatures()
versions, qsVersion := checkVersions(qsMissingFeatures)
results := slices.Concat( results := slices.Concat(
checkSystemInfo(), checkSystemInfo(),
checkVersions(qsMissingFeatures), versions,
checkDMSInstallation(), checkDMSInstallation(),
checkWindowManagers(), checkPackageRepos(),
checkWindowManagers(qsVersion),
qsFeatures, qsFeatures,
checkOptionalDependencies(), checkOptionalDependencies(),
checkConfigurationFiles(), checkConfigurationFiles(),
@@ -285,15 +288,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
@@ -684,18 +687,18 @@ func readOSRelease() map[string]string {
return result return result
} }
func checkVersions(qsMissingFeatures bool) []checkResult { func checkVersions(qsMissingFeatures bool) (results []checkResult, qsSemver string) {
dmsCliPath, _ := os.Executable() dmsCliPath, _ := os.Executable()
dmsCliDetails := "" dmsCliDetails := ""
if doctorVerbose { if doctorVerbose {
dmsCliDetails = dmsCliPath dmsCliDetails = dmsCliPath
} }
results := []checkResult{ results = []checkResult{
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"}, {catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"},
} }
qsVersion, qsStatus, qsPath, qsDetails := getQuickshellVersionInfo(qsMissingFeatures) qsVersion, qsStatus, qsPath, qsDetails, qsSemver := getQuickshellVersionInfo(qsMissingFeatures)
if doctorVerbose && qsPath != "" && qsDetails == "" { if doctorVerbose && qsPath != "" && qsDetails == "" {
qsDetails = qsPath qsDetails = qsPath
} }
@@ -708,7 +711,7 @@ func checkVersions(qsMissingFeatures bool) []checkResult {
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"}) results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"})
} }
return results return results, qsSemver
} }
func getDMSShellVersion() (version, path string) { func getDMSShellVersion() (version, path string) {
@@ -731,9 +734,9 @@ func getDMSShellVersion() (version, path string) {
return "", "" return "", ""
} }
func getQuickshellVersionInfo(missingFeatures bool) (string, status, string, string) { func getQuickshellVersionInfo(missingFeatures bool) (display string, st status, path, details, semver string) {
if !utils.CommandExists("qs") { if !utils.CommandExists("qs") {
return "Not installed", statusError, "", "" return "Not installed", statusError, "", "", ""
} }
qsPath, _ := exec.LookPath("qs") qsPath, _ := exec.LookPath("qs")
@@ -745,21 +748,21 @@ func getQuickshellVersionInfo(missingFeatures bool) (string, status, string, str
if errors.As(err, &exitErr) { if errors.As(err, &exitErr) {
details = quickshellVersionFailureDetails(string(exitErr.Stderr)) details = quickshellVersionFailureDetails(string(exitErr.Stderr))
} }
return "Installed (version check failed)", statusWarn, qsPath, details return "Installed (version check failed)", statusWarn, qsPath, details, ""
} }
fullVersion := strings.TrimSpace(string(output)) fullVersion := strings.TrimSpace(string(output))
if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 { if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 {
if version.CompareVersions(matches[1], "0.2.0") < 0 { if version.CompareVersions(matches[1], "0.2.0") < 0 {
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath, "" return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath, "", matches[1]
} }
if missingFeatures { if missingFeatures {
return fullVersion, statusWarn, qsPath, "" return fullVersion, statusWarn, qsPath, "", matches[1]
} }
return fullVersion, statusOK, qsPath, "" return fullVersion, statusOK, qsPath, "", matches[1]
} }
return fullVersion, statusWarn, qsPath, "" return fullVersion, statusWarn, qsPath, "", ""
} }
func quickshellVersionFailureDetails(stderr string) string { func quickshellVersionFailureDetails(stderr string) string {
@@ -804,7 +807,37 @@ func checkDMSInstallation() []checkResult {
return results return results
} }
func checkWindowManagers() []checkResult { 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(qsVersion string) []checkResult {
compositors := []struct { compositors := []struct {
name, versionCmd, versionArg string name, versionCmd, versionArg string
parseVersion func(string) string parseVersion func(string) string
@@ -856,7 +889,16 @@ func checkWindowManagers() []checkResult {
}) })
} }
if c.name == "Hyprland" { if c.name == "Hyprland" {
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...) results = append(results, checkHyprlandQuickshellFloor(ver, qsVersion)...)
results = append(results, checkHyprlandDeprecatedConfig()...)
}
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",
})
} }
} }
@@ -878,7 +920,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 +928,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, ", "),
@@ -947,18 +1026,39 @@ func checkCompositorBlurSupport() checkResult {
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"} return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
} }
// Same detection as the keybinds parser: a regular hyprland.lua wins, else hyprland.conf is read-only. func checkHyprlandQuickshellFloor(hyprVer, qsVer string) []checkResult {
func checkHyprlandConfigFormat(hyprDir string) []checkResult { if !hyprlandReadsOnlyLua(hyprVer) || qsVer == "" || version.CompareVersions(qsVer, "0.3.2") >= 0 {
if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.lua")); err == nil && st.Mode().IsRegular() {
return nil
}
if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.conf")); err != nil || !st.Mode().IsRegular() {
return nil return nil
} }
return []checkResult{{ return []checkResult{{
catCompositor, "Hyprland Lua config", statusWarn, catCompositor, "Quickshell for Hyprland 0.57", statusWarn,
"Hyprland config is hyprland.conf", fmt.Sprintf("Quickshell %s is older than 0.3.2", qsVer),
"DMS keybinds, window rules and displays are read-only until you run `dms setup` to migrate to Lua. Hyprland 0.57 drops .conf support.", "Hyprland 0.57 sends workspace addresses that older Quickshell collapses to workspace 0, breaking named and special workspaces. Upgrade Quickshell.",
doctorDocsURL + "#compositor-checks",
}}
}
// Hyprland before 0.57 answers `unknown request`, which fails to parse and is skipped.
func checkHyprlandDeprecatedConfig() []checkResult {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return nil
}
out, err := exec.Command("hyprctl", "-j", "deprecated-config").Output()
if err != nil {
return nil
}
return hyprlandDeprecatedConfigResults(out)
}
func hyprlandDeprecatedConfigResults(out []byte) []checkResult {
var notices []string
if err := json.Unmarshal(out, &notices); err != nil || len(notices) == 0 {
return nil
}
return []checkResult{{
catCompositor, "Hyprland deprecated config", statusWarn,
fmt.Sprintf("%d deprecated option(s)", len(notices)),
strings.Join(notices, "; "),
doctorDocsURL + "#compositor-checks", doctorDocsURL + "#compositor-checks",
}} }}
} }
@@ -967,6 +1067,14 @@ func hyprlandLacksLua(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0 return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
} }
func hyprlandReadsOnlyLua(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.57.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 {
@@ -1500,6 +1608,8 @@ func checkSystemdServices() []checkResult {
status = statusInfo status = statusInfo
} }
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"})
results = append(results, checkGreetdSession("/etc/greetd/config.toml")...)
results = append(results, checkRetiredGreeterPackages()...)
case doctorVerbose: case doctorVerbose:
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"})
} }
@@ -1507,6 +1617,112 @@ func checkSystemdServices() []checkResult {
return results return results
} }
func greeterLuaHint() string {
return "Hyprland 0.57 only reads Lua; switch to the dux-greeter package and rerun `dms-greeter install`. " + greeterPackageInstallHint()
}
func checkGreetdSession(configPath string) []checkResult {
data, err := os.ReadFile(configPath)
if err != nil {
return nil
}
fields := strings.Fields(greetdDefaultSessionCommand(string(data)))
if len(fields) == 0 {
return nil
}
warn := func(message string) []checkResult {
return []checkResult{{catServices, "Greeter session", statusWarn, message, greeterLuaHint(), doctorDocsURL + "#services"}}
}
if bin, err := exec.LookPath(fields[0]); err == nil && isLegacyWrapperScript(bin) {
return warn("greetd runs a pre-1.6 greeter wrapper script")
}
if !strings.EqualFold(greeterFlagValue(fields, "--command"), "hyprland") {
return nil
}
cfg := strings.Trim(greeterFlagValue(fields, "-C", "--config"), `"'`)
if cfg == "" || strings.HasSuffix(cfg, ".lua") {
return nil
}
content, err := os.ReadFile(cfg)
if err != nil || greeterHyprlandLuaContent(content) {
return nil
}
return warn("Greeter Hyprland config is not Lua: " + cfg)
}
var greeterHyprlandLuaPattern = regexp.MustCompile(`(^|[^[:alnum:]_])hl\.`)
// Mirrors dux-greeter's launcher.IsHyprlandLuaConfig content check.
func greeterHyprlandLuaContent(content []byte) bool {
for line := range strings.Lines(string(content)) {
if strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
if greeterHyprlandLuaPattern.MatchString(line) {
return true
}
}
return false
}
func greetdDefaultSessionCommand(configTOML string) string {
inDefault := false
for line := range strings.SplitSeq(configTOML, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
inDefault = line == "[default_session]"
continue
}
key, value, ok := strings.Cut(line, "=")
if !inDefault || !ok || strings.TrimSpace(key) != "command" {
continue
}
value = strings.TrimSpace(value)
if unquoted, err := strconv.Unquote(value); err == nil {
return unquoted
}
return strings.Trim(value, `'`)
}
return ""
}
func greeterFlagValue(fields []string, flags ...string) string {
for i, f := range fields {
for _, flag := range flags {
if f == flag && i+1 < len(fields) {
return fields[i+1]
}
if value, ok := strings.CutPrefix(f, flag+"="); ok {
return value
}
}
}
return ""
}
func checkRetiredGreeterPackages() []checkResult {
if !utils.CommandExists("pacman") {
return nil
}
// pacman -Q exits non-zero when any name is missing but still lists the installed ones
out, _ := exec.Command("pacman", "-Q", "greetd-dms-greeter", "greetd-dms-greeter-bin", "greetd-dms-greeter-git").Output()
var installed []string
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
if name, _, _ := strings.Cut(line, " "); name != "" {
installed = append(installed, name)
}
}
if len(installed) == 0 {
return nil
}
return []checkResult{{
catServices, "Greeter package", statusWarn,
"Retired package installed: " + strings.Join(installed, ", "),
greeterLuaHint(),
doctorDocsURL + "#services",
}}
}
// Chromium, Electron and libadwaita follow light/dark through the settings // Chromium, Electron and libadwaita follow light/dark through the settings
// portal; without it they fall back to GTK theme colours that never update. // portal; without it they fall back to GTK theme colours that never update.
func checkSettingsPortal() []checkResult { func checkSettingsPortal() []checkResult {
@@ -1712,6 +1928,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 +1965,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 +2040,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 +2049,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 +2095,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 +2114,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)
} }
} }
} }
+81 -19
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)
} }
} }
@@ -413,24 +433,66 @@ func TestHyprlandLacksLua(t *testing.T) {
} }
} }
func TestCheckHyprlandConfigFormat(t *testing.T) { func TestCheckHyprlandQuickshellFloor(t *testing.T) {
for name, tc := range map[string]struct { for _, tc := range []struct {
files []string hypr, qs string
warn bool warn bool
}{ }{
"lua only": {[]string{"hyprland.lua"}, false}, {"0.57.0", "0.3.1", true},
"lua wins": {[]string{"hyprland.lua", "hyprland.conf"}, false}, {"0.57.0", "0.3.2", false},
"conf only": {[]string{"hyprland.conf"}, true}, {"0.56.1", "0.2.1", false},
"neither": {nil, false}, {"0.57.0", "", false},
} { } {
if got := checkHyprlandQuickshellFloor(tc.hypr, tc.qs); (len(got) == 1 && got[0].status == statusWarn) != tc.warn || (!tc.warn && len(got) != 0) {
t.Errorf("hyprland %q quickshell %q: got %+v, want warn=%v", tc.hypr, tc.qs, got, tc.warn)
}
}
}
func TestHyprlandDeprecatedConfigResults(t *testing.T) {
got := hyprlandDeprecatedConfigResults([]byte(`["general:no_border_on_floating", "decoration:shadow:range"]`))
if len(got) != 1 || got[0].status != statusWarn || !strings.Contains(got[0].details, "decoration:shadow:range") {
t.Fatalf("got %+v, want one warning listing both keys", got)
}
for _, out := range []string{"[]", "unknown request"} {
if got := hyprlandDeprecatedConfigResults([]byte(out)); len(got) != 0 {
t.Errorf("%q: got %+v, want nothing", out, got)
}
}
}
func TestCheckGreetdSession(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
for _, f := range tc.files { wrapper := filepath.Join(dir, "dms-greeter")
if err := os.WriteFile(filepath.Join(dir, f), nil, 0o644); err != nil { if err := os.WriteFile(wrapper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
hyprlang := filepath.Join(dir, "dms-hypr.conf")
if err := os.WriteFile(hyprlang, []byte("# see hl.dsp docs\nexec-once = waybar\n"), 0o644); err != nil {
t.Fatal(err)
} }
got := checkHyprlandConfigFormat(dir) luaConf := filepath.Join(dir, "lua-hypr.conf")
if warned := len(got) == 1 && got[0].status == statusWarn; warned != tc.warn || (!tc.warn && len(got) != 0) { if err := os.WriteFile(luaConf, []byte("hl.config({ misc = { disable_hyprland_logo = true } })\n"), 0o644); err != nil {
t.Fatal(err)
}
for name, tc := range map[string]struct {
command string
warn bool
}{
"pre-1.6 wrapper": {wrapper + " --command niri", true},
"hyprlang greeter config": {"dux-greeter --command hyprland -C " + hyprlang, true},
"hyprlang via --config=": {"dux-greeter --command hyprland --config='" + hyprlang + "'", true},
"lua content in a .conf": {"dux-greeter --command hyprland -C " + luaConf, false},
"lua greeter config": {"dux-greeter --command hyprland -C /etc/greetd/dms-hypr.lua", false},
"unreadable greeter config": {"dux-greeter --command hyprland -C " + filepath.Join(dir, "missing.conf"), false},
"niri greeter": {"dux-greeter --command niri -C /etc/greetd/niri.kdl", false},
} {
config := filepath.Join(t.TempDir(), "config.toml")
toml := "[terminal]\nvt = 1\n\n[default_session]\ncommand = \"" + tc.command + "\"\nuser = \"greeter\"\n"
if err := os.WriteFile(config, []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
if got := checkGreetdSession(config); (len(got) == 1 && got[0].status == statusWarn) != tc.warn || (!tc.warn && len(got) != 0) {
t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn) t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn)
} }
} }
+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()
} }
+17 -12
View File
@@ -14,22 +14,26 @@ import (
var greeterCmd = &cobra.Command{ var greeterCmd = &cobra.Command{
Use: "greeter", Use: "greeter",
Short: "Deprecated: moved to the standalone dms-greeter binary", Short: "Deprecated: moved to the standalone dms-greeter binary",
Long: "The greeter has moved to the standalone dms-greeter package.\nThis command forwards to 'dms-greeter' when it is installed.", Long: "The greeter has moved to the standalone dux-greeter package.\nThis command forwards to 'dms-greeter' when it is installed.",
DisableFlagParsing: true, DisableFlagParsing: true,
SilenceUsage: true, SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, args []string) error {
binary, err := exec.LookPath("dms-greeter") binary, err := exec.LookPath("dms-greeter")
if err != nil { if err != nil {
return fmt.Errorf("'dms greeter' has moved to the standalone dms-greeter package.\n %s", greeterPackageInstallHint()) return fmt.Errorf("'dms greeter' has moved to the standalone dux-greeter package.\n %s", greeterPackageInstallHint())
} }
if isLegacyWrapperScript(binary) { if isLegacyWrapperScript(binary) {
return fmt.Errorf("'dms greeter' has moved to the standalone dms-greeter package; %s is the old wrapper script.\n %s", binary, greeterPackageInstallHint()) return fmt.Errorf("'dms greeter' has moved to the standalone dux-greeter package; %s is the old wrapper script.\n %s", binary, greeterPackageInstallHint())
} }
fmt.Fprintln(os.Stderr, "warning: 'dms greeter' is deprecated; use 'dms-greeter' directly") fmt.Fprintln(os.Stderr, "warning: 'dms greeter' is deprecated; use 'dms-greeter' directly")
return syscall.Exec(binary, append([]string{"dms-greeter"}, args...), os.Environ()) return syscall.Exec(binary, append([]string{"dms-greeter"}, args...), os.Environ())
}, },
} }
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 {
@@ -46,11 +50,11 @@ func isLegacyWrapperScript(path string) bool {
func greeterPackageInstallHint() string { func greeterPackageInstallHint() string {
osInfo, err := distros.GetOSInfo() osInfo, err := distros.GetOSInfo()
if err != nil { if err != nil {
return "Install package: dms-greeter" return "Install package: dux-greeter"
} }
config, exists := distros.Registry[osInfo.Distribution.ID] config, exists := distros.Registry[osInfo.Distribution.ID]
if !exists { if !exists {
return "Install package: dms-greeter" return "Install package: dux-greeter"
} }
if config.ID == "pika" { if config.ID == "pika" {
@@ -59,18 +63,19 @@ func greeterPackageInstallHint() string {
switch config.Family { switch config.Family {
case distros.FamilyDebian: case distros.FamilyDebian:
return "Install with 'sudo apt install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#debian)" return "Install with 'sudo apt install dux-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#debian)"
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 dux-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 dux-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 dux-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 dux-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 dux-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)
}
}
+3 -6
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",
@@ -563,10 +563,7 @@ func compositorPrimaryPaths(wm deps.WindowManager) []string {
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
return []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")} return []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland: case deps.WindowManagerHyprland:
return []string{ return []string{filepath.Join(config.HyprlandConfigDir(), "hyprland.lua")}
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
case deps.WindowManagerMango: case deps.WindowManagerMango:
return []string{ return []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"), filepath.Join(homeDir, ".config", "mango", "config.conf"),
+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 -3
View File
@@ -2,17 +2,20 @@ 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/config" "github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"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{
@@ -54,7 +57,6 @@ func (b *dmsBackend) Close() {
func (b *dmsBackend) Done() <-chan error { return b.done } func (b *dmsBackend) Done() <-chan error { return b.done }
func bootBackend(ctx context.Context) (shellapp.Backend, error) { func bootBackend(ctx context.Context) (shellapp.Backend, error) {
config.CleanupStrayHyprlandConfFile(log.Infof)
server.CLIVersion = Version server.CLIVersion = Version
srv := server.New() srv := server.New()
@@ -83,18 +85,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 {
+4 -4
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,12 +9,13 @@ 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
github.com/godbus/dbus/v5 v5.2.4 github.com/godbus/dbus/v5 v5.2.4
github.com/holoplot/go-evdev v0.0.0-20260909133534-d873ed210bda github.com/holoplot/go-evdev v0.0.0-20260909133534-d873ed210bda
github.com/kevinburke/ssh_config v1.6.0
github.com/klauspost/compress v1.20.3 github.com/klauspost/compress v1.20.3
github.com/lucasb-eyer/go-colorful v1.4.1 github.com/lucasb-eyer/go-colorful v1.4.1
github.com/mattn/go-isatty v0.0.24 github.com/mattn/go-isatty v0.0.24
@@ -31,6 +32,7 @@ require (
github.com/yuin/goldmark v1.8.6 github.com/yuin/goldmark v1.8.6
go.etcd.io/bbolt v1.5.0 go.etcd.io/bbolt v1.5.0
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/crypto v0.58.0
golang.org/x/image v0.47.0 golang.org/x/image v0.47.0
golang.org/x/sys v0.49.0 golang.org/x/sys v0.49.0
golang.org/x/text v0.43.0 golang.org/x/text v0.43.0
@@ -71,7 +73,6 @@ require (
github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect
github.com/mattn/go-runewidth v0.0.31 // indirect github.com/mattn/go-runewidth v0.0.31 // indirect
@@ -97,7 +98,6 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
golang.org/x/net v0.61.0 // indirect golang.org/x/net v0.61.0 // indirect
golang.org/x/sync v0.24.0 // indirect golang.org/x/sync v0.24.0 // indirect
+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=
+21 -5
View File
@@ -35,15 +35,25 @@ aarch64)
;; ;;
esac esac
# Get the latest release version REPO_URL="https://github.com/duxlabs-dev/dux-shell"
LATEST_VERSION=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
# The web redirect has no per-ip quota; api.github.com allows 60 unauthenticated requests per hour per ip
LATEST_VERSION=$(curl -sI "$REPO_URL/releases/latest" | tr -d '\r' | awk 'tolower($1) == "location:" { sub(".*/releases/tag/", "", $2); print $2 }')
if [ -z "$LATEST_VERSION" ]; then
API_RESPONSE=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest)
LATEST_VERSION=$(printf "%s" "$API_RESPONSE" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
fi
if [ -z "$LATEST_VERSION" ]; then if [ -z "$LATEST_VERSION" ]; then
printf "%bError: Could not fetch latest version%b\n" "$RED" "$NC" printf "%bError: Could not fetch latest version%b\n" "$RED" "$NC"
if printf "%s" "$API_RESPONSE" | grep -q "rate limit"; then
printf "GitHub API rate limit reached for your IP address (60 requests per hour). Wait an hour or install from a different network.\n"
fi
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 +62,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="$REPO_URL/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
}
if string(value) == "null" {
continue
}
settings[key] = value
} }
} }
return disabled, nil 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"))
})
}
}
+10 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_screencopy" "github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_screencopy"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_color_management" "github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_color_management"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_viewporter" "github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_viewporter"
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
wlhelpers "github.com/duxlabs-dev/dux-shell/core/internal/wayland/client" wlhelpers "github.com/duxlabs-dev/dux-shell/core/internal/wayland/client"
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap" "github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap"
@@ -535,9 +536,8 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
ls.state.OnScreencopyReady() ls.state.OnScreencopyReady()
screenBuf := ls.state.ScreenBuffer() screenBuf := ls.state.ScreenBuffer()
if screenBuf != nil && ls.output.transform != TransformNormal { if screenBuf != nil {
invTransform := InverseTransform(ls.output.transform) transformed, err := ls.output.untransformFrame(screenBuf)
transformed, err := screenBuf.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 != screenBuf { } else if transformed != screenBuf {
@@ -562,6 +562,13 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
}) })
} }
func (o *Output) untransformFrame(buf *ShmBuffer) (*ShmBuffer, error) {
if screenshot.FrameInLogicalOrientation(o.transform, o.width, o.height, buf.Width, buf.Height) {
return buf, nil
}
return buf.ApplyTransform(InverseTransform(o.transform))
}
func (p *Picker) redrawSurface(ls *LayerSurface) { func (p *Picker) redrawSurface(ls *LayerSurface) {
slot := ls.state.FrontIndex() slot := ls.state.FrontIndex()
if ls.slotBusy[slot] || ls.framePending { if ls.slotBusy[slot] || ls.framePending {
+21
View File
@@ -0,0 +1,21 @@
package colorpicker
import "testing"
func TestUntransformFrameLeavesAnUprightFrame(t *testing.T) {
buf, err := CreateShmBuffer(1080, 1920, 1080*4)
if err != nil {
t.Fatal(err)
}
defer buf.Close()
output := &Output{width: 1920, height: 1080, transform: Transform90}
got, err := output.untransformFrame(buf)
if err != nil {
t.Fatal(err)
}
if got != buf {
defer got.Close()
t.Fatalf("rotated a frame already in logical orientation to %dx%d", got.Width, got.Height)
}
}
+25 -168
View File
@@ -70,9 +70,7 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm dep
filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
}, },
"Hyprland": { "Hyprland": {
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"), filepath.Join(HyprlandConfigDir(), "hyprland.lua"),
// A conf-only install still counts: deploying moves it into backups.
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
}, },
"Mango": { "Mango": {
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"), filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
@@ -262,7 +260,8 @@ func (cd *ConfigDeployer) deployNiriConfig(terminalCommand string, useSystemd bo
newConfig := strings.ReplaceAll(NiriConfig, "{{TERMINAL_COMMAND}}", terminalCommand) newConfig := strings.ReplaceAll(NiriConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if !cd.systemdAutostart(deps.WindowManagerNiri, useSystemd) { autostart := cd.systemdAutostart(deps.WindowManagerNiri, useSystemd)
if !autostart {
newConfig = cd.transformNiriConfigForNonSystemd(newConfig, terminalCommand) newConfig = cd.transformNiriConfigForNonSystemd(newConfig, terminalCommand)
} }
@@ -276,14 +275,19 @@ func (cd *ConfigDeployer) deployNiriConfig(terminalCommand string, useSystemd bo
} }
} }
// niri reloads config.kdl as soon as it changes, so its includes must already exist.
if err := cd.deployNiriDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
if err := os.WriteFile(result.Path, []byte(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.deployNiriDmsConfigs(dmsDir, terminalCommand); err != nil { if autostart {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) cd.startInLiveSession(deps.WindowManagerNiri)
return result, result.Error
} }
result.Deployed = true result.Deployed = true
@@ -604,11 +608,10 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
return builder.String(), nil return builder.String(), nil
} }
// deployHyprlandConfig handles Hyprland configuration deployment with backup and merging
func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystemd bool) (DeploymentResult, error) { func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{ result := DeploymentResult{
ConfigType: "Hyprland", ConfigType: "Hyprland",
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"), Path: filepath.Join(HyprlandConfigDir(), "hyprland.lua"),
} }
if err := hyprlandLuaUnsupported(cd.hyprlandVersion); err != nil { if err := hyprlandLuaUnsupported(cd.hyprlandVersion); err != nil {
result.Error = err result.Error = err
@@ -627,22 +630,14 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem
return result, result.Error return result, result.Error
} }
timestamp := time.Now().Format("2006-01-02_15-04-05") existing, err := os.ReadFile(result.Path)
backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp) if err != nil && !os.IsNotExist(err) {
var existingConfig string
existingData, existingPath, err := readExistingHyprlandConfig(configDir)
if err != nil {
result.Error = err result.Error = err
return result, result.Error return result, result.Error
} }
if existingData != "" { if err == nil {
existingConfig = existingData backupDir := filepath.Join(configDir, hyprlandBackupDirName, time.Now().Format("2006-01-02_15-04-05"))
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath)) result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, "hyprland.lua"), existing)
}
// A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland
// watches the live config and regenerates a stub one if it disappears first.
if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") {
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData))
if err != nil { if err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err) result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error return result, result.Error
@@ -651,19 +646,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem
} }
newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand) newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
autostart := cd.systemdAutostart(deps.WindowManagerHyprland, useSystemd)
if !cd.systemdAutostart(deps.WindowManagerHyprland, useSystemd) { if !autostart {
newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand) newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
} }
if existingConfig != "" { // Hyprland reloads hyprland.lua as soon as it changes, so its required fragments must already exist.
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir) if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
if err != nil { result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err)) return result, result.Error
} else {
newConfig = mergedConfig
cd.log("Successfully merged existing monitor sections")
}
} }
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
@@ -671,30 +662,9 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem
return result, result.Error return result, result.Error
} }
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir) if autostart {
if err != nil { cd.startInLiveSession(deps.WindowManagerHyprland)
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
return result, result.Error
} }
if mainConfBackup != "" && result.BackupPath == "" {
result.BackupPath = mainConfBackup
cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup))
}
if movedLegacy > 0 {
if result.BackupPath == "" {
result.BackupPath = backupDir
}
cd.log(fmt.Sprintf("Moved %d legacy hyprlang config(s) to %s", movedLegacy, backupDir))
}
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
cd.log(fmt.Sprintf(format, v...))
})
result.Deployed = true result.Deployed = true
cd.log("Successfully deployed Hyprland configuration") cd.log("Successfully deployed Hyprland configuration")
@@ -725,80 +695,6 @@ func backupHyprlandConfigFile(dst string, data []byte) (string, error) {
return dst, nil return dst, nil
} }
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) {
mainConf := filepath.Join(configDir, "hyprland.conf")
legacyPaths := []string{mainConf}
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
if err != nil {
return 0, "", err
}
legacyPaths = append(legacyPaths, dmsConfPaths...)
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
if err != nil {
return 0, "", err
}
legacyPaths = append(legacyPaths, backupPaths...)
moved := 0
mainConfBackup := ""
for _, src := range legacyPaths {
info, err := os.Lstat(src)
if os.IsNotExist(err) {
continue
}
if err != nil {
return moved, mainConfBackup, err
}
if info.IsDir() {
continue
}
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel))
if err != nil {
return moved, mainConfBackup, err
}
if src == mainConf {
mainConfBackup = dst
}
moved++
}
return moved, mainConfBackup, nil
}
func moveHyprlandConfigFile(src, dst string) (string, error) {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", err
}
dst = uniqueBackupPath(dst)
if err := os.Rename(src, dst); err != nil {
return "", err
}
return dst, nil
}
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
var paths []string
patterns := []string{
filepath.Join(configDir, "hyprland.conf.backup.*"),
filepath.Join(configDir, "hyprland.lua.backup.*"),
filepath.Join(dmsDir, "*.conf.backup.*"),
filepath.Join(dmsDir, "*.lua.backup.*"),
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
paths = append(paths, matches...)
}
return paths, nil
}
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error { func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
configs := []struct { configs := []struct {
name string name string
@@ -838,45 +734,6 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
return nil return nil
} }
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
_ = newConfig
lines := extractHyprlangMonitorLines(existingConfig)
if len(lines) == 0 {
return newConfig, nil
}
outputsPath := filepath.Join(dmsDir, "outputs.lua")
if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 {
cd.log("Skipping monitor migration: dms/outputs.lua already exists")
return newConfig, nil
}
var b strings.Builder
b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n")
ok := 0
for _, line := range lines {
lua, err := hyprlangMonitorLineToLua(line)
if err != nil {
cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err))
continue
}
b.WriteString(lua)
b.WriteByte('\n')
ok++
}
if ok == 0 {
return newConfig, nil
}
b.WriteByte('\n')
b.WriteString("-- Default fallback\n")
b.WriteString("hl.monitor({ output = \"\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })\n")
if err := os.WriteFile(outputsPath, []byte(b.String()), 0o644); err != nil {
return newConfig, err
}
cd.log("Migrated monitor sections to dms/outputs.lua")
return newConfig, nil
}
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string { func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
envVars := fmt.Sprintf(`environment { envVars := fmt.Sprintf(`environment {
XDG_CURRENT_DESKTOP "niri" XDG_CURRENT_DESKTOP "niri"
+64 -276
View File
@@ -16,6 +16,8 @@ import (
// Tests never reach the user's systemd; stubAddWants counts the add-wants calls a deploy makes. // Tests never reach the user's systemd; stubAddWants counts the add-wants calls a deploy makes.
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
systemctlUser = func(...string) error { return nil } systemctlUser = func(...string) error { return nil }
// Hyprland paths follow XDG_CONFIG_HOME; tests pin HOME and must not write a real config.
os.Unsetenv("XDG_CONFIG_HOME")
os.Exit(m.Run()) os.Exit(m.Run())
} }
@@ -34,55 +36,6 @@ func stubAddWants(t *testing.T, addWants error) *int {
return &calls return &calls
} }
func TestCleanupStrayHyprlandConfFile(t *testing.T) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "test-signature")
}
t.Run("leaves conf alone when no hyprland.lua present", func(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated")
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
})
t.Run("moves stray conf into backup when hyprland.lua exists", func(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath)
assert.NoFileExists(t, dmsConfPath)
assert.FileExists(t, luaPath)
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
require.NoError(t, err)
require.Len(t, entries, 1)
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf"))
})
}
func TestMergeNiriOutputSections(t *testing.T) { func TestMergeNiriOutputSections(t *testing.T) {
cd := &ConfigDeployer{} cd := &ConfigDeployer{}
@@ -182,7 +135,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)
@@ -256,63 +209,8 @@ func getGhosttyPath() string {
return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config") return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config")
} }
func TestMergeHyprlandMonitorSections(t *testing.T) {
cd := &ConfigDeployer{}
t.Run("no monitors in existing", func(t *testing.T) {
tmp := t.TempDir()
out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp)
require.NoError(t, err)
assert.Equal(t, `hl.config({})`, out)
_, e := os.Stat(filepath.Join(tmp, "outputs.lua"))
assert.True(t, os.IsNotExist(e))
})
t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) {
tmp := t.TempDir()
existing := `monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`
out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp)
require.NoError(t, err)
assert.Equal(t, `return`, out)
b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua"))
require.NoError(t, err)
s := string(b)
assert.Contains(t, s, "hl.monitor")
assert.Contains(t, s, "DP-1")
assert.Contains(t, s, "HDMI-A-1")
assert.Contains(t, s, "eDP-1")
assert.Contains(t, s, "preferred") // fallback rule at end
})
t.Run("skips when outputs lua already exists", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "outputs.lua")
require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644))
_, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp)
require.NoError(t, err)
b, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "-- keep\n", string(b))
})
}
func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
got, err := hyprlangMonitorLineToLua(`monitor = DP-1, 1920x1080@144, 0x0, 1, transform, 1, vrr, 2, bitdepth, 10, cm, hdr, sdrbrightness, 1.2, sdrsaturation, 0.98`)
require.NoError(t, err)
assert.Contains(t, got, `output = "DP-1"`)
assert.Contains(t, got, `transform = 1`)
assert.Contains(t, got, `vrr = 2`)
assert.Contains(t, got, `bitdepth = 10`)
assert.Contains(t, got, `cm = "hdr"`)
assert.Contains(t, got, `sdrbrightness = 1.2`)
assert.Contains(t, got, `sdrsaturation = 0.98`)
}
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 +222,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)
@@ -343,95 +241,31 @@ func TestHyprlandConfigDeployment(t *testing.T) {
assert.Contains(t, string(content), "hl.config(") assert.Contains(t, string(content), "hl.config(")
}) })
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) { t.Run("deploy hyprland config backs up the existing hyprland.lua", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge") td, err := os.MkdirTemp("", "duxinstall-hyprland-backup")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
existingContent := `# My existing Hyprland config existing := "hl.config({ autogenerated = true })\n"
monitor = DP-1, 1920x1080@144, 0x0, 1 luaPath := filepath.Join(td, ".config", "hypr", "hyprland.lua")
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5 require.NoError(t, os.MkdirAll(filepath.Dir(luaPath), 0o755))
require.NoError(t, os.WriteFile(luaPath, []byte(existing), 0o644))
general {
gaps_in = 10
}
`
hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf")
err = os.MkdirAll(filepath.Dir(hyprPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
require.NoError(t, err)
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
result, err := cd.deployHyprlandConfig("kitty", true) result, err := cd.deployHyprlandConfig("kitty", true)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "Hyprland", result.ConfigType)
assert.True(t, result.Deployed)
assert.NotEmpty(t, result.BackupPath)
assert.FileExists(t, result.Path)
assert.FileExists(t, result.BackupPath)
backupContent, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
assert.Contains(t, result.BackupPath, hyprlandBackupDirName) assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
assert.NoFileExists(t, hyprPath) backup, err := os.ReadFile(result.BackupPath)
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
newContent, err := os.ReadFile(result.Path)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(newContent), `require("dms.binds")`) assert.Equal(t, existing, string(backup))
content, err := os.ReadFile(result.Path)
outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua")
outBytes, err := os.ReadFile(outputsPath)
require.NoError(t, err) require.NoError(t, err)
outs := string(outBytes) assert.Contains(t, string(content), `require("dms.binds")`)
assert.Contains(t, outs, `hl.monitor`) assert.NotContains(t, string(content), "autogenerated")
assert.Contains(t, outs, "DP-1")
assert.Contains(t, outs, "HDMI-A-1")
})
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")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
confPath := filepath.Join(configDir, "hyprland.conf")
require.NoError(t, os.WriteFile(luaPath, []byte(`require("dms.binds")`+"\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join(configDir, "missing-legacy.conf"), confPath))
result, err := cd.deployHyprlandConfig("kitty", true)
require.NoError(t, err)
assert.Equal(t, luaPath, result.Path)
_, err = os.Lstat(confPath)
assert.True(t, os.IsNotExist(err), "root hyprland.conf symlink should be moved out of the live config directory")
_, err = os.Lstat(filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"))
assert.NoError(t, err)
}) })
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 +291,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 +329,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 +404,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 +437,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 +470,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 +512,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)
@@ -724,66 +558,6 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true") assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
}) })
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
hyprConf := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
require.NoError(t, os.MkdirAll(filepath.Dir(hyprConf), 0o755))
require.NoError(t, os.WriteFile(hyprConf, []byte("monitor = , preferred, auto, 1\n"), 0o644))
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.deployConfigurationsInternal(
context.Background(),
deps.WindowManagerHyprland,
deps.TerminalGhostty,
nil,
allFalse,
nil,
true,
)
require.NoError(t, err)
for _, r := range results {
if r.ConfigType == "Hyprland" && r.Deployed {
t.Fatalf("expected Hyprland deployment to be skipped when legacy config exists and replace=false")
}
}
})
}
func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) {
td := t.TempDir()
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05")
require.NoError(t, os.MkdirAll(backupDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644))
autogenerated := "autogenerated = 1\n$mainMod = SUPER\n"
require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644))
moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
require.NoError(t, err)
assert.Equal(t, 1, moved)
assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup)
kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf"))
require.NoError(t, err)
assert.Equal(t, userConfig, string(kept))
stray, err := os.ReadFile(mainConfBackup)
require.NoError(t, err)
assert.Equal(t, autogenerated, string(stray))
} }
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) { func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
@@ -800,33 +574,6 @@ func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
assert.Equal(t, "-- first\n", string(first)) assert.Equal(t, "-- first\n", string(first))
} }
func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644))
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
go func() {
for range logChan {
}
}()
defer close(logChan)
result, err := cd.deployHyprlandConfig("ghostty", true)
require.NoError(t, err)
assert.NoFileExists(t, confPath)
backup, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, userConfig, string(backup))
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
}
func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) { func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -862,6 +609,47 @@ func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) {
} }
} }
func TestDeployHyprlandStartsShellInLiveSession(t *testing.T) {
cases := []struct {
name string
instance string
unitActive bool
useSystemd bool
wantStarted bool
}{
{"live session", "sig", true, true, true},
{"session unit inactive", "sig", false, true, false},
{"other session", "", true, true, false},
{"no systemd", "sig", true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", tc.instance)
t.Setenv("NIRI_SOCKET", "")
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
started := false
prev := systemctlUser
systemctlUser = func(args ...string) error {
switch args[0] {
case "is-active":
if !tc.unitActive {
return errors.New("inactive")
}
case "start":
started = true
}
return nil
}
t.Cleanup(func() { systemctlUser = prev })
_, err := NewConfigDeployer(nil).deployHyprlandConfig("kitty", tc.useSystemd)
require.NoError(t, err)
assert.Equal(t, tc.wantStarted, started)
})
}
}
func TestDeployNiriConfigSystemdAutostart(t *testing.T) { func TestDeployNiriConfigSystemdAutostart(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -1,3 +1,3 @@
-- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua -- Per-output monitor rules. Deploy writes ~/.config/hypr/dms/outputs.lua
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
+3 -1
View File
@@ -1,9 +1,11 @@
-- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/ -- Hyprland configuration (Lua) — https://wiki.hypr.land/configuring/
-- DMS_STARTUP_BEGIN -- DMS_STARTUP_BEGIN
hl.on("hyprland.start", function() hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd --all") hl.exec_cmd("dbus-update-activation-environment --systemd --all")
if not os.getenv("HYPRLAND_NO_SD_TARGET") then
hl.exec_cmd("systemctl --user start hyprland-session.target") hl.exec_cmd("systemctl --user start hyprland-session.target")
end
end) end)
-- DMS_STARTUP_END -- DMS_STARTUP_END
@@ -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
+4 -162
View File
@@ -2,12 +2,11 @@ package config
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
) )
const ( const (
@@ -15,93 +14,6 @@ const (
hyprlandStartupEnd = "-- DMS_STARTUP_END" hyprlandStartupEnd = "-- DMS_STARTUP_END"
) )
func extractHyprlangMonitorLines(hyprlang string) []string {
re := regexp.MustCompile(`(?m)^\s*#?\s*monitor\s*=.*$`)
return re.FindAllString(hyprlang, -1)
}
func hyprlangMonitorLineToLua(line string) (string, error) {
re := regexp.MustCompile(`(?i)^\s*#?\s*monitor\s*=\s*(.*)\s*$`)
m := re.FindStringSubmatch(line)
if m == nil {
return "", fmt.Errorf("not a monitor line")
}
rest := strings.TrimSpace(m[1])
parts := strings.Split(rest, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 4 {
if len(parts) == 2 && strings.EqualFold(parts[1], "disable") {
return fmt.Sprintf(`hl.monitor({ output = %s, disabled = true })`, strconv.Quote(parts[0])), nil
}
return "", fmt.Errorf("expected at least 4 comma-separated fields")
}
out := parts[0]
mode := parts[1]
pos := parts[2]
scaleStr := parts[3]
scaleField := formatMonitorScaleLua(scaleStr)
fields := []string{
fmt.Sprintf("output = %s", strconv.Quote(out)),
fmt.Sprintf("mode = %s", strconv.Quote(mode)),
fmt.Sprintf("position = %s", strconv.Quote(pos)),
scaleField,
}
for i := 4; i < len(parts); i += 2 {
key := strings.ToLower(strings.TrimSpace(parts[i]))
if key == "" {
continue
}
if i+1 >= len(parts) {
fields = append(fields, fmt.Sprintf("%s = true", hyprlangMonitorOptionToLuaKey(key)))
continue
}
val := strings.TrimSpace(parts[i+1])
if converted, ok := formatMonitorOptionLua(key, val); ok {
fields = append(fields, converted)
}
}
return fmt.Sprintf(`hl.monitor({ %s })`, strings.Join(fields, ", ")), nil
}
func formatMonitorScaleLua(scaleStr string) string {
if scaleStr == "auto" {
return `scale = "auto"`
}
if f, err := strconv.ParseFloat(scaleStr, 64); err == nil {
return fmt.Sprintf(`scale = %g`, f)
}
return fmt.Sprintf(`scale = %s`, strconv.Quote(scaleStr))
}
func hyprlangMonitorOptionToLuaKey(key string) string {
switch strings.ToLower(strings.TrimSpace(key)) {
case "10bit":
return "bitdepth"
default:
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_")
}
}
func formatMonitorOptionLua(key, val string) (string, bool) {
luaKey := hyprlangMonitorOptionToLuaKey(key)
switch luaKey {
case "transform", "vrr", "bitdepth", "supports_wide_color", "supports_hdr", "sdr_max_luminance", "max_luminance", "max_avg_luminance":
if _, err := strconv.Atoi(val); err == nil {
return fmt.Sprintf("%s = %s", luaKey, val), true
}
case "sdrbrightness", "sdrsaturation", "sdr_min_luminance", "min_luminance":
if _, err := strconv.ParseFloat(val, 64); err == nil {
return fmt.Sprintf("%s = %s", luaKey, val), true
}
case "cm", "sdr_eotf", "icc", "mirror":
return fmt.Sprintf("%s = %s", luaKey, strconv.Quote(val)), true
}
return "", false
}
func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string { func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
start := strings.Index(config, hyprlandStartupBegin) start := strings.Index(config, hyprlandStartupBegin)
end := strings.Index(config, hyprlandStartupEnd) end := strings.Index(config, hyprlandStartupEnd)
@@ -122,76 +34,6 @@ func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
return config[:start] + replacement + config[endClose:] return config[:start] + replacement + config[endClose:]
} }
func readExistingHyprlandConfig(configDir string) (data string, sourcePath string, err error) { func HyprlandConfigDir() string {
luaPath := filepath.Join(configDir, "hyprland.lua") return filepath.Join(utils.XDGConfigHome(), "hypr")
if b, e := os.ReadFile(luaPath); e == nil {
return string(b), luaPath, nil
} else if !os.IsNotExist(e) {
return "", "", e
}
confPath := filepath.Join(configDir, "hyprland.conf")
if b, e := os.ReadFile(confPath); e == nil {
return string(b), confPath, nil
} else if !os.IsNotExist(e) {
return "", "", e
}
return "", "", nil
}
// CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
// top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
// when hyprland.lua also exists as the live config.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return
}
home := os.Getenv("HOME")
if home == "" {
return
}
configDir := filepath.Join(home, ".config", "hypr")
luaPath := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(luaPath); err != nil {
return
}
var strayPaths []string
confPath := filepath.Join(configDir, "hyprland.conf")
if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, confPath)
}
dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
if err == nil {
for _, p := range dmsConfPaths {
if info, err := os.Lstat(p); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, p)
}
}
}
if len(strayPaths) == 0 {
return
}
ts := time.Now().Format("2006-01-02_15-04-05")
moved := 0
for _, src := range strayPaths {
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
if err != nil {
if logFn != nil {
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
}
continue
}
moved++
if logFn != nil {
logFn("Moved stray Hyprland conf file to %s", dst)
}
}
if moved > 0 && logFn != nil {
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
}
} }
+19 -15
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,19 +109,24 @@ 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) { autostart := cd.systemdAutostart(deps.WindowManagerMango, useSystemd)
if autostart {
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "") newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
} }
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil {
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
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 autostart {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) cd.startInLiveSession(deps.WindowManagerMango)
return result, result.Error
} }
result.Deployed = true result.Deployed = true
@@ -132,7 +136,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 +146,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 +164,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 +186,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")
+16
View File
@@ -150,3 +150,19 @@ func (cd *ConfigDeployer) systemdAutostart(wm deps.WindowManager, useSystemd boo
cd.log("DMS starts as dms.service with " + unit) cd.log("DMS starts as dms.service with " + unit)
return true return true
} }
// An already active session unit ignores a new wants link until next login, so a deploy
// from inside that session starts dms.service itself.
func (cd *ConfigDeployer) startInLiveSession(wm deps.WindowManager) {
if running, ok := RunningCompositor(); !ok || running != wm {
return
}
if systemctlUser("is-active", "--quiet", SessionUnitName(wm)) != nil {
return
}
if err := systemctlUser("start", "--no-block", "dms.service"); err != nil {
cd.log(fmt.Sprintf("Warning: failed to start dms.service: %v", err))
return
}
cd.log("Started dms.service in the running session")
}
+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)
+1 -5
View File
@@ -16,19 +16,15 @@ type Status struct {
Effective bool Effective bool
OverriddenBy int OverriddenBy int
StatusMessage string StatusMessage string
ConfigFormat string
ReadOnly bool
} }
func BuildStatus(scan IncludeScan, exists bool, entriesAfter int, format string, readOnly bool, msgs Messages) Status { func BuildStatus(scan IncludeScan, exists bool, entriesAfter int, msgs Messages) Status {
status := Status{ status := Status{
Exists: exists, Exists: exists,
Included: scan.DMSSeen, Included: scan.DMSSeen,
IncludePosition: scan.DMSPosition, IncludePosition: scan.DMSPosition,
TotalIncludes: scan.Count, TotalIncludes: scan.Count,
EntriesAfterDMS: entriesAfter, EntriesAfterDMS: entriesAfter,
ConfigFormat: format,
ReadOnly: readOnly,
} }
if !exists { if !exists {
+6 -9
View File
@@ -47,7 +47,7 @@ var allDomains = map[string]Messages{
func TestMissingFragmentIsNotEffective(t *testing.T) { func TestMissingFragmentIsNotEffective(t *testing.T) {
for domain, msgs := range allDomains { for domain, msgs := range allDomains {
status := BuildStatus(NewScan(), false, 0, "", false, msgs) status := BuildStatus(NewScan(), false, 0, msgs)
if status.Effective { if status.Effective {
t.Errorf("%s: a missing fragment must not be effective", domain) t.Errorf("%s: a missing fragment must not be effective", domain)
} }
@@ -64,7 +64,7 @@ func TestExistingButNotIncludedIsNotEffective(t *testing.T) {
for domain, msgs := range allDomains { for domain, msgs := range allDomains {
scan := NewScan() scan := NewScan()
scan.Count = 2 scan.Count = 2
status := BuildStatus(scan, true, 0, "", false, msgs) status := BuildStatus(scan, true, 0, msgs)
if status.Effective { if status.Effective {
t.Errorf("%s: an unincluded fragment must not be effective", domain) t.Errorf("%s: an unincluded fragment must not be effective", domain)
} }
@@ -77,7 +77,7 @@ func TestExistingButNotIncludedIsNotEffective(t *testing.T) {
func TestIncludedWithEntriesAfterReportsOverride(t *testing.T) { func TestIncludedWithEntriesAfterReportsOverride(t *testing.T) {
for domain, msgs := range allDomains { for domain, msgs := range allDomains {
scan := IncludeScan{Count: 3, DMSPosition: 2, DMSSeen: true} scan := IncludeScan{Count: 3, DMSPosition: 2, DMSSeen: true}
status := BuildStatus(scan, true, 5, "", false, msgs) status := BuildStatus(scan, true, 5, msgs)
if !status.Effective { if !status.Effective {
t.Errorf("%s: an overridden fragment is still effective", domain) t.Errorf("%s: an overridden fragment is still effective", domain)
} }
@@ -93,7 +93,7 @@ func TestIncludedWithEntriesAfterReportsOverride(t *testing.T) {
func TestIncludedWithNothingAfterIsActive(t *testing.T) { func TestIncludedWithNothingAfterIsActive(t *testing.T) {
for domain, msgs := range allDomains { for domain, msgs := range allDomains {
scan := IncludeScan{Count: 1, DMSPosition: 1, DMSSeen: true} scan := IncludeScan{Count: 1, DMSPosition: 1, DMSSeen: true}
status := BuildStatus(scan, true, 0, "", false, msgs) status := BuildStatus(scan, true, 0, msgs)
if !status.Effective { if !status.Effective {
t.Errorf("%s: an included fragment with nothing after it is effective", domain) t.Errorf("%s: an included fragment with nothing after it is effective", domain)
} }
@@ -106,9 +106,9 @@ func TestIncludedWithNothingAfterIsActive(t *testing.T) {
} }
} }
func TestScanAndFormatCarryThrough(t *testing.T) { func TestScanCarriesThrough(t *testing.T) {
scan := IncludeScan{Count: 4, DMSPosition: 3, DMSSeen: true} scan := IncludeScan{Count: 4, DMSPosition: 3, DMSSeen: true}
status := BuildStatus(scan, true, 0, "lua", true, hyprlandRules) status := BuildStatus(scan, true, 0, hyprlandRules)
if status.TotalIncludes != 4 || status.IncludePosition != 3 { if status.TotalIncludes != 4 || status.IncludePosition != 3 {
t.Errorf("includes = %d/%d, want 3/4", status.IncludePosition, status.TotalIncludes) t.Errorf("includes = %d/%d, want 3/4", status.IncludePosition, status.TotalIncludes)
@@ -116,7 +116,4 @@ func TestScanAndFormatCarryThrough(t *testing.T) {
if !status.Included { if !status.Included {
t.Error("DMSSeen must land on Included") t.Error("DMSSeen must land on Included")
} }
if status.ConfigFormat != "lua" || !status.ReadOnly {
t.Errorf("format/readOnly = %q/%v, want lua/true", status.ConfigFormat, status.ReadOnly)
}
} }
+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)
} }
} }
} }
+36 -3
View File
@@ -113,6 +113,39 @@ func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar")) return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
} }
// renamedPackages maps distro packages to their pre-rename names. rpm -q, dpkg-query and xbps-query
// ignore Provides, so hosts that have not upgraded since the rename only match the old name.
var renamedPackages = map[string]string{
"dux-greeter": "dms-greeter",
"dux-greeter-git": "dms-greeter-git",
"dux-search": "danksearch",
"dux-calendar": "dankcalendar",
"dux-calendar-git": "dankcalendar-git",
"dux-gop": "dgop",
}
func installedName(installed func(string) bool, pkg string) (string, bool) {
if installed(pkg) {
return pkg, true
}
if old, ok := renamedPackages[pkg]; ok && installed(old) {
return old, true
}
return "", false
}
func (b *BaseDistribution) detectGreeterPackage(installed func(string) bool) deps.Dependency {
_, gitInstalled := installedName(installed, "dux-greeter-git")
_, stableInstalled := installedName(installed, "dux-greeter")
dep := b.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || stableInstalled)
return withGitVariant(dep, gitInstalled)
}
func (b *BaseDistribution) detectCalendarPackage(installed func(string) bool) deps.Dependency {
_, gitInstalled := installedName(installed, "dux-calendar-git")
return withGitVariant(b.detectDankCalendar(), gitInstalled)
}
func withGitVariant(dep deps.Dependency, gitInstalled bool) deps.Dependency { func withGitVariant(dep deps.Dependency, gitInstalled bool) deps.Dependency {
dep.CanToggle = true dep.CanToggle = true
if gitInstalled { if gitInstalled {
@@ -502,7 +535,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 +574,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 +692,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)
} }
+54
View File
@@ -3,6 +3,7 @@ package distros
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/duxlabs-dev/dux-shell/core/internal/deps" "github.com/duxlabs-dev/dux-shell/core/internal/deps"
@@ -73,3 +74,56 @@ func TestParseHyprlandVersion(t *testing.T) {
} }
} }
} }
func TestDetectGreeterPackageAcceptsOldNames(t *testing.T) {
cases := []struct {
installed string
wantInstalled bool
wantVariant deps.PackageVariant
}{
{"", false, deps.VariantStable},
{"dms-greeter", true, deps.VariantStable},
{"dux-greeter", true, deps.VariantStable},
{"dms-greeter-git", true, deps.VariantGit},
{"dux-greeter-git", true, deps.VariantGit},
}
for _, tc := range cases {
dep := NewBaseDistribution(nil).detectGreeterPackage(func(p string) bool { return p == tc.installed })
if (dep.Status == deps.StatusInstalled) != tc.wantInstalled || dep.Variant != tc.wantVariant {
t.Errorf("%q installed: status %v variant %v, want installed=%v variant %v", tc.installed, dep.Status, dep.Variant, tc.wantInstalled, tc.wantVariant)
}
}
}
func TestDetectCalendarPackageAcceptsOldNames(t *testing.T) {
for installed, want := range map[string]deps.PackageVariant{
"": deps.VariantStable,
"dankcalendar-git": deps.VariantGit,
"dux-calendar-git": deps.VariantGit,
} {
dep := NewBaseDistribution(nil).detectCalendarPackage(func(p string) bool { return p == installed })
if dep.Variant != want {
t.Errorf("%q installed: variant %v, want %v", installed, dep.Variant, want)
}
}
}
func TestRenamedPackagesInstallNewNames(t *testing.T) {
cases := map[string]Distribution{
"fedora": NewFedoraDistribution(Registry["fedora"], nil),
"opensuse": NewOpenSUSEDistribution(Registry["opensuse-tumbleweed"], nil),
"debian": NewDebianDistribution(Registry["debian"], nil),
"ubuntu": NewUbuntuDistribution(Registry["ubuntu"], nil),
"void": NewVoidDistribution(Registry["void"], nil),
}
for id, d := range cases {
for _, variant := range []deps.PackageVariant{deps.VariantStable, deps.VariantGit} {
packages := d.GetPackageMappingWithVariants(deps.WindowManagerNiri, map[string]deps.PackageVariant{"dms-greeter": variant, "dankcalendar": variant})
for dep, want := range map[string]string{"dms-greeter": "dux-greeter", "danksearch": "dux-search", "dankcalendar": "dux-calendar"} {
if got := strings.TrimSuffix(packages[dep].Name, "-git"); got != want {
t.Errorf("%s %s (%v) installs %q, want %q", id, dep, variant, packages[dep].Name, want)
}
}
}
}
}
+73 -26
View File
@@ -62,7 +62,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, d.detectGit()) dependencies = append(dependencies, d.detectGit())
dependencies = append(dependencies, d.detectWindowManager(wm)) dependencies = append(dependencies, d.detectWindowManager(wm))
dependencies = append(dependencies, d.detectQuickshell()) dependencies = append(dependencies, d.detectQuickshell())
dependencies = append(dependencies, d.detectDMSGreeter()) dependencies = append(dependencies, d.detectGreeterPackage(d.packageInstalled))
dependencies = append(dependencies, d.detectXDGPortal()) dependencies = append(dependencies, d.detectXDGPortal())
dependencies = append(dependencies, d.detectAccountsService()) dependencies = append(dependencies, d.detectAccountsService())
@@ -77,7 +77,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, d.detectMatugen()) dependencies = append(dependencies, d.detectMatugen())
dependencies = append(dependencies, d.detectDanksearch()) dependencies = append(dependencies, d.detectDanksearch())
dependencies = append(dependencies, withGitVariant(d.detectDankCalendar(), d.packageInstalled("dankcalendar-git"))) dependencies = append(dependencies, d.detectCalendarPackage(d.packageInstalled))
return dependencies, nil return dependencies, nil
} }
@@ -94,12 +94,6 @@ func (d *DebianDistribution) detectAccountsService() deps.Dependency {
return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice")) return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice"))
} }
func (d *DebianDistribution) detectDMSGreeter() deps.Dependency {
gitInstalled := d.packageInstalled("dms-greeter-git")
dep := d.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || d.packageInstalled("dms-greeter"))
return withGitVariant(dep, gitInstalled)
}
func (d *DebianDistribution) packageInstalled(pkg string) bool { func (d *DebianDistribution) packageInstalled(pkg string) bool {
return debianPackageInstalledPrecisely(pkg) return debianPackageInstalledPrecisely(pkg)
} }
@@ -159,11 +153,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("dux-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: "dux-search", Repository: RepoTypeOBS, RepoURL: obsDeps},
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"), "dankcalendar": variantMapping("dux-calendar", variants["dankcalendar"], RepoTypeOBS, obsDeps),
} }
if wm == deps.WindowManagerNiri { if wm == deps.WindowManagerNiri {
@@ -177,14 +171,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 +238,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 +485,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 +556,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")
+230 -37
View File
@@ -2,9 +2,13 @@ package distros
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"maps"
"os/exec" "os/exec"
"slices"
"strings" "strings"
"sync"
"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"
@@ -23,9 +27,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 +55,9 @@ type FedoraDistribution struct {
*BaseDistribution *BaseDistribution
*ManualPackageInstaller *ManualPackageInstaller
config DistroConfig config DistroConfig
rpmInstalled func(pkg string) bool
ostreeRequested func() []string
} }
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution { func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
@@ -106,7 +110,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
} }
dependencies = append(dependencies, wmDep) dependencies = append(dependencies, wmDep)
dependencies = append(dependencies, f.detectQuickshell()) dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectDMSGreeter()) dependencies = append(dependencies, f.detectGreeterPackage(f.packageInstalled))
dependencies = append(dependencies, f.detectXDGPortal()) dependencies = append(dependencies, f.detectXDGPortal())
dependencies = append(dependencies, f.detectAccountsService()) dependencies = append(dependencies, f.detectAccountsService())
@@ -128,7 +132,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectMatugen()) dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDanksearch()) dependencies = append(dependencies, f.detectDanksearch())
dependencies = append(dependencies, withGitVariant(f.detectDankCalendar(), f.packageInstalled("dankcalendar-git"))) dependencies = append(dependencies, f.detectCalendarPackage(f.packageInstalled))
return dependencies, nil return dependencies, nil
} }
@@ -138,6 +142,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 +157,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 +191,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 +216,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 +228,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 +290,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 +298,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("dux-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: "dux-search", Repository: RepoTypeCOPR, RepoURL: coprDeps},
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, "avengemedia/danklinux"), "dankcalendar": variantMapping("dux-calendar", variants["dankcalendar"], RepoTypeCOPR, coprDeps),
} }
switch wm { switch wm {
@@ -244,10 +321,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 +334,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 {
@@ -308,12 +385,6 @@ func (f *FedoraDistribution) detectAccountsService() deps.Dependency {
} }
} }
func (f *FedoraDistribution) detectDMSGreeter() deps.Dependency {
gitInstalled := f.packageInstalled("dms-greeter-git")
dep := f.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || f.packageInstalled("dms-greeter"))
return withGitVariant(dep, gitInstalled)
}
func (f *FedoraDistribution) getPrerequisites() []string { func (f *FedoraDistribution) getPrerequisites() []string {
return []string{ return []string{
"dnf-plugins-core", "dnf-plugins-core",
@@ -633,7 +704,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 +718,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 +728,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 +815,125 @@ 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)
}
// rpm-ostree status runs over D-Bus and the layer view rebuilds its commands on every render.
var rpmOstreeRequested = sync.OnceValue(func() []string {
out, err := exec.Command("rpm-ostree", "status", "--json").Output()
if err != nil {
return nil
}
var status struct {
Deployments []struct {
Requested []string `json:"requested-packages"`
} `json:"deployments"`
}
if json.Unmarshal(out, &status) != nil || len(status.Deployments) == 0 {
return nil
}
return status.Deployments[0].Requested
})
func (f *FedoraDistribution) layeredRequests() map[string]bool {
list := f.ostreeRequested
if list == nil {
list = rpmOstreeRequested
}
requested := map[string]bool{}
for _, name := range list() {
requested[name] = true
}
return requested
}
// layeredName is what --uninstall must name: the request, which may still be the pre-rename name
// even when its Provides already resolved to the renamed package.
func layeredName(requested map[string]bool, pkg string) string {
if old, ok := renamedPackages[pkg]; ok && !requested[pkg] && requested[old] {
return old
}
return pkg
}
// 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{}
}
requested := f.layeredRequests()
// 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, layeredName(requested, 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"))
}
}
// A request keeps its old name forever and resolves by name before Provides, ignoring Obsoletes.
for _, name := range slices.Sorted(maps.Keys(renamedPackages)) {
old := renamedPackages[name]
if !requested[old] || slices.Contains(uninstall, old) {
continue
}
uninstall = append(uninstall, old)
if !requested[name] && !slices.Contains(pkgs, name) {
pkgs = append(pkgs, name)
}
}
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
}
+245
View File
@@ -0,0 +1,245 @@
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)
f.ostreeRequested = func() []string { return 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] }
f.ostreeRequested = func() []string { return []string{"dms", "dms-greeter"} }
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 dux-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 TestFedoraLayerCommandsRenamedRequests(t *testing.T) {
cases := []struct {
name string
rpm string
requested []string
want string
}{
{"old package and request", "dms-greeter", []string{"dms-greeter", "danksearch"}, "sudo rpm-ostree install dux-greeter-git dux-search --uninstall dms-greeter danksearch"},
{"old request resolved through Provides", "dux-greeter", []string{"dms-greeter"}, "sudo rpm-ostree install dux-greeter-git --uninstall dms-greeter"},
{"new request", "dux-greeter", []string{"dux-greeter"}, "sudo rpm-ostree install dux-greeter-git --uninstall dux-greeter"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := NewFedoraDistribution(Registry["fedora"], nil)
f.rpmInstalled = func(pkg string) bool { return pkg == tc.rpm }
f.ostreeRequested = func() []string { return tc.requested }
dependencies := []deps.Dependency{{Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit, CanToggle: true}}
got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, map[string]bool{"dms-greeter": true}, nil), "\n")
if !strings.Contains(got, tc.want) {
t.Fatalf("missing %q in:\n%s", tc.want, 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 -32
View File
@@ -75,7 +75,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
dependencies = append(dependencies, o.detectGit()) dependencies = append(dependencies, o.detectGit())
dependencies = append(dependencies, o.detectWindowManager(wm)) dependencies = append(dependencies, o.detectWindowManager(wm))
dependencies = append(dependencies, o.detectQuickshell()) dependencies = append(dependencies, o.detectQuickshell())
dependencies = append(dependencies, o.detectDMSGreeter()) dependencies = append(dependencies, o.detectGreeterPackage(o.packageInstalled))
dependencies = append(dependencies, o.detectXDGPortal()) dependencies = append(dependencies, o.detectXDGPortal())
dependencies = append(dependencies, o.detectAccountsService()) dependencies = append(dependencies, o.detectAccountsService())
@@ -92,7 +92,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
dependencies = append(dependencies, o.detectMatugen()) dependencies = append(dependencies, o.detectMatugen())
dependencies = append(dependencies, o.detectDanksearch()) dependencies = append(dependencies, o.detectDanksearch())
dependencies = append(dependencies, withGitVariant(o.detectDankCalendar(), o.packageInstalled("dankcalendar-git"))) dependencies = append(dependencies, o.detectCalendarPackage(o.packageInstalled))
return dependencies, nil return dependencies, nil
} }
@@ -132,10 +132,68 @@ 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) detectDMSGreeter() deps.Dependency { func (o *OpenSUSEDistribution) RepoMoves() []RepoMove {
gitInstalled := o.packageInstalled("dms-greeter-git") if !obsMigrationReady {
dep := o.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || o.packageInstalled("dms-greeter")) return nil
return withGitVariant(dep, gitInstalled) }
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) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
@@ -154,11 +212,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("dux-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: "dux-search", Repository: RepoTypeOBS, RepoURL: obsDeps},
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"), "dankcalendar": variantMapping("dux-calendar", variants["dankcalendar"], RepoTypeOBS, obsDeps),
} }
switch wm { switch wm {
@@ -179,28 +237,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 +548,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 +751,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)
} }
+60 -29
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,
} }
file, err := os.Open("/etc/os-release") // 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")
}
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)
}
}
+109 -24
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)
@@ -64,7 +70,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, u.detectGit()) dependencies = append(dependencies, u.detectGit())
dependencies = append(dependencies, u.detectWindowManager(wm)) dependencies = append(dependencies, u.detectWindowManager(wm))
dependencies = append(dependencies, u.detectQuickshell()) dependencies = append(dependencies, u.detectQuickshell())
dependencies = append(dependencies, u.detectDMSGreeter()) dependencies = append(dependencies, u.detectGreeterPackage(u.packageInstalled))
dependencies = append(dependencies, u.detectXDGPortal()) dependencies = append(dependencies, u.detectXDGPortal())
dependencies = append(dependencies, u.detectAccountsService()) dependencies = append(dependencies, u.detectAccountsService())
@@ -81,7 +87,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, u.detectMatugen()) dependencies = append(dependencies, u.detectMatugen())
dependencies = append(dependencies, u.detectDanksearch()) dependencies = append(dependencies, u.detectDanksearch())
dependencies = append(dependencies, withGitVariant(u.detectDankCalendar(), u.packageInstalled("dankcalendar-git"))) dependencies = append(dependencies, u.detectCalendarPackage(u.packageInstalled))
return dependencies, nil return dependencies, nil
} }
@@ -98,12 +104,6 @@ func (u *UbuntuDistribution) detectAccountsService() deps.Dependency {
return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice")) return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice"))
} }
func (u *UbuntuDistribution) detectDMSGreeter() deps.Dependency {
gitInstalled := u.packageInstalled("dms-greeter-git")
dep := u.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || u.packageInstalled("dms-greeter"))
return withGitVariant(dep, gitInstalled)
}
func (u *UbuntuDistribution) packageInstalled(pkg string) bool { func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
return debianPackageInstalledPrecisely(pkg) return debianPackageInstalledPrecisely(pkg)
} }
@@ -123,6 +123,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 +184,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("dux-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: "dux-search", Repository: RepoTypePPA, RepoURL: ppaDeps},
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, "ppa:avengemedia/danklinux"), "dankcalendar": variantMapping("dux-calendar", 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 +209,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 +310,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 +463,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)
+14 -5
View File
@@ -20,6 +20,7 @@ type VariantSwap struct {
type VariantSwapper interface { type VariantSwapper interface {
InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool)
SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error
packageInstalled(pkg string) bool
} }
type SwapStatus int type SwapStatus int
@@ -53,18 +54,26 @@ func PlanVariantSwap(d Distribution, wm deps.WindowManager, dep string, target d
case current == target: case current == target:
return VariantSwap{}, SwapAlreadyOnTarget return VariantSwap{}, SwapAlreadyOnTarget
case target == deps.VariantGit: case target == deps.VariantGit:
return VariantSwap{Dep: dep, From: stable, To: git}, SwapNeeded return VariantSwap{Dep: dep, From: installedAs(swapper, stable), To: git}, SwapNeeded
default: default:
return VariantSwap{Dep: dep, From: git, To: stable}, SwapNeeded return VariantSwap{Dep: dep, From: installedAs(swapper, git), To: stable}, SwapNeeded
} }
} }
// installedAs names the package the remove step must target, which is the old name on hosts not yet upgraded past a rename.
func installedAs(swapper VariantSwapper, m PackageMapping) PackageMapping {
if name, ok := installedName(swapper.packageInstalled, m.Name); ok {
m.Name = name
}
return m
}
// installedVariantByName works when the two variants ship under different package names. // installedVariantByName works when the two variants ship under different package names.
func installedVariantByName(installed func(string) bool, stable, git PackageMapping) (deps.PackageVariant, bool) { func installedVariantByName(installed func(string) bool, stable, git PackageMapping) (deps.PackageVariant, bool) {
switch { if _, ok := installedName(installed, packageBaseName(git.Name)); ok {
case installed(packageBaseName(git.Name)):
return deps.VariantGit, true return deps.VariantGit, true
case installed(packageBaseName(stable.Name)): }
if _, ok := installedName(installed, packageBaseName(stable.Name)); ok {
return deps.VariantStable, true return deps.VariantStable, true
} }
return deps.VariantStable, false return deps.VariantStable, false
+13 -8
View File
@@ -15,13 +15,15 @@ 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("dux-greeter", variants["dms-greeter"], RepoTypeCOPR, "duxlabs/dux-repo"),
"matugen": {Name: "matugen", Repository: RepoTypeSystem}, "matugen": {Name: "matugen", Repository: RepoTypeSystem},
} }
} }
func (f *swapFake) packageInstalled(pkg string) bool { return f.installed[pkg] }
func (f *swapFake) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) { func (f *swapFake) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
return installedVariantByName(func(p string) bool { return f.installed[p] }, stable, git) return installedVariantByName(f.packageInstalled, stable, git)
} }
func (f *swapFake) SwapVariants(_ context.Context, swaps []VariantSwap, _ string, _ chan<- InstallProgressMsg) error { func (f *swapFake) SwapVariants(_ context.Context, swaps []VariantSwap, _ string, _ chan<- InstallProgressMsg) error {
@@ -38,9 +40,12 @@ func TestPlanVariantSwap(t *testing.T) {
want SwapStatus want SwapStatus
from, to string from, to string
}{ }{
{"stable to git", "dms-greeter", "dms-greeter", deps.VariantGit, SwapNeeded, "dms-greeter", "dms-greeter-git"}, {"stable to git", "dms-greeter", "dux-greeter", deps.VariantGit, SwapNeeded, "dux-greeter", "dux-greeter-git"},
{"git to stable", "dms-greeter", "dms-greeter-git", deps.VariantStable, SwapNeeded, "dms-greeter-git", "dms-greeter"}, {"git to stable", "dms-greeter", "dux-greeter-git", deps.VariantStable, SwapNeeded, "dux-greeter-git", "dux-greeter"},
{"already on target", "dms-greeter", "dms-greeter-git", deps.VariantGit, SwapAlreadyOnTarget, "", ""}, {"stable to git from the pre-rename package", "dms-greeter", "dms-greeter", deps.VariantGit, SwapNeeded, "dms-greeter", "dux-greeter-git"},
{"git to stable from the pre-rename package", "dms-greeter", "dms-greeter-git", deps.VariantStable, SwapNeeded, "dms-greeter-git", "dux-greeter"},
{"already on target", "dms-greeter", "dux-greeter-git", deps.VariantGit, SwapAlreadyOnTarget, "", ""},
{"already on target under the pre-rename name", "dms-greeter", "dms-greeter-git", deps.VariantGit, SwapAlreadyOnTarget, "", ""},
{"not installed", "dms-greeter", "", deps.VariantGit, SwapNotInstalled, "", ""}, {"not installed", "dms-greeter", "", deps.VariantGit, SwapNotInstalled, "", ""},
{"same mapping both ways", "matugen", "matugen", deps.VariantGit, SwapNoVariant, "", ""}, {"same mapping both ways", "matugen", "matugen", deps.VariantGit, SwapNoVariant, "", ""},
} }
@@ -56,7 +61,7 @@ func TestPlanVariantSwap(t *testing.T) {
} }
func TestSwapReinstalledVariantsSkipsSwappedDeps(t *testing.T) { func TestSwapReinstalledVariantsSkipsSwappedDeps(t *testing.T) {
d := &swapFake{installed: map[string]bool{"dms-greeter": true}} d := &swapFake{installed: map[string]bool{"dux-greeter": true}}
dependencies := []deps.Dependency{ dependencies := []deps.Dependency{
{Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit}, {Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit},
{Name: "matugen", Status: deps.StatusInstalled}, {Name: "matugen", Status: deps.StatusInstalled},
@@ -68,8 +73,8 @@ func TestSwapReinstalledVariantsSkipsSwappedDeps(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(d.swapped) != 1 || d.swapped[0].To.Name != "dms-greeter-git" { if len(d.swapped) != 1 || d.swapped[0].To.Name != "dux-greeter-git" {
t.Fatalf("swapped = %+v, want only dms-greeter → dms-greeter-git", d.swapped) t.Fatalf("swapped = %+v, want only dux-greeter → dux-greeter-git", d.swapped)
} }
if !skip["dms-greeter"] || skip["matugen"] { if !skip["dms-greeter"] || skip["matugen"] {
t.Fatalf("skip = %v, want the swapped greeter skipped and matugen reinstalled", skip) t.Fatalf("skip = %v, want the swapped greeter skipped and matugen reinstalled", skip)
+107 -15
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"
) )
@@ -68,7 +69,7 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, v.detectGit()) dependencies = append(dependencies, v.detectGit())
dependencies = append(dependencies, v.detectWindowManager(wm)) dependencies = append(dependencies, v.detectWindowManager(wm))
dependencies = append(dependencies, v.detectQuickshell()) dependencies = append(dependencies, v.detectQuickshell())
dependencies = append(dependencies, v.detectDMSGreeter()) dependencies = append(dependencies, v.detectGreeterPackage(v.packageInstalled))
dependencies = append(dependencies, v.detectXDGPortal()) dependencies = append(dependencies, v.detectXDGPortal())
dependencies = append(dependencies, v.detectAccountsService()) dependencies = append(dependencies, v.detectAccountsService())
dependencies = append(dependencies, v.detectDBus()) dependencies = append(dependencies, v.detectDBus())
@@ -128,12 +129,6 @@ func (v *VoidDistribution) detectXDGPortal() deps.Dependency {
return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk")) return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk"))
} }
func (v *VoidDistribution) detectDMSGreeter() deps.Dependency {
gitInstalled := v.packageInstalled("dms-greeter-git")
dep := v.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || v.packageInstalled("dms-greeter"))
return withGitVariant(dep, gitInstalled)
}
func (v *VoidDistribution) detectAccountsService() deps.Dependency { func (v *VoidDistribution) detectAccountsService() deps.Dependency {
return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice")) return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice"))
} }
@@ -193,6 +188,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 +312,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("dux-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDepsRepo),
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo}, "danksearch": {Name: "dux-search", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo},
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo}, "dankcalendar": {Name: "dux-calendar", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo},
} }
switch wm { switch wm {
@@ -521,8 +613,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:
+129
View File
@@ -0,0 +1,129 @@
package gitssh
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"github.com/go-git/go-git/v6/plumbing/client"
"github.com/go-git/go-git/v6/plumbing/transport"
xssh "github.com/go-git/go-git/v6/plumbing/transport/ssh"
"github.com/go-git/go-git/v6/plumbing/transport/ssh/knownhosts"
"github.com/kevinburke/ssh_config"
gossh "golang.org/x/crypto/ssh"
)
func ClientOptions() []client.Option {
return []client.Option{client.WithSSHAuth(agentAuth{})}
}
type agentAuth struct{}
func (agentAuth) ClientConfig(ctx context.Context, req *transport.Request) (*gossh.ClientConfig, error) {
auth, err := xssh.NewSSHAgentAuth(username(req.URL))
if err != nil {
return nil, err
}
db, err := knownHostsDB()
if err != nil {
return nil, err
}
auth.HostKeyCallback = db.HostKeyCallback()
cfg, err := auth.ClientConfig(ctx, req)
if err != nil {
return nil, err
}
cfg.HostKeyAlgorithms = db.HostKeyAlgorithms(hostWithPort(req.URL))
if len(cfg.HostKeyAlgorithms) == 0 {
cfg.HostKeyAlgorithms = gossh.SupportedAlgorithms().HostKeys
}
return cfg, nil
}
func username(u *url.URL) string {
if u.User == nil || u.User.Username() == "" {
return xssh.DefaultUsername
}
return u.User.Username()
}
func hostWithPort(u *url.URL) string {
hostname := u.Hostname()
port := u.Port()
if configHost := ssh_config.Get(hostname, "Hostname"); configHost != "" {
hostname = configHost
}
if port == "" {
if configPort := ssh_config.Get(u.Hostname(), "Port"); configPort != "" {
if _, err := strconv.Atoi(configPort); err == nil {
port = configPort
}
}
}
if port == "" {
port = strconv.Itoa(xssh.DefaultPort)
}
return net.JoinHostPort(hostname, port)
}
// x/crypto rejects a whole known_hosts file over one key type it cannot parse,
// so entries like ssh-mldsa44-ed25519 are blanked before the file is loaded.
func knownHostsDB() (*knownhosts.HostKeyDB, error) {
dir, err := os.MkdirTemp("", "dms-known-hosts-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
var filtered []string
for i, file := range knownHostsFiles() {
content, err := os.ReadFile(file)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
path := filepath.Join(dir, fmt.Sprintf("%d-%s", i, filepath.Base(file)))
if err := os.WriteFile(path, blankUnparseableLines(content), 0o600); err != nil {
return nil, err
}
filtered = append(filtered, path)
}
if len(filtered) == 0 {
return nil, errors.New("unable to find any valid known_hosts file, set SSH_KNOWN_HOSTS env variable")
}
return knownhosts.NewDB(filtered...)
}
func knownHostsFiles() []string {
if files := filepath.SplitList(os.Getenv("SSH_KNOWN_HOSTS")); len(files) != 0 {
return files
}
home, err := os.UserHomeDir()
if err != nil {
return []string{"/etc/ssh/ssh_known_hosts"}
}
return []string{filepath.Join(home, ".ssh", "known_hosts"), "/etc/ssh/ssh_known_hosts"}
}
func blankUnparseableLines(content []byte) []byte {
lines := bytes.Split(content, []byte("\n"))
for i, line := range lines {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 || trimmed[0] == '#' {
continue
}
if _, _, _, _, _, err := gossh.ParseKnownHosts(trimmed); err != nil {
lines[i] = nil
}
}
return bytes.Join(lines, []byte("\n"))
}
+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
}
+36 -50
View File
@@ -1,6 +1,7 @@
package providers package providers
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -17,8 +18,6 @@ type HyprlandProvider struct {
configPath string configPath string
dmsBindsIncluded bool dmsBindsIncluded bool
parsed bool parsed bool
// hyprlang configs (0.55/0.56) reject hl.dsp text, so binds carry no LuaAction there
luaConfig bool
} }
func NewHyprlandProvider(configPath string) *HyprlandProvider { func NewHyprlandProvider(configPath string) *HyprlandProvider {
@@ -50,7 +49,6 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
h.dmsBindsIncluded = result.DMSBindsIncluded h.dmsBindsIncluded = result.DMSBindsIncluded
h.parsed = true h.parsed = true
h.luaConfig = result.ConfigFormat == "lua"
categorizedBinds := make(map[string][]keybinds.Keybind) categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys) h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys)
@@ -92,7 +90,7 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
} }
for _, kb := range section.Keybinds { for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher) category := h.categorizeBind(&kb)
bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys) bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
categorizedBinds[category] = append(categorizedBinds[category], bind) categorizedBinds[category] = append(categorizedBinds[category], bind)
} }
@@ -102,6 +100,19 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
} }
} }
var luaFunctionActionPattern = regexp.MustCompile(`^(function\s*\(|[A-Za-z_][A-Za-z0-9_.]*$)`)
// A function or named-function action says nothing about what it does; its description does.
func (h *HyprlandProvider) categorizeBind(kb *HyprlandKeyBinding) string {
if kb.Comment == "" || kb.Params != "" || !luaFunctionActionPattern.MatchString(kb.Dispatcher) {
return h.categorizeByDispatcher(kb.Dispatcher)
}
if _, known := luaActionStringFromHyprlangAction(kb.Dispatcher); known {
return h.categorizeByDispatcher(kb.Dispatcher)
}
return h.categorizeByDispatcher(strings.ToLower(kb.Comment))
}
func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
switch { switch {
case dispatcher == "exec" || strings.HasPrefix(dispatcher, "hl.dsp.exec_cmd("): case dispatcher == "exec" || strings.HasPrefix(dispatcher, "hl.dsp.exec_cmd("):
@@ -122,7 +133,7 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
dispatcher == "splitratio" || dispatcher == "splitratio" ||
dispatcher == "resizeactive": dispatcher == "resizeactive":
return "Window" return "Window"
case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"): case dispatcher == "exit" || dispatcher == "reloadconfig" || strings.Contains(dispatcher, "dpms"):
return "System" return "System"
default: default:
return "Other" return "Other"
@@ -159,11 +170,9 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
Flags: kb.Flags, Flags: kb.Flags,
HasDefault: hasDefault, HasDefault: hasDefault,
} }
if h.luaConfig {
if expr, ok := luaActionStringFromHyprlangAction(rawAction); ok { if expr, ok := luaActionStringFromHyprlangAction(rawAction); ok {
bind.LuaAction = expr bind.LuaAction = expr
} }
}
if (source == "dms" || source == "dms-default") && conflicts != nil { if (source == "dms" || source == "dms-default") && conflicts != nil {
normalizedKey := strings.ToLower(keyStr) normalizedKey := strings.ToLower(keyStr)
@@ -374,31 +383,21 @@ type hyprlandOverrideBind struct {
RawLuaAction bool RawLuaAction bool
} }
// Include repair would write a stub hyprland.lua, replacing a conf-only user's whole config.
func (h *HyprlandProvider) ensureWritableConfig() error { func (h *HyprlandProvider) ensureWritableConfig() error {
if h.isLegacyConfigReadOnly() { _, err := hyprlandMainConfigPath(h.configPath)
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing keybinds") switch {
} case err == nil:
return nil return nil
case errors.Is(err, os.ErrNotExist):
return fmt.Errorf("hyprland.lua not found in %s; run `dms setup` to migrate before editing keybinds: %w", h.configPath, err)
default:
return fmt.Errorf("cannot read hyprland.lua in %s: %w", h.configPath, err)
} }
func (h *HyprlandProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
expanded = h.configPath
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
} }
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
return readLuaOrHyprlangOverride(h.GetOverridePath()) return readLuaOverride(h.GetOverridePath())
} }
func canonicalHyprlandOverrideKey(key string) string { func canonicalHyprlandOverrideKey(key string) string {
@@ -1098,13 +1097,22 @@ func luaActionStringFromHyprlangAction(action string) (string, bool) {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true
} }
case "lockgroups": case "lockgroups":
return dispatcherToggleTableCall("hl.dsp.group.lock", params), true // 0.57 locks only the active group without all; 0.56 ignores the field
return luaDispatcherTableCall("hl.dsp.group.lock", luaBoolField("all", true), luaStringField("action", luaToggleActionValue(params))), true
case "lockactivegroup": case "lockactivegroup":
lockArg, window := firstParam(params)
if window != "" {
return luaDispatcherTableCall("hl.dsp.group.lock", luaStringField("action", luaToggleActionValue(lockArg)), luaStringField("window", window)), true
}
// lock_active is deprecated on 0.57 but still the only active-group lock on 0.56
return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true
case "denywindowfromgroup": case "denywindowfromgroup":
return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true
case "forcerendererreload": case "forcerendererreload":
return `hl.dsp.force_renderer_reload()`, true return `hl.dsp.force_renderer_reload()`, true
case "reloadconfig":
// hl.dsp.reload_config is 0.57-only; calling nil on 0.55/0.56 aborts loading the file
return `hl.dsp.exec_cmd("hyprctl reload")`, true
case "forceidle": case "forceidle":
if params != "" && isBareLuaNumber(params) { if params != "" && isBareLuaNumber(params) {
return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true
@@ -1231,7 +1239,7 @@ func luaKeyComboToInternalKey(combo string) string {
return strings.Join(parts, "+") return strings.Join(parts, "+")
} }
func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) { func readLuaOverride(path string) (map[string]*hyprlandOverrideBind, error) {
binds := make(map[string]*hyprlandOverrideBind) binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -1241,7 +1249,6 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
return nil, err return nil, err
} }
lines := expandLuaConfigLines(strings.Split(string(data), "\n")) lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
parser := NewHyprlandParser("")
pendingUnbinds := make(map[string]string) pendingUnbinds := make(map[string]string)
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@@ -1257,28 +1264,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
normalizedKey := hyprlandOverrideMapKey(kb.Key) normalizedKey := hyprlandOverrideMapKey(kb.Key)
binds[normalizedKey] = kb binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey) delete(pendingUnbinds, normalizedKey)
continue
} }
if !strings.HasPrefix(line, "bind") {
continue
}
kb := parser.parseBindLine(line)
if kb == nil {
continue
}
action := kb.Dispatcher
if kb.Params != "" {
action = kb.Dispatcher + " " + kb.Params
}
keyStr := canonicalHyprlandOverrideKey(parser.formatBindKey(kb))
normalizedKey := hyprlandOverrideMapKey(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: kb.Comment,
Flags: kb.Flags,
}
delete(pendingUnbinds, normalizedKey)
} }
for normKey, origKey := range pendingUnbinds { for normKey, origKey := range pendingUnbinds {
binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true} binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true}
@@ -39,7 +39,43 @@ func expandLuaConfigLines(lines []string) []string {
func expandLuaConfigLinesEnv(lines []string) ([]string, luaVarEnv) { func expandLuaConfigLinesEnv(lines []string) ([]string, luaVarEnv) {
env := luaVarEnv{} env := luaVarEnv{}
return expandLuaBlock(lines, env), env return expandLuaBlock(joinLuaBindSpans(lines), env), env
}
// The bind parser is line based; fold wrapped hl.bind( calls, keeping only the closing line's comment.
func joinLuaBindSpans(lines []string) []string {
out := make([]string, 0, len(lines))
for i := 0; i < len(lines); i++ {
masked := luaMaskStrings(lines[i])
depth := luaParenDelta(masked)
if depth <= 0 || !strings.Contains(masked, "hl.bind") {
out = append(out, lines[i])
continue
}
parts := []string{strings.TrimSpace(luaStripLineComment(lines[i]))}
j := i + 1
for ; j < len(lines) && depth > 0; j++ {
depth += luaParenDelta(luaMaskStrings(lines[j]))
part := lines[j]
if depth > 0 {
part = luaStripLineComment(part)
}
if part = strings.TrimSpace(part); part != "" {
parts = append(parts, part)
}
}
if depth > 0 {
out = append(out, lines[i])
continue
}
out = append(out, strings.Join(parts, " "))
i = j - 1
}
return out
}
func luaParenDelta(masked string) int {
return strings.Count(masked, "(") - strings.Count(masked, ")")
} }
func expandLuaBlock(lines []string, env luaVarEnv) []string { func expandLuaBlock(lines []string, env luaVarEnv) []string {
@@ -5,7 +5,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -13,14 +12,6 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
) )
const (
TitleRegex = "#+!"
HideComment = "[hidden]"
CommentBindPattern = "#/#"
)
var ModSeparators = []rune{'+', ' '}
type HyprlandKeyBinding struct { type HyprlandKeyBinding struct {
Mods []string `json:"mods"` Mods []string `json:"mods"`
Key string `json:"key"` Key string `json:"key"`
@@ -39,8 +30,6 @@ type HyprlandSection struct {
} }
type HyprlandParser struct { type HyprlandParser struct {
contentLines []string
readingLine int
configDir string configDir string
currentSource string currentSource string
dmsBindsExists bool dmsBindsExists bool
@@ -54,20 +43,15 @@ type HyprlandParser struct {
processedFiles map[string]bool processedFiles map[string]bool
dmsProcessed bool dmsProcessed bool
removedKeys map[string]bool // hl.unbind targets removedKeys map[string]bool // hl.unbind targets
// rebindSources maps a removed key to the files that unbind and then rebind // Files that unbind then rebind a removed key; only their own bind survives the removal.
// it. Their own bind survives the removal; binds from anywhere else do not.
rebindSources map[string]map[string]bool rebindSources map[string]map[string]bool
fileUnbinds map[string]bool // hl.unbind targets seen so far in the file being parsed fileUnbinds map[string]bool // hl.unbind targets seen so far in the file being parsed
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf} defaultDMSKeys map[string]bool // keys present in dms/binds.lua
configFormat string
readOnly bool
mainMod string mainMod string
} }
func NewHyprlandParser(configDir string) *HyprlandParser { func NewHyprlandParser(configDir string) *HyprlandParser {
return &HyprlandParser{ return &HyprlandParser{
contentLines: []string{},
readingLine: 0,
configDir: configDir, configDir: configDir,
walker: configfrag.NewWalker(isDMSBindsPrimarySourcePath), walker: configfrag.NewWalker(isDMSBindsPrimarySourcePath),
dmsBindKeys: make(map[string]bool), dmsBindKeys: make(map[string]bool),
@@ -82,47 +66,6 @@ func NewHyprlandParser(configDir string) *HyprlandParser {
} }
} }
func (p *HyprlandParser) ReadContent(directory string) error {
expandedDir, err := utils.ExpandPath(directory)
if err != nil {
return err
}
info, err := os.Stat(expandedDir)
if err != nil {
return err
}
if !info.IsDir() {
return os.ErrNotExist
}
confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf"))
if err != nil {
return err
}
if len(confFiles) == 0 {
return os.ErrNotExist
}
var combinedContent []string
for _, confFile := range confFiles {
if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() {
data, err := os.ReadFile(confFile)
if err == nil {
combinedContent = append(combinedContent, string(data))
}
}
}
if len(combinedContent) == 0 {
return os.ErrNotExist
}
fullContent := strings.Join(combinedContent, "\n")
p.contentLines = strings.Split(fullContent, "\n")
return nil
}
var hyprlandDirectionWords = map[string]string{ var hyprlandDirectionWords = map[string]string{
"l": "left", "left": "left", "l": "left", "left": "left",
"r": "right", "right": "right", "r": "right", "right": "right",
@@ -401,95 +344,24 @@ func describeHyprlandMove(params string) string {
return "Move window " + strings.Join(parts, ", ") return "Move window " + strings.Join(parts, ", ")
} }
func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding {
line := p.contentLines[lineNumber]
return p.parseBindLine(line)
}
func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection {
titleRegex := regexp.MustCompile(TitleRegex)
for p.readingLine < len(p.contentLines) {
line := p.contentLines[p.readingLine]
loc := titleRegex.FindStringIndex(line)
if loc != nil && loc[0] == 0 {
headingScope := strings.Index(line, "!")
if headingScope <= scope {
p.readingLine--
return currentContent
}
sectionName := strings.TrimSpace(line[headingScope+1:])
p.readingLine++
childSection := &HyprlandSection{
Children: []HyprlandSection{},
Keybinds: []HyprlandKeyBinding{},
Name: sectionName,
}
result := p.getBindsRecursive(childSection, headingScope)
currentContent.Children = append(currentContent.Children, *result)
} else if strings.HasPrefix(line, CommentBindPattern) {
keybind := p.getKeybindAtLine(p.readingLine)
if keybind != nil {
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
}
} else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") {
} else {
keybind := p.getKeybindAtLine(p.readingLine)
if keybind != nil {
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
}
}
p.readingLine++
}
return currentContent
}
func (p *HyprlandParser) ParseKeys() *HyprlandSection {
p.readingLine = 0
rootSection := &HyprlandSection{
Children: []HyprlandSection{},
Keybinds: []HyprlandKeyBinding{},
Name: "",
}
return p.getBindsRecursive(rootSection, 0)
}
func ParseHyprlandKeys(path string) (*HyprlandSection, error) {
parser := NewHyprlandParser(path)
if err := parser.ReadContent(path); err != nil {
return nil, err
}
return parser.ParseKeys(), nil
}
type HyprlandParseResult struct { type HyprlandParseResult struct {
Section *HyprlandSection Section *HyprlandSection
DMSBindsIncluded bool DMSBindsIncluded bool
DMSStatus *configfrag.Status DMSStatus *configfrag.Status
ConflictingConfigs map[string]*HyprlandKeyBinding ConflictingConfigs map[string]*HyprlandKeyBinding
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf} DefaultDMSKeys map[string]bool // keys with a DMS default in binds.lua
ConfigFormat string // "lua" or "hyprlang"
MainMod string // value of the mainMod Lua variable when the config defines one MainMod string // value of the mainMod Lua variable when the config defines one
} }
var hyprlandBindsMessages = configfrag.Messages{ var hyprlandBindsMessages = configfrag.Messages{
Missing: "dms/binds.lua (or legacy binds.conf) does not exist", Missing: "dms/binds.lua does not exist",
NotIncluded: "dms binds are not loaded from Hyprland config (require / source)", NotIncluded: "dms binds are not loaded from Hyprland config (require)",
Overridden: "Some DMS binds may be overridden by config binds", Overridden: "Some DMS binds may be overridden by config binds",
Active: "DMS binds are active", Active: "DMS binds are active",
} }
func (p *HyprlandParser) buildDMSStatus() *configfrag.Status { func (p *HyprlandParser) buildDMSStatus() *configfrag.Status {
status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, p.configFormat, p.readOnly, hyprlandBindsMessages) status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, hyprlandBindsMessages)
return &status return &status
} }
@@ -543,28 +415,15 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
return nil, err return nil, err
} }
dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua") dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.lua")
dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf") if _, err := os.Stat(dmsBindsPath); err == nil {
dmsBindsPath := ""
if _, err := os.Stat(dmsBindsLua); err == nil {
p.dmsBindsExists = true p.dmsBindsExists = true
dmsBindsPath = dmsBindsLua
} else if _, err := os.Stat(dmsBindsConf); err == nil {
p.dmsBindsExists = true
dmsBindsPath = dmsBindsConf
} }
mainConfig, err := hyprlandMainConfigPath(p.configDir) mainConfig, err := hyprlandMainConfigPath(p.configDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
} else {
p.configFormat = "hyprlang"
p.readOnly = true
}
section, err := p.parseFileWithSource(mainConfig, "") section, err := p.parseFileWithSource(mainConfig, "")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -649,58 +508,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp
return nil, err return nil, err
} }
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName) return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
} }
prevSource := p.currentSource
p.currentSource = absPath
section := &HyprlandSection{Name: sectionName}
lines := strings.SplitSeq(string(data), "\n")
for line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, section, filepath.Dir(absPath))
continue
}
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.parseBindLine(line)
if kb == nil {
continue
}
kb.Source = p.currentSource
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
return section, nil
}
func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) {
matched := p.walker.IncludeAssignment(baseDir, line, func(absPath string) error {
includedSection, err := p.parseFileWithSource(absPath, "")
if err != nil {
return err
}
section.Children = append(section.Children, *includedSection)
return nil
})
if matched {
p.dmsProcessed = true
}
}
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) { func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
if strings.EqualFold(filepath.Ext(dmsBindsPath), ".lua") {
sub, err := p.parseLuaLinesFromPath(dmsBindsPath) sub, err := p.parseLuaLinesFromPath(dmsBindsPath)
if err != nil { if err != nil {
return return
@@ -708,36 +519,6 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp
section.Keybinds = append(section.Keybinds, sub.Keybinds...) section.Keybinds = append(section.Keybinds, sub.Keybinds...)
section.Children = append(section.Children, sub.Children...) section.Children = append(section.Children, sub.Children...)
p.dmsProcessed = true p.dmsProcessed = true
return
}
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return
}
prevSource := p.currentSource
p.currentSource = dmsBindsPath
lines := strings.SplitSeq(string(data), "\n")
for line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.parseBindLine(line)
if kb == nil {
continue
}
kb.Source = dmsBindsPath
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
p.dmsProcessed = true
} }
func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) { func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) {
@@ -930,110 +711,6 @@ func luaBindOptDescription(optSuffix string) string {
return luaTableStringField(optSuffix, "description") return luaTableStringField(optSuffix, "description")
} }
// Hyprland 0.56 field order: `bind[flags] = MODS, key, [device,] [description,] dispatcher, params`.
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return nil
}
var flags string
if bindType := strings.TrimSpace(parts[0]); bindType != CommentBindPattern {
var ok bool
if flags, ok = extractBindFlags(bindType); !ok {
return nil
}
}
keys, comment, _ := strings.Cut(parts[1], "#")
comment = strings.TrimSpace(comment)
dispatcherIndex := 2
descIndex := -1
if strings.Contains(flags, "k") {
dispatcherIndex++
}
if strings.Contains(flags, "d") {
descIndex = dispatcherIndex
dispatcherIndex++
}
keyFields := strings.SplitN(keys, ",", dispatcherIndex+2)
if len(keyFields) <= dispatcherIndex {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
dispatcher := strings.TrimSpace(keyFields[dispatcherIndex])
var params string
if len(keyFields) > dispatcherIndex+1 {
params = strings.TrimSpace(keyFields[dispatcherIndex+1])
}
if descIndex >= 0 && comment == "" {
comment = strings.TrimSpace(keyFields[descIndex])
}
if strings.HasPrefix(comment, HideComment) {
return nil
}
if comment == "" {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
var modList []string
if mods != "" {
modstring := mods + string(ModSeparators[0])
idx := 0
for index, char := range modstring {
if slices.Contains(ModSeparators, char) {
if index-idx > 1 {
modList = append(modList, modstring[idx:index])
}
idx = index + 1
}
}
}
return &HyprlandKeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
Flags: flags,
}
}
// ok is false for `binds:...` and for any letter Hyprland rejects, since that drops the whole bind.
func extractBindFlags(bindType string) (string, bool) {
flags, ok := strings.CutPrefix(strings.TrimSpace(bindType), "bind")
if !ok {
return "", false
}
for i := 0; i < len(flags); i++ {
if !isHyprlangBindFlag(flags[i]) {
return "", false
}
}
return flags, true
}
// s (multi-key), k (device field) and d (description field) exist only in hyprlang.
func isHyprlangBindFlag(c byte) bool {
switch c {
case 's', 'k', 'd':
return true
}
for _, fo := range hyprlandBindFlagOptions {
if fo.flag == c {
return true
}
}
return false
}
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) { func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
parser := NewHyprlandParser(path) parser := NewHyprlandParser(path)
section, err := parser.ParseWithDMS() section, err := parser.ParseWithDMS()
@@ -1047,7 +724,6 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
DMSStatus: parser.buildDMSStatus(), DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs, ConflictingConfigs: parser.conflictingConfigs,
DefaultDMSKeys: parser.defaultDMSKeys, DefaultDMSKeys: parser.defaultDMSKeys,
ConfigFormat: parser.configFormat,
MainMod: parser.mainMod, MainMod: parser.mainMod,
}, nil }, nil
} }
@@ -1261,6 +937,9 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if after, ok := strings.CutPrefix(u, "hyprctl dispatch "); ok { if after, ok := strings.CutPrefix(u, "hyprctl dispatch "); ok {
return splitDispatchCommand(strings.TrimSpace(after)) return splitDispatchCommand(strings.TrimSpace(after))
} }
if strings.TrimSpace(u) == "hyprctl reload" {
return "reloadconfig", ""
}
return "exec", u return "exec", u
} }
} }
@@ -1355,7 +1034,13 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
case strings.HasPrefix(expr, "hl.dsp.group.lock_active("): case strings.HasPrefix(expr, "hl.dsp.group.lock_active("):
return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action")) return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.group.lock("): case strings.HasPrefix(expr, "hl.dsp.group.lock("):
return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action")) lockArg := luaToggleActionToLockArg(luaTableStringField(expr, "action"))
window := luaTableStringField(expr, "window")
// Before 0.57 group.lock locked every group and ignored all; 0.57 locks only the active one without it
if all, _ := luaTableBoolField(expr, "all"); window == "" && (all || !luaconfig.HyprlandAtLeast(0, 57)) {
return "lockgroups", lockArg
}
return joinDispatcherParams("lockactivegroup", lockArg, window)
case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("): case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("):
return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action")) return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.focus("): case strings.HasPrefix(expr, "hl.dsp.focus("):
@@ -1536,6 +1221,8 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
return "releaseinputcapture", "" return "releaseinputcapture", ""
case strings.Contains(expr, "hl.dsp.force_renderer_reload()"): case strings.Contains(expr, "hl.dsp.force_renderer_reload()"):
return "forcerendererreload", "" return "forcerendererreload", ""
case strings.Contains(expr, "hl.dsp.reload_config()"):
return "reloadconfig", ""
case strings.HasPrefix(expr, "hl.dsp.force_idle("): case strings.HasPrefix(expr, "hl.dsp.force_idle("):
return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle") return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle")
case strings.Contains(expr, "hl.dsp.exit()"): case strings.Contains(expr, "hl.dsp.exit()"):
@@ -1767,13 +1454,7 @@ func isDMSBindsUserOverridePath(p string) bool {
func isDMSBindsPrimarySourcePath(p string) bool { func isDMSBindsPrimarySourcePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p)) p = filepath.ToSlash(strings.TrimSpace(p))
if strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") || p == "dms/binds.lua" || p == "./dms/binds.lua" { return strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua")
return true
}
if strings.Contains(p, "/dms/binds.conf") || strings.HasSuffix(p, "dms/binds.conf") {
return true
}
return p == "dms/binds.conf" || p == "./dms/binds.conf"
} }
func hyprlandMainConfigPath(dir string) (string, error) { func hyprlandMainConfigPath(dir string) (string, error) {
@@ -1782,12 +1463,12 @@ func hyprlandMainConfigPath(dir string) (string, error) {
return "", err return "", err
} }
luaPath := filepath.Join(expandedDir, "hyprland.lua") luaPath := filepath.Join(expandedDir, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() { st, err := os.Stat(luaPath)
return luaPath, nil if err != nil {
} return "", err
confPath := filepath.Join(expandedDir, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return confPath, nil
} }
if !st.Mode().IsRegular() {
return "", os.ErrNotExist return "", os.ErrNotExist
} }
return luaPath, nil
}
@@ -1,6 +1,7 @@
package providers package providers
import ( import (
"github.com/duxlabs-dev/dux-shell/core/internal/luaconfig"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -96,8 +97,11 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
{`hl.dsp.group.prev()`, "changegroupactive", "b"}, {`hl.dsp.group.prev()`, "changegroupactive", "b"},
{`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"}, {`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"},
{`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"}, {`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"},
{`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"}, {`hl.dsp.group.lock({ all = true, action = "on" })`, "lockgroups", "lock"},
{`hl.dsp.group.lock({ action = "off", window = "class:^(kitty)$" })`, "lockactivegroup", "unlock class:^(kitty)$"},
{`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"}, {`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"},
{`hl.dsp.reload_config()`, "reloadconfig", ""},
{`hl.dsp.exec_cmd("hyprctl reload")`, "reloadconfig", ""},
{`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"}, {`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"},
{`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"}, {`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"},
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"}, {`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
@@ -202,8 +206,10 @@ func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
{"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`}, {"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`},
{"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`}, {"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`},
{"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`}, {"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`},
{"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`}, {"lockgroups lock", `hl.dsp.group.lock({ all = true, action = "on" })`},
{"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`}, {"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`},
{"lockactivegroup lock class:^(kitty)$", `hl.dsp.group.lock({ action = "on", window = "class:^(kitty)$" })`},
{"reloadconfig", `hl.dsp.exec_cmd("hyprctl reload")`},
{"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`}, {"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`},
{"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`}, {"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`},
{"setfloating", `hl.dsp.window.float({ action = "on" })`}, {"setfloating", `hl.dsp.window.float({ action = "on" })`},
@@ -306,6 +312,34 @@ func TestParseLuaBindLineUpgradesMappableDispatchWrapper(t *testing.T) {
} }
} }
func TestParseLuaBindLineBareGroupLockFollowsHyprlandVersion(t *testing.T) {
prev := luaconfig.HyprlandVersion
t.Cleanup(func() { luaconfig.HyprlandVersion = prev })
for _, tc := range []struct {
minor int
action string
want string
}{
{56, "lockgroups toggle", `hl.dsp.group.lock({ all = true, action = "toggle" })`},
{57, "lockactivegroup toggle", `hl.dsp.group.lock_active({ action = "toggle" })`},
} {
minor := tc.minor
luaconfig.HyprlandVersion = func() (int, int) { return 0, minor }
got, ok := parseLuaBindOverrideLine(`hl.bind("SUPER + G", hl.dsp.group.lock({ action = "toggle" }))`)
if !ok {
t.Fatalf("0.%d: expected line to parse", minor)
}
if got.Action != tc.action {
t.Fatalf("0.%d: action = %q, want %q", minor, got.Action, tc.action)
}
var sb strings.Builder
writeLuaBindLine(&sb, got)
if !strings.Contains(sb.String(), tc.want) {
t.Fatalf("0.%d: expected %q, got %q", minor, tc.want, sb.String())
}
}
}
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) { func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
overridePath := filepath.Join(tmpDir, "binds-user.lua") overridePath := filepath.Join(tmpDir, "binds-user.lua")
@@ -317,7 +351,7 @@ hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help"))
t.Fatal(err) t.Fatal(err)
} }
binds, err := readLuaOrHyprlangOverride(overridePath) binds, err := readLuaOverride(overridePath)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -401,7 +435,7 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
t.Fatal(err) t.Fatal(err)
} }
binds, err := readLuaOrHyprlangOverride(overridePath) binds, err := readLuaOverride(overridePath)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -474,7 +508,7 @@ hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
} }
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) { func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
tmpDir := t.TempDir() tmpDir := hyprLuaTestDir(t)
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -519,7 +553,7 @@ func TestIsRawLuaActionText(t *testing.T) {
} }
func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) { func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
tmpDir := t.TempDir() tmpDir := hyprLuaTestDir(t)
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -548,7 +582,7 @@ func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
} }
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) { func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
tmpDir := t.TempDir() tmpDir := hyprLuaTestDir(t)
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -587,7 +621,7 @@ hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
} }
func TestHyprlandSetBindTranslatesScrollWheelToMouse(t *testing.T) { func TestHyprlandSetBindTranslatesScrollWheelToMouse(t *testing.T) {
tmpDir := t.TempDir() tmpDir := hyprLuaTestDir(t)
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -642,7 +676,7 @@ func readFile(t *testing.T, path string) string {
} }
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) { func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir() tmpDir := hyprLuaTestDir(t)
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -672,7 +706,7 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
} }
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) { func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
tmpDir := t.TempDir() tmpDir := hyprLuaTestDir(t)
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -797,7 +831,7 @@ func TestHyprlandBindFlagsRoundTrip(t *testing.T) {
} }
func TestHyprlandSetBindKeepsDeviceTable(t *testing.T) { func TestHyprlandSetBindKeepsDeviceTable(t *testing.T) {
tmpDir := t.TempDir() tmpDir := hyprLuaTestDir(t)
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -840,420 +874,19 @@ func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected SetBind to reject conf-only Hyprland config") t.Fatal("expected SetBind to reject conf-only Hyprland config")
} }
if !strings.Contains(err.Error(), "read-only") { if !strings.Contains(err.Error(), "dms setup") {
t.Fatalf("expected read-only error, got %v", err) t.Fatalf("expected a run-dms-setup error, got %v", err)
} }
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err) t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err)
} }
} }
func TestHyprlandGetKeybindAtLine(t *testing.T) { func hyprLuaTestDir(t *testing.T) string {
tests := []struct { t.Helper()
name string dir := t.TempDir()
line string if err := os.WriteFile(filepath.Join(dir, "hyprland.lua"), nil, 0o644); err != nil {
expected *HyprlandKeyBinding t.Fatal(err)
}{
{
name: "basic_keybind",
line: "bind = SUPER, Q, killactive",
expected: &HyprlandKeyBinding{
Mods: []string{"SUPER"},
Key: "Q",
Dispatcher: "killactive",
Params: "",
Comment: "Close window",
},
},
{
name: "keybind_with_params",
line: "bind = SUPER, left, movefocus, l",
expected: &HyprlandKeyBinding{
Mods: []string{"SUPER"},
Key: "left",
Dispatcher: "movefocus",
Params: "l",
Comment: "move focus left",
},
},
{
name: "keybind_with_comment",
line: "bind = SUPER, T, exec, kitty # Open terminal",
expected: &HyprlandKeyBinding{
Mods: []string{"SUPER"},
Key: "T",
Dispatcher: "exec",
Params: "kitty",
Comment: "Open terminal",
},
},
{
name: "keybind_hidden",
line: "bind = SUPER, H, exec, secret # [hidden]",
expected: nil,
},
{
name: "keybind_multiple_mods",
line: "bind = SUPER+SHIFT, F, fullscreen, 0",
expected: &HyprlandKeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "F",
Dispatcher: "fullscreen",
Params: "0",
Comment: "Toggle fullscreen",
},
},
{
name: "keybind_no_mods",
line: "bind = , Print, exec, screenshot",
expected: &HyprlandKeyBinding{
Mods: []string{},
Key: "Print",
Dispatcher: "exec",
Params: "screenshot",
Comment: "screenshot",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewHyprlandParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if tt.expected == nil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Errorf("expected %+v, got nil", tt.expected)
return
}
if result.Key != tt.expected.Key {
t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key)
}
if result.Dispatcher != tt.expected.Dispatcher {
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expected.Dispatcher)
}
if result.Params != tt.expected.Params {
t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params)
}
if result.Comment != tt.expected.Comment {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment)
}
if len(result.Mods) != len(tt.expected.Mods) {
t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods))
} else {
for i := range result.Mods {
if result.Mods[i] != tt.expected.Mods[i] {
t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i])
}
}
}
})
}
}
func TestHyprlandParseKeysWithSections(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "hyprland.conf")
content := `##! Window Management
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 0
###! Movement
bind = SUPER, left, movefocus, l
bind = SUPER, right, movefocus, r
##! Applications
bind = SUPER, T, exec, kitty # Terminal
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseHyprlandKeys(tmpDir)
if err != nil {
t.Fatalf("ParseHyprlandKeys failed: %v", err)
}
if len(section.Children) != 2 {
t.Errorf("Expected 2 top-level sections, got %d", len(section.Children))
}
if len(section.Children) >= 1 {
windowMgmt := section.Children[0]
if windowMgmt.Name != "Window Management" {
t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management")
}
if len(windowMgmt.Keybinds) != 2 {
t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds))
}
if len(windowMgmt.Children) != 1 {
t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children))
} else {
movement := windowMgmt.Children[0]
if movement.Name != "Movement" {
t.Errorf("Movement section name = %q, want %q", movement.Name, "Movement")
}
if len(movement.Keybinds) != 2 {
t.Errorf("Movement keybinds = %d, want 2", len(movement.Keybinds))
}
}
}
if len(section.Children) >= 2 {
apps := section.Children[1]
if apps.Name != "Applications" {
t.Errorf("Second section name = %q, want %q", apps.Name, "Applications")
}
if len(apps.Keybinds) != 1 {
t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds))
}
if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" {
t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal")
}
}
}
func TestHyprlandParseKeysWithCommentBinds(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
content := `#/# = SUPER, A, exec, app1
bind = SUPER, B, exec, app2
#/# = SUPER, C, exec, app3 # Custom comment
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseHyprlandKeys(tmpDir)
if err != nil {
t.Fatalf("ParseHyprlandKeys failed: %v", err)
}
if len(section.Keybinds) != 3 {
t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds))
}
if len(section.Keybinds) > 0 && section.Keybinds[0].Key != "A" {
t.Errorf("First keybind key = %q, want %q", section.Keybinds[0].Key, "A")
}
if len(section.Keybinds) > 1 && section.Keybinds[1].Key != "B" {
t.Errorf("Second keybind key = %q, want %q", section.Keybinds[1].Key, "B")
}
if len(section.Keybinds) > 2 && section.Keybinds[2].Comment != "Custom comment" {
t.Errorf("Third keybind comment = %q, want %q", section.Keybinds[2].Comment, "Custom comment")
}
}
func TestHyprlandReadContentMultipleFiles(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "a.conf")
file2 := filepath.Join(tmpDir, "b.conf")
content1 := "bind = SUPER, Q, killactive\n"
content2 := "bind = SUPER, T, exec, kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil {
t.Fatalf("Failed to write file1: %v", err)
}
if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil {
t.Fatalf("Failed to write file2: %v", err)
}
parser := NewHyprlandParser("")
if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
section := parser.ParseKeys()
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds from multiple files, got %d", len(section.Keybinds))
}
}
func TestHyprlandReadContentErrors(t *testing.T) {
tests := []struct {
name string
path string
}{
{
name: "nonexistent_directory",
path: "/nonexistent/path/that/does/not/exist",
},
{
name: "empty_directory",
path: t.TempDir(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseHyprlandKeys(tt.path)
if err == nil {
t.Error("Expected error, got nil")
}
})
}
}
func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) {
parser := NewHyprlandParser("")
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
result := parser.getKeybindAtLine(0)
if result == nil {
t.Fatal("Expected keybind, got nil")
}
expected := "notify-send 'Title' 'Message, with comma'"
if result.Params != expected {
t.Errorf("Params = %q, want %q", result.Params, expected)
}
}
func TestHyprlandEmptyAndCommentLines(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
content := `
# This is a comment
bind = SUPER, Q, killactive
# Another comment
bind = SUPER, T, exec, kitty
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseHyprlandKeys(tmpDir)
if err != nil {
t.Fatalf("ParseHyprlandKeys failed: %v", err)
}
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds))
}
}
func TestExtractBindFlags(t *testing.T) {
tests := []struct {
bindType string
expected string
}{
{"bind", ""},
{"binde", "e"},
{"bindel", "el"},
{" binde ", "e"},
{"bindkd", "kd"},
{"bindpcg", "pcg"},
}
for _, tt := range tests {
t.Run(tt.bindType, func(t *testing.T) {
result, ok := extractBindFlags(tt.bindType)
if !ok || result != tt.expected {
t.Errorf("extractBindFlags(%q) = %q, %v, want %q", tt.bindType, result, ok, tt.expected)
}
})
}
for _, bindType := range []string{"notbind", "", "bindq", "binds:allow_workspace_cycles"} {
if flags, ok := extractBindFlags(bindType); ok {
t.Errorf("extractBindFlags(%q) = %q, want rejected", bindType, flags)
}
}
}
func TestHyprlandBindFlags(t *testing.T) {
tests := []struct {
name string
line string
expectedFlags string
expectedKey string
expectedDisp string
expectedDesc string
}{
{
name: "binde (repeat on hold)",
line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
expectedFlags: "e",
expectedKey: "XF86AudioRaiseVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
},
{
name: "bindd (description)",
line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty",
expectedFlags: "d",
expectedKey: "Q",
expectedDisp: "exec",
expectedDesc: "Open my favourite terminal",
},
{
name: "bindk (per-device)",
line: "bindk = SUPER, Q, at-translated-set-2-keyboard, killactive",
expectedFlags: "k",
expectedKey: "Q",
expectedDisp: "killactive",
expectedDesc: "Close window",
},
{
name: "binddk (device before description)",
line: "binddk = SUPER, T, !my-macropad, Terminal, exec, kitty",
expectedFlags: "dk",
expectedKey: "T",
expectedDisp: "exec",
expectedDesc: "Terminal",
},
{
name: "bindux (submap-universal, input capture)",
line: "bindux = SUPER, Escape, submap, reset",
expectedFlags: "ux",
expectedKey: "Escape",
expectedDisp: "submap",
expectedDesc: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewHyprlandParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if result == nil {
t.Fatal("Expected keybind, got nil")
}
if result.Flags != tt.expectedFlags {
t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags)
}
if result.Key != tt.expectedKey {
t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey)
}
if result.Dispatcher != tt.expectedDisp {
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp)
}
if result.Comment != tt.expectedDesc {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc)
}
})
} }
return dir
} }
@@ -186,7 +186,6 @@ func TestHyprlandConvertKeybindLuaAction(t *testing.T) {
{"hl.dsp.no_op()", "", ""}, {"hl.dsp.no_op()", "", ""},
} }
p := NewHyprlandProvider(t.TempDir()) p := NewHyprlandProvider(t.TempDir())
p.luaConfig = true
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.dispatcher+" "+tt.params, func(t *testing.T) { t.Run(tt.dispatcher+" "+tt.params, func(t *testing.T) {
kb := &HyprlandKeyBinding{Mods: []string{"SUPER"}, Key: "X", Dispatcher: tt.dispatcher, Params: tt.params} kb := &HyprlandKeyBinding{Mods: []string{"SUPER"}, Key: "X", Dispatcher: tt.dispatcher, Params: tt.params}
@@ -217,45 +216,41 @@ func TestHyprlandLuaBindLabels(t *testing.T) {
} }
} }
func TestHyprlandCheatSheetConfigFormat(t *testing.T) { func TestHyprlandCheatSheetIgnoresLegacyConf(t *testing.T) {
const conf = "bind = SUPER, Q, killactive\n"
const lua = `hl.bind("SUPER + Q", hl.dsp.window.close())` + "\n"
tests := []struct {
name string
files map[string]string
wantFormat string
wantReadOnly bool
wantLua string
}{
{"hyprlang only", map[string]string{"hyprland.conf": conf}, "hyprlang", true, ""},
{"lua only", map[string]string{"hyprland.lua": lua}, "lua", false, `hl.dsp.window.close()`},
{"lua wins over leftover conf", map[string]string{"hyprland.conf": conf, "hyprland.lua": lua}, "lua", false, `hl.dsp.window.close()`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil { if err := os.WriteFile(filepath.Join(dir, "hyprland.conf"), []byte("bind = SUPER, Q, killactive\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(dir, "dms", "binds.lua"), nil, 0o644); err != nil { if sheet, err := NewHyprlandProvider(dir).GetCheatSheet(); err == nil {
t.Fatal(err) t.Fatalf("GetCheatSheet read hyprland.conf: %+v", sheet.Binds)
} }
for name, content := range tt.files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
} }
func TestHyprlandCheatSheetReadsWrappedBinds(t *testing.T) {
dir := t.TempDir()
config := `local function cycle_ws()
hl.dispatch("workspace e+1")
end
hl.bind("SUPER + T",
hl.dsp.exec_cmd("kitty"), -- launcher
{ description = "Terminal", repeating = true })
hl.bind("SUPER + C", cycle_ws, { description = "Next workspace" })
`
if err := os.WriteFile(filepath.Join(dir, "hyprland.lua"), []byte(config), 0o644); err != nil {
t.Fatal(err)
} }
sheet, err := NewHyprlandProvider(dir).GetCheatSheet() sheet, err := NewHyprlandProvider(dir).GetCheatSheet()
if err != nil { if err != nil {
t.Fatalf("GetCheatSheet: %v", err) t.Fatalf("GetCheatSheet: %v", err)
} }
if sheet.DMSStatus == nil || sheet.DMSStatus.ConfigFormat != tt.wantFormat || sheet.DMSStatus.ReadOnly != tt.wantReadOnly {
t.Fatalf("DMSStatus = %+v, want format %q readOnly %v", sheet.DMSStatus, tt.wantFormat, tt.wantReadOnly) exec := sheet.Binds["Execute"]
if len(exec) != 1 || exec[0].Key != "SUPER+T" || exec[0].Description != "Terminal" || exec[0].Flags != "ed" {
t.Fatalf("Execute binds = %+v, want the wrapped SUPER+T bind with its options", exec)
} }
binds := sheet.Binds["Window"] ws := sheet.Binds["Workspace"]
if len(binds) != 1 || binds[0].Action != "killactive" || binds[0].LuaAction != tt.wantLua { if len(ws) != 1 || ws[0].Key != "SUPER+C" || ws[0].Description != "Next workspace" {
t.Fatalf("Window binds = %+v, want killactive with LuaAction %q", binds, tt.wantLua) t.Fatalf("Workspace binds = %+v, want the named-function bind filed by its description", ws)
}
})
} }
} }
@@ -290,7 +290,7 @@ var mangowcBindsMessages = configfrag.Messages{
} }
func (p *MangoWCParser) buildDMSStatus() *configfrag.Status { func (p *MangoWCParser) buildDMSStatus() *configfrag.Status {
status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, "", false, mangowcBindsMessages) status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, mangowcBindsMessages)
return &status return &status
} }
@@ -569,7 +569,7 @@ var niriBindsMessages = configfrag.Messages{
func (p *NiriParser) buildDMSStatus() *configfrag.Status { func (p *NiriParser) buildDMSStatus() *configfrag.Status {
scan := configfrag.IncludeScan{Count: p.includeCount, DMSPosition: p.dmsIncludePos, DMSSeen: p.dmsBindsIncluded} scan := configfrag.IncludeScan{Count: p.includeCount, DMSPosition: p.dmsIncludePos, DMSSeen: p.dmsBindsIncluded}
status := configfrag.BuildStatus(scan, p.dmsBindsExists, p.bindsAfterDMS, "", false, niriBindsMessages) status := configfrag.BuildStatus(scan, p.dmsBindsExists, p.bindsAfterDMS, niriBindsMessages)
return &status return &status
} }
+1 -7
View File
@@ -30,11 +30,9 @@ func TestDMSBindsStatusKeySpellings(t *testing.T) {
Effective: true, Effective: true,
OverriddenBy: 4, OverriddenBy: 4,
StatusMessage: "DMS binds are active", StatusMessage: "DMS binds are active",
ConfigFormat: "lua",
ReadOnly: true,
}) })
want := []string{"exists", "included", "includePosition", "totalIncludes", "bindsAfterDms", "effective", "overriddenBy", "statusMessage", "configFormat", "readOnly"} want := []string{"exists", "included", "includePosition", "totalIncludes", "bindsAfterDms", "effective", "overriddenBy", "statusMessage"}
if len(keys) != len(want) { if len(keys) != len(want) {
t.Fatalf("key count = %d, want %d: %v", len(keys), len(want), keys) t.Fatalf("key count = %d, want %d: %v", len(keys), len(want), keys)
} }
@@ -55,8 +53,6 @@ func TestDMSBindsStatusFromCarriesEveryField(t *testing.T) {
Effective: true, Effective: true,
OverriddenBy: 7, OverriddenBy: 7,
StatusMessage: "DMS binds are active", StatusMessage: "DMS binds are active",
ConfigFormat: "lua",
ReadOnly: true,
}) })
want := DMSBindsStatus{ want := DMSBindsStatus{
@@ -68,8 +64,6 @@ func TestDMSBindsStatusFromCarriesEveryField(t *testing.T) {
Effective: true, Effective: true,
OverriddenBy: 7, OverriddenBy: 7,
StatusMessage: "DMS binds are active", StatusMessage: "DMS binds are active",
ConfigFormat: "lua",
ReadOnly: true,
} }
if *got != want { if *got != want {
t.Errorf("DMSBindsStatusFrom = %+v, want %+v", *got, want) t.Errorf("DMSBindsStatusFrom = %+v, want %+v", *got, want)
-4
View File
@@ -32,8 +32,6 @@ type DMSBindsStatus struct {
Effective bool `json:"effective"` Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"` OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"` StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
const ( const (
@@ -127,7 +125,5 @@ func DMSBindsStatusFrom(s configfrag.Status) *DMSBindsStatus {
Effective: s.Effective, Effective: s.Effective,
OverriddenBy: s.OverriddenBy, OverriddenBy: s.OverriddenBy,
StatusMessage: s.StatusMessage, StatusMessage: s.StatusMessage,
ConfigFormat: s.ConfigFormat,
ReadOnly: s.ReadOnly,
} }
} }
+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()
@@ -0,0 +1,33 @@
package luaconfig
import (
"encoding/json"
"fmt"
"os/exec"
"sync"
)
// HyprlandVersion is the running compositor's major.minor, 0.0 when hyprctl is unavailable. Tests replace it.
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 HyprlandAtLeast(major, minor int) bool {
haveMajor, haveMinor := HyprlandVersion()
if haveMajor != major {
return haveMajor > major
}
return haveMinor >= minor
}
+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) {
+60 -2
View File
@@ -3,14 +3,72 @@ package netfetch
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http"
"path"
"strings"
"time" "time"
) )
const latestReleaseURL = "https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest" const (
latestReleaseRedirectURL = "https://github.com/duxlabs-dev/dux-shell/releases/latest"
latestReleaseAPIURL = "https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest"
releaseLookupTimeout = 10 * time.Second
)
var ErrGitHubRateLimited = errors.New("github api rate limit exceeded (60 unauthenticated requests per hour per ip)")
func LatestReleaseTag(ctx context.Context) (string, error) { func LatestReleaseTag(ctx context.Context) (string, error) {
body, err := Bytes(ctx, latestReleaseURL, Options{Timeout: 10 * time.Second}) tag, redirectErr := latestTagFromRedirect(ctx, latestReleaseRedirectURL)
if redirectErr == nil {
return tag, nil
}
tag, apiErr := latestTagFromAPI(ctx, latestReleaseAPIURL)
if apiErr == nil {
return tag, nil
}
return "", fmt.Errorf("release redirect: %w; release api: %w", redirectErr, apiErr)
}
func latestTagFromRedirect(ctx context.Context, url string) (string, error) {
opts := Options{
Timeout: releaseLookupTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := opts.request(ctx, url)
if err != nil {
return "", err
}
req.Method = http.MethodHead
resp, err := opts.client().Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
return "", &StatusError{Code: resp.StatusCode}
}
location := resp.Header.Get("Location")
tag := path.Base(location)
if tag == "" || tag == "." || tag == "/" || !strings.Contains(location, "/releases/tag/") {
return "", fmt.Errorf("unexpected release redirect: %q", location)
}
return tag, nil
}
func latestTagFromAPI(ctx context.Context, url string) (string, error) {
body, err := Bytes(ctx, url, Options{Timeout: releaseLookupTimeout})
var status *StatusError
if errors.As(err, &status) && (status.Code == http.StatusForbidden || status.Code == http.StatusTooManyRequests) {
return "", ErrGitHubRateLimited
}
if err != nil { if err != nil {
return "", err return "", err
} }
Loaded 100 of 619 files, more files were not shown because too many files have changed in this diff. Show more