Compare commits

...
143 Commits
Author SHA1 Message Date
bbedward 7e758c935f control center: close the band on a second tile click instead of walking history
fixes #4016
port 2.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

port 2.0

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

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

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

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

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

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

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

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

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

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

port 2.0

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

fixes #3975
port 2.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Port 2.0

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

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

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

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

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

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

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

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

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

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

Port 2.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+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
+69 -8
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Download / publish an XBPS repository prefix on Cloudflare R2.
# Shared by dux-shell and dux-repo (same prefix); keep both copies identical.
# ETag guard: publish aborts if another writer replaced x86_64-repodata mid-run.
set -euo pipefail set -euo pipefail
@@ -19,13 +22,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/" \
@@ -35,7 +79,7 @@ download() {
} }
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 +102,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 +163,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 +181,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 +196,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 +217,7 @@ publish() {
} }
done done
assert_repodata_unchanged "before publishing"
verify_immutable_packages verify_immutable_packages
build_manifest build_manifest
archive_retired archive_retired
+2
View File
@@ -71,6 +71,8 @@ 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"
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"
+64
View File
@@ -0,0 +1,64 @@
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
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/")],
];
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");
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
+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"
+1
View File
@@ -308,6 +308,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: Install OSC - name: Install OSC
run: | run: |
+1
View File
@@ -100,6 +100,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
+4 -4
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:
@@ -49,7 +47,9 @@ jobs:
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)" # dms/current is shared with dux-repo; 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."
+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."
}
-24
View File
@@ -55,32 +55,8 @@ var resolveIncludeCmd = &cobra.Command{
Run: runResolveInclude, Run: runResolveInclude,
} }
var mangoMigrateMain bool
var mangoMigrateCmd = &cobra.Command{
Use: "mango-migrate",
Short: "Respell Mango config keys for the installed Mango",
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dialect := mangoconf.Detect()
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
for _, path := range changed {
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
}
// Printed before any error: the shell needs the dialect even when a fragment failed.
fmt.Println(dialect)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() { func init() {
configCmd.AddCommand(resolveIncludeCmd) configCmd.AddCommand(resolveIncludeCmd)
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
configCmd.AddCommand(mangoMigrateCmd)
} }
type IncludeResult struct { type IncludeResult struct {
+118 -41
View File
@@ -285,15 +285,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
@@ -858,6 +858,14 @@ func checkWindowManagers() []checkResult {
if c.name == "Hyprland" { if c.name == "Hyprland" {
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...) results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
} }
if c.name == "mangowc" && mangoLacksSnakeCase(ver) {
results = append(results, checkResult{
catCompositor, "Mango config format", statusError,
fmt.Sprintf("Mango %s is older than 0.18", ver),
"DMS writes Mango config with the snake_case keys of Mango 0.18 and later, which this release rejects; upgrade Mango",
doctorDocsURL + "#compositor-checks",
})
}
} }
if !foundAny { if !foundAny {
@@ -878,7 +886,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 +894,70 @@ func checkWindowManagers() []checkResult {
return results return results
} }
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`) var (
mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
mangoUnknownKeyword = regexp.MustCompile(`Unknown keyword:\s*(\S+)`)
ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
)
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult { // Mango's parser follows source= and always exits 0; only its output tells.
var mangoParseConfig = func(path string) (string, error) {
if _, err := exec.LookPath("mango"); err != nil {
return "", err
}
out, _ := exec.Command("mango", "-p", "-c", path).CombinedOutput()
return string(out), nil
}
// Each "Unknown keyword" line is followed by a location line naming the file.
func mangoUnknownKeywords(output string) []string {
var found []string
seen := map[string]bool{}
key := ""
for line := range strings.SplitSeq(ansiEscape.ReplaceAllString(output, ""), "\n") {
if m := mangoUnknownKeyword.FindStringSubmatch(line); m != nil {
key = m[1]
continue
}
slash := strings.LastIndex(line, "/")
if key == "" || slash < 0 {
continue
}
file, _, _ := strings.Cut(line[slash+1:], ":")
entry := key + " (" + file + ")"
if !seen[entry] {
seen[entry] = true
found = append(found, entry)
}
key = ""
}
return found
}
func checkMangoConfig(mangoDir string) []checkResult {
url := doctorDocsURL + "#compositor-checks" url := doctorDocsURL + "#compositor-checks"
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf")) mainPath := filepath.Join(mangoDir, "config.conf")
paths = append([]string{filepath.Join(mangoDir, "config.conf")}, paths...) var results []checkResult
if output, err := mangoParseConfig(mainPath); err == nil {
if unknown := mangoUnknownKeywords(output); len(unknown) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
"Unknown keys: " + strings.Join(unknown, ", "),
"Mango 0.18 renamed its config keys to snake_case. Run migrate-config-keys.sh from Mango's 0.18.0 release over ~/.config/mango, or rename the keys by hand.", url})
}
}
var stale, overviewBinds []string paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
paths = append([]string{mainPath}, paths...)
var overviewBinds []string
for _, path := range paths { for _, path := range paths {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
continue continue
} }
if dialect.Translate(string(data)) != string(data) {
stale = append(stale, filepath.Base(path))
}
if mangoLegacyOverviewBinds.Match(data) { if mangoLegacyOverviewBinds.Match(data) {
overviewBinds = append(overviewBinds, filepath.Base(path)) overviewBinds = append(overviewBinds, filepath.Base(path))
} }
} }
var results []checkResult
if len(stale) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
fmt.Sprintf("Not %s keys: %s", dialect, strings.Join(stale, ", ")),
"This Mango rejects those lines as unknown keywords. Run `dms config mango-migrate --main` (config.conf is backed up).", url})
}
if len(overviewBinds) > 0 { if len(overviewBinds) > 0 {
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn, results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "), "Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
@@ -967,6 +1012,10 @@ func hyprlandLacksLua(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0 return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
} }
func mangoLacksSnakeCase(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.18.0") < 0
}
func firstSubmatch(re *regexp.Regexp) func(string) string { func firstSubmatch(re *regexp.Regexp) func(string) string {
return func(s string) string { return func(s string) string {
if m := re.FindStringSubmatch(s); len(m) > 1 { if m := re.FindStringSubmatch(s); len(m) > 1 {
@@ -1712,6 +1761,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 +1798,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 // 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 false return families
} }
func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailable bool, url string) checkResult { 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 +1873,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 +1882,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 +1928,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 +1947,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
+46 -15
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) {
shellPath := t.TempDir() for family, relPaths := range bundledFontRelPaths {
fontPath := filepath.Join(shellPath, "DCommon", "assets", "fonts", "google-sans-flex", "GoogleSansFlex.ttf") t.Run(family, func(t *testing.T) {
missing := checkConfiguredFont("UI font", "Google Sans Flex", shellPath, "", false, "") shellPath := t.TempDir()
if missing.status != statusWarn { if missing := checkConfiguredFont("Font", family, shellPath, nil, false, ""); 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 {
t.Fatal(err)
}
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
t.Fatal(err)
}
for _, name := range []string{family, " " + strings.ToUpper(family) + " "} {
if result := checkConfiguredFont("Font", name, shellPath, nil, false, ""); result.status != statusOK {
t.Fatalf("bundled font %q status = %v, want %v", name, result.status, statusOK)
}
}
})
} }
if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil { }
t.Fatal(err)
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,
} }
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil { for name, want := range cases {
t.Fatal(err) if got := fontInFcList(name, families); got != want {
} t.Errorf("fontInFcList(%q) = %v, want %v", name, got, want)
for _, family := range []string{"Google Sans Flex", " google sans flex "} {
result := checkConfiguredFont("UI font", family, shellPath, "", false, "")
if result.status != statusOK {
t.Fatalf("bundled font %q status = %v, want %v", family, result.status, statusOK)
} }
} }
} }
+28 -8
View File
@@ -8,8 +8,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
) )
func TestQuickshellVersionFailureDetails(t *testing.T) { func TestQuickshellVersionFailureDetails(t *testing.T) {
@@ -385,19 +383,41 @@ func TestCheckXDGMenuFile(t *testing.T) {
} }
} }
func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) { func TestCheckMangoConfigReportsUnknownKeysAndOverviewBinds(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644) _ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "dms", "layout.conf"), []byte("border_px=2\n"), 0o644) // Shape of `mango -p` 0.18.2 output, colours included; the same key twice collapses.
parsed := "\x1b[1m\x1b[31m[ERROR]:\x1b[0m Unknown keyword: \x1b[1m\x1b[31mexec-once\x1b[0m\n" +
"\x1b[1;31m╰─\x1b[1;33m[Index]\x1b[0m \x1b[1;36m" + dir + "/config.conf\x1b[0m:\x1b[1;35m1\x1b[0m\n ╰─exec-once=dms run\n\n" +
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:10\n ╰─overviewgappo=30\n" +
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:12\n"
prev := mangoParseConfig
mangoParseConfig = func(string) (string, error) { return parsed, nil }
t.Cleanup(func() { mangoParseConfig = prev })
if got := checkMangoConfig(dir, mangoconf.Legacy); len(got) != 2 || got[0].message != "Not legacy keys: layout.conf" { got := checkMangoConfig(dir)
t.Fatalf("legacy: %+v", got) if len(got) != 2 || got[0].message != "Unknown keys: exec-once (config.conf), overviewgappo (layout.conf)" || got[1].name != "Mango mouse binds" {
t.Fatalf("got %+v", got)
} }
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" { mangoParseConfig = func(string) (string, error) { return "", os.ErrNotExist }
t.Fatalf("snake: %+v", got) 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)
}
} }
} }
+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()
} }
+1 -1
View File
@@ -67,7 +67,7 @@ func greeterPackageInstallHint() string {
case distros.FamilyFedora: case distros.FamilyFedora:
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)" return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
case distros.FamilyArch: case distros.FamilyArch:
return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'" return "Install from AUR with 'paru -S dux-greeter-bin' or 'yay -S dux-greeter-bin'"
case distros.FamilyVoid: case distros.FamilyVoid:
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)" return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)"
default: default:
+2 -2
View File
@@ -130,7 +130,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "layout.conf", mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig }, hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) }, mangoContent: func(_ string) string { return config.MangoLayoutConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
@@ -138,7 +138,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "colors.conf", mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig }, hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) }, mangoContent: func(_ string) string { return config.MangoColorsConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
+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) { if commandBlockedByPolicy("setup", blocked) {
t.Fatal("expected greeter sync to remain available on immutable/image-based systems") t.Fatal("setup only writes to the home directory and must stay available on image-based systems")
} }
} }
+34 -1
View File
@@ -2,17 +2,21 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-go/shellapp" "github.com/duxlabs-dev/dux-go/shellapp"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"github.com/duxlabs-dev/dux-shell/core/internal/config" "github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/gpu" "github.com/duxlabs-dev/dux-shell/core/internal/gpu"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server" "github.com/duxlabs-dev/dux-shell/core/internal/server"
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed" "github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/afero"
) )
var shellApp = shellapp.New(shellapp.Config{ var shellApp = shellapp.New(shellapp.Config{
@@ -83,18 +87,47 @@ func preLaunch() {
ensureFontCache() ensureFontCache()
} }
// The elapsed-timer driver keeps the scrolling wallpaper spring in step above 60 Hz,
// but it makes every other animation jump after a dropped frame (#3837).
func scrollingWallpaperConfigured() bool {
configDir, err := os.UserConfigDir()
if err != nil {
return false
}
data, err := os.ReadFile(filepath.Join(configDir, "dms", "settings.json"))
if err != nil {
return false
}
return wantsSimpleAnimationDriver(data)
}
func wantsSimpleAnimationDriver(settings []byte) bool {
var parsed struct {
WallpaperFillMode string `json:"wallpaperFillMode"`
}
if json.Unmarshal(settings, &parsed) != nil {
return false
}
return parsed.WallpaperFillMode == "Scrolling"
}
func dmsExtraEnv(string) []string { func dmsExtraEnv(string) []string {
var env []string var env []string
if selfPath, err := os.Executable(); err == nil { if selfPath, err := os.Executable(); err == nil {
env = append(env, "DMS_EXECUTABLE="+selfPath) env = append(env, "DMS_EXECUTABLE="+selfPath)
} }
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" { if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" && scrollingWallpaperConfigured() {
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1") env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
} }
if _, set := os.LookupEnv("MALLOC_CONF"); !set { if _, set := os.LookupEnv("MALLOC_CONF"); !set {
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000") env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
} }
env = append(env, gpu.EGLVendorEnv()...) env = append(env, gpu.EGLVendorEnv()...)
policyEnv, err := clipolicy.ShellEnv(afero.NewOsFs())
if err != nil {
log.Errorf("CLI policy unreadable, enforcing every shell switch and ignoring its lock settings: %v", err)
}
env = append(env, policyEnv...)
// systemd user services miss the login shell PATH, so tools in ~/.local/bin // systemd user services miss the login shell PATH, so tools in ~/.local/bin
// (herdr, pip/cargo installs) would be invisible to the shell and its children. // (herdr, pip/cargo installs) would be invisible to the shell and its children.
for _, entry := range utils.EnvWithUserBinPath(nil) { for _, entry := range utils.EnvWithUserBinPath(nil) {
+28 -1
View File
@@ -20,9 +20,16 @@ var runSystemctl = func(args ...string) error {
return err return err
} }
// Assigned in init: shellApp's config refers back to trySystemdRestart.
var shellRunning func() bool
func init() {
shellRunning = func() bool { return len(shellApp.PIDs()) > 0 }
}
func trySystemdRestart() (bool, error) { func trySystemdRestart() (bool, error) {
if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil { if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil {
return false, nil return trySystemdStart()
} }
if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil { if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil {
@@ -30,3 +37,23 @@ func trySystemdRestart() (bool, error) {
} }
return true, nil return true, nil
} }
// With nothing running, restart would spawn an unmanaged daemon that blocks
// the unit once the session target starts it. A running hand-started
// instance is left to the plain restart path.
func trySystemdStart() (bool, error) {
if shellRunning() {
return false, nil
}
if err := runSystemctl("--user", "is-enabled", "--quiet", dmsSystemdUnit); err != nil {
return false, nil
}
if err := runSystemctl("--user", "is-active", "--quiet", "graphical-session.target"); err != nil {
return false, nil
}
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return true, fmt.Errorf("starting %s: %w", dmsSystemdUnit, err)
}
return true, nil
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"errors"
"slices"
"strings"
"testing"
)
func TestTrySystemdRestart(t *testing.T) {
tests := []struct {
name string
running bool
ok []string
wantHandled bool
wantCall string
}{
{"active unit restarts", false, []string{"is-active dms.service", "restart"}, true, "restart dms.service"},
{"enabled inactive unit starts", false, []string{"is-enabled", "is-active graphical-session.target", "start"}, true, "start dms.service"},
{"hand-started instance keeps plain restart", true, []string{"is-enabled", "is-active graphical-session.target", "start"}, false, ""},
{"session target not up", false, []string{"is-enabled"}, false, ""},
{"unit disabled", false, []string{"is-active graphical-session.target"}, false, ""},
{"no systemd", false, nil, false, ""},
}
origSystemctl, origRunning := runSystemctl, shellRunning
t.Cleanup(func() { runSystemctl, shellRunning = origSystemctl, origRunning })
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shellRunning = func() bool { return tt.running }
var calls []string
runSystemctl = func(args ...string) error {
call := strings.Join(slices.DeleteFunc(slices.Clone(args), func(a string) bool {
return strings.HasPrefix(a, "--")
}), " ")
calls = append(calls, call)
for _, prefix := range tt.ok {
if strings.HasPrefix(call, prefix) {
return nil
}
}
return errors.New("exit status 3")
}
handled, err := trySystemdRestart()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if handled != tt.wantHandled {
t.Fatalf("handled = %v, want %v (calls %q)", handled, tt.wantHandled, calls)
}
if tt.wantCall != "" && !slices.Contains(calls, tt.wantCall) {
t.Fatalf("missing %q in calls %q", tt.wantCall, calls)
}
if !tt.wantHandled && slices.ContainsFunc(calls, func(c string) bool { return strings.HasPrefix(c, "start") }) {
t.Fatalf("unexpected start in calls %q", calls)
}
})
}
}
@@ -32,9 +32,9 @@ var (
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "dankinstall", Use: "duxinstall",
Short: "Install Dux Shell and its dependencies", Short: "Install Dux Shell and its dependencies",
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal. Long: `duxinstall sets up Dux Shell with your chosen compositor and terminal.
Without flags, it launches an interactive TUI. Providing either --compositor Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags. or --term activates headless (unattended) mode, which requires both flags.
@@ -42,7 +42,7 @@ or --term activates headless (unattended) mode, which requires both flags.
Headless mode requires cached credentials or a passwordless rule for your Headless mode requires cached credentials or a passwordless rule for your
privilege escalation tool (sudo, doas, or run0).`, privilege escalation tool (sudo, doas, or run0).`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: runDankinstall, RunE: runDuxinstall,
SilenceErrors: true, SilenceErrors: true,
SilenceUsage: true, SilenceUsage: true,
} }
@@ -66,7 +66,7 @@ func init() {
func main() { func main() {
if os.Getuid() == 0 { if os.Getuid() == 0 {
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root") fmt.Fprintln(os.Stderr, "Error: duxinstall must not be run as root")
os.Exit(1) os.Exit(1)
} }
@@ -76,7 +76,7 @@ func main() {
} }
} }
func runDankinstall(cmd *cobra.Command, args []string) error { func runDuxinstall(cmd *cobra.Command, args []string) error {
headlessMode := compositor != "" || term != "" headlessMode := compositor != "" || term != ""
if !headlessMode { if !headlessMode {
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/duxlabs-dev/dux-shell/core module github.com/duxlabs-dev/dux-shell/core
go 1.27.1 go 1.27.3
require ( require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0 github.com/Wifx/gonetworkmanager/v2 v2.2.0
@@ -9,7 +9,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/dlclark/regexp2/v2 v2.9.1 github.com/dlclark/regexp2/v2 v2.9.1
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500 github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/go-git/go-git/v6 v6.0.0-beta.1 github.com/go-git/go-git/v6 v6.0.0-beta.1
+2 -2
View File
@@ -97,8 +97,8 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ= github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500 h1:y02m7QgoX4rwauY9HePIPGCRDJ/fMirBmgj78IkeUsc= github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a h1:AgR1FUYWhq5JgptZy0dwk1vsuHCXmOjxLFkB6iS/uTc=
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk= github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk=
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok= github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok=
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0= github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
+9 -3
View File
@@ -43,7 +43,7 @@ if [ -z "$LATEST_VERSION" ]; then
exit 1 exit 1
fi fi
printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC" printf "%bInstalling Duxinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC"
# Download and install # Download and install
TEMP_DIR=$(mktemp -d) TEMP_DIR=$(mktemp -d)
@@ -52,8 +52,14 @@ cd "$TEMP_DIR" || exit 1
# Download the gzipped binary and its checksum # Download the gzipped binary and its checksum
printf "%bDownloading installer...%b\n" "$GREEN" "$NC" printf "%bDownloading installer...%b\n" "$GREEN" "$NC"
curl -L "https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz" -o "installer.gz" RELEASE_URL="https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION"
curl -L "https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz.sha256" -o "expected.sha256" # install.duxlabs.dev serves this file from master; releases before 2.0.1 only ship dankinstall-*.gz
ASSET="duxinstall-$ARCH.gz"
if ! curl -fsLI "$RELEASE_URL/$ASSET" >/dev/null; then
ASSET="dankinstall-$ARCH.gz"
fi
curl -fL "$RELEASE_URL/$ASSET" -o "installer.gz"
curl -fL "$RELEASE_URL/$ASSET.sha256" -o "expected.sha256"
# Get the expected checksum # Get the expected checksum
EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256) EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256)
+139 -13
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"
) )
@@ -14,11 +17,46 @@ const (
) )
type File struct { type File struct {
PolicyVersion int `json:"policy_version"` PolicyVersion int `json:"policy_version"`
ImmutableSystem *bool `json:"immutable_system"` ImmutableSystem *bool `json:"immutable_system"`
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 { if err != nil {
return false, err return nil, err
} }
if policy != nil && policy.DisableRegistries != nil { if policy != nil {
disabled = *policy.DisableRegistries policies = append(policies, policy)
} }
} }
return disabled, nil 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 {
return false, err
}
return flag(policies, func(p *File) *bool { return p.DisableRegistries }), nil
}
// 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 settings
}
// LockSettings returns the managed lock settings as raw JSON by key. A policy
// file that fails to load manages none, and its error is returned.
func LockSettings(fs afero.Fs) (map[string]json.RawMessage, error) {
policies, err := load(fs)
return lockSettings(policies), err
}
// LockSettingBool returns a managed boolean lock setting, or nil when it is
// not managed or not a boolean, which the shell ignores too.
func LockSettingBool(settings map[string]json.RawMessage, key string) *bool {
var value *bool
if err := json.Unmarshal(settings[key], &value); err != nil {
return nil
}
return value
}
// ShellEnv returns every shell switch as DMS_POLICY_<KEY>=1 or 0 and the
// managed lock settings as DMS_POLICY_LOCK_SETTINGS=<JSON object>, so a value
// inherited by the shell never counts. A policy file that fails to load turns
// every switch on and manages no lock setting, and its error is returned with
// them.
func ShellEnv(fs afero.Fs) ([]string, error) {
policies, err := load(fs)
env := make([]string, 0, len(shellSwitches)+1)
for _, s := range shellSwitches {
value := "0"
if err != nil || flag(policies, s.value) {
value = "1"
}
env = append(env, "DMS_POLICY_"+strings.ToUpper(s.key)+"="+value)
}
// The values come from json.Unmarshal, so they marshal.
settings, _ := json.Marshal(lockSettings(policies))
env = append(env, "DMS_POLICY_LOCK_SETTINGS="+string(settings))
return env, err
} }
+184
View File
@@ -1,6 +1,8 @@
package clipolicy package clipolicy
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -45,3 +47,185 @@ func TestRegistriesDisabled(t *testing.T) {
}) })
} }
} }
func TestLogindUnlockDisabled(t *testing.T) {
tests := []struct {
name string
packaged string
admin string
want bool
wantErr string
}{
{name: "no policy files"},
{name: "admin policy", admin: `{"disable_logind_unlock": true}`, want: true},
{name: "admin policy overrides packaged", packaged: `{"disable_logind_unlock": true}`, admin: `{"disable_logind_unlock": false}`},
{name: "another key", admin: `{"disable_lock_ipc_unlock": true}`},
{name: "malformed admin policy", admin: "{not json", wantErr: "failed to parse " + AdminPath},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
if tt.packaged != "" {
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
}
if tt.admin != "" {
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
}
disabled, err := LogindUnlockDisabled(fs)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, disabled)
})
}
}
var shellSwitchEnv = map[string]string{
"disable_lock_ipc_unlock": "DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK",
"disable_logind_unlock": "DMS_POLICY_DISABLE_LOGIND_UNLOCK",
"disable_custom_lock_pam": "DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM",
"disable_user_plugins": "DMS_POLICY_DISABLE_USER_PLUGINS",
"disable_settings_ipc_set": "DMS_POLICY_DISABLE_SETTINGS_IPC_SET",
}
func TestShellEnv(t *testing.T) {
tests := []struct {
name string
packaged string
admin string
on bool
}{
{name: "no policy files"},
{name: "packaged policy", packaged: `{"KEY": true}`, on: true},
{name: "admin policy", admin: `{"KEY": true}`, on: true},
{name: "admin policy overrides packaged", packaged: `{"KEY": true}`, admin: `{"KEY": false}`},
{name: "admin policy without the key keeps packaged", packaged: `{"KEY": true}`, admin: `{"immutable_system": true}`, on: true},
}
for key, name := range shellSwitchEnv {
for _, tt := range tests {
t.Run(key+"/"+tt.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
if tt.packaged != "" {
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(strings.ReplaceAll(tt.packaged, "KEY", key)), 0o644))
}
if tt.admin != "" {
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(strings.ReplaceAll(tt.admin, "KEY", key)), 0o644))
}
var want []string
for _, other := range shellSwitchEnv {
if other == name && tt.on {
want = append(want, other+"=1")
} else {
want = append(want, other+"=0")
}
}
want = append(want, "DMS_POLICY_LOCK_SETTINGS={}")
env, err := ShellEnv(fs)
require.NoError(t, err)
assert.ElementsMatch(t, want, env)
})
}
}
}
func TestShellEnvMalformedPolicyTurnsEverySwitchOnAndIgnoresLockSettings(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true}}`), 0o644))
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644))
want := []string{"DMS_POLICY_LOCK_SETTINGS={}"}
for _, name := range shellSwitchEnv {
want = append(want, name+"=1")
}
env, err := ShellEnv(fs)
require.ErrorContains(t, err, "failed to parse "+AdminPath)
assert.ElementsMatch(t, want, env)
}
func TestLockSettings(t *testing.T) {
tests := []struct {
name string
packaged string
admin string
want string
wantErr string
}{
{name: "no policy files", want: `{}`},
{name: "no lock settings", admin: `{"disable_registries": true}`, want: `{}`},
{name: "packaged policy", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": ""}}`, want: `{"lockBeforeSuspend": true, "customPowerActionLock": ""}`},
{name: "admin policy overrides packaged per key", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "acLockTimeout": 300}}`, admin: `{"lock_settings": {"lockBeforeSuspend": false}}`, want: `{"lockBeforeSuspend": false, "acLockTimeout": 300}`},
{name: "admin policy without lock settings keeps packaged", packaged: `{"lock_settings": {"enableFprint": false}}`, admin: `{"disable_registries": true}`, want: `{"enableFprint": false}`},
{name: "null leaves a key unset", packaged: `{"lock_settings": {"u2fMode": "and"}}`, admin: `{"lock_settings": {"u2fMode": null, "lockAtStartup": null}}`, want: `{"u2fMode": "and"}`},
{
name: "only lock settings are managed",
admin: `{"lock_settings": {"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and", "lockScreenShowPowerActions": false, "lockPamPath": "/tmp/pam", "unknownSetting": 1}}`,
want: `{"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and"}`,
},
{name: "values are passed as written", admin: `{"lock_settings": {"lockBeforeSuspend": "yes", "acLockTimeout": [1]}}`, want: `{"lockBeforeSuspend": "yes", "acLockTimeout": [1]}`},
{name: "malformed admin policy manages nothing", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: "{not json", want: `{}`, wantErr: "failed to parse " + AdminPath},
{name: "lock settings not an object", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: `{"lock_settings": ["lockBeforeSuspend"]}`, want: `{}`, wantErr: "failed to parse " + AdminPath},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
if tt.packaged != "" {
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
}
if tt.admin != "" {
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
}
settings, err := LockSettings(fs)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
got, err := json.Marshal(settings)
require.NoError(t, err)
assert.JSONEq(t, tt.want, string(got))
})
}
}
func TestShellEnvLockSettings(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": false, "acLockTimeout": 300}}`), 0o644))
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": "", "lockScreenShowWeather": false}}`), 0o644))
env, err := ShellEnv(fs)
require.NoError(t, err)
assert.Contains(t, env, `DMS_POLICY_LOCK_SETTINGS={"acLockTimeout":300,"customPowerActionLock":"","lockBeforeSuspend":true}`)
}
func TestLockSettingBool(t *testing.T) {
on, off := true, false
tests := []struct {
name string
settings string
want *bool
}{
{name: "not managed", settings: `{"lockAtStartup": true}`},
{name: "managed on", settings: `{"lockBeforeSuspend": true}`, want: &on},
{name: "managed off", settings: `{"lockBeforeSuspend": false}`, want: &off},
{name: "null", settings: `{"lockBeforeSuspend": null}`},
{name: "not a boolean", settings: `{"lockBeforeSuspend": "true"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var settings map[string]json.RawMessage
require.NoError(t, json.Unmarshal([]byte(tt.settings), &settings))
assert.Equal(t, tt.want, LockSettingBool(settings, "lockBeforeSuspend"))
})
}
}
+13 -13
View File
@@ -182,7 +182,7 @@ layout {
} }
func TestConfigDeploymentFlow(t *testing.T) { func TestConfigDeploymentFlow(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-test") tempDir, err := os.MkdirTemp("", "duxinstall-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -312,7 +312,7 @@ func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
} }
func TestHyprlandConfigDeployment(t *testing.T) { func TestHyprlandConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-test") tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -324,7 +324,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
cd := NewConfigDeployer(logChan) cd := NewConfigDeployer(logChan)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) { t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty") td, err := os.MkdirTemp("", "duxinstall-hyprland-empty")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
@@ -344,7 +344,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
}) })
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) { t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge") td, err := os.MkdirTemp("", "duxinstall-hyprland-merge")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
@@ -408,7 +408,7 @@ general {
}) })
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) { t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-lua-conf-symlink") td, err := os.MkdirTemp("", "duxinstall-hyprland-lua-conf-symlink")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
@@ -431,7 +431,7 @@ general {
}) })
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) { t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds") td, err := os.MkdirTemp("", "duxinstall-hyprland-refresh-binds")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(td) defer os.RemoveAll(td)
os.Setenv("HOME", td) os.Setenv("HOME", td)
@@ -457,7 +457,7 @@ general {
} }
func TestKittyConfigDeployment(t *testing.T) { func TestKittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-kitty-test") tempDir, err := os.MkdirTemp("", "duxinstall-kitty-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -495,7 +495,7 @@ func TestKittyConfigDeployment(t *testing.T) {
} }
func TestAlacrittyConfigDeployment(t *testing.T) { func TestAlacrittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-alacritty-test") tempDir, err := os.MkdirTemp("", "duxinstall-alacritty-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -570,7 +570,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
} }
t.Run("replaceConfigs nil deploys config", func(t *testing.T) { t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test") tempDir, err := os.MkdirTemp("", "duxinstall-replace-nil-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -603,7 +603,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}) })
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) { t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test") tempDir, err := os.MkdirTemp("", "duxinstall-replace-missing-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -636,7 +636,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}) })
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) { t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test") tempDir, err := os.MkdirTemp("", "duxinstall-replace-exists-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -678,7 +678,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}) })
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) { t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test") tempDir, err := os.MkdirTemp("", "duxinstall-replace-true-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -725,7 +725,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}) })
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) { t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test") tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-legacy-skip-test")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
@@ -1,6 +1,6 @@
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf). # Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
# Remove `source=./dms/colors.conf` from config.conf to override manually. # Remove `source=./dms/colors.conf` from config.conf to override manually.
bordercolor = 0x595959ff border_color = 0x595959ff
focuscolor = 0x8ab4f8ff focus_color = 0x8ab4f8ff
urgentcolor = 0xff5555ff urgent_color = 0xff5555ff
@@ -1,8 +1,12 @@
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf). # Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
border_radius=12 border_radius=12
gappih=5 gap_inner_horizontal=5
gappiv=5 gap_inner_vertical=5
gappoh=5 gap_outer_horizontal=5
gappov=5 gap_outer_vertical=5
borderpx=2 border_px=2
# layer_animations would tween every DMS layer move; override with no_animation:0 rules after the source lines.
layer_rule=no_animation:1,layer_name:^(quickshell)$
layer_rule=no_animation:1,layer_name:^dms:.*
+1 -1
View File
@@ -7,7 +7,7 @@ env=XDG_SESSION_TYPE,wayland
# Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs # Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs
# exec= on every config reload, and DMS reloads the config. # exec= on every config reload, and DMS reloads the config.
exec-once=dms run exec_once=dms run
source=./dms/colors.conf source=./dms/colors.conf
source=./dms/layout.conf source=./dms/layout.conf
+10 -12
View File
@@ -41,14 +41,13 @@ func nonEmptyFile(path string) (string, bool) {
} }
var ( var (
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|keymode|key_mode)\s*=`) mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|key_mode)\s*=`)
mangoMonitorLine = regexp.MustCompile(`^\s*(monitorrule|monitor_rule)\s*=`) mangoMonitorLine = regexp.MustCompile(`^\s*monitor_rule\s*=`)
mangoWindowLine = regexp.MustCompile(`^\s*(windowrule|window_rule|windowrule-once|window_rule_once)\s*=`) mangoWindowLine = regexp.MustCompile(`^\s*window_rule(_once)?\s*=`)
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\n(exec-once|exec_once)=dms run\n`) mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\nexec_once=dms run\n`)
) )
var ( var (
mangoDialect = mangoconf.Detect
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
) )
@@ -110,17 +109,16 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
} }
dialect := mangoDialect()
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand) newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) { if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "") newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
} }
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil { if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err) result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error return result, result.Error
} }
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, dialect, existing, timestamp); err != nil { if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error return result, result.Error
} }
@@ -132,7 +130,7 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
// Like niri, fragments the user or the shell already filled are left alone; the // Like niri, fragments the user or the shell already filled are left alone; the
// replaced config.conf hands its binds, monitor and window rules to empty ones. // replaced config.conf hands its binds, monitor and window rules to empty ones.
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string, dialect mangoconf.Dialect, oldMain, timestamp string) error { func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand, oldMain, timestamp string) error {
stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand) stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
bindsPath := filepath.Join(dmsDir, "binds.conf") bindsPath := filepath.Join(dmsDir, "binds.conf")
if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 { if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 {
@@ -142,7 +140,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
} }
cd.log(fmt.Sprintf("Backed up existing binds to %s", backup)) cd.log(fmt.Sprintf("Backed up existing binds to %s", backup))
if cd.replaceBinds { if cd.replaceBinds {
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(stockBinds)), 0o644); err != nil { if err := os.WriteFile(bindsPath, []byte(stockBinds), 0o644); err != nil {
return fmt.Errorf("failed to write binds.conf: %w", err) return fmt.Errorf("failed to write binds.conf: %w", err)
} }
cd.log("Deployed DMS stock binds.conf") cd.log("Deployed DMS stock binds.conf")
@@ -160,7 +158,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
binds = mangoconf.BindsMovedHeader + "\n" + old binds = mangoconf.BindsMovedHeader + "\n" + old
cd.log("Moved existing binds from config.conf to binds.conf") cd.log("Moved existing binds from config.conf to binds.conf")
} }
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(binds)), 0o644); err != nil { if err := os.WriteFile(bindsPath, []byte(binds), 0o644); err != nil {
return fmt.Errorf("failed to write binds.conf: %w", err) return fmt.Errorf("failed to write binds.conf: %w", err)
} }
} }
@@ -182,7 +180,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name)) cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue continue
} }
if err := os.WriteFile(path, []byte(dialect.Translate(cfg.content)), 0o644); err != nil { if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err) return fmt.Errorf("failed to write %s: %w", cfg.name, err)
} }
cd.log(fmt.Sprintf("Deployed %s", cfg.name)) cd.log(fmt.Sprintf("Deployed %s", cfg.name))
+17 -17
View File
@@ -13,12 +13,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func stubMango(t *testing.T, dialect mangoconf.Dialect, target bool, addWants error) *int { func stubMango(t *testing.T, target bool, addWants error) *int {
t.Helper() t.Helper()
prevDialect, prevTarget := mangoDialect, mangoSessionTargetInstalled prevTarget := mangoSessionTargetInstalled
mangoDialect = func() mangoconf.Dialect { return dialect }
mangoSessionTargetInstalled = func() bool { return target } mangoSessionTargetInstalled = func() bool { return target }
t.Cleanup(func() { mangoDialect, mangoSessionTargetInstalled = prevDialect, prevTarget }) t.Cleanup(func() { mangoSessionTargetInstalled = prevTarget })
return stubAddWants(t, addWants) return stubAddWants(t, addWants)
} }
@@ -31,16 +30,16 @@ func readMango(t *testing.T, home, rel string) string {
const oldMangoConfig = `exec-once=waybar const oldMangoConfig = `exec-once=waybar
bind=SUPER,Return,spawn,foot bind=SUPER,Return,spawn,foot
keymode=resize key_mode=resize
bind=NONE,h,resizewin,-10,0 bind=NONE,h,resizewin,-10,0
monitorrule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1 monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
windowrule=appid:pavucontrol,isfloating:1 window_rule=app_id:pavucontrol,is_floating:1
` `
func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) { func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
stubMango(t, mangoconf.Legacy, false, nil) stubMango(t, false, nil)
require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644))
@@ -48,25 +47,26 @@ func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, result.BackupPath) assert.NotEmpty(t, result.BackupPath)
assert.Contains(t, readMango(t, home, "config.conf"), "exec-once=dms run") assert.Contains(t, readMango(t, home, "config.conf"), "exec_once=dms run")
assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf") assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf")
assert.Equal(t, "", readMango(t, home, "dms/input.conf")) assert.Equal(t, "", readMango(t, home, "dms/input.conf"))
binds := readMango(t, home, "dms/binds.conf") binds := readMango(t, home, "dms/binds.conf")
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkeymode=resize\nbind=NONE,h,resizewin,-10,0") assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkey_mode=resize\nbind=NONE,h,resizewin,-10,0")
assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}") assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}")
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "monitor_rule=name:^DP-1$")
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1") assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1")
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "windowrule=appid:pavucontrol,isfloating:1") assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "window_rule=app_id:pavucontrol,is_floating:1")
} }
func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) { func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
for _, replace := range []bool{false, true} { for _, replace := range []bool{false, true} {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
stubMango(t, mangoconf.Legacy, false, nil) stubMango(t, false, nil)
dms := filepath.Join(home, ".config", "mango", "dms") dms := filepath.Join(home, ".config", "mango", "dms")
require.NoError(t, os.MkdirAll(dms, 0o755)) require.NoError(t, os.MkdirAll(dms, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\nkey_mode=resize\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("windowrule=appid:keep,isglobal:1\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("window_rule=app_id:keep,is_global:1\n"), 0o644))
cd := NewConfigDeployer(nil) cd := NewConfigDeployer(nil)
cd.SetReplaceBinds(replace) cd.SetReplaceBinds(replace)
@@ -76,8 +76,8 @@ func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*")) backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*"))
assert.Len(t, backups, 1, "replace=%v", replace) assert.Len(t, backups, 1, "replace=%v", replace)
binds := readMango(t, home, "dms/binds.conf") binds := readMango(t, home, "dms/binds.conf")
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\n", "replace=%v", replace) assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\nkey_mode=resize\n", "replace=%v", replace)
assert.Equal(t, "windowrule=appid:keep,isglobal:1\n", readMango(t, home, "dms/windowrules.conf")) assert.Equal(t, "window_rule=app_id:keep,is_global:1\n", readMango(t, home, "dms/windowrules.conf"), "kept fragments are left alone")
} }
} }
@@ -97,7 +97,7 @@ func TestMangoDeploySystemdAutostart(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
calls := stubMango(t, mangoconf.Snake, tc.target, tc.addWants) calls := stubMango(t, tc.target, tc.addWants)
_, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true) _, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true)
require.NoError(t, err) require.NoError(t, err)
main := readMango(t, home, "config.conf") main := readMango(t, home, "config.conf")
+1 -1
View File
@@ -43,7 +43,7 @@ func TestDisableSessionRemovesEveryWantsLink(t *testing.T) {
func TestEnableSessionMangoWithoutTarget(t *testing.T) { func TestEnableSessionMangoWithoutTarget(t *testing.T) {
t.Setenv("HOME", t.TempDir()) t.Setenv("HOME", t.TempDir())
calls := stubMango(t, mangoconf.Snake, false, nil) calls := stubMango(t, false, nil)
unit, err := EnableSession(deps.WindowManagerMango) unit, err := EnableSession(deps.WindowManagerMango)
require.Error(t, err) require.Error(t, err)
assert.Empty(t, unit) assert.Empty(t, unit)
+31 -35
View File
@@ -128,7 +128,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
func (a *ArchDistribution) detectDanksearch() deps.Dependency { func (a *ArchDistribution) detectDanksearch() deps.Dependency {
dep := a.BaseDistribution.detectDanksearch() dep := a.BaseDistribution.detectDanksearch()
dep.CanToggle = true dep.CanToggle = true
if a.packageInstalled("dsearch-git") { if a.packageInstalled("dux-search-git") {
dep.Variant = deps.VariantGit dep.Variant = deps.VariantGit
} }
return dep return dep
@@ -136,14 +136,14 @@ func (a *ArchDistribution) detectDanksearch() deps.Dependency {
func (a *ArchDistribution) detectDMS() deps.Dependency { func (a *ArchDistribution) detectDMS() deps.Dependency {
dep := a.BaseDistribution.detectDMS() dep := a.BaseDistribution.detectDMS()
if a.packageInstalled("dms-shell-git") { if a.packageInstalled("dux-shell-git") {
dep.Variant = deps.VariantGit dep.Variant = deps.VariantGit
} }
return dep return dep
} }
func (a *ArchDistribution) detectDankCalendar() deps.Dependency { func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dankcalendar-git")) return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dux-calendar-git"))
} }
func (a *ArchDistribution) detectXDGPortal() deps.Dependency { func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
@@ -155,19 +155,17 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
} }
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency { func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin") installed := a.packageInstalled("dux-greeter-git") || a.packageInstalled("dux-greeter-bin")
dep := a.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", installed) dep := a.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", installed)
dep.CanToggle = true dep.CanToggle = true
if a.packageInstalled("greetd-dms-greeter-git") { if a.packageInstalled("dux-greeter-git") {
dep.Variant = deps.VariantGit dep.Variant = deps.VariantGit
} }
return dep return dep
} }
func (a *ArchDistribution) packageInstalled(pkg string) bool { func (a *ArchDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("pacman", "-Q", pkg) return exec.Command("pacman", "-Q", pkg).Run() == nil
err := cmd.Run()
return err == nil
} }
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package // parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
@@ -270,7 +268,7 @@ func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageM
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR} return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR} return PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
} }
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
@@ -286,30 +284,30 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-search-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-search-bin", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-calendar-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-calendar-bin", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit { if variant == deps.VariantGit {
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-greeter-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-greeter-bin", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
if forceDMSGit || variant == deps.VariantGit { if forceDMSGit || variant == deps.VariantGit {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dux-shell-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem} return PackageMapping{Name: "dux-shell", Repository: RepoTypeSystem}
} }
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency { func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
@@ -396,7 +394,7 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags) systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") { if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dux-shell") {
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil { if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to preinstall quickshell-git: %w", err) return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
} }
@@ -566,7 +564,7 @@ func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPass
Progress: 0.18, Progress: 0.18,
Step: "Building quickshell-git before system packages...", Step: "Building quickshell-git before system packages...",
IsComplete: false, IsComplete: false,
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict", CommandInfo: "Installing quickshell-git ahead of dux-shell to avoid conflict",
} }
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32) return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
} }
@@ -587,7 +585,8 @@ func (a *ArchDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap
} }
if swap.To.Repository == RepoTypeAUR { if swap.To.Repository == RepoTypeAUR {
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, swap.From.Name, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil { from := swap.From.Name
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, from, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil {
return err return err
} }
continue continue
@@ -608,11 +607,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", "))) a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
args := []string{"pacman", "-S", "--needed", "--noconfirm"} args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, packages...)
if slices.Contains(packages, "dms-shell") {
args = append(args, "--assume-installed", "dms-shell-compositor=1")
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages, Phase: PhaseSystemPackages,
@@ -658,7 +653,7 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
} }
} }
// Reorder packages to ensure dms-shell-git dependencies are installed first // Reorder packages to ensure dux-shell-git dependencies are installed first
orderedPackages := a.reorderAURPackages(packages) orderedPackages := a.reorderAURPackages(packages)
baseProgress := 0.67 baseProgress := 0.67
@@ -699,7 +694,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
var dmsShell []string var dmsShell []string
for _, pkg := range packages { for _, pkg := range packages {
if pkg == "dms-shell-git" { if pkg == "dux-shell-git" {
dmsShell = append(dmsShell, pkg) dmsShell = append(dmsShell, pkg)
} else { } else {
isDep := false isDep := false
@@ -735,7 +730,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
return fmt.Errorf("failed to get user home directory: %w", err) return fmt.Errorf("failed to get user home directory: %w", err)
} }
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "aur-builds", pkg) buildDir := filepath.Join(homeDir, ".cache", "duxinstall", "aur-builds", pkg)
// Clean up any existing cache first // Clean up any existing cache first
if err := os.RemoveAll(buildDir); err != nil { if err := os.RemoveAll(buildDir); err != nil {
@@ -781,17 +776,18 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
} }
} }
if pkg == "dms-shell-git" { if pkg == "dux-shell-git" {
srcinfoPath := filepath.Join(packageDir, ".SRCINFO") srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
depsToRemove := []string{ // extra ships dgop only as dux-gop (provides dgop), which makepkg resolves but pacman -Si does not
"depends = quickshell", srcinfoEdits := []string{
"depends = dgop", "/depends = quickshell/d",
`s/^\(\s*depends = \)dgop$/\1dux-gop/`,
} }
for _, dep := range depsToRemove { for _, edit := range srcinfoEdits {
sedCmd := exec.CommandContext(ctx, "sed", "-i", fmt.Sprintf("/%s/d", dep), srcinfoPath) sedCmd := exec.CommandContext(ctx, "sed", "-i", edit, srcinfoPath)
if err := sedCmd.Run(); err != nil { if err := sedCmd.Run(); err != nil {
return fmt.Errorf("failed to remove dependency %s from .SRCINFO for %s: %w", dep, pkg, err) return fmt.Errorf("failed to patch .SRCINFO for %s: %w", pkg, err)
} }
} }
} }
+1 -1
View File
@@ -659,7 +659,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
if err != nil { if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err) return fmt.Errorf("failed to get user home directory: %w", err)
} }
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds") tmpDir := filepath.Join(homeDir, ".cache", "duxinstall", "manual-builds")
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
+93 -5
View File
@@ -3,6 +3,7 @@ package distros
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"os/exec" "os/exec"
"strings" "strings"
@@ -23,9 +24,6 @@ func init() {
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan) return NewFedoraDistribution(config, logChan)
}) })
Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan) return NewFedoraDistribution(config, logChan)
}) })
@@ -54,6 +52,8 @@ type FedoraDistribution struct {
*BaseDistribution *BaseDistribution
*ManualPackageInstaller *ManualPackageInstaller
config DistroConfig config DistroConfig
rpmInstalled func(pkg string) bool
} }
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution { func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
@@ -138,6 +138,9 @@ func (f *FedoraDistribution) detectXDGPortal() deps.Dependency {
} }
func (f *FedoraDistribution) packageInstalled(pkg string) bool { func (f *FedoraDistribution) packageInstalled(pkg string) bool {
if f.rpmInstalled != nil {
return f.rpmInstalled(pkg)
}
cmd := exec.Command("rpm", "-q", pkg) cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run() err := cmd.Run()
return err == nil return err == nil
@@ -150,21 +153,33 @@ 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 { if err != nil {
return false return rpmLooksLikeGitBuild(pkg)
} }
return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(coprRepo, "/", ":")) return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(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 {
return false
}
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 {
if err := f.enableCOPRRepos(ctx, swapTargets(swaps, RepoTypeCOPR), sudoPassword, progressChan); err != nil { if err := f.enableCOPRRepos(ctx, swapTargets(swaps, RepoTypeCOPR), sudoPassword, progressChan); err != nil {
return err return err
@@ -744,3 +759,76 @@ 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 coprRepoFilePath(repo string) string {
owner, project, _ := strings.Cut(repo, "/")
return fmt.Sprintf("/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:%s:%s.repo", owner, project)
}
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",
coprRepoFilePath(repo), owner, project, chroot, owner, project, chroot)
}
// Shown to the user, never run. Terra-only packages have no rpm-ostree path.
func (f *FedoraDistribution) LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string {
var commands []string
var uninstall []string
skip := maps.Clone(disabledFlags)
if skip == nil {
skip = map[string]bool{}
}
// rpm-ostree refuses to install what is already layered, so a reinstall mark only means a variant switch.
for _, dep := range dependencies {
if !reinstallFlags[dep.Name] || skip[dep.Name] || dep.Status != deps.StatusInstalled {
continue
}
swap, status := PlanVariantSwap(f, wm, dep.Name, dep.Variant)
if status != SwapNeeded {
skip[dep.Name] = true
continue
}
uninstall = append(uninstall, swap.From.Name)
if swap.From.Name == swap.To.Name && swap.From.Repository == RepoTypeCOPR {
commands = append(commands, "sudo rm -f "+coprRepoFilePath(swap.From.RepoURL))
}
}
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 /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo")
}
}
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")
}
+127
View File
@@ -0,0 +1,127 @@
package distros
import (
"fmt"
"os/exec"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/spf13/afero"
)
// Markers only reached when /run/ostree-booted is absent, such as a chroot.
var immutableIDs = map[string]bool{
"bluefin": true,
"bazzite": true,
"aurora": true,
"secureblue": true,
"zirconium": true,
"fedora-iot": true,
"fedora-coreos": true,
"opensuse-microos": true,
"opensuse-aeon": true,
"steamos": true,
"org.gnome.os": true,
"blendos": true,
"arkane": true,
}
var immutableVariantMarkers = []string{"silverblue", "kinoite", "sericea", "onyx", "atomic", "coreos", "iot"}
var immutableMarkerFiles = []struct {
path string
reason string
}{
{"/run/ostree-booted", "ostree-based system"},
{"/usr/sbin/transactional-update", "transactional-update system"},
{"/usr/bin/abroot", "ABRoot system"},
}
const ZirconiumSwitchCommand = "sudo bootc switch ghcr.io/zirconium-dev/zirconium:latest"
// The commands are shown to the user, never run.
type PackageLayerer interface {
LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string
}
func DetectImmutable() (bool, string) {
fs := afero.NewOsFs()
return detectImmutable(fs, readOSRelease(fs))
}
func detectImmutable(fs afero.Fs, osRelease map[string]string) (bool, string) {
for _, marker := range immutableMarkerFiles {
if exists, _ := afero.Exists(fs, marker.path); exists {
return true, fmt.Sprintf("%s (%s)", marker.reason, marker.path)
}
}
id := strings.ToLower(osRelease["ID"])
if immutableIDs[id] {
return true, "os-release ID=" + id
}
variantID := strings.ToLower(osRelease["VARIANT_ID"])
for _, marker := range immutableVariantMarkers {
if strings.Contains(variantID, marker) {
return true, "os-release VARIANT_ID=" + variantID
}
}
for _, like := range strings.Fields(strings.ToLower(osRelease["ID_LIKE"])) {
if like == "microos" || like == "ostree" {
return true, "os-release ID_LIKE=" + like
}
}
return false, ""
}
func ImmutableGuidance(info *OSInfo) string {
var b strings.Builder
fmt.Fprintf(&b, "%s is an image-based system: %s.\n\n", info.PrettyName, info.ImmutableReason)
b.WriteString("duxinstall has no package mapping for this image, so it cannot tell what is installed or deploy configuration for it. Nothing was changed on this system.\n\n")
b.WriteString("See " + site.Docs + "/getting-started for other options.")
return b.String()
}
func PendingDependencies(dependencies []deps.Dependency, reinstallFlags, disabledFlags map[string]bool) []deps.Dependency {
var pending []deps.Dependency
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pending = append(pending, dep)
}
return pending
}
// detectDMS keys off the clone directory; packaged hosts only have /usr/bin/dms.
func ApplyImmutableDetection(dependencies []deps.Dependency) []deps.Dependency {
if _, err := exec.LookPath("dms"); err != nil {
return dependencies
}
for i := range dependencies {
if dependencies[i].Name != "dms (Dux Shell)" || dependencies[i].Status != deps.StatusMissing {
continue
}
dependencies[i].Status = deps.StatusInstalled
// "dms (git) v2.0.0-5333" or "dms v2.0.0"
out, err := exec.Command("dms", "version").Output()
if err != nil {
continue
}
fields := strings.Fields(string(out))
if len(fields) > 0 {
dependencies[i].Version = fields[len(fields)-1]
}
if strings.Contains(string(out), "(git)") {
dependencies[i].Variant = deps.VariantGit
}
}
return dependencies
}
+218
View File
@@ -0,0 +1,218 @@
package distros
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/spf13/afero"
)
func writeOSRelease(t *testing.T, fs afero.Fs, body string) {
t.Helper()
if err := afero.WriteFile(fs, "/etc/os-release", []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestDetectImmutable(t *testing.T) {
cases := []struct {
name string
files []string
osRelease map[string]string
want bool
}{
{"ostree marker beats plain fedora id", []string{"/run/ostree-booted"}, map[string]string{"ID": "fedora"}, true},
{"silverblue variant without marker", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "silverblue"}, true},
{"sway atomic variant", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "sway-atomic"}, true},
{"workstation fedora", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "workstation"}, false},
{"bazzite id", nil, map[string]string{"ID": "bazzite", "ID_LIKE": "fedora"}, true},
{"microos", []string{"/usr/sbin/transactional-update"}, map[string]string{"ID": "opensuse-microos"}, true},
{"aeon by id_like", nil, map[string]string{"ID": "opensuse-aeon-rc", "ID_LIKE": "suse opensuse microos"}, true},
{"tumbleweed", nil, map[string]string{"ID": "opensuse-tumbleweed", "ID_LIKE": "suse opensuse"}, false},
{"vanilla by abroot", []string{"/usr/bin/abroot"}, map[string]string{"ID": "ubuntu", "ID_LIKE": "debian"}, true},
{"steamos", nil, map[string]string{"ID": "steamos", "ID_LIKE": "arch"}, true},
{"arch", nil, map[string]string{"ID": "arch"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
for _, f := range c.files {
if err := afero.WriteFile(fs, f, nil, 0o644); err != nil {
t.Fatal(err)
}
}
got, reason := detectImmutable(fs, c.osRelease)
if got != c.want {
t.Fatalf("detectImmutable = %v (%q), want %v", got, reason, c.want)
}
if got && reason == "" {
t.Fatal("immutable without a reason")
}
})
}
}
func TestGetOSInfoImmutable(t *testing.T) {
t.Run("unregistered image id is accepted when immutable", func(t *testing.T) {
fs := afero.NewMemMapFs()
writeOSRelease(t, fs, "ID=\"zirconium\"\nID_LIKE=\"zirconium\"\nPRETTY_NAME=\"Zirconium\"\nVERSION_ID=44\n")
if err := afero.WriteFile(fs, "/run/ostree-booted", nil, 0o644); err != nil {
t.Fatal(err)
}
info, err := getOSInfo(fs, "amd64")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !info.Immutable || info.Distribution.ID != "zirconium" || info.VersionID != "44" {
t.Fatalf("got %+v", info)
}
})
t.Run("fedora derivative resolves through id_like", func(t *testing.T) {
fs := afero.NewMemMapFs()
writeOSRelease(t, fs, "ID=bazzite\nID_LIKE=fedora\nPRETTY_NAME=Bazzite\nVERSION_ID=44\n")
info, err := getOSInfo(fs, "amd64")
if err != nil {
t.Fatal(err)
}
if !info.Immutable || info.ID != "bazzite" || info.Distribution.ID != "fedora" {
t.Fatalf("got %+v", info)
}
})
t.Run("unregistered mutable id still errors", func(t *testing.T) {
fs := afero.NewMemMapFs()
writeOSRelease(t, fs, "ID=slackware\nPRETTY_NAME=Slackware\n")
if _, err := getOSInfo(fs, "amd64"); err == nil {
t.Fatal("expected unsupported distribution error")
}
})
t.Run("silverblue resolves to fedora but is flagged", func(t *testing.T) {
fs := afero.NewMemMapFs()
writeOSRelease(t, fs, "ID=fedora\nVARIANT_ID=silverblue\nPRETTY_NAME=\"Fedora Linux 44 (Silverblue)\"\nVERSION_ID=44\n")
info, err := getOSInfo(fs, "amd64")
if err != nil {
t.Fatal(err)
}
if !info.Immutable || info.Distribution.ID != "fedora" || info.VariantID != "silverblue" {
t.Fatalf("got %+v", info)
}
})
t.Run("bluefin is no longer a mutable fedora", func(t *testing.T) {
if IsDistroSupported("bluefin") {
t.Fatal("bluefin must not be registered as an installable distribution")
}
})
}
func TestPendingDependencies(t *testing.T) {
all := []deps.Dependency{
{Name: "dms (Dux Shell)", Status: deps.StatusMissing},
{Name: "niri", Status: deps.StatusInstalled},
{Name: "quickshell", Status: deps.StatusInstalled},
{Name: "danksearch", Status: deps.StatusMissing},
}
got := PendingDependencies(all, map[string]bool{"quickshell": true}, map[string]bool{"danksearch": true})
if len(got) != 2 || got[0].Name != "dms (Dux Shell)" || got[1].Name != "quickshell" {
t.Fatalf("got %+v", got)
}
}
func TestApplyImmutableDetection(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}, {Name: "niri", Status: deps.StatusMissing}})
if got[0].Status != deps.StatusInstalled || got[1].Status != deps.StatusMissing {
t.Fatalf("got %+v", got)
}
}
func TestFedoraLayerCommands(t *testing.T) {
f := NewFedoraDistribution(Registry["fedora"], nil)
dependencies := []deps.Dependency{
{Name: "dms (Dux Shell)", Status: deps.StatusMissing},
{Name: "niri", Status: deps.StatusMissing, Variant: deps.VariantGit},
{Name: "ghostty", Status: deps.StatusInstalled},
{Name: "matugen", Status: deps.StatusMissing},
{Name: "danksearch", Status: deps.StatusMissing},
{Name: "xwayland-satellite", Status: deps.StatusMissing},
}
got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, nil, map[string]bool{"danksearch": true}), "\n")
for _, want := range []string{
"_copr:copr.fedorainfracloud.org:avengemedia:dms.repo https://copr.fedorainfracloud.org/coprs/avengemedia/dms/repo/fedora-$(rpm -E %fedora)/avengemedia-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, "avengemedia:danklinux.repo https") != 1 {
t.Fatalf("danklinux repo must be fetched exactly once:\n%s", got)
}
}
func TestImmutableGuidance(t *testing.T) {
steam := &OSInfo{PrettyName: "SteamOS", Distribution: DistroInfo{ID: "steamos"}, ImmutableReason: "os-release ID=steamos"}
got := ImmutableGuidance(steam)
if !strings.Contains(got, "SteamOS is an image-based system") || !strings.Contains(got, "Nothing was changed") {
t.Fatalf("got %q", got)
}
}
func TestFedoraLayerCommandsSwapVariants(t *testing.T) {
f := NewFedoraDistribution(Registry["fedora"], nil)
installed := map[string]bool{"dms-greeter": true, "dms": true}
f.rpmInstalled = func(pkg string) bool { return installed[pkg] }
orig := installedFromCOPR
installedFromCOPR = func(pkg, repo string) bool { return pkg == "dms" && repo == "avengemedia/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:avengemedia:dms-git.repo",
"sudo rpm-ostree install dms dms-greeter-git --uninstall dms dms-greeter",
} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in:\n%s", want, got)
}
}
if strings.Contains(got, "danksearch") {
t.Fatalf("reinstall mark without a variant change must not be layered:\n%s", got)
}
}
func TestApplyImmutableDetectionReadsVariant(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\necho 'dms (git) v2.0.0-5333'\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}})
if got[0].Variant != deps.VariantGit || got[0].Version != "v2.0.0-5333" {
t.Fatalf("got %+v", got[0])
}
}
+5 -5
View File
@@ -95,8 +95,8 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
m.log("Installing niri from source...") m.log("Installing niri from source...")
homeDir, _ := os.UserHomeDir() homeDir, _ := os.UserHomeDir()
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build") buildDir := filepath.Join(homeDir, ".cache", "duxinstall", "niri-build")
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp") tmpDir := filepath.Join(homeDir, ".cache", "duxinstall", "tmp")
if err := os.MkdirAll(buildDir, 0o755); err != nil { if err := os.MkdirAll(buildDir, 0o755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err) return fmt.Errorf("failed to create build directory: %w", err)
} }
@@ -173,7 +173,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
return fmt.Errorf("HOME environment variable not set") return fmt.Errorf("HOME environment variable not set")
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
@@ -279,7 +279,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
return fmt.Errorf("HOME environment variable not set") return fmt.Errorf("HOME environment variable not set")
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
@@ -342,7 +342,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
return fmt.Errorf("HOME environment variable not set") return fmt.Errorf("HOME environment variable not set")
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
+1 -1
View File
@@ -703,7 +703,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)
} }
+62 -31
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs" "github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
"github.com/spf13/afero"
) )
// DistroInfo contains basic information about a distribution // DistroInfo contains basic information about a distribution
@@ -19,12 +20,18 @@ type DistroInfo struct {
// OSInfo contains complete OS information // OSInfo contains complete OS information
type OSInfo struct { type OSInfo struct {
// Raw os-release ID; Distribution.ID is the registered distro it resolves to via ID_LIKE (bazzite -> fedora).
ID string
Distribution DistroInfo Distribution DistroInfo
Version string Version string
VersionID string VersionID string
VersionCodename string VersionCodename string
PrettyName string PrettyName string
Architecture string Architecture string
IDLike []string
VariantID string
Immutable bool
ImmutableReason string
} }
// GetOSInfo detects the current OS and returns information about it // GetOSInfo detects the current OS and returns information about it
@@ -37,50 +44,74 @@ func GetOSInfo() (*OSInfo, error) {
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH)) return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
} }
info := &OSInfo{ return getOSInfo(afero.NewOsFs(), runtime.GOARCH)
Architecture: runtime.GOARCH, }
// An unregistered ID is only an error on a mutable system; immutable hosts
// stop at the guidance screen and need no Distribution implementation.
func getOSInfo(fs afero.Fs, arch string) (*OSInfo, error) {
values := readOSRelease(fs)
if len(values) == 0 {
return nil, fmt.Errorf("cannot read /etc/os-release")
} }
file, err := os.Open("/etc/os-release") info := &OSInfo{
Architecture: arch,
Version: values["VERSION"],
VersionID: values["VERSION_ID"],
VersionCodename: values["VERSION_CODENAME"],
PrettyName: values["PRETTY_NAME"],
IDLike: strings.Fields(values["ID_LIKE"]),
VariantID: values["VARIANT_ID"],
}
if info.VersionID == "" {
info.VersionID = values["BUILD_ID"]
}
info.Immutable, info.ImmutableReason = detectImmutable(fs, values)
info.ID = values["ID"]
id := info.ID
config, exists := Registry[id]
if !exists && !info.Immutable {
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", id))
}
if !exists {
for _, like := range info.IDLike {
if likeConfig, ok := Registry[like]; ok {
id, config = like, likeConfig
break
}
}
}
info.Distribution = DistroInfo{ID: id, HexColorCode: config.ColorHex}
return info, nil
}
func readOSRelease(fs afero.Fs) map[string]string {
values := make(map[string]string)
file, err := fs.Open("/etc/os-release")
if err != nil { if err != nil {
return nil, err return values
} }
defer file.Close() defer file.Close()
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() parts := strings.SplitN(scanner.Text(), "=", 2)
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 { if len(parts) != 2 {
continue continue
} }
values[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'")
key := parts[0]
value := strings.Trim(parts[1], "\"'")
switch key {
case "ID":
config, exists := Registry[value]
if !exists {
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value))
}
info.Distribution = DistroInfo{
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() return values
}
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
+49 -3
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)
@@ -149,10 +155,10 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
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)
@@ -265,6 +271,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 +424,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)
+105 -52
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,12 +104,18 @@ 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)
if err != nil { if err != nil {
@@ -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 {
if err != nil { sudoPassword, err = r.installPackages(distro, dependencies, wm, reinstallItems, disabledItems)
return err if err != nil {
} return err
// 8. Install packages
fmt.Fprintln(os.Stdout, "Installing packages...")
r.log("Starting package installation")
progressChan := make(chan distros.InstallProgressMsg, 100)
installErr := make(chan error, 1)
go func() {
defer close(progressChan)
installErr <- distro.InstallPackages(
context.Background(),
dependencies,
wm,
sudoPassword,
reinstallItems,
disabledItems,
false, // skipGlobalUseFlags
progressChan,
)
}()
// Consume progress messages and print them
for msg := range progressChan {
if msg.Error != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
} else if msg.Step != "" {
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
}
if msg.LogOutput != "" {
r.log(msg.LogOutput)
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
} }
} }
if err := <-installErr; err != nil { useSystemd := distros.HasSystemd()
return fmt.Errorf("package installation failed: %w", err)
}
useSystemd := true // 9. Greeter setup. On images dms-greeter escalates itself, so it is left to the user.
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid { if osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
useSystemd = false fmt.Fprintln(os.Stdout, "Greeter: run 'dms-greeter install --yes' to configure greetd.")
} }
if !osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
// 9. Greeter setup (if dms-greeter was included)
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...") fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
logFunc := func(line string) { logFunc := func(line string) {
r.log(line) r.log(line)
@@ -531,14 +518,14 @@ func (r *Runner) resolveSudoPassword() (string, error) {
return "", fmt.Errorf( return "", fmt.Errorf(
"sudo authentication required but no cached credentials found\n" + "sudo authentication required but no cached credentials found\n" +
"Options:\n" + "Options:\n" +
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" + " 1. Run 'sudo -v' before duxinstall to cache credentials\n" +
" 2. Configure passwordless sudo for your user", " 2. Configure passwordless sudo for your user",
) )
case privesc.ToolDoas: case privesc.ToolDoas:
return "", fmt.Errorf( return "", fmt.Errorf(
"doas authentication required but no cached credentials found\n" + "doas authentication required but no cached credentials found\n" +
"Options:\n" + "Options:\n" +
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" + " 1. Run 'doas true' before duxinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user", " 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
) )
case privesc.ToolRun0: case privesc.ToolRun0:
@@ -569,3 +556,69 @@ func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
} }
return false return false
} }
func (r *Runner) requireLayeredPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) error {
pending := distros.PendingDependencies(dependencies, reinstallItems, disabledItems)
if len(pending) == 0 {
return nil
}
layerer, ok := distro.(distros.PackageLayerer)
if !ok {
return fmt.Errorf("this image has no known way to add packages; install them with the image's own tooling, reboot, and run duxinstall again")
}
fmt.Fprintln(os.Stdout, "Packages must be added to the image first. Run these, reboot, then run duxinstall again:")
fmt.Fprintln(os.Stdout)
for _, cmd := range layerer.LayerCommands(dependencies, wm, reinstallItems, disabledItems) {
fmt.Fprintf(os.Stdout, " %s\n", cmd)
}
fmt.Fprintln(os.Stdout)
r.log("Aborted: packages not layered on image-based host")
return ErrPackagesNotLayered
}
func (r *Runner) installPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) (string, error) {
// 7. Authenticate sudo
sudoPassword, err := r.resolveSudoPassword()
if err != nil {
return "", err
}
// 8. Install packages
fmt.Fprintln(os.Stdout, "Installing packages...")
r.log("Starting package installation")
progressChan := make(chan distros.InstallProgressMsg, 100)
installErr := make(chan error, 1)
go func() {
defer close(progressChan)
installErr <- distro.InstallPackages(
context.Background(),
dependencies,
wm,
sudoPassword,
reinstallItems,
disabledItems,
false, // skipGlobalUseFlags
progressChan,
)
}()
for msg := range progressChan {
if msg.Error != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
} else if msg.Step != "" {
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
}
if msg.LogOutput != "" {
r.log(msg.LogOutput)
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
}
}
if err := <-installErr; err != nil {
return "", fmt.Errorf("package installation failed: %w", err)
}
return sudoPassword, nil
}
+8 -5
View File
@@ -23,15 +23,18 @@ type FileLogger struct {
func NewFileLogger() (*FileLogger, error) { func NewFileLogger() (*FileLogger, error) {
timestamp := time.Now().Unix() timestamp := time.Now().Unix()
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp. logDir := os.Getenv("DUXINSTALL_LOG_DIR")
logDir := os.Getenv("DANKINSTALL_LOG_DIR") if logDir == "" {
// Pre-rename name, still set in existing scripts.
logDir = os.Getenv("DANKINSTALL_LOG_DIR")
}
if logDir == "" { if logDir == "" {
logDir = "/tmp" logDir = "/tmp"
} }
if err := os.MkdirAll(logDir, 0o755); err != nil { if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err) return nil, fmt.Errorf("failed to create log directory: %w", err)
} }
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp)) logPath := filepath.Join(logDir, fmt.Sprintf("duxinstall-%d.log", timestamp))
file, err := os.Create(logPath) file, err := os.Create(logPath)
if err != nil { if err != nil {
@@ -49,7 +52,7 @@ func NewFileLogger() (*FileLogger, error) {
passwordRe: passwordRe, passwordRe: passwordRe,
} }
header := fmt.Sprintf("=== DankInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339)) header := fmt.Sprintf("=== DuxInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339))
logger.writeToFile(header) logger.writeToFile(header)
return logger, nil return logger, nil
@@ -102,7 +105,7 @@ func (l *FileLogger) Close() error {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
footer := fmt.Sprintf("\n=== DankInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339)) footer := fmt.Sprintf("\n=== DuxInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339))
l.writer.WriteString(footer) //nolint:errcheck l.writer.WriteString(footer) //nolint:errcheck
l.writer.Flush() l.writer.Flush()
+19
View File
@@ -0,0 +1,19 @@
// Package mangoconf holds what DMS knows about Mango's config files: where they
// live, the session target, and the markers DMS leaves in the binds fragment.
package mangoconf
import (
"fmt"
"strings"
)
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
const maxValueLen = 255
func CheckLine(line string) error {
_, value, ok := strings.Cut(line, "=")
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
return nil
}
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
}
-216
View File
@@ -1,216 +0,0 @@
// Package mangoconf maps Mango config keys between the run-together names of
// Mango <= 0.17.5 and the snake_case names of upstream 8adc6bc3 (mangowm/mango#1491).
// Neither parser accepts the other's names.
package mangoconf
import (
"bytes"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"sync"
)
type Dialect int
const (
Legacy Dialect = iota
Snake
)
// Upstream migration order: where two legacy names share a snake name, the
// window-rule spelling comes first and wins the reverse lookup.
var renames = [][2]string{
{"windowrule-once", "window_rule_once"},
{"windowrule", "window_rule"},
{"monitorrule", "monitor_rule"},
{"tagrule", "tag_rule"},
{"layerrule", "layer_rule"},
{"devicerule", "device_rule"},
{"source-optional", "source_optional"},
{"exec-once", "exec_once"},
{"keymode", "key_mode"},
{"isnoborder", "no_border"},
{"isnoshadow", "no_shadow"},
{"isnoradius", "no_radius"},
{"isnoanimation", "no_animation"},
{"isnosizehint", "no_size_hint"},
{"noblur", "no_blur"},
{"noshadow", "no_shadow"},
{"noanim", "no_animation"},
{"nofocus", "no_focus"},
{"nofadein", "no_fade_in"},
{"nofadeout", "no_fade_out"},
{"noswallow", "no_swallow"},
{"special_gappih", "special_gap_inner_horizontal"},
{"special_gappiv", "special_gap_inner_vertical"},
{"special_gappoh", "special_gap_outer_horizontal"},
{"special_gappov", "special_gap_outer_vertical"},
{"gappih", "gap_inner_horizontal"},
{"gappiv", "gap_inner_vertical"},
{"gappoh", "gap_outer_horizontal"},
{"gappov", "gap_outer_vertical"},
{"overviewgappi", "overview_gap_inner"},
{"overviewgappo", "overview_gap_outer"},
{"default_mfact", "default_master_factor"},
{"default_nmaster", "default_master_count"},
{"animation_curve_opafadein", "animation_curve_opacity_fade_in"},
{"animation_curve_opafadeout", "animation_curve_opacity_fade_out"},
{"dwindle_hsplit", "dwindle_horizontal_split"},
{"dwindle_vsplit", "dwindle_vertical_split"},
{"fadein_begin_opacity", "fade_in_begin_opacity"},
{"fadeout_begin_opacity", "fade_out_begin_opacity"},
{"focusdir_only_zone_overlap", "focus_direction_only_zone_overlap"},
{"force_fakemaximize", "force_fake_maximize"},
{"idleinhibit_ignore_visible", "idle_inhibit_ignore_visible"},
{"idleinhibit_when_focus", "idle_inhibit_when_focus"},
{"idleinhibit_when_fullscreen", "idle_inhibit_when_fullscreen"},
{"syncobj_enable", "sync_obj_enable"},
{"isfakefullscreen", "is_fake_fullscreen"},
{"isfloating", "is_floating"},
{"isfullscreen", "is_fullscreen"},
{"isunglobal", "is_unmanaged_global"},
{"isglobal", "is_global"},
{"isnamedscratchpad", "is_named_scratchpad"},
{"isopensilent", "is_open_silent"},
{"isoverlay", "is_overlay"},
{"istagsilent", "is_tag_silent"},
{"isterm", "is_term"},
{"appid", "app_id"},
{"bordercolor", "border_color"},
{"borderpx", "border_px"},
{"dropcolor", "drop_color"},
{"focuscolor", "focus_color"},
{"globalcolor", "global_color"},
{"globalkeybinding", "global_key_binding"},
{"maximizescreencolor", "maximized_screen_color"},
{"mfact", "master_factor"},
{"nmaster", "master_count"},
{"numlockon", "numlock_on"},
{"offsetx", "offset_x"},
{"offsety", "offset_y"},
{"overlaycolor", "overlay_color"},
{"rootcolor", "root_color"},
{"scratchpadcolor", "scratchpad_color"},
{"shadowscolor", "shadows_color"},
{"sloppyfocus", "sloppy_focus"},
{"smartgaps", "smart_gaps"},
{"splitcolor", "split_color"},
{"urgentcolor", "urgent_color"},
{"warpcursor", "warp_cursor"},
}
var toSnake, toLegacy = func() (map[string]string, map[string]string) {
fwd := make(map[string]string, len(renames))
rev := make(map[string]string, len(renames))
for _, r := range renames {
fwd[r[0]] = r[1]
if _, ok := rev[r[1]]; !ok {
rev[r[1]] = r[0]
}
}
return fwd, rev
}()
// Normalize returns the snake_case name for a key in either dialect.
func Normalize(key string) string {
if s, ok := toSnake[key]; ok {
return s
}
return key
}
// Key spells a snake_case key in this dialect.
func (d Dialect) Key(snake string) string {
if d == Snake {
return snake
}
if l, ok := toLegacy[snake]; ok {
return l
}
return snake
}
var keyLine = regexp.MustCompile(`^(\s*)([A-Za-z][A-Za-z_-]*)(\s*=\s*)(.*)$`)
// Translate respells every line key, and the field keys of rule lines, in this dialect.
func (d Dialect) Translate(text string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
m := keyLine.FindStringSubmatch(line)
if m == nil {
continue
}
key := Normalize(m[2])
value := m[4]
switch key {
case "window_rule", "window_rule_once", "tag_rule":
value = d.translateFields(value, nil)
case "layer_rule":
value = d.translateFields(value, legacyLayerFields)
}
lines[i] = m[1] + d.Key(key) + m[3] + value
}
return strings.Join(lines, "\n")
}
// Layer rules spell these without the window rules' "is" prefix.
var legacyLayerFields = map[string]string{"no_shadow": "noshadow", "no_animation": "noanim"}
func (d Dialect) translateFields(value string, legacyOverride map[string]string) string {
parts := strings.Split(value, ",")
for i, part := range parts {
k, v, ok := strings.Cut(part, ":")
if !ok {
continue
}
trimmed := strings.TrimSpace(k)
key := d.Key(Normalize(trimmed))
if l, ok := legacyOverride[Normalize(trimmed)]; ok && d == Legacy {
key = l
}
parts[i] = strings.Replace(k, trimmed, key, 1) + ":" + v
}
return strings.Join(parts, ",")
}
// Legacy builds carry exec_once as a symbol name, so it cannot tell the dialects apart.
var snakeMarker = []byte("gap_inner_horizontal")
var (
detectOnce sync.Once
detected Dialect
)
// Detect reads the mango binary: snake_case shipped on master builds still reporting 0.17.5, before the 0.18.0 release.
func Detect() Dialect {
detectOnce.Do(func() {
path, err := exec.LookPath("mango")
if err != nil {
return
}
detected = DetectBinary(path)
})
return detected
}
func DetectBinary(path string) Dialect {
data, err := os.ReadFile(path)
if err != nil || !bytes.Contains(data, snakeMarker) {
return Legacy
}
return Snake
}
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
const maxValueLen = 255
func CheckLine(line string) error {
_, value, ok := strings.Cut(line, "=")
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
return nil
}
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
}
-68
View File
@@ -1,68 +0,0 @@
package mangoconf
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTranslateRespellsKeysBothWays(t *testing.T) {
legacy := "exec-once=dms run\nsource=./dms/colors.conf\ngappih = 5\nbordercolor = 0x595959ff\nwindowrule=appid:foo,isfloating:1,isnoanimation:1\nlayerrule=noanim:1,noshadow:1,layer_name:rofi\n# exec-once=kept\n"
snake := "exec_once=dms run\nsource=./dms/colors.conf\ngap_inner_horizontal = 5\nborder_color = 0x595959ff\nwindow_rule=app_id:foo,is_floating:1,no_animation:1\nlayer_rule=no_animation:1,no_shadow:1,layer_name:rofi\n# exec-once=kept\n"
assert.Equal(t, snake, Snake.Translate(legacy))
assert.Equal(t, legacy, Legacy.Translate(snake))
assert.Equal(t, legacy, Legacy.Translate(legacy))
assert.Equal(t, "tag_rule=id:1,master_factor:0.6,master_count:2\n", Snake.Translate("tagrule=id:1,mfact:0.6,nmaster:2\n"))
}
func TestDetectBinary(t *testing.T) {
dir := t.TempDir()
old := filepath.Join(dir, "old")
cur := filepath.Join(dir, "new")
require.NoError(t, os.WriteFile(old, []byte("\x00run_exec_once\x00gappih\x00"), 0o755))
require.NoError(t, os.WriteFile(cur, []byte("\x00exec_once\x00gap_inner_horizontal\x00"), 0o755))
assert.Equal(t, Legacy, DetectBinary(old))
assert.Equal(t, Snake, DetectBinary(cur))
assert.Equal(t, Legacy, DetectBinary(filepath.Join(dir, "missing")))
}
func TestMigrateLeavesUserConfigUnlessAsked(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
main := filepath.Join(dir, "config.conf")
layout := filepath.Join(dir, "dms", "layout.conf")
input := filepath.Join(dir, "dms", "input.conf")
require.NoError(t, os.WriteFile(main, []byte("exec-once=dms run\n"), 0o644))
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
require.NoError(t, os.WriteFile(input, []byte("numlockon=1\n"), 0o644))
changed, err := Snake.Migrate(dir, false)
require.NoError(t, err)
assert.Equal(t, []string{layout, input}, changed)
data, _ := os.ReadFile(main)
assert.Equal(t, "exec-once=dms run\n", string(data))
data, _ = os.ReadFile(input)
assert.Equal(t, "numlock_on=1\n", string(data))
changed, err = Snake.Migrate(dir, true)
require.NoError(t, err)
assert.Equal(t, []string{main}, changed)
backups, _ := filepath.Glob(main + ".backup.*")
assert.Len(t, backups, 1)
}
func TestMigrateReportsUnwritableFragmentAndContinues(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
binds := filepath.Join(dir, "dms", "binds.conf")
layout := filepath.Join(dir, "dms", "layout.conf")
require.NoError(t, os.WriteFile(binds, []byte("exec-once=foo\n"), 0o444))
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
changed, err := Snake.Migrate(dir, false)
assert.ErrorContains(t, err, "binds.conf")
assert.Equal(t, []string{layout}, changed)
}
-57
View File
@@ -1,57 +0,0 @@
package mangoconf
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// DMS-owned fragments under mango/dms; config.conf is the user's and only
// migrated on request.
var fragments = []string{"binds.conf", "colors.conf", "layout.conf", "cursor.conf", "input.conf", "outputs.conf", "windowrules.conf"}
// Migrate respells the dms fragments, and config.conf (after a backup) when includeMain is set.
// It returns the files rewritten; an unwritable file is reported without stopping the others.
func (d Dialect) Migrate(mangoDir string, includeMain bool) ([]string, error) {
var changed []string
var errs []error
paths := make([]string, 0, len(fragments)+1)
for _, f := range fragments {
paths = append(paths, filepath.Join(mangoDir, "dms", f))
}
if includeMain {
paths = append(paths, filepath.Join(mangoDir, "config.conf"))
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
translated := d.Translate(string(data))
if translated == string(data) {
continue
}
if filepath.Base(path) == "config.conf" {
backup := path + ".backup." + time.Now().Format("2006-01-02_15-04-05")
if err := os.WriteFile(backup, data, 0o644); err != nil {
errs = append(errs, fmt.Errorf("backup %s: %w", path, err))
continue
}
}
if err := os.WriteFile(path, []byte(translated), 0o644); err != nil {
errs = append(errs, fmt.Errorf("write %s: %w", path, err))
continue
}
changed = append(changed, path)
}
return changed, errors.Join(errs...)
}
func (d Dialect) String() string {
if d == Snake {
return "snake"
}
return "legacy"
}
+23 -24
View File
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"math" "math"
"os" "os"
"os/exec" "os/exec"
@@ -20,7 +19,6 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/dank16" "github.com/duxlabs-dev/dux-shell/core/internal/dank16"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
"github.com/lucasb-eyer/go-colorful" "github.com/lucasb-eyer/go-colorful"
@@ -44,7 +42,6 @@ const (
TemplateKindGTK TemplateKindGTK
TemplateKindVSCode TemplateKindVSCode
TemplateKindEmacs TemplateKindEmacs
TemplateKindMango
) )
type TemplateDef struct { type TemplateDef struct {
@@ -63,7 +60,7 @@ var templateRegistry = []TemplateDef{
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true}, {ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"}, {ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"}, {ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE", Kind: TemplateKindMango}, {ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE"},
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"}, {ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"}, {ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"}, {ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"},
@@ -667,7 +664,7 @@ func userConfigSection(opts *Options) string {
if err != nil { if err != nil {
return "[config]\n\n" return "[config]\n\n"
} }
section := extractTOMLSection(string(data), "[config]", "[templates]") section := extractTOMLSection(string(data), "[config]", "\n[templates")
if section == "" { if section == "" {
return "[config]\n\n" return "[config]\n\n"
} }
@@ -725,12 +722,6 @@ output_path = '%s'
for _, editor := range vscodeEditors { for _, editor := range vscodeEditors {
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir) appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
} }
case TemplateKindMango:
configFile := tmpl.ConfigFile
if mangoconf.Detect() == mangoconf.Snake {
configFile = "mangowc-snake.toml"
}
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, configFile)
case TemplateKindEmacs: case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" { if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile) appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
@@ -749,7 +740,7 @@ output_path = '%s'
if opts.RunUserTemplates { if opts.RunUserTemplates {
if data, err := os.ReadFile(userConfigPath); err == nil { if data, err := os.ReadFile(userConfigPath); err == nil {
templatesSection := extractTOMLSection(string(data), "[templates]", "") templatesSection := extractTOMLSection("\n"+string(data), "\n[templates", "\n[config]")
if templatesSection != "" { if templatesSection != "" {
cfgFile.WriteString(templatesSection) cfgFile.WriteString(templatesSection)
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
@@ -965,6 +956,7 @@ var vscodeEditors = []vscodeEditor{
{"cursor", ".cursor"}, {"cursor", ".cursor"},
{"windsurf", ".windsurf"}, {"windsurf", ".windsurf"},
{"positron", ".positron"}, {"positron", ".positron"},
{"antigravity", ".antigravity-ide"},
{"vscode-insiders", ".vscode-insiders"}, {"vscode-insiders", ".vscode-insiders"},
} }
@@ -1154,21 +1146,32 @@ func runMatugen(baseArgs []string, sourceMode string) error {
// matugen's stderr is echoed as before and its tail travels in the error, so a failure // matugen's stderr is echoed as before and its tail travels in the error, so a failure
// reaching the shell through the socket still names the broken template or option. // reaching the shell through the socket still names the broken template or option.
// It goes through a file, not a pipe: with a pipe, Wait blocks until every hook
// child that inherited stderr exits, and a backgrounded post_hook never does (#3901).
func execMatugen(args []string) error { func execMatugen(args []string) error {
stderrFile, err := os.CreateTemp("", "matugen-stderr-*")
if err != nil {
return fmt.Errorf("matugen: %w", err)
}
defer os.Remove(stderrFile.Name())
defer stderrFile.Close()
cmd := exec.Command("matugen", args...) cmd := exec.Command("matugen", args...)
cmd.Env = utils.EnvWithUserBinPath(nil) cmd.Env = utils.EnvWithUserBinPath(nil)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
var stderr bytes.Buffer cmd.Stderr = stderrFile
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) runErr := cmd.Run()
err := cmd.Run()
if err == nil { stderr, _ := os.ReadFile(stderrFile.Name())
os.Stderr.Write(stderr)
if runErr == nil {
return nil return nil
} }
tail := stderrTail(stderr.String(), 6) tail := stderrTail(string(stderr), 6)
if tail == "" { if tail == "" {
return fmt.Errorf("matugen: %w", err) return fmt.Errorf("matugen: %w", runErr)
} }
return fmt.Errorf("matugen: %w\n%s", err, tail) return fmt.Errorf("matugen: %w\n%s", runErr, tail)
} }
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
@@ -1218,11 +1221,7 @@ func writeDryRunConfig(opts *Options) (string, error) {
} }
defer cfgFile.Close() defer cfgFile.Close()
section := userConfigSection(opts) cfgFile.WriteString(userConfigSection(opts))
if idx := strings.Index(section, "\n[templates"); idx != -1 {
section = section[:idx+1]
}
cfgFile.WriteString(section)
cfgFile.WriteString("[templates]\n") cfgFile.WriteString("[templates]\n")
return cfgFile.Name(), nil return cfgFile.Name(), nil
} }
+26 -1
View File
@@ -511,7 +511,7 @@ func TestBuildMergedConfigColorsOnly(t *testing.T) {
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs") assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
} }
func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) { func TestBuildMergedConfigMangoSession(t *testing.T) {
t.Setenv("MANGO_INSTANCE_SIGNATURE", "") t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -555,6 +555,31 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
t.Fatalf("failed to read merged config: %v", err) t.Fatalf("failed to read merged config: %v", err)
} }
assert.NotContains(t, string(output), "[templates.dmsmango]") assert.NotContains(t, string(output), "[templates.dmsmango]")
socket := filepath.Join(tempDir, "mango.sock")
if err := os.WriteFile(socket, nil, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MANGO_INSTANCE_SIGNATURE", socket)
checker := mocks_utils.NewMockAppChecker(t)
checker.EXPECT().AnyCommandExists("mango").Return(true)
opts.AppChecker = checker
cfgFile, err = os.CreateTemp(tempDir, "merged-active-*.toml")
if err != nil {
t.Fatal(err)
}
defer cfgFile.Close()
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
t.Fatal(err)
}
if err := cfgFile.Close(); err != nil {
t.Fatal(err)
}
output, err = os.ReadFile(cfgFile.Name())
if err != nil {
t.Fatal(err)
}
assert.Contains(t, string(output), mangowcConfig)
} }
func TestAppendConfigConfigDirExists(t *testing.T) { func TestAppendConfigConfigDirExists(t *testing.T) {
+26
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"sync"
"github.com/duxlabs-dev/dux-go/wayland/client" "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management" "github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management"
@@ -151,6 +152,31 @@ func GetHyprlandMonitorScale(name string) float64 {
return 0 return 0
} }
// !TODO: remove with the frameInLogicalOrientation version fallback
var hyprlandVersion = sync.OnceValues(func() (int, int) {
output, err := exec.Command("hyprctl", "-j", "version").Output()
if err != nil {
return 0, 0
}
var info struct {
Version string `json:"version"`
}
if err := json.Unmarshal(output, &info); err != nil {
return 0, 0
}
var major, minor int
fmt.Sscanf(info.Version, "%d.%d", &major, &minor)
return major, minor
})
func hyprlandVersionAtLeast(major, minor int) bool {
haveMajor, haveMinor := hyprlandVersion()
if haveMajor != major {
return haveMajor > major
}
return haveMinor >= minor
}
func getHyprlandFocusedMonitor() string { func getHyprlandFocusedMonitor() string {
output, err := exec.Command("hyprctl", "-j", "monitors").Output() output, err := exec.Command("hyprctl", "-j", "monitors").Output()
if err != nil { if err != nil {
+6 -9
View File
@@ -609,15 +609,12 @@ func (r *RegionSelector) preCaptureOutput(output *WaylandOutput, pc *PreCapture,
pc.yInverted = false pc.yInverted = false
} }
if output.transform != TransformNormal { transformed, err := output.untransformFrame(capturedBuf)
invTransform := InverseTransform(output.transform) if err != nil {
transformed, err := capturedBuf.ApplyTransform(invTransform) log.Error("apply transform failed", "err", err)
if err != nil { } else if transformed != capturedBuf {
log.Error("apply transform failed", "err", err) capturedBuf.Close()
} else if transformed != capturedBuf { capturedBuf = transformed
capturedBuf.Close()
capturedBuf = transformed
}
} }
if withCursor { if withCursor {
+23 -6
View File
@@ -49,6 +49,28 @@ func (o *WaylandOutput) effectiveScale() float64 {
return scale return scale
} }
// Hyprland renders screencopy frames in the output's logical orientation since
// 901881c9f (v0.57.0); wlroots and niri still hand back the native framebuffer.
// !TODO: drop the version fallback once every distro ships hyprland >= 0.57
func (o *WaylandOutput) frameInLogicalOrientation(frameW, frameH int) bool {
switch o.transform {
case TransformNormal:
return true
case Transform90, Transform270, TransformFlipped90, TransformFlipped270:
if o.width != o.height {
return frameW == int(o.height) && frameH == int(o.width)
}
}
return DetectCompositor() == CompositorHyprland && hyprlandVersionAtLeast(0, 57)
}
func (o *WaylandOutput) untransformFrame(buf *ShmBuffer) (*ShmBuffer, error) {
if o.frameInLogicalOrientation(buf.Width, buf.Height) {
return buf, nil
}
return buf.ApplyTransform(InverseTransform(o.transform))
}
func (o *WaylandOutput) bounds() Region { func (o *WaylandOutput) bounds() Region {
x, y, w, h := o.x, o.y, o.width, o.height x, y, w, h := o.x, o.y, o.width, o.height
if DetectCompositor() == CompositorHyprland { if DetectCompositor() == CompositorHyprland {
@@ -677,12 +699,7 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
result.YInverted = false result.YInverted = false
} }
if output.transform == TransformNormal { transformed, err := output.untransformFrame(result.Buffer)
return result, nil
}
invTransform := InverseTransform(output.transform)
transformed, err := result.Buffer.ApplyTransform(invTransform)
if err != nil { if err != nil {
result.Buffer.Close() result.Buffer.Close()
return nil, fmt.Errorf("apply transform: %w", err) return nil, fmt.Errorf("apply transform: %w", err)
@@ -40,10 +40,6 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
handleSubscribe(conn, req, m) handleSubscribe(conn, req, m)
case "clipboard.search": case "clipboard.search":
handleSearch(conn, req, m) handleSearch(conn, req, m)
case "clipboard.getConfig":
handleGetConfig(conn, req, m)
case "clipboard.setConfig":
handleSetConfig(conn, req, m)
case "clipboard.store": case "clipboard.store":
handleStore(conn, req, m) handleStore(conn, req, m)
case "clipboard.pinEntry": case "clipboard.pinEntry":
@@ -320,40 +316,6 @@ func handleSearch(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
conn.Respond(req.ID, m.Search(p)) conn.Respond(req.ID, m.Search(p))
} }
func handleGetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
conn.Respond(req.ID, m.GetConfig())
}
func handleSetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
cfg := m.GetConfig()
if v, ok := req.Get[float64]("maxHistory"); ok {
cfg.MaxHistory = int(v)
}
if v, ok := req.Get[float64]("maxEntrySize"); ok {
cfg.MaxEntrySize = int64(v)
}
if v, ok := req.Get[float64]("autoClearDays"); ok {
cfg.AutoClearDays = int(v)
}
if v, ok := req.Get[bool]("clearAtStartup"); ok {
cfg.ClearAtStartup = v
}
if v, ok := req.Get[bool]("disabled"); ok {
cfg.Disabled = v
}
if v, ok := req.Get[float64]("maxPinned"); ok {
cfg.MaxPinned = int(v)
}
if err := m.SetConfig(cfg); err != nil {
conn.RespondError(req.ID, err.Error())
return
}
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "config updated"})
}
func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
data, err := params.String(req.Params, "data") data, err := params.String(req.Params, "data")
if err != nil { if err != nil {
+168 -64
View File
@@ -2,6 +2,7 @@ package clipboard
import ( import (
"bytes" "bytes"
"cmp"
"encoding/binary" "encoding/binary"
"errors" "errors"
"fmt" "fmt"
@@ -29,6 +30,7 @@ import (
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
berrors "go.etcd.io/bbolt/errors"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client" wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control" "github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
@@ -55,8 +57,14 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
configPath, _ := getConfigPath() configPath, _ := getConfigPath()
db, err := openDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
m := &Manager{ m := &Manager{
config: config, config: config,
db: db,
configPath: configPath, configPath: configPath,
display: display, display: display,
wlCtx: wlCtx, wlCtx: wlCtx,
@@ -69,6 +77,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
if !config.Disabled { if !config.Disabled {
if err := m.setupRegistry(); err != nil { if err := m.setupRegistry(); err != nil {
db.Close()
return nil, err return nil, err
} }
} }
@@ -78,12 +87,6 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
go m.watchConfig() go m.watchConfig()
db, err := openDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
m.db = db
if err := m.migrateHashes(); err != nil { if err := m.migrateHashes(); err != nil {
log.Errorf("Failed to migrate hashes: %v", err) log.Errorf("Failed to migrate hashes: %v", err)
} }
@@ -117,6 +120,9 @@ func openDB(path string) (*bolt.DB, error) {
if err == nil { if err == nil {
return db, nil return db, nil
} }
if errors.Is(err, berrors.ErrTimeout) {
return nil, err
}
log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err) log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err)
@@ -244,6 +250,9 @@ func (m *Manager) setupRegistry() error {
m.dataControlMgr = dataControlMgr m.dataControlMgr = dataControlMgr
log.Info("Bound ext_data_control_manager_v1") log.Info("Bound ext_data_control_manager_v1")
case "wl_seat": case "wl_seat":
if m.seat != nil {
return
}
seat := wlclient.NewSeat(ctx) seat := wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
log.Errorf("Failed to bind wl_seat: %v", err) log.Errorf("Failed to bind wl_seat: %v", err)
@@ -272,17 +281,65 @@ func (m *Manager) setupRegistry() error {
} }
func (m *Manager) setupDataDeviceSync() { func (m *Manager) setupDataDeviceSync() {
if m.dataControlMgr == nil || m.seat == nil { if !m.bindDataDevice() {
return return
} }
ctx := m.display.Context() if err := m.display.Context().Dispatch(); err != nil {
log.Errorf("Failed to dispatch initial events: %v", err)
return
}
log.Info("Data device setup complete")
}
// Runs on the dispatcher goroutine; a disabled manager must hold no device so it receives no offers.
func (m *Manager) syncTracking() {
if m.getConfig().Disabled {
m.releaseDataDevice()
return
}
if m.dataDevice != nil {
return
}
if m.registry == nil {
if err := m.setupRegistry(); err != nil {
log.Errorf("Failed to enable clipboard tracking: %v", err)
return
}
}
if m.bindDataDevice() {
log.Info("Data device setup complete")
}
}
func (m *Manager) releaseDataDevice() {
m.deviceBound.Store(false)
if m.currentOffer != nil {
m.releaseOffer(m.currentOffer)
m.currentOffer = nil
}
if m.dataDevice == nil {
return
}
m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1).Destroy()
m.dataDevice = nil
m.mimeTypes = nil
}
func (m *Manager) bindDataDevice() bool {
if m.dataControlMgr == nil || m.seat == nil {
return false
}
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
dataDevice, err := dataMgr.GetDataDevice(m.seat) dataDevice, err := dataMgr.GetDataDevice(m.seat)
if err != nil { if err != nil {
log.Errorf("Failed to send get_data_device request: %v", err) log.Errorf("Failed to send get_data_device request: %v", err)
return return false
} }
dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
@@ -302,8 +359,12 @@ func (m *Manager) setupDataDeviceSync() {
}) })
dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
// The first event replays the selection that existed before binding.
if !m.initialized { if !m.initialized {
m.initialized = true m.initialized = true
if e.Id != nil {
m.releaseOffer(e.Id)
}
return return
} }
@@ -331,6 +392,11 @@ func (m *Manager) setupDataDeviceSync() {
return return
} }
// An event can land between a runtime disable and the posted release.
if m.getConfig().Disabled {
return
}
m.offerMutex.RLock() m.offerMutex.RLock()
mimes := m.offerMimeTypes[offer] mimes := m.offerMimeTypes[offer]
m.offerMutex.RUnlock() m.offerMutex.RUnlock()
@@ -389,14 +455,10 @@ func (m *Manager) setupDataDeviceSync() {
go m.readAndStore(r, preferredMime, altR, altMime) go m.readAndStore(r, preferredMime, altR, altMime)
}) })
m.initialized = false
m.dataDevice = dataDevice m.dataDevice = dataDevice
m.deviceBound.Store(true)
if err := ctx.Dispatch(); err != nil { return true
log.Errorf("Failed to dispatch initial events: %v", err)
return
}
log.Info("Data device setup complete")
} }
func (m *Manager) releaseOffer(offer any) { func (m *Manager) releaseOffer(offer any) {
@@ -515,6 +577,9 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []by
} }
func (m *Manager) storeEntry(entry Entry) error { func (m *Manager) storeEntry(entry Entry) error {
if m.getConfig().Disabled {
return errTrackingDisabled
}
if m.db == nil { if m.db == nil {
return fmt.Errorf("database not available") return fmt.Errorf("database not available")
} }
@@ -569,21 +634,31 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
} }
func (m *Manager) trimLengthInTx(b *bolt.Bucket) error { func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
if m.config.MaxHistory < 0 { maxHistory := m.getConfig().MaxHistory
if maxHistory < 0 {
return nil return nil
} }
type row struct {
key []byte
entry Entry
}
var rows []row
c := b.Cursor() c := b.Cursor()
var count int for k, v := c.First(); k != nil; k, v = c.Next() {
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntryMeta(v)
if err == nil && entry.Pinned { if err != nil || entry.Pinned {
continue continue
} }
if count < m.config.MaxHistory { rows = append(rows, row{slices.Clone(k), entry})
count++ }
continue
} slices.SortFunc(rows, func(a, b row) int {
if err := b.Delete(k); err != nil { return compareEntriesNewestFirst(a.entry, b.entry)
})
for _, r := range rows[min(maxHistory, len(rows)):] {
if err := b.Delete(r.key); err != nil {
return err return err
} }
} }
@@ -601,7 +676,7 @@ func encodeEntry(e Entry) ([]byte, error) {
binary.Write(buf, binary.BigEndian, uint32(len(e.Preview))) binary.Write(buf, binary.BigEndian, uint32(len(e.Preview)))
buf.WriteString(e.Preview) buf.WriteString(e.Preview)
binary.Write(buf, binary.BigEndian, int32(e.Size)) binary.Write(buf, binary.BigEndian, int32(e.Size))
binary.Write(buf, binary.BigEndian, e.Timestamp.Unix()) binary.Write(buf, binary.BigEndian, e.Timestamp.UnixNano())
if e.IsImage { if e.IsImage {
buf.WriteByte(1) buf.WriteByte(1)
} else { } else {
@@ -667,7 +742,13 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
var timestamp int64 var timestamp int64
binary.Read(buf, binary.BigEndian, &timestamp) binary.Read(buf, binary.BigEndian, &timestamp)
e.Timestamp = time.Unix(timestamp, 0) // Rows written before the switch to nanosecond precision hold seconds;
// no second-resolution stamp reaches 1e12 until the year 33658.
if timestamp < 1e12 {
e.Timestamp = time.Unix(timestamp, 0)
} else {
e.Timestamp = time.Unix(0, timestamp)
}
var isImage byte var isImage byte
binary.Read(buf, binary.BigEndian, &isImage) binary.Read(buf, binary.BigEndian, &isImage)
@@ -1043,9 +1124,25 @@ func (m *Manager) GetHistory() []Entry {
go m.deleteStaleEntries(stale) go m.deleteStaleEntries(stale)
} }
sortEntriesNewestFirst(history)
return history return history
} }
// sortEntriesNewestFirst orders by timestamp, falling back to id: TouchEntry
// rewrites a row in place to keep its id stable, so bucket key order stops
// tracking recency.
func sortEntriesNewestFirst(entries []Entry) {
slices.SortFunc(entries, compareEntriesNewestFirst)
}
func compareEntriesNewestFirst(a, b Entry) int {
if c := b.Timestamp.Compare(a.Timestamp); c != 0 {
return c
}
return cmp.Compare(b.ID, a.ID)
}
func (m *Manager) deleteStaleEntries(ids []uint64) { func (m *Manager) deleteStaleEntries(ids []uint64) {
if m.db == nil { if m.db == nil {
return return
@@ -1181,9 +1278,24 @@ func (m *Manager) TouchEntry(id uint64) error {
return err return err
} }
entry.Timestamp = time.Now() // Round(0) drops the monotonic reading so the in-memory stamp compares the
// same way as the one decoded back from the bucket.
entry.Timestamp = time.Now().Round(0)
if err := m.storeEntry(*entry); err != nil { // storeEntry would dedupe the row against itself and hand out a fresh
// sequence number, invalidating the id the UI is still holding.
err = m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return fmt.Errorf("clipboard bucket missing")
}
encoded, err := encodeEntry(*entry)
if err != nil {
return err
}
return b.Put(itob(id), encoded)
})
if err != nil {
return err return err
} }
@@ -1336,19 +1448,22 @@ func (m *Manager) reopenDB() {
m.db = db m.db = db
} }
var errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol") var (
errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol")
errTrackingDisabled = errors.New("clipboard tracking disabled")
)
// Both are bound once in NewManager; a disabled manager never binds them but // A disabled manager still serves copy requests; failing here lets callers
// still serves copy requests, so callers must fail before touching history. // such as dms screenshot fall back to their own copy.
func (m *Manager) canTakeSelection() bool { func (m *Manager) canTakeSelection() bool {
return m.dataControlMgr != nil && m.dataDevice != nil return m.deviceBound.Load() && !m.getConfig().Disabled
} }
func (m *Manager) SetClipboard(data []byte, mimeType string) error { func (m *Manager) SetClipboard(data []byte, mimeType string) error {
if !m.canTakeSelection() { if !m.canTakeSelection() {
return errSelectionUnavailable return errSelectionUnavailable
} }
if int64(len(data)) > m.config.MaxEntrySize { if int64(len(data)) > m.getConfig().MaxEntrySize {
return fmt.Errorf("data too large") return fmt.Errorf("data too large")
} }
@@ -1366,7 +1481,7 @@ func (m *Manager) SetClipboardEntry(entry *Entry) error {
if !m.canTakeSelection() { if !m.canTakeSelection() {
return errSelectionUnavailable return errSelectionUnavailable
} }
if int64(len(entry.Data)) > m.config.MaxEntrySize { if int64(len(entry.Data)) > m.getConfig().MaxEntrySize {
return fmt.Errorf("data too large") return fmt.Errorf("data too large")
} }
@@ -1515,16 +1630,7 @@ func (m *Manager) Close() {
m.subMutex.Unlock() m.subMutex.Unlock()
m.releaseCurrentSource() m.releaseCurrentSource()
m.releaseDataDevice()
if m.currentOffer != nil {
m.releaseOffer(m.currentOffer)
m.currentOffer = nil
}
if m.dataDevice != nil {
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
device.Destroy()
}
if m.dataControlMgr != nil { if m.dataControlMgr != nil {
mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
@@ -1724,19 +1830,12 @@ func (m *Manager) Search(params SearchParams) SearchResult {
} }
} }
func (m *Manager) GetConfig() Config {
return m.config
}
func (m *Manager) SetConfig(cfg Config) error { func (m *Manager) SetConfig(cfg Config) error {
m.configMutex.Lock() if err := SaveConfig(cfg); err != nil {
m.config = cfg return err
m.configMutex.Unlock() }
m.applyConfigChange(cfg)
m.updateState() return nil
m.notifySubscribers()
return SaveConfig(cfg)
} }
func (m *Manager) getConfig() Config { func (m *Manager) getConfig() Config {
@@ -1777,7 +1876,12 @@ func (m *Manager) watchConfig() {
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
continue continue
} }
newCfg := LoadConfig() // os.WriteFile truncates first; parsing that empty read would
// briefly reset every setting to its default.
newCfg, err := readConfig()
if err != nil {
continue
}
m.applyConfigChange(newCfg) m.applyConfigChange(newCfg)
case err, ok := <-watcher.Errors: case err, ok := <-watcher.Errors:
if !ok { if !ok {
@@ -1794,11 +1898,9 @@ func (m *Manager) applyConfigChange(newCfg Config) {
m.config = newCfg m.config = newCfg
m.configMutex.Unlock() m.configMutex.Unlock()
switch { if newCfg.Disabled != oldCfg.Disabled {
case newCfg.Disabled && !oldCfg.Disabled: log.Infof("Clipboard tracking disabled=%v", newCfg.Disabled)
log.Info("Clipboard tracking disabled") m.post(m.syncTracking)
case !newCfg.Disabled && oldCfg.Disabled:
log.Info("Clipboard tracking enabled")
} }
m.updateState() m.updateState()
@@ -1809,7 +1911,7 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
cfg := m.getConfig() cfg := m.getConfig()
if cfg.Disabled { if cfg.Disabled {
return fmt.Errorf("clipboard tracking disabled") return errTrackingDisabled
} }
if m.db == nil { if m.db == nil {
@@ -2148,6 +2250,8 @@ func (m *Manager) GetPinnedEntries() []Entry {
log.Errorf("Failed to get pinned entries: %v", err) log.Errorf("Failed to get pinned entries: %v", err)
} }
sortEntriesNewestFirst(pinned)
return pinned return pinned
} }
+109 -2
View File
@@ -3,6 +3,7 @@ package clipboard
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"image" "image"
"image/png" "image/png"
"net" "net"
@@ -18,6 +19,7 @@ import (
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/duxlabs-dev/dux-go/ipc"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control" "github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext" mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext"
"github.com/duxlabs-dev/dux-shell/core/internal/server/models" "github.com/duxlabs-dev/dux-shell/core/internal/server/models"
@@ -51,13 +53,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
mockCtx := mocks_wlcontext.NewMockWaylandContext(t) mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe() mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe()
return &Manager{ m := &Manager{
config: DefaultConfig(), config: DefaultConfig(),
db: db, db: db,
wlCtx: mockCtx, wlCtx: mockCtx,
dataControlMgr: &ext_data_control.ExtDataControlManagerV1{}, dataControlMgr: &ext_data_control.ExtDataControlManagerV1{},
dataDevice: &ext_data_control.ExtDataControlDeviceV1{}, dataDevice: &ext_data_control.ExtDataControlDeviceV1{},
} }
m.deviceBound.Store(true)
return m
} }
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) { func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
@@ -443,7 +447,9 @@ func TestUnpinEntry_KeepsTopUnpinnedDuplicate(t *testing.T) {
history = m.GetHistory() history = m.GetHistory()
require.Len(t, history, 3) require.Len(t, history, 3)
require.Equal(t, topHistoryDuplicate.ID, history[0].ID) // GetHistory sorts by timestamp, so the newer row leads even though its
// id is lower.
require.Equal(t, olderHistoryDuplicate.ID, history[0].ID)
require.NoError(t, m.UnpinEntry(pinnedID)) require.NoError(t, m.UnpinEntry(pinnedID))
history = m.GetHistory() history = m.GetHistory()
@@ -887,6 +893,24 @@ func TestOpenDB_HealsGarbageFile(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestOpenDB_LockedByAnotherProcessKeepsHistory(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
holder, err := openDB(path)
require.NoError(t, err)
m := &Manager{config: DefaultConfig(), db: holder, dbPath: path}
require.NoError(t, m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"}))
_, err = openDB(path)
require.Error(t, err)
require.NoError(t, holder.Close())
reopened, err := openDB(path)
require.NoError(t, err)
defer reopened.Close()
m = &Manager{config: DefaultConfig(), db: reopened, dbPath: path}
assert.Len(t, m.GetHistory(), 1)
}
func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) { func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) {
for _, tt := range corruptDBCases { for _, tt := range corruptDBCases {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -1040,3 +1064,86 @@ func TestCopyFile_DisabledTrackingRefusesCopy(t *testing.T) {
require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable) require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable)
assert.Empty(t, m.GetHistory()) assert.Empty(t, m.GetHistory())
} }
func TestApplyConfigChange_DisableReleasesDataDevice(t *testing.T) {
sock := filepath.Join(t.TempDir(), "wayland-test")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
display, err := wlclient.Connect(sock)
require.NoError(t, err)
t.Cleanup(func() { display.Context().Close() })
m := newTestManagerWithDB(t)
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) { fn() })
m.wlCtx = mockCtx
device := ext_data_control.NewExtDataControlDeviceV1(display.Context())
m.dataDevice = device
cfg := DefaultConfig()
cfg.Disabled = true
m.applyConfigChange(cfg)
assert.True(t, device.IsZombie())
assert.Nil(t, m.dataDevice)
require.ErrorIs(t, m.CopyText("secret"), errSelectionUnavailable)
assert.Empty(t, m.GetHistory())
}
func TestTouchEntry_SurvivesTrimWhenHistoryFull(t *testing.T) {
m := newTestManagerWithDB(t)
m.config.MaxHistory = 3
for i := range 3 {
require.NoError(t, m.storeEntry(Entry{
Data: []byte(fmt.Sprintf("entry-%d", i)),
MimeType: "text/plain",
Timestamp: time.Now(),
}))
}
oldest := m.GetHistory()[2]
require.NoError(t, m.TouchEntry(oldest.ID))
// A new copy trims the bucket back to MaxHistory.
require.NoError(t, m.storeEntry(Entry{
Data: []byte("newcomer"),
MimeType: "text/plain",
Timestamp: time.Now(),
}))
history := m.GetHistory()
require.Len(t, history, 3)
ids := make([]uint64, len(history))
for i, e := range history {
ids[i] = e.ID
}
assert.Contains(t, ids, oldest.ID, "the touched row must survive trimming")
assert.Equal(t, oldest.ID, history[1].ID, "the touched row ranks second, behind the newer copy")
}
func TestGetHistory_SameSecondOrdersByID(t *testing.T) {
m := newTestManagerWithDB(t)
m.config.MaxHistory = 10
stamp := time.Now()
for i := range 3 {
require.NoError(t, m.storeEntry(Entry{
Data: []byte(fmt.Sprintf("same-second-%d", i)),
MimeType: "text/plain",
Timestamp: stamp,
}))
}
// Touching the oldest row rewrites it in place, so the bucket now yields
// it first even though every row shares a timestamp.
oldest := m.GetHistory()[2]
require.NoError(t, m.TouchEntry(oldest.ID))
history := m.GetHistory()
require.Len(t, history, 3)
assert.Equal(t, oldest.ID, history[0].ID, "the touched row must lead: its timestamp is newer")
assert.Greater(t, history[1].ID, history[2].ID, "rows still tied on timestamp break on newest id")
}
+14 -4
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
@@ -44,22 +45,30 @@ func getConfigPath() (string, error) {
} }
func LoadConfig() Config { func LoadConfig() Config {
cfg, err := readConfig()
if err != nil {
return DefaultConfig()
}
return cfg
}
func readConfig() (Config, error) {
cfg := DefaultConfig() cfg := DefaultConfig()
path, err := getConfigPath() path, err := getConfigPath()
if err != nil { if err != nil {
return cfg return cfg, err
} }
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return cfg return cfg, err
} }
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return DefaultConfig() return DefaultConfig(), err
} }
return cfg return cfg, nil
} }
func SaveConfig(cfg Config) error { func SaveConfig(cfg Config) error {
@@ -140,6 +149,7 @@ type Manager struct {
pasteSupported bool pasteSupported bool
initialized bool initialized bool
deviceBound atomic.Bool
alive bool alive bool
stopChan chan struct{} stopChan chan struct{}
+6 -4
View File
@@ -1,6 +1,7 @@
package dbus package dbus
import ( import (
"context"
"fmt" "fmt"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/duxlabs-dev/dux-go/ipc"
@@ -43,7 +44,7 @@ func extractObjectParams(p map[string]any, requirePath bool) (objectParams, erro
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
} }
func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) { func HandleRequest(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
switch req.Method { switch req.Method {
case "dbus.call": case "dbus.call":
handleCall(conn, req, m) handleCall(conn, req, m)
@@ -58,7 +59,7 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID s
case "dbus.listNames": case "dbus.listNames":
handleListNames(conn, req, m) handleListNames(conn, req, m)
case "dbus.subscribe": case "dbus.subscribe":
handleSubscribe(conn, req, m, clientID) handleSubscribe(ctx, conn, req, m, clientID)
case "dbus.unsubscribe": case "dbus.unsubscribe":
handleUnsubscribe(conn, req, m) handleUnsubscribe(conn, req, m)
default: default:
@@ -200,7 +201,7 @@ func handleListNames(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
conn.Respond(req.ID, result) conn.Respond(req.ID, result)
} }
func handleSubscribe(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) { func handleSubscribe(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
if id := params.StringOpt(req.Params, "clientId", ""); id != "" { if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
clientID = id clientID = id
} }
@@ -216,12 +217,13 @@ func handleSubscribe(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID
iface := params.StringOpt(req.Params, "interface", "") iface := params.StringOpt(req.Params, "interface", "")
member := params.StringOpt(req.Params, "member", "") member := params.StringOpt(req.Params, "member", "")
result, err := m.Subscribe(clientID, bus, sender, path, iface, member) result, err := m.Subscribe(conn, clientID, bus, sender, path, iface, member)
if err != nil { if err != nil {
conn.RespondError(req.ID, err.Error()) conn.RespondError(req.ID, err.Error())
return return
} }
m.ReleaseOnClose(ctx, conn)
conn.Respond(req.ID, result) conn.Respond(req.ID, result)
} }
+30 -11
View File
@@ -1,12 +1,14 @@
package dbus package dbus
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"strings" "strings"
"github.com/duxlabs-dev/dux-go/dbusutil" "github.com/duxlabs-dev/dux-go/dbusutil"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -188,7 +190,7 @@ func (m *Manager) ListNames(bus string) (*ListNamesResult, error) {
return &ListNamesResult{Names: names}, nil return &ListNamesResult{Names: names}, nil
} }
func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) { func (m *Manager) Subscribe(owner *ipc.ConnWriter, clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
conn, err := m.getConn(bus) conn, err := m.getConn(bus)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -223,6 +225,7 @@ func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (
Interface: iface, Interface: iface,
Member: member, Member: member,
ClientID: clientID, ClientID: clientID,
Owner: owner,
} }
m.subscriptions.Store(subID, sub) m.subscriptions.Store(subID, sub)
@@ -267,10 +270,23 @@ func (m *Manager) Unsubscribe(subID string) error {
return nil return nil
} }
func (m *Manager) UnsubscribeClient(clientID string) { // Match rules live as long as the request connection that added them, not the
// subscribe socket, which the shell re-dials whenever its service list changes.
func (m *Manager) ReleaseOnClose(ctx context.Context, owner *ipc.ConnWriter) {
if _, loaded := m.owners.LoadOrStore(owner, struct{}{}); loaded {
return
}
go func() {
<-ctx.Done()
m.owners.Delete(owner)
m.unsubscribeOwner(owner)
}()
}
func (m *Manager) unsubscribeOwner(owner *ipc.ConnWriter) {
var toDelete []string var toDelete []string
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool { m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
if sub.ClientID == clientID { if sub.Owner == owner {
toDelete = append(toDelete, subID) toDelete = append(toDelete, subID)
} }
return true return true
@@ -281,20 +297,23 @@ func (m *Manager) UnsubscribeClient(clientID string) {
} }
} }
// A re-dialed subscribe socket can register before the old one tears down, so
// the old release must only drop the channel it created. Channels are never
// closed here because dispatchSignal may be mid-send on another goroutine.
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent { func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
ch := make(chan SignalEvent, 64) ch := make(chan SignalEvent, 64)
existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch) m.subscribersMu.Lock()
if loaded { m.signalSubscribers.Store(clientID, ch)
return existing m.subscribersMu.Unlock()
}
return ch return ch
} }
func (m *Manager) UnsubscribeSignals(clientID string) { func (m *Manager) UnsubscribeSignals(clientID string, ch chan SignalEvent) {
if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok { m.subscribersMu.Lock()
close(ch) defer m.subscribersMu.Unlock()
if current, ok := m.signalSubscribers.Load(clientID); ok && current == ch {
m.signalSubscribers.Delete(clientID)
} }
m.UnsubscribeClient(clientID)
} }
func (m *Manager) processSystemSignals() { func (m *Manager) processSystemSignals() {
+6
View File
@@ -1,6 +1,9 @@
package dbus package dbus
import ( import (
"sync"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-go/syncmap" "github.com/duxlabs-dev/dux-go/syncmap"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -12,7 +15,9 @@ type Manager struct {
sessionSignals chan *dbus.Signal sessionSignals chan *dbus.Signal
subscriptions syncmap.Map[string, *signalSubscription] subscriptions syncmap.Map[string, *signalSubscription]
subscribersMu sync.Mutex
signalSubscribers syncmap.Map[string, chan SignalEvent] signalSubscribers syncmap.Map[string, chan SignalEvent]
owners syncmap.Map[*ipc.ConnWriter, struct{}]
} }
type signalSubscription struct { type signalSubscription struct {
@@ -22,6 +27,7 @@ type signalSubscription struct {
Interface string Interface string
Member string Member string
ClientID string ClientID string
Owner *ipc.ConnWriter
} }
type SignalEvent struct { type SignalEvent struct {
+25 -2
View File
@@ -1,9 +1,15 @@
package loginctl package loginctl
import ( import (
"errors"
"fmt" "fmt"
) )
var (
errLockBeforeSuspendManaged = errors.New("lock before suspend is managed by administrator policy")
errSleepInhibitorManaged = errors.New("the sleep inhibitor is managed by administrator policy")
)
func (m *Manager) Lock() error { func (m *Manager) Lock() error {
if m.sessionObj == nil { if m.sessionObj == nil {
return fmt.Errorf("session object not available") return fmt.Errorf("session object not available")
@@ -85,8 +91,21 @@ func (m *Manager) Terminate() error {
return nil return nil
} }
func (m *Manager) SetLockBeforeSuspend(enabled bool) { // applyLockPolicy starts from the managed settings, before the shell connects.
func (m *Manager) applyLockPolicy() {
if managed := m.lockPolicy.LockBeforeSuspend; managed != nil {
m.lockBeforeSuspend.Store(*managed)
}
enabled, managed := m.lockPolicy.sleepInhibitor()
m.sleepInhibitorEnabled.Store(enabled || !managed)
}
func (m *Manager) SetLockBeforeSuspend(enabled bool) error {
if managed := m.lockPolicy.LockBeforeSuspend; managed != nil && *managed != enabled {
return errLockBeforeSuspendManaged
}
m.lockBeforeSuspend.Store(enabled) m.lockBeforeSuspend.Store(enabled)
return nil
} }
func (m *Manager) markLockerReady() { func (m *Manager) markLockerReady() {
@@ -107,7 +126,10 @@ func (m *Manager) markLockerReady() {
m.prelockedReady.Store(true) m.prelockedReady.Store(true)
} }
func (m *Manager) SetSleepInhibitorEnabled(enabled bool) { func (m *Manager) SetSleepInhibitorEnabled(enabled bool) error {
if managedValue, managed := m.lockPolicy.sleepInhibitor(); managed && managedValue != enabled {
return errSleepInhibitorManaged
}
m.sleepInhibitorEnabled.Store(enabled) m.sleepInhibitorEnabled.Store(enabled)
if enabled { if enabled {
// Re-acquire inhibitor if enabled // Re-acquire inhibitor if enabled
@@ -116,4 +138,5 @@ func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
// Release inhibitor if disabled // Release inhibitor if disabled
m.releaseSleepInhibitor() m.releaseSleepInhibitor()
} }
return nil
} }
+8 -2
View File
@@ -100,7 +100,10 @@ func handleSetLockBeforeSuspend(conn *ipc.ConnWriter, req ipc.Request, manager *
return return
} }
manager.SetLockBeforeSuspend(enabled) if err := manager.SetLockBeforeSuspend(enabled); err != nil {
conn.RespondError(req.ID, err.Error())
return
}
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"}) conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"})
} }
@@ -111,7 +114,10 @@ func handleSetSleepInhibitorEnabled(conn *ipc.ConnWriter, req ipc.Request, manag
return return
} }
manager.SetSleepInhibitorEnabled(enabled) if err := manager.SetSleepInhibitorEnabled(enabled); err != nil {
conn.RespondError(req.ID, err.Error())
return
}
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"}) conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
} }
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"net" "net"
"os"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -424,3 +425,85 @@ func TestHandleRequest(t *testing.T) {
assert.Empty(t, resp.Error) assert.Empty(t, resp.Error)
}) })
} }
func TestHandleSetLockBeforeSuspend(t *testing.T) {
on, off := true, false
tests := []struct {
name string
managed *bool
request bool
want bool
wantErr bool
}{
{name: "unmanaged", request: true, want: true},
{name: "managed, same value", managed: &on, request: true, want: true},
{name: "managed on, request off", managed: &on, request: false, want: true, wantErr: true},
{name: "managed off, request on", managed: &off, request: true, want: false, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &Manager{lockPolicy: LockPolicy{LockBeforeSuspend: tt.managed}}
manager.applyLockPolicy()
mc := newMockNetConn()
conn := ipc.NewConnWriter(mc)
HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setLockBeforeSuspend", Params: map[string]any{"enabled": tt.request}}, manager)
var resp ipc.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
if tt.wantErr {
assert.Equal(t, "lock before suspend is managed by administrator policy", resp.Error)
} else {
assert.Empty(t, resp.Error)
}
assert.Equal(t, tt.want, manager.lockBeforeSuspend.Load())
})
}
}
func TestHandleSetSleepInhibitorEnabled(t *testing.T) {
on, off := true, false
tests := []struct {
name string
policy LockPolicy
request bool
want bool
wantErr bool
}{
{name: "unmanaged", request: false, want: false},
{name: "both managed on, request off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: false, want: true, wantErr: true},
{name: "both managed on, request on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: true, want: true},
{name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}, request: true, want: false, wantErr: true},
{name: "integration managed off", policy: LockPolicy{LoginctlLockIntegration: &off}, request: true, want: false, wantErr: true},
{name: "lock before suspend managed on, integration left to the user", policy: LockPolicy{LockBeforeSuspend: &on}, request: false, want: false},
{name: "integration managed on, lock before suspend left to the user", policy: LockPolicy{LoginctlLockIntegration: &on}, request: false, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &Manager{lockPolicy: tt.policy}
manager.applyLockPolicy()
inhibit, err := os.CreateTemp(t.TempDir(), "inhibit")
require.NoError(t, err)
held := manager.sleepInhibitorEnabled.Load()
if held {
manager.inhibitFile = inhibit
}
mc := newMockNetConn()
conn := ipc.NewConnWriter(mc)
HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setSleepInhibitorEnabled", Params: map[string]any{"enabled": tt.request}}, manager)
var resp ipc.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
if tt.wantErr {
assert.Equal(t, "the sleep inhibitor is managed by administrator policy", resp.Error)
assert.Equal(t, held, manager.inhibitFile != nil, "a refused request leaves the inhibitor as it was")
} else {
assert.Empty(t, resp.Error)
}
assert.Equal(t, tt.want, manager.sleepInhibitorEnabled.Load())
})
}
}
+5 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
func NewManager() (*Manager, error) { func NewManager(keepLockOnUnlock bool, lockPolicy LockPolicy) (*Manager, error) {
conn, err := dbus.SystemBus() conn, err := dbus.SystemBus()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to system bus: %w", err) return nil, fmt.Errorf("failed to connect to system bus: %w", err)
@@ -25,8 +25,11 @@ func NewManager() (*Manager, error) {
conn: conn, conn: conn,
dirty: make(chan struct{}, 1), dirty: make(chan struct{}, 1),
signals: make(chan *dbus.Signal, 256), signals: make(chan *dbus.Signal, 256),
keepLockOnUnlock: keepLockOnUnlock,
lockPolicy: lockPolicy,
} }
m.sleepInhibitorEnabled.Store(true) m.applyLockPolicy()
if err := m.initialize(); err != nil { if err := m.initialize(); err != nil {
return nil, err return nil, err
@@ -126,3 +126,28 @@ func TestStateChangedMeaningfully(t *testing.T) {
}) })
} }
} }
func TestManager_ApplyLockPolicy(t *testing.T) {
on, off := true, false
tests := []struct {
name string
policy LockPolicy
lockBeforeSuspend bool
sleepInhibitorEnabled bool
}{
{name: "unmanaged", sleepInhibitorEnabled: true},
{name: "lock before suspend managed on", policy: LockPolicy{LockBeforeSuspend: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true},
{name: "both managed on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true},
{name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}},
{name: "integration managed off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &off}, lockBeforeSuspend: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &Manager{lockPolicy: tt.policy}
manager.applyLockPolicy()
assert.Equal(t, tt.lockBeforeSuspend, manager.lockBeforeSuspend.Load())
assert.Equal(t, tt.sleepInhibitorEnabled, manager.sleepInhibitorEnabled.Load())
})
}
}
+16 -7
View File
@@ -30,13 +30,22 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
} }
case dbusSessionInterface + ".Unlock": case dbusSessionInterface + ".Unlock":
lockedHint := m.sessionLockedHint() if m.keepLockOnUnlock {
m.stateMutex.Lock() // Only the locker clears LockedHint, so take both from logind, and
m.state.Locked = false // keep the cached state when logind cannot be read.
m.state.LockedHint = lockedHint if m.sessionObj != nil && m.updateSessionState() == nil && !m.snapshotState().Locked {
m.stateMutex.Unlock() m.prelockedReady.Store(false)
m.notifySubscribers() }
m.prelockedReady.Store(false) m.notifySubscribers()
} else {
lockedHint := m.sessionLockedHint()
m.stateMutex.Lock()
m.state.Locked = false
m.state.LockedHint = lockedHint
m.stateMutex.Unlock()
m.notifySubscribers()
m.prelockedReady.Store(false)
}
// Cancel the lock timer if it's still running // Cancel the lock timer if it's still running
m.lockTimerMu.Lock() m.lockTimerMu.Lock()
+30 -14
View File
@@ -37,42 +37,58 @@ func TestManager_HandleDBusSignal_Lock(t *testing.T) {
func TestManager_HandleDBusSignal_Unlock(t *testing.T) { func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
lockedHint bool keepLock bool
call *dbus.Call // logind's GetAll answer; nil means no session object
wantLocked bool
wantLockedHint bool
}{ }{
{name: "logind cleared LockedHint", lockedHint: false}, {name: "logind cleared LockedHint", call: lockedHintCall(false)},
{name: "logind still reports LockedHint", lockedHint: true}, {name: "logind still reports LockedHint", call: lockedHintCall(true), wantLockedHint: true},
{name: "logind unreachable", call: &dbus.Call{Err: assert.AnError}},
{name: "policy, LockedHint still set", keepLock: true, call: lockedHintCall(true), wantLocked: true, wantLockedHint: true},
{name: "policy, LockedHint cleared", keepLock: true, call: lockedHintCall(false)},
{name: "policy, logind unreachable", keepLock: true, call: &dbus.Call{Err: assert.AnError}, wantLocked: true, wantLockedHint: true},
{name: "policy, no session object", keepLock: true, wantLocked: true, wantLockedHint: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
sessionObj := mockdbus.NewMockBusObject(t)
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(&dbus.Call{
Body: []any{map[string]dbus.Variant{"LockedHint": dbus.MakeVariant(tt.lockedHint)}},
})
manager := &Manager{ manager := &Manager{
state: &SessionState{ state: &SessionState{
Locked: true, Locked: true,
LockedHint: true, LockedHint: true,
}, },
stateMutex: sync.RWMutex{}, stateMutex: sync.RWMutex{},
dirty: make(chan struct{}, 1), dirty: make(chan struct{}, 1),
sessionObj: sessionObj, keepLockOnUnlock: tt.keepLock,
} }
if tt.call != nil {
sessionObj := mockdbus.NewMockBusObject(t)
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(tt.call)
manager.sessionObj = sessionObj
}
manager.markLockerReady()
manager.handleDBusSignal(&dbus.Signal{ manager.handleDBusSignal(&dbus.Signal{
Name: "org.freedesktop.login1.Session.Unlock", Name: "org.freedesktop.login1.Session.Unlock",
}) })
assert.Equal(t, tt.wantLocked, manager.prelockedReady.Load())
manager.stateMutex.RLock() manager.stateMutex.RLock()
defer manager.stateMutex.RUnlock() defer manager.stateMutex.RUnlock()
assert.False(t, manager.state.Locked) assert.Equal(t, tt.wantLocked, manager.state.Locked)
assert.Equal(t, tt.lockedHint, manager.state.LockedHint) assert.Equal(t, tt.wantLockedHint, manager.state.LockedHint)
}) })
} }
} }
func lockedHintCall(lockedHint bool) *dbus.Call {
return &dbus.Call{Body: []any{map[string]dbus.Variant{
"LockedHint": dbus.MakeVariant(lockedHint),
}}}
}
func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) { func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
t.Run("preparing for sleep - true", func(t *testing.T) { t.Run("preparing for sleep - true", func(t *testing.T) {
manager := &Manager{ manager := &Manager{
+22
View File
@@ -69,4 +69,26 @@ type Manager struct {
lockTimer *time.Timer lockTimer *time.Timer
sleepInhibitorEnabled atomic.Bool sleepInhibitorEnabled atomic.Bool
fallbackDelay time.Duration fallbackDelay time.Duration
keepLockOnUnlock bool
lockPolicy LockPolicy
}
// LockPolicy holds the lock settings an administrator policy manages; nil
// leaves a setting to the shell.
type LockPolicy struct {
LockBeforeSuspend *bool
LoginctlLockIntegration *bool
}
// sleepInhibitor returns the inhibitor state the shell asks for,
// loginctlLockIntegration && lockBeforeSuspend, when the managed settings
// decide it on their own.
func (p LockPolicy) sleepInhibitor() (enabled, managed bool) {
switch {
case p.LockBeforeSuspend != nil && !*p.LockBeforeSuspend, p.LoginctlLockIntegration != nil && !*p.LoginctlLockIntegration:
return false, true
case p.LockBeforeSuspend != nil && p.LoginctlLockIntegration != nil:
return true, true
}
return false, false
} }
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -278,7 +279,7 @@ func (a *SecretAgent) GetSecrets(
a.backend.cachedVPNCredsMu.Lock() a.backend.cachedVPNCredsMu.Lock()
cached := a.backend.cachedVPNCreds cached := a.backend.cachedVPNCreds
if cached != nil && cached.ConnectionUUID == connUuid { if cached != nil && cached.ConnectionUUID == connUuid && slices.Contains(fields, "password") {
a.backend.cachedVPNCreds = nil a.backend.cachedVPNCreds = nil
a.backend.cachedVPNCredsMu.Unlock() a.backend.cachedVPNCredsMu.Unlock()
@@ -451,7 +452,7 @@ func (a *SecretAgent) GetSecrets(
VpnService: vpnSvc, VpnService: vpnSvc,
SettingName: settingName, SettingName: settingName,
Fields: fields, Fields: fields,
FieldsInfo: buildFieldsInfo(settingName, fields, vpnSvc), FieldsInfo: buildFieldsInfo(settingName, fields, hints, vpnSvc),
Hints: hints, Hints: hints,
Reason: reason, Reason: reason,
ConnectionId: connId, ConnectionId: connId,
@@ -577,13 +578,13 @@ func (a *SecretAgent) GetSecrets(
default: default:
out[settingName] = sec out[settingName] = sec
} }
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) { secrets := make(map[string]string)
secrets := make(map[string]string) for k, v := range reply.Secrets {
for k, v := range reply.Secrets { if k != "username" && k != "challenge-response" {
if k != "username" { secrets[k] = v
secrets[k] = v
}
} }
}
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || (reply.Save && len(secrets) > 0)) {
a.backend.pendingVPNSaveMu.Lock() a.backend.pendingVPNSaveMu.Lock()
a.backend.pendingVPNSave = &pendingVPNCredentials{ a.backend.pendingVPNSave = &pendingVPNCredentials{
ConnectionPath: string(path), ConnectionPath: string(path),
@@ -835,13 +836,46 @@ func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap)
return hints return hints
} }
return infer8021xFields(conn) return infer8021xFields(conn)
case "vpn", "wireguard": case "vpn":
return parseVPNHints(hints).fields
case "wireguard":
return hints return hints
default: default:
return []string{} return []string{}
} }
} }
const (
vpnHintMessagePrefix = "x-vpn-message:"
vpnHintChallengePrefix = "x-dynamic-challenge:"
vpnHintChallengeEchoPrefix = "x-dynamic-challenge-echo:"
)
type vpnPromptHints struct {
fields []string
echo map[string]bool
}
// NM-openvpn asks for a CRV1 challenge answer as x-dynamic-challenge[-echo]:<secret>
// and NM appends the plugin's prompt text as x-vpn-message:<text>, which the modal shows.
func parseVPNHints(hints []string) vpnPromptHints {
parsed := vpnPromptHints{echo: map[string]bool{}}
for _, hint := range hints {
switch {
case strings.HasPrefix(hint, vpnHintMessagePrefix):
case strings.HasPrefix(hint, vpnHintChallengeEchoPrefix):
field := strings.TrimPrefix(hint, vpnHintChallengeEchoPrefix)
parsed.echo[field] = true
parsed.fields = append(parsed.fields, field)
case strings.HasPrefix(hint, vpnHintChallengePrefix):
parsed.fields = append(parsed.fields, strings.TrimPrefix(hint, vpnHintChallengePrefix))
default:
parsed.fields = append(parsed.fields, hint)
}
}
return parsed
}
func infer8021xFields(conn map[string]nmVariantMap) []string { func infer8021xFields(conn map[string]nmVariantMap) []string {
dot1x, ok := conn["802-1x"] dot1x, ok := conn["802-1x"]
if !ok { if !ok {
@@ -877,7 +911,8 @@ func infer8021xFields(conn map[string]nmVariantMap) []string {
return fields return fields
} }
func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo { func buildFieldsInfo(setting string, fields []string, hints []string, vpnService string) []FieldInfo {
echo := parseVPNHints(hints).echo
result := make([]FieldInfo, 0, len(fields)) result := make([]FieldInfo, 0, len(fields))
for _, f := range fields { for _, f := range fields {
info := FieldInfo{Name: f} info := FieldInfo{Name: f}
@@ -899,6 +934,9 @@ func buildFieldsInfo(setting string, fields []string, vpnService string) []Field
} }
case "vpn": case "vpn":
info.Label, info.IsSecret = vpnFieldMeta(f, vpnService) info.Label, info.IsSecret = vpnFieldMeta(f, vpnService)
if echo[f] {
info.IsSecret = false
}
default: default:
info.Label = f info.Label = f
info.IsSecret = true info.IsSecret = true
@@ -1052,6 +1090,8 @@ func vpnFieldMeta(field, _ string) (label string, isSecret bool) {
return "IPSec Pre-Shared Key", true return "IPSec Pre-Shared Key", true
case "cert-pass": case "cert-pass":
return "Certificate Password", true return "Certificate Password", true
case "challenge-response":
return "Challenge Response", true
case "http-proxy-password": case "http-proxy-password":
return "HTTP Proxy Password", true return "HTTP Proxy Password", true
case "username": case "username":
@@ -308,6 +308,47 @@ func TestSecretAgent_GetSecrets_NoInteractionFlag(t *testing.T) {
assert.Contains(t, err.Error(), "NoSecrets") assert.Contains(t, err.Error(), "NoSecrets")
} }
func TestSecretAgent_GetSecrets_OpenVPNDynamicChallenge(t *testing.T) {
conn := map[string]nmVariantMap{
"connection": {
"id": dbus.MakeVariant("Work VPN"),
"type": dbus.MakeVariant("vpn"),
},
"vpn": {
"service-type": dbus.MakeVariant("org.freedesktop.NetworkManager.openvpn"),
},
}
tests := []struct {
name string
hint string
isSecret bool
}{
{"noecho", "x-dynamic-challenge:challenge-response", true},
{"echo", "x-dynamic-challenge-echo:challenge-response", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{Secrets: map[string]string{"challenge-response": "123456"}},
}
agent := &SecretAgent{prompts: broker}
hints := []string{tt.hint, "x-vpn-message:Enter your OTP"}
out, err := agent.GetSecrets(conn, "/test/path", "vpn", hints, nmSecretAgentFlagAllowInteraction)
assert.Nil(t, err)
req := <-broker.asked
assert.Equal(t, []string{"challenge-response"}, req.Fields)
assert.Equal(t, []FieldInfo{{Name: "challenge-response", Label: "Challenge Response", IsSecret: tt.isSecret}}, req.FieldsInfo)
secrets, _ := out["vpn"]["secrets"].Value().(map[string]string)
assert.Equal(t, map[string]string{"challenge-response": "123456"}, secrets)
})
}
}
func TestBuildWiFiSecretsResponse(t *testing.T) { func TestBuildWiFiSecretsResponse(t *testing.T) {
t.Run("wpa-psk returns psk", func(t *testing.T) { t.Run("wpa-psk returns psk", func(t *testing.T) {
out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"}) out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"})
@@ -258,16 +258,11 @@ func (b *NetworkManagerBackend) listEthernetConnections() ([]WiredConnection, er
return nil, fmt.Errorf("no ethernet device available") return nil, fmt.Errorf("no ethernet device available")
} }
s := b.settings settingsMgr, err := b.networkManagerSettings()
if s == nil { if err != nil {
s, err := gonetworkmanager.NewSettings() return nil, err
if err != nil {
return nil, fmt.Errorf("failed to get settings: %w", err)
}
b.settings = s
} }
settingsMgr := s.(gonetworkmanager.Settings)
connections, err := settingsMgr.ListConnections() connections, err := settingsMgr.ListConnections()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get connections: %w", err) return nil, fmt.Errorf("failed to get connections: %w", err)
@@ -305,6 +305,9 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
} }
b.updateHotspotState() b.updateHotspotState()
b.listCellularConnections() b.listCellularConnections()
// Device state does not change when an inactive wired profile is
// added or deleted, so the profile list must be refreshed here too.
b.listEthernetConnections()
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
} }
@@ -130,3 +130,57 @@ func TestNetworkManagerBackend_HandleWiFiChange_CoalescesAccessPointBurst(t *tes
return stateChangeCalls.Load() == 1 return stateChangeCalls.Load() == 1
}, 4*wifiNetworksUpdateDelay, 10*time.Millisecond) }, 4*wifiNetworksUpdateDelay, 10*time.Millisecond)
} }
func TestNetworkManagerBackend_HandleDBusSignal_ConnectionRemovedRefreshesWiredConnections(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe()
remaining := mock_gonetworkmanager.NewMockConnection(t)
remaining.EXPECT().GetPath().Return(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/1")).Maybe()
remaining.EXPECT().GetSettings().Return(gonetworkmanager.ConnectionSettings{
"connection": {
"id": "Wired connection 1",
"uuid": "uuid-1",
"type": "802-3-ethernet",
},
}, nil).Maybe()
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{remaining}, nil).Maybe()
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
backend.settings = mockSettings
backend.ethernetDevice = mock_gonetworkmanager.NewMockDevice(t)
backend.stateMutex.Lock()
backend.state.WiredConnections = []WiredConnection{
{ID: "Wired connection 1", UUID: "uuid-1", Type: "802-3-ethernet"},
{ID: "Wired connection 2", UUID: "uuid-2", Type: "802-3-ethernet"},
}
backend.stateMutex.Unlock()
backend.handleDBusSignal(&dbus.Signal{
Name: "org.freedesktop.NetworkManager.Settings.ConnectionRemoved",
Body: []any{dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/2")},
})
backend.stateMutex.RLock()
defer backend.stateMutex.RUnlock()
assert.Len(t, backend.state.WiredConnections, 1)
assert.Equal(t, "uuid-1", backend.state.WiredConnections[0].UUID)
}
func TestNetworkManagerBackend_ListEthernetConnections_InvalidSettingsHandle(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
backend.settings = "not a settings handle"
backend.ethernetDevice = mock_gonetworkmanager.NewMockDevice(t)
assert.NotPanics(t, func() {
_, err = backend.listEthernetConnections()
})
assert.Error(t, err)
}
@@ -3,10 +3,12 @@ package network
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os/user"
"sort" "sort"
"github.com/Wifx/gonetworkmanager/v2" "github.com/Wifx/gonetworkmanager/v2"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/godbus/dbus/v5"
) )
func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) { func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) {
@@ -768,6 +770,9 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
"type": "802-11-wireless", "type": "802-11-wireless",
"autoconnect": true, "autoconnect": true,
} }
if !b.canModifySystemConnections() {
restrictConnectionToCurrentUser(settings["connection"])
}
settings["ipv4"] = map[string]any{"method": "auto"} settings["ipv4"] = map[string]any{"method": "auto"}
settings["ipv6"] = map[string]any{"method": "auto"} settings["ipv6"] = map[string]any{"method": "auto"}
@@ -934,6 +939,39 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
// updateConnectionCredentials applies user-supplied credentials to a saved // updateConnectionCredentials applies user-supplied credentials to a saved
// profile before activation; without this a retype after a password change is // profile before activation; without this a retype after a password change is
// silently ignored in favor of the stored (stale) secret. // silently ignored in favor of the stored (stale) secret.
const nmPermissionModifySystem = dbusNMInterface + ".settings.modify.system"
// GetPermissions never prompts. Short of a silent yes, a shared profile prompts on
// creation and on every secret-agent request (NM re-checks the agent), so own it
// per user like gnome-control-center does.
func (b *NetworkManagerBackend) canModifySystemConnections() bool {
conn := b.dbusConn
if conn == nil {
var err error
conn, err = dbus.ConnectSystemBus()
if err != nil {
return false
}
defer conn.Close()
}
var permissions map[string]string
obj := conn.Object(dbusNMInterface, dbus.ObjectPath(dbusNMPath))
if err := obj.Call(dbusNMInterface+".GetPermissions", 0).Store(&permissions); err != nil {
log.Warnf("[createAndConnectWiFi] GetPermissions failed, creating a user-owned profile: %v", err)
return false
}
return permissions[nmPermissionModifySystem] == "yes"
}
func restrictConnectionToCurrentUser(connMeta map[string]any) {
current, err := user.Current()
if err != nil || current.Username == "" {
return
}
connMeta["permissions"] = []string{"user:" + current.Username + ":"}
}
func updateConnectionCredentials(conn gonetworkmanager.Connection, req ConnectionRequest) error { func updateConnectionCredentials(conn gonetworkmanager.Connection, req ConnectionRequest) error {
settings, err := conn.GetSettings() settings, err := conn.GetSettings()
if err != nil { if err != nil {
+8 -4
View File
@@ -203,13 +203,13 @@ func newRequestMux(readiness *systemd.Readiness) *ipc.Mux {
evdev.HandleRequest(conn, req, evdevManager) evdev.HandleRequest(conn, req, evdevManager)
})) }))
mux.HandlePrefix("dbus.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) { mux.HandlePrefix("dbus.", func(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, _ *ipc.Subscriber) {
if dbusManager == nil { if dbusManager == nil {
conn.RespondError(req.ID, "dbus manager not initialized") conn.RespondError(req.ID, "dbus manager not initialized")
return return
} }
serverDbus.HandleRequest(conn, req, dbusManager, dbusClientID) serverDbus.HandleRequest(ctx, conn, req, dbusManager, dbusClientID)
})) })
mux.HandlePrefix("clipboard.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) { mux.HandlePrefix("clipboard.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) {
if clipboardManager == nil { if clipboardManager == nil {
@@ -279,7 +279,11 @@ func handleClipboardSetConfig(conn *ipc.ConnWriter, req ipc.Request) {
cfg.MaxPinned = int(v) cfg.MaxPinned = int(v)
} }
if err := clipboard.SaveConfig(cfg); err != nil { save := clipboard.SaveConfig
if clipboardManager != nil {
save = clipboardManager.SetConfig
}
if err := save(cfg); err != nil {
conn.RespondError(req.ID, err.Error()) conn.RespondError(req.ID, err.Error())
return return
} }
+17 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/duxlabs-dev/dux-go/ipc" "github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-go/syncmap" "github.com/duxlabs-dev/dux-go/syncmap"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"github.com/duxlabs-dev/dux-shell/core/internal/geolocation" "github.com/duxlabs-dev/dux-shell/core/internal/geolocation"
"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/matugen" "github.com/duxlabs-dev/dux-shell/core/internal/matugen"
@@ -37,6 +38,7 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/server/wlroutput" "github.com/duxlabs-dev/dux-shell/core/internal/server/wlroutput"
"github.com/duxlabs-dev/dux-shell/core/internal/systemd" "github.com/duxlabs-dev/dux-shell/core/internal/systemd"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/afero"
) )
const APIVersion = 38 const APIVersion = 38
@@ -108,7 +110,19 @@ func InitializeNetworkManager() error {
} }
func InitializeLoginctlManager() error { func InitializeLoginctlManager() error {
manager, err := loginctl.NewManager() keepLockOnUnlock, err := clipolicy.LogindUnlockDisabled(afero.NewOsFs())
if err != nil {
log.Errorf("CLI policy unreadable, keeping locks through logind Unlock: %v", err)
keepLockOnUnlock = true
}
lockSettings, err := clipolicy.LockSettings(afero.NewOsFs())
if err != nil {
log.Errorf("CLI policy unreadable, ignoring its lock settings: %v", err)
}
manager, err := loginctl.NewManager(keepLockOnUnlock, loginctl.LockPolicy{
LockBeforeSuspend: clipolicy.LockSettingBool(lockSettings, "lockBeforeSuspend"),
LoginctlLockIntegration: clipolicy.LockSettingBool(lockSettings, "loginctlLockIntegration"),
})
if err != nil { if err != nil {
log.Warnf("Failed to initialize loginctl manager: %v", err) log.Warnf("Failed to initialize loginctl manager: %v", err)
return err return err
@@ -781,7 +795,7 @@ func handleSubscribe(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request)
mgr := dbusManager mgr := dbusManager
events := mgr.SubscribeSignals(dbusClient) events := mgr.SubscribeSignals(dbusClient)
forwardSubscription(&wg, eventChan, stopChan, "dbus", events, func() { forwardSubscription(&wg, eventChan, stopChan, "dbus", events, func() {
mgr.UnsubscribeSignals(dbusClient) mgr.UnsubscribeSignals(dbusClient, events)
}, nil) }, nil)
} }
@@ -1119,7 +1133,7 @@ func (s *Server) Serve(printDocs bool) error {
log.Info(" clipboard.paste - Get current clipboard text") log.Info(" clipboard.paste - Get current clipboard text")
log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)") log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)")
log.Info(" clipboard.getConfig - Get clipboard configuration") log.Info(" clipboard.getConfig - Get clipboard configuration")
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)") log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?, disabled?, maxPinned?)")
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)") log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
log.Info("Notify:") log.Info("Notify:")
log.Info(" notify.watchAction - Open a file when a notification action fires (params: id, path)") log.Info(" notify.watchAction - Open a file when a notification action fires (params: id, path)")
@@ -3,6 +3,7 @@ package sysupdate
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"os/exec" "os/exec"
"strings" "strings"
) )
@@ -21,18 +22,21 @@ func (flatpakBackend) RunsInTerminal() bool { return false }
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") } func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) { func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
// Run `flatpak update` // Without --no-deps an update needing an uninstalled runtime prompts first, and declining aborts before the table prints.
cmd := exec.CommandContext(ctx, "flatpak", "update") cmd := exec.CommandContext(ctx, "flatpak", "update", "--no-deps")
cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { pkgs := parseFlatpakUpdateOutput(string(out), flatpakInstalled(ctx))
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(out) > 0 { // Declining the final prompt also exits non-zero, so only a failure without a table is an error.
} else if len(out) == 0 { if err != nil && len(pkgs) == 0 {
return nil, err if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
if msg := lastNonEmptyLine(string(exitErr.Stderr)); msg != "" {
return nil, fmt.Errorf("flatpak update: %s", msg)
}
} }
return nil, err
} }
installed := flatpakInstalled(ctx) return pkgs, nil
return parseFlatpakUpdateOutput(string(out), installed), nil
} }
type flatpakInstalledEntry struct { type flatpakInstalledEntry struct {
@@ -2,6 +2,7 @@ package sysupdate
import ( import (
"reflect" "reflect"
"strings"
"testing" "testing"
) )
@@ -100,3 +101,47 @@ func TestParseFlatpakUpdateOutput(t *testing.T) {
}) })
} }
} }
func TestFlatpakCheckUpdates(t *testing.T) {
const runtimePrompt = `[ "$1" = list ] && exit 0
for a in "$@"; do
if [ "$a" = --no-deps ]; then
printf ' 1.\tVesktop\tdev.vencord.Vesktop\tstable\tu\tflathub\t< 130 MB\n\nProceed with these changes? [Y/n]: '
exit 1
fi
done
printf 'Required runtime for dev.vencord.Vesktop/x86_64/stable found in remote flathub\nDo you want to install it? [Y/n]: '
echo 'error: The application dev.vencord.Vesktop/x86_64/stable requires the runtime org.freedesktop.Platform/x86_64/26.08 which is not installed' >&2
exit 1`
for _, tt := range []struct {
name string
script string
wantRef string
wantErr string
}{
{name: "update needing a new runtime", script: runtimePrompt, wantRef: "dev.vencord.Vesktop//stable"},
{
name: "failure without a table",
script: "[ \"$1\" = list ] && exit 0\necho 'Looking for updates…'\necho 'error: Unable to load summary from remote flathub' >&2\nexit 1",
wantErr: "Unable to load summary",
},
} {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
writeUpdateExecutable(t, dir, "flatpak", tt.script)
pkgs, err := flatpakBackend{}.CheckUpdates(t.Context())
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("err = %v, want %q (a failed check must not read as up to date)", err, tt.wantErr)
}
return
}
if err != nil || len(pkgs) != 1 || pkgs[0].Ref != tt.wantRef {
t.Errorf("pkgs = %#v, err = %v, want %s", pkgs, err, tt.wantRef)
}
})
}
}
+12 -8
View File
@@ -183,13 +183,17 @@ func (m *Manager) Close() {
}) })
} }
// Zero turns scheduled checks off; manual and startup checks still run.
func (m *Manager) SetInterval(seconds int) { func (m *Manager) SetInterval(seconds int) {
if seconds < minIntervalSeconds { if seconds > 0 && seconds < minIntervalSeconds {
seconds = minIntervalSeconds seconds = minIntervalSeconds
} }
m.mu.Lock() m.mu.Lock()
m.state.IntervalSeconds = seconds m.state.IntervalSeconds = max(seconds, 0)
m.state.NextCheckUnix = m.nextCheckLocked() m.state.NextCheckUnix = 0
if seconds > 0 {
m.state.NextCheckUnix = m.nextCheckLocked()
}
m.mu.Unlock() m.mu.Unlock()
m.wake() m.wake()
m.markDirty() m.markDirty()
@@ -284,7 +288,11 @@ func (m *Manager) wake() {
func (m *Manager) scheduler() { func (m *Manager) scheduler() {
defer m.schedulerWG.Done() defer m.schedulerWG.Done()
for { for {
if !m.held() { m.mu.RLock()
interval := m.state.IntervalSeconds
next := m.state.NextCheckUnix
m.mu.RUnlock()
if !m.held() || interval == 0 {
select { select {
case <-m.stopChan: case <-m.stopChan:
return return
@@ -293,10 +301,6 @@ func (m *Manager) scheduler() {
continue continue
} }
m.mu.RLock()
interval := m.state.IntervalSeconds
next := m.state.NextCheckUnix
m.mu.RUnlock()
if interval < minIntervalSeconds { if interval < minIntervalSeconds {
interval = minIntervalSeconds interval = minIntervalSeconds
} }
+2 -2
View File
@@ -17,7 +17,7 @@ const ownerQueryTimeout = 10 * time.Second
var dmsPackageNames = map[string]bool{ var dmsPackageNames = map[string]bool{
"dms": true, "dms-git": true, "dms": true, "dms-git": true,
"dms-shell": true, "dms-shell-git": true, "dms-shell-bin": true, "dux-shell": true, "dux-shell-git": true,
} }
// Arch/Debian/SUSE: 1.7.0+git4915.56234761; Fedora COPR: 0.0.git.4915.56234761 // Arch/Debian/SUSE: 1.7.0+git4915.56234761; Fedora COPR: 0.0.git.4915.56234761
@@ -114,7 +114,7 @@ func packageOwner(ctx context.Context, path string) (InstallMethod, string, stri
switch { switch {
case commandExists("pacman"): case commandExists("pacman"):
// "/usr/bin/dms is owned by dms-shell-git 1.7.0+git4915-1" // "/usr/bin/dms is owned by dux-shell-git 2.0.0+git4915-1"
out := runOutput(ctx, "pacman", "-Qo", path) out := runOutput(ctx, "pacman", "-Qo", path)
fields := strings.Fields(out) fields := strings.Fields(out)
if len(fields) < 2 { if len(fields) < 2 {
+8 -3
View File
@@ -85,11 +85,11 @@ func TestDetectChannel(t *testing.T) {
} }
func TestFindShellPackagePrefersOwner(t *testing.T) { func TestFindShellPackagePrefersOwner(t *testing.T) {
pkgs := []Package{{Name: "dms-shell"}, {Name: "dms-shell-git"}, {Name: "firefox"}} pkgs := []Package{{Name: "dux-shell"}, {Name: "dux-shell-git"}, {Name: "firefox"}}
if got := findShellPackage(pkgs, "dms-shell-git"); got == nil || got.Name != "dms-shell-git" { if got := findShellPackage(pkgs, "dux-shell-git"); got == nil || got.Name != "dux-shell-git" {
t.Errorf("owner match = %v", got) t.Errorf("owner match = %v", got)
} }
if got := findShellPackage(pkgs, ""); got == nil || got.Name != "dms-shell" { if got := findShellPackage(pkgs, ""); got == nil || got.Name != "dux-shell" {
t.Errorf("fallback = %v", got) t.Errorf("fallback = %v", got)
} }
if got := findShellPackage([]Package{{Name: "firefox"}}, "dms"); got != nil { if got := findShellPackage([]Package{{Name: "firefox"}}, "dms"); got != nil {
@@ -222,6 +222,11 @@ func TestSetIntervalSchedulesFromLastCheck(t *testing.T) {
if next := m.GetState().NextCheckUnix; next < now+startupGraceSeconds-5 || next > now+startupGraceSeconds+5 { if next := m.GetState().NextCheckUnix; next < now+startupGraceSeconds-5 || next > now+startupGraceSeconds+5 {
t.Errorf("overdue next = now+%d, want now+%d", next-now, startupGraceSeconds) t.Errorf("overdue next = now+%d, want now+%d", next-now, startupGraceSeconds)
} }
m.SetInterval(0)
if state := m.GetState(); state.IntervalSeconds != 0 || state.NextCheckUnix != 0 {
t.Errorf("never: interval = %d, next = %d, want both 0", state.IntervalSeconds, state.NextCheckUnix)
}
} }
func TestRefreshWithoutBackendsAdvancesDeadline(t *testing.T) { func TestRefreshWithoutBackendsAdvancesDeadline(t *testing.T) {
+1 -29
View File
@@ -1,7 +1,6 @@
package tailscale package tailscale
import ( import (
"fmt"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -103,35 +102,8 @@ func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg
} }
if !ps.LastSeen.IsZero() { if !ps.LastSeen.IsZero() {
peer.LastSeen = formatRelativeTime(ps.LastSeen) peer.LastSeen = ps.LastSeen.UTC().Format(time.RFC3339)
} }
return peer return peer
} }
// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago").
func formatRelativeTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
default:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
}
@@ -169,23 +169,3 @@ func TestPeerSorting(t *testing.T) {
assert.Equal(t, "beta", state.Peers[1].Hostname) assert.Equal(t, "beta", state.Peers[1].Hostname)
assert.Equal(t, "zebra", state.Peers[2].Hostname) assert.Equal(t, "zebra", state.Peers[2].Hostname)
} }
func TestFormatRelativeTime(t *testing.T) {
tests := []struct {
name string
duration string
contains string
}{
{"minutes", "5m", "minutes ago"},
{"hours", "3h", "hours ago"},
{"days", "48h", "days ago"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, _ := time.ParseDuration(tt.duration)
result := formatRelativeTime(time.Now().Add(-d))
assert.Contains(t, result, tt.contains)
})
}
}
@@ -194,3 +194,24 @@ func TestAlertRecordSurvivesRestart(t *testing.T) {
assert.Nil(t, s.Limit, "the alert already fired before the restart") assert.Nil(t, s.Limit, "the alert already fired before the restart")
} }
} }
func TestOpenDBLockedByAnotherProcessKeepsData(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
c := &clock{now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)}
now := func() time.Time { return c.now }
m, err := newManager(path, now)
require.NoError(t, err)
m.SetState("firefox", true, 1)
c.advance(10 * time.Second)
m.Flush()
_, err = newManager(path, now)
require.Error(t, err)
m.Close()
m, err = newManager(path, now)
require.NoError(t, err)
defer m.Close()
assert.Equal(t, int64(10), m.Summary(1)[0].Active)
}
+5
View File
@@ -2,6 +2,7 @@ package wellbeing
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -11,6 +12,7 @@ import (
"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/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
berrors "go.etcd.io/bbolt/errors"
) )
var ( var (
@@ -31,6 +33,9 @@ func openDB(path string) (*bolt.DB, error) {
if err == nil { if err == nil {
return db, nil return db, nil
} }
if errors.Is(err, berrors.ErrTimeout) {
return nil, err
}
log.Errorf("Wellbeing db unreadable, starting over: %v", err) log.Errorf("Wellbeing db unreadable, starting over: %v", err)
corruptPath := path + ".corrupt" corruptPath := path + ".corrupt"
os.Remove(corruptPath) os.Remove(corruptPath)
+5
View File
@@ -14,6 +14,7 @@ type Model struct {
state ApplicationState state ApplicationState
osInfo *distros.OSInfo osInfo *distros.OSInfo
resume *layerResume
dependencies []deps.Dependency dependencies []deps.Dependency
err error err error
@@ -137,6 +138,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateDetectingDepsState(msg) return m.updateDetectingDepsState(msg)
case StateDependencyReview: case StateDependencyReview:
return m.updateDependencyReviewState(msg) return m.updateDependencyReviewState(msg)
case StateLayerPackages:
return m.updateLayerPackagesState(msg)
case StateGentooUseFlags: case StateGentooUseFlags:
return m.updateGentooUseFlagsState(msg) return m.updateGentooUseFlagsState(msg)
case StateGentooGCCCheck: case StateGentooGCCCheck:
@@ -180,6 +183,8 @@ func (m Model) View() string {
return m.viewDetectingDeps() return m.viewDetectingDeps()
case StateDependencyReview: case StateDependencyReview:
return m.viewDependencyReview() return m.viewDependencyReview()
case StateLayerPackages:
return m.viewLayerPackages()
case StateGentooUseFlags: case StateGentooUseFlags:
return m.viewGentooUseFlags() return m.viewGentooUseFlags()
case StateGentooGCCCheck: case StateGentooGCCCheck:
+90
View File
@@ -0,0 +1,90 @@
package tui
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
// Carries the first run's choices across the reboot that layering needs.
type layerResume struct {
WindowManager string `json:"window_manager"`
Terminal string `json:"terminal"`
Selected []string `json:"selected"`
}
func resumePath() string {
dir := os.Getenv("XDG_STATE_HOME")
if dir == "" {
dir = filepath.Join(os.Getenv("HOME"), ".local", "state")
}
return filepath.Join(dir, "duxinstall", "resume.json")
}
func saveLayerResume(r layerResume) error {
path := resumePath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.Marshal(r)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func loadLayerResume() *layerResume {
data, err := os.ReadFile(resumePath())
if err != nil {
return nil
}
var r layerResume
if json.Unmarshal(data, &r) != nil || r.WindowManager == "" || r.Terminal == "" {
return nil
}
return &r
}
func clearLayerResume() {
os.Remove(resumePath())
}
func (m Model) currentLayerResume() layerResume {
r := layerResume{}
if wms := m.windowManagerChoices(); m.selectedWM >= 0 && m.selectedWM < len(wms) {
r.WindowManager = strings.ToLower(wms[m.selectedWM].label)
}
if terms := m.terminalChoices(); m.selectedTerminal >= 0 && m.selectedTerminal < len(terms) {
r.Terminal = strings.ToLower(terms[m.selectedTerminal].label)
}
for _, dep := range m.dependencies {
if !dep.Required && !m.disabledItems[dep.Name] {
r.Selected = append(r.Selected, dep.Name)
}
}
return r
}
// false: the saved choices no longer fit this host.
func (m *Model) applyLayerResume() bool {
if m.resume == nil {
return false
}
wm, term := -1, -1
for i, c := range m.windowManagerChoices() {
if strings.ToLower(c.label) == m.resume.WindowManager {
wm = i
}
}
for i, c := range m.terminalChoices() {
if strings.ToLower(c.label) == m.resume.Terminal {
term = i
}
}
if wm < 0 || term < 0 {
return false
}
m.selectedWM, m.selectedTerminal = wm, term
return true
}
Loaded 100 of 516 files, more files were not shown because too many files have changed in this diff. Show more