Compare commits

..
99 Commits
Author SHA1 Message Date
dms-ci[bot] 4a87e8227d bump VERSION to v1.6.3 2026-10-07 17:47:03 +00:00
bbedward c0b26caf2b dank-qml-common: classify scroll input by phase instead of pixelDelta
stable-1.6 branch of the submodule carries dank-qml-common 1ae7222 (port of d630451), so DankFlickable, DankListView and DankGridView stop treating Qt 6.12 mouse wheels as touchpads. The flake input, flake.lock and .gitmodules follow that branch.
2026-10-07 13:15:39 -04:00
Vladimir Kostelei c6c40dbced fix: fingerprint lid open (#3448)
* lock: keep the fingerprint reader usable across suspend

The reader stopped responding after the lid was closed and opened, after a long
idle period, and after suspend.

The shell held a PAM session for as long as the screen was locked, so a suspend
always landed on a running verify. fprintd then suspends the device under the
verify, pam_fprintd's ReleaseDevice fails with "device is still busy", and the
claim is never released, so every later Claim fails until fprintd restarts. The
reader is now released when the monitors blank and armed again when they come
back, so a suspend has nothing left to wedge.

The PAM profile drops to max-tries=1 and sets an explicit timeout. pam_fprintd
only counts a wrong finger, so one finished session is now one wrong scan and
maxFprintTries means the number of scans it names rather than five times that.
A timeout expiry arrives as PamResult.Error, exactly like a device fault, so a
session that ran at least as long as the configured timeout is treated as a
planned renewal instead of spending the error budget and painting an error.

A suspend that still lands while the screen is on wedges as before. Recovery now
waits out fprintd's idle timeout instead of climbing to it, which brings the
reader back about 37 seconds after resume instead of about 98.

SessionService gains lidOpened from UPower, because logind can ignore the lid on
AC and resume alone is not enough. The lock PAM state machine is fixed alongside:
PamResult.Failed was dropped, a pending retry outlived the contexts that owned
it, and an availability check reset the wrong-scan counter.

* lock: time the fingerprint session from the verify, not the claim

pam_fprintd's timeout runs from the verify, but the session age was measured
from start(), which folds the claim into it. A slow claim plus a verify that
failed before its own timeout could add up past the timeout and be taken for a
planned renewal, skipping the backoff. A session that never reached a verify had
the same problem.

The age now starts at the message pam_fprintd sends when the verify begins, so a
session that never verified can never qualify as an expiry, and a slow claim no
longer ages a fault. The message is translated and quickshell clears it before
completed, so only its timing is used, never its text.

That message arrives after pam_fprintd has already armed its deadline, because
VerifyStart is asynchronous, so the age read here runs short of the real timeout
and a slack makes it up. The bias is deliberate. A fault misread as a timeout
costs one extra retry, while a timeout misread as a fault parks the reader behind
a backoff that grows to a minute, which on an idle lock screen would leave it
unusable most of the time.

Also corrects the comment that claimed a timeout is reported without a message.

---------

(cherry picked from commit d7074a53eb)
2026-10-07 13:05:09 -04:00
Satoshoe 02ddd010a0 settings: skip unchanged saves so external edits are not swallowed (#3504)
saveSettings() set _selfWrite before setText(). When the serialized
settings were identical to the file, nothing was written, no
fileChanged followed, and the flag stayed set. The next external edit
of settings.json was then taken for our own write and never reloaded.

(cherry picked from commit 84594e6449)
2026-10-07 13:03:42 -04:00
bbedward 6eb9df87cf network: set routing preference only from settings
fixes #3068

stable-1.6: core part only, the settings and network popout changes are master-only

(cherry picked from commit 089dc98166)
2026-10-07 13:03:41 -04:00
Artem Timofeev 0adbdfdde1 fix(power): reconcile refresh rate after resume (#3459)
(cherry picked from commit 8fedfae1dc)
2026-10-07 13:01:32 -04:00
LemonMon fb002d1977 fix: stabilize tray item key to prevent position jumps on property changes (#3386)
* fix: keep tray order stable when tooltipTitle changes dynamically

Revert the earlier simplistic key approach: keep getTrayItemKey()
unchanged (id::tooltipTitle) so duplicate-id disambiguation (#1542)
is preserved and persisted id::tooltipTitle entries keep matching.

Instead make persisted-key lookups tolerant of dynamic tooltipTitle:
resolveOrderIndex() in sortByPreferredOrder() does exact match first,
falling back to prefix matching an id::* entry only when the id has a
single instance. findSavedTrayKey()/isTrayIdHidden() replace direct
SessionData.isHiddenTrayId() calls and showTrayId() now receives the
actual saved key.

Prefix fallback is gated by trayItemCountForId() to single-instance
ids only, so multiple items sharing an id always match exactly and
hiding/ordering one never affects the others.

* fix: bail on ambiguous saved tray keys in fallback

* tray: use occurrence index as stable key, add one-time session migration

* tray: pin stable key per item instance so it never flips when siblings come and go

* tray: assign unique per-id sequential keys to avoid collision after sibling exit

* tray: reuse free key slots so a restarted singleton keeps its plain id

(cherry picked from commit 60342b7416)
2026-10-07 13:01:06 -04:00
Marlon Kranz 8b7c9085ca fix(notifications): edit rules from the menu instead of deleting them (#3670)
* fix(notifications): edit rules from the menu instead of deleting them

A notification rule can set an action and Bypass DND together, but
Unmute and Block in Do Not Disturb spliced the whole rule, dropping the
other setting. They now reset only their own field, and a rule is
removed once it no longer does anything (action and urgency default,
no bypass). Mute and Allow in Do Not Disturb set their field on the
app's existing rule instead of appending one that first-match policy
would shadow.

The menu now only reads and edits enabled, exact rules on the app name
or desktop entry. It used to match any rule whose pattern equaled the
app name, including summary, body, contains and regex rules, and could
delete disabled ones.

Also adds a Copy item to the notification menu on popups and center
cards, copying the summary and body as plain text.

* fix(notifications): let mute apply alongside other rule actions

Rule actions are first-match, so a mute rule behind an app's existing
rule (e.g. No history) never applied while the menu showed Unmute.
Mute is now evaluated like Bypass DND: any matching enabled mute rule
disables the popup, and the other actions stay first-match, so No
history and Mute both apply.

* fix(notifications): skip the sound for dropped and muted notifications

The notification sound played before the check that dismisses a
notification with neither a popup nor a center entry, so dropped
notifications (transient while muted, Popup only plus Mute) still made
a sound. Play it only for notifications that are kept, and not when a
mute rule applies, like Do Not Disturb.

stable-1.6: ported the rule edits and the sound/mute policy only, the Copy menu item depends on NotificationActions which stable does not have

(cherry picked from commit 51f115f11e)
2026-10-07 13:00:11 -04:00
Marcus Ramberg 8b34bbf71a fix: scope bluez dbus match to bluezService (#3573)
Ensure the watcher only seees bluez messages

(cherry picked from commit 05377e690a)
2026-10-07 12:58:50 -04:00
bbedward fd468a6b78 brightness: ddcutil-parity ddc probing, bounded rereads
fixes #3616

(cherry picked from commit 0b9d931ea3)
2026-10-07 12:58:37 -04:00
Pedro Jose Soto Rivera 7c04b61bcc network(networkd): treat bridge, bond, team and vlan links as wired (#3706)
* network(networkd): treat bridge, bond, team and vlan links as wired

When the IP configuration lives on a bridge or link aggregate, the
enslaved member NIC reports opState=enslaved and the aggregate itself
was dropped by isWired() because only Type=ether was accepted, so the
widget showed Disconnected. Accept the same link types NetworkManager
already handles (#1581).

Also treat lxdbr*/incusbr* as virtual so container bridges, now of a
wired type, are not picked as the uplink.

Fixes #3463

* network(networkd): only treat a bridge as wired if it has a physical member

Name prefixes can't cover every container/VM bridge (lxcbr0, waydroid0,
...). Check the bridge's members in /sys/class/net/<br>/brif instead and
only accept it as the uplink when at least one member is not virtual
(veth*, tap*, ...). This replaces the lxdbr*/incusbr* prefixes.

(cherry picked from commit f886789835)
2026-10-07 12:58:37 -04:00
Pedro Jose Soto Rivera bc4916a132 network(iwd): follow the Wi-Fi device when iwd recreates it (#3707)
When iwd restarts it recreates wlan0 under a new D-Bus path. Only the
Station interface was tracked on InterfacesAdded, so devicePath kept
pointing at the dead object: the PropertiesChanged match for the new
device was never registered, updateState() read Powered from the old
path, and SetWiFiEnabled() failed with "No matching method found".
WiFiEnabled stayed false after handleStationRemoved() until DMS was
restarted.

Handle the Device interface on InterfacesAdded/InterfacesRemoved: move
devicePath and its signal match to the new object and take Name and
Powered from the signal. Process every interface in an
InterfacesRemoved signal instead of stopping at the first match.

Refs #3495

(cherry picked from commit ff50b492b1)
2026-10-07 12:58:37 -04:00
Stephanos Kamprogiannis 58a356bb05 idle: wake monitors after automatic lock (#3633)
Locking rearms the normal idle monitors and can lose their wake transition.
Enable the existing locked-monitor wake listener for idle power-off too.

(cherry picked from commit 22fa08f0ff)
2026-10-07 12:58:37 -04:00
arfan 3061e1301d fix(theme-vscode): update editorSuggestWidget colors for improved visibility (#3599)
* fix(theme-vscode): update editorSuggestWidget colors for improved visibility in dark, default, and light themes

* fix(theme-vscode): update button.hoverBackground to use primary_fixed colors for consistency

(cherry picked from commit c1818dee2c)
2026-10-07 12:58:37 -04:00
arfan e333da2013 fix(theme): update focus highlight colors for list and quick input components (#3579)
(cherry picked from commit 814620a934)
2026-10-07 12:58:37 -04:00
bbedward 307d05d76c matugen: fix kitty cursor color
fixes #3520

(cherry picked from commit f3af870d93)
2026-10-07 12:58:37 -04:00
bbedward 10dbe73335 matugen: fix kde color scheme templates ignoring the accent
(cherry picked from commit f102a83d9f)
2026-10-07 12:58:37 -04:00
Huỳnh Thiện Lộc b6fee3a98f fix(island): avoid bottom clipping in media expanded sheet (#3593)
(cherry picked from commit 0717908d53)
2026-10-07 12:58:37 -04:00
Thomas Kroll 6166d51c7c fix(workspace): ignore duplicate submit in rename modal (#3549)
Pressing Enter in the name field fires both the text field's accepted
signal and the dialog's own Return handler, so submitAndClose() runs
twice. The first call renames and hides the window, which clears the
field; the second call then renames to an empty string and unsets the
workspace name. The OK button only fires once, so it was unaffected.

Ignore submits once the window is hidden.

Signed-off-by: Thomas Kroll <[email protected]>
(cherry picked from commit eb0cb80884)
2026-10-07 12:58:37 -04:00
Huỳnh Thiện Lộc d06fcccff9 fix(notepad): hide placeholder during IME composition (#3646)
(cherry picked from commit 340f2d179c)
2026-10-07 12:58:37 -04:00
Marlon Kranz d9cfeded47 fix(lock): show the lock screen on every display when none is selected (#3666)
With two or more screens connected and none of them in the lock screen
display list (a laptop docked at a different desk), every surface was
inactive and no password field rendered, so the only way out was a
TTY. getFilteredScreens only falls back to all screens with
showOnLastDisplay and exactly one screen, which the lock screen picker
never sets. Treat an empty result as all screens, the same as the
default "all" preference.

(cherry picked from commit 6a320399c9)
2026-10-07 12:58:37 -04:00
bbedward c4ced3ac9d disable fanout 2026-10-07 12:36:06 -04:00
bbedward ce2ec80d80 bar: detect touchpads by scroll phase instead of pixelDelta 2026-10-07 12:34:11 -04:00
bbedward a340eba493 config: drop Before=graphical-session.target from the generated hyprland target
(cherry picked from commit 507b818a55)
2026-10-06 11:05:47 -04:00
dms-ci[bot] 2db7646fe3 bump VERSION to v1.6.2 2026-09-17 15:07:40 +00:00
bbedward 097de493bb displayconfig: fix custom scale option 2026-09-17 10:33:09 -04:00
bbedward a7db0354ae niri: fix workspace updates never applying
fixes #3462
port 1.6

(cherry picked from commit 36bb62ac66)
2026-09-17 14:03:55 +00:00
bbedward 2d2502cc67 qt: write real newlines in new qt5ct/qt6ct config
fixes #3457
port 1.6

(cherry picked from commit 49a639a358)
2026-09-17 14:03:53 +00:00
Huỳnh Thiện Lộc 402a8515ee feat(screenshot): add flag to control HUD scale and visibility (#3398)
* feat(screenshot): add --hud flag to control HUD scale and visibility

* fix(screenshot): clamp HUD scale to 1-4 and clarify flag description

(cherry picked from commit 13192370f5)
2026-09-17 09:44:00 -04:00
phoropter 4cf0f0c898 Update vesktop.css (#3410)
Fix text being the same color as mention badge background.

(cherry picked from commit b4f6da25ee)
2026-09-17 09:43:51 -04:00
Domen Kožar fd0564a3ec tray: use available width for menu labels (#3456)
(cherry picked from commit 1bf35426ea)
2026-09-17 09:43:22 -04:00
Marlon Kranz 07f8675a7b fix(frame): keep launcher edge-hover on the Top layer (#3453)
Opening the launcher by hovering the frame edge requested the Overlay
layer via CompositorService.framePeerSurfacesUseOverlayForScreen(),
which resolves to whether the frame is configured for the screen and
is true in normal use. DankLauncherV2ModalConnected's
frameOwnsConnectedChrome requires the Top layer, so the overlay
request silently dropped connected-chrome anchoring: the launcher
opened dead-centered with a different animation instead of emerging
from the configured frame side.

The keybind/bar-button path already opens on the Top layer by passing
no overlay argument. The edge-hover trigger only runs in connected
frame mode to begin with (gated by usesConnectedFrameChromeForScreen
in this file's own eligible check), so there's no case where it needs
standalone/overlay behavior. Match the keybind path.

(cherry picked from commit 579d6f7b5c)
2026-09-17 09:41:33 -04:00
Alexander cab2a1a3bc dock: fix overview visibility on niri (#3460)
(cherry picked from commit 81d5962650)
2026-09-17 09:41:21 -04:00
bbedward 41dd91fe75 dank16: use the real primary_container and fix light-mode derivation
fixes #3405
fixes #3406
ref #3408
port 1.6

(cherry picked from commit 641d65cbd9)
2026-09-16 14:06:18 +00:00
dms-ci[bot] c074b220ee nix: update vendorHash for go.mod changes 2026-09-16 09:32:22 -04:00
bbedward 243d716703 core: bump dankgop
port 1.6

(cherry picked from commit c9506bc847)
2026-09-16 13:28:09 +00:00
Giacomo Di Clerico fde2181c3b tailscale: handle profile switches and bus updates reactively (#3427)
* tailscale: handle profile switches and bus updates reactively

* tailscale: address review feedback on test coverage and dead code

(cherry picked from commit 218f7f752f)
2026-09-15 16:46:47 -04:00
Harsh Kumar a5ffd18596 display config: read live layout from hyprland instead of the cached wlr snapshot (#3418)
(cherry picked from commit d7b5812561)
2026-09-15 16:46:47 -04:00
bbedward e0aa1f0177 frame: close hairline gap at the screen edge
ref #3389

(cherry picked from commit 4d5bac28c9)
2026-09-15 16:46:47 -04:00
bbedward e73b9c3ce1 evdev: open input devices read-only
go-evdev default is O_RDWR but we dont need to open evdev devices RW

(cherry picked from commit 6e9a657132)
2026-09-15 16:46:47 -04:00
Mattias 69955286a7 privacy: stop counting screen captures as camera use (#3428)
Apps capturing the screen through PipeWire, such as OBS, create a live
video input stream, which the camera check counted as camera use. Skip
streams tagged with the Screen media role.

(cherry picked from commit 6ecdd6d45d)
2026-09-15 16:46:47 -04:00
Timofey d3a02f6342 hyprland: use focusedMonitor instead of focusedWorkspace.monitor (#3424)
Hyprland's workspace refactor (hyprwm/Hyprland#16140) broke
Hyprland.focusedWorkspace.monitor as a way to find the focused screen
(quickshell-mirror/quickshell#1119). Quickshell already exposes
Hyprland.focusedMonitor directly, so read the monitor from there

(cherry picked from commit 05c807b7cd)
2026-09-15 16:46:47 -04:00
gibbert 86234e5b09 fix(audio): avoid false negative when some event sounds can't be found (#3437)
(cherry picked from commit 6af2b1fbc3)
2026-09-15 16:46:47 -04:00
deepfriedret 938fabe27d fix(media widget): prevent title from truncating with scroll song title on (#3436)
(cherry picked from commit 0133962936)
2026-09-15 16:46:47 -04:00
Mario Mohar 1b34aa0ce1 fix(battery): report when no charge threshold exists instead of faking success (#3385)
* fix(battery): report when no charge threshold exists instead of faking success

* fix(battery): hide the charge limit controls when there is no battery

Follow-up to the review on #3385: the new explanation showed on
desktops and VMs with no battery at all, where it reads as a fault
report about hardware the machine was never going to have.

Both the explanation and the apply button now require
BatteryService.batteryAvailable. Hiding the button there is the same
point the pull request makes elsewhere: offering a control that cannot
work is what produced the false success in the first place. A machine
without a battery has no charge threshold to write to, so the limit was
never applicable.

BatteryService.batteryAvailable is already the gate used twice in this
file for the same reason.

(cherry picked from commit 6b50696b8a)
2026-09-15 16:46:47 -04:00
Huỳnh Thiện Lộc 76ed5d68fa fix(clipboard): preserve pinned state when editing entries (#3412)
* fix(clipboard): preserve pinned state when editing entries

* fix(clipboard): address PR review feedback on editEntry

* fix(clipboard): validate empty text and entry size in EditEntry

* fix(clipboard): gate saving entry on full text load completion

* fix(clipboard): add user feedback and error fallback for entry loading

* fix(clipboard): keep entry and gate save with error toast on load failure

* fix(clipboard): reject non-text edits and add translator contexts

* fix(clipboard): support alt text mime types in EditEntry and UI guards

* fix(clipboard): pre-fetch entry before opening editor and allow retry on load failure

(cherry picked from commit 4141089e63)
2026-09-15 16:46:47 -04:00
Mario Mohar b69496dbcd fix(settings): size the plugin update spinner to fit its row (#3383)
(cherry picked from commit cf74c10561)
2026-09-15 16:46:46 -04:00
bbedward ef179f9c92 disable dcal from release dispatch 2026-09-15 16:41:16 -04:00
bbedward b451d4d8e0 doctor: add aqueous and scroll
port 1.6

(cherry picked from commit 09ddd6da82)
2026-09-11 13:47:33 +00:00
purian23 12f7675950 refactor(island): add island satellite hover option
fixes #3296
port 1.6

(cherry picked from commit 60675ecb39)
2026-09-10 03:36:41 +00:00
dms-ci[bot] 06cd88e990 nix: update vendorHash for go.mod changes 2026-09-09 09:10:51 -04:00
dms-ci[bot] aa4b99def4 bump VERSION to v1.6.1 2026-09-08 17:14:24 +00:00
Zoey Bauer 4015f634c4 add support for shelly (#3367)
* add support for shelly

* fixed terminal failure

* updated to have it not allow adding ignore aur

* updated error strings

* Fixed label

port 1.6

(cherry picked from commit 8fe918ff24)
2026-09-08 15:50:35 +00:00
bbedward b2d1ec6690 systray: dont wrap context menu title
port 1.6

(cherry picked from commit 2bf2754ad2)
2026-09-08 14:19:25 +00:00
Huỳnh Thiện Lộc 1efc10dc7e fix(screenshot): force full-damage commit on cursor toggle (#3365)
* fix(screenshot): force full-damage commit on cursor toggle with compositor v4

* test(screenshot): drop tautological cursor-mismatch test

(cherry picked from commit 0f058468a5)
2026-09-08 09:24:09 -04:00
BB 94710cf643 feat: support for Aqueous Compositor (#3357)
* updated dank to support aqueous

* further updates

* aqueous support work

* updated to support workspace options better in aqueous

* added blur to dms shell for aqueous

* added keybinding update

* remove unneeded blur

* clean up per claud review

* swapped to ipc

* aqueous: move IPC integration to QML, delete test fixtures (since we
dont have a QML test structure even setup), delete in-repo docs

* fix staticcheck

* aqueous: consolidate more aqueous specific stuff

* add aqueous logo

---------

Co-authored-by: Zoey <[email protected]>
(cherry picked from commit b9365610c8)
2026-09-08 09:23:54 -04:00
bbedward 3711cb11fa core: bump dankgo
fixes #3361
port 1.6

(cherry picked from commit 8232144bb7)
2026-09-08 01:19:29 +00:00
bbedward 40a0eb6841 workspace: keep hit area out of adjacent spacers
fixes #3360
port 1.6

(cherry picked from commit fb07a91108)
2026-09-08 01:19:27 +00:00
bbedward 0a643028b3 hyprland: stop overwriting the config backup during lua migration
fixes #3359
port 1.6

(cherry picked from commit 24e17a5957)
2026-09-08 01:02:22 +00:00
bbedward 0189eb0dbb frame: draw panel backgrounds when fullscreen hides the frame
port 1.6

(cherry picked from commit 17d4ea6f88)
2026-09-08 00:45:35 +00:00
bbedward ed26bdebdc i18n: sync, change plugin workflow
port 1.6

(cherry picked from commit 8c42700a4c)
2026-09-08 00:45:32 +00:00
bbedward 2e2c74e355 i18n: sync
port 1.6

(cherry picked from commit 9bf8174984)
2026-09-07 17:51:21 +00:00
bbedward c1a3be60c3 fix(launcher): stop injectnig plugins into app categories
port 1.6

(cherry picked from commit 0f56536142)
2026-09-07 14:37:09 +00:00
bbedward f8260513b1 frame: keep overlay backgrounds with their content
fixes #3350
port 1.6

(cherry picked from commit de27853c46)
2026-09-07 14:29:53 +00:00
bbedward ed1855946f ci: disable qml-common from release fanout 2026-09-07 10:01:55 -04:00
bbedward c0bc1d8885 display: watch the session store for active profile changes
port 1.6

(cherry picked from commit 0890db93db)
2026-09-07 13:55:38 +00:00
Huỳnh Thiện Lộc 3f09d7b1c8 feat(screenshot): add touch & stylus support for region capture (#3353)
* feat(screenshot): add touch and stylus input support for region capture

* fix(screenshot): guard against finishSelection on touch up during scroll phase

port 1.6

(cherry picked from commit a9fce46fec)
2026-09-07 13:11:50 +00:00
bbedward 710f20d085 i18n: make sync script auto-pull plugins for translation
port 1.6

(cherry picked from commit c1e53dbc33)
2026-09-06 21:05:10 +00:00
bbedward 6261e580aa bar: refresh blur region after widget layout settles
ref #3348
port 1.6

(cherry picked from commit 62ad4c2cb7)
2026-09-06 17:54:19 +00:00
bbedward 42912ebe74 launcher: stop rebuilding the category map per app and index i18n misses
fixes #3345
port 1.6

(cherry picked from commit c76c29139f)
2026-09-06 17:40:35 +00:00
bbedward e8a755b85d controlcenter: release dgop refs while the popout is closed
ref #3349

(cherry picked from commit 654c6dce8f)
2026-09-06 13:40:26 -04:00
bbedward 6713f05e02 audio: add output card selection
port 1.6

(cherry picked from commit 8188f53da5)
2026-09-06 17:15:06 +00:00
bbedward 5d39702136 lock: keep the password cursor inside the field and follow it through overflow and wrapped reveals
fixes #3319
port 1.6

(cherry picked from commit 5baef07048)
2026-09-05 16:26:30 +00:00
bbedward 166b82df1b network: reject weak hotspot passwords before handing them to NetworkManager
fixes #3318
port 1.6

(cherry picked from commit f402731ef2)
2026-09-05 16:21:54 +00:00
bbedward bca1466162 dash, notifications, island: fix calendar banner overflow, expand chevron, and player row clipping
fixes #3300
fixes #3301
fixes #3317
port 1.6

(cherry picked from commit 59f6f1fa71)
2026-09-05 16:21:52 +00:00
bbedward 75b8932b20 network: keep saved wifi profiles when NM fails with no-secrets
fixes #3313
port 1.6

(cherry picked from commit c0f775ae49)
2026-09-05 16:21:49 +00:00
bbedward a1b51b191f bar: focus workspace icons on any workspace and wire island overview loader
fixes #3309
fixes #3310

port 1.6

(cherry picked from commit 3d6e45f430)
2026-09-05 15:40:15 +00:00
bbedward 5defc26929 display config: judge profile outputs by live heads, not stored make/model
fixes #3306
port 1.6

(cherry picked from commit 85bc7604c2)
2026-09-05 15:40:12 +00:00
bbedward 71e8a85cd1 screenshot: resolve region and picker keys through the seat keymap
fixes #3311
port 1.6

(cherry picked from commit 3674d0f480)
2026-09-05 15:19:16 +00:00
Huỳnh Thiện Lộc 95b2750e5f fix(screenshot): scale HUD and selection label for HiDPI displays (#3308)
port 1.6

(cherry picked from commit 5f124de8d4)
2026-09-05 15:10:48 +00:00
bbedward 7f1a423174 theme: read the dynamic error color per mode
fixes #3298
port 1.6

(cherry picked from commit 59a03f450d)
2026-09-05 14:47:12 +00:00
bbedward 33e599100c systemd: keep the inherited hard file limit in dms.service
fixes #3312
port 1.6

(cherry picked from commit 3797422832)
2026-09-05 14:23:00 +00:00
Adolfo Ochagavía cb85620777 Fix repeated day in calendar (#3302)
Root cause: we were creating a `Date` that did not exist in my timezone
(Santiago, Chile) because of DST. The JS engine decided to pick the
previous day, so I ended up with two times 5 September.

Fix: introduce a `DateOnly` object that internally uses UTC (to prevent
DST issues). Other parts of the system still use `Date`, which
probably should be refactored to use `DateOnly` as well (maybe in an
upcoming PR?). In the meantime, we provide a function to export a
`DateOnly` as a `Date`, using noon as the time instead of midnight
(because there are no DST changes at noon AFAIK).

We tested this manually (see the attached screenshots), but didn't find
a suitable place for automated tests.

port 1.6

(cherry picked from commit e4793327b8)
2026-09-04 23:42:17 +00:00
purian23 faf25e98a5 feat(island): vertical orientation layout management support
Port 1.6

(cherry picked from commit e18931353b)
2026-09-04 21:57:39 +00:00
bbedward d7b264701e bar: keep the widget font size the user asked for
ref #3295
port 1.6

(cherry picked from commit cf3c76a79f)
2026-09-04 17:11:05 +00:00
bbedward c929a8952b screenshot: add instancelock on runtimedir/wayland-display to prevent
multiple instances from running

fixes #3293
port 1.6

(cherry picked from commit 7e18a68b82)
2026-09-04 17:04:14 +00:00
bbedward 82089c588a launcher: revalidate the cached launcher sections on load
fixes #3292
port 1.6

(cherry picked from commit 96a21fbe08)
2026-09-04 16:02:55 +00:00
bbedward 19a56d1835 clock: align the horizontal time block to whole pixels
fixes #3291
port 1.6

(cherry picked from commit fbffa410af)
2026-09-04 16:02:53 +00:00
bbedward 8371fa8ef5 audio: resolve the sound theme before the login sound plays
fixes #3290
port 1.6

(cherry picked from commit 936b42b4e7)
2026-09-04 16:02:51 +00:00
Huỳnh Thiện Lộc acca7fae68 fix(dash): dim today button when on current month (#3294)
* fix(dash): dim today button when on current month and hide tooltip on click

* fix(dash): use Theme.shortDuration for calendar today button opacity animation

* refactor(dash): use standard DankActionButton tooltipText for calendar today button

* chore(dash): remove unused todayButton id

* fix(dash): allow returning to today when another day in current month is selected

(cherry picked from commit 07d8e28668)
2026-09-04 10:38:44 -04:00
bbedward a478c27fd0 bar: make the global shadow toggles authoritative
(cherry picked from commit 4e2afb53cc)
2026-09-04 10:38:37 -04:00
bbedward 094dcdd035 matugen: stop embedding the adw-gtk3 body in the gtk light template
fixes #3282
port 1.6

(cherry picked from commit eb53d8983e)
2026-09-04 14:38:01 +00:00
bbedward 344c1e610c theme: write desktop settings through dconf instead of gsettings
fixes #3280
port 1.6

(cherry picked from commit a665ba8ca5)
2026-09-04 14:37:58 +00:00
bbedward fdf8722818 matugen: give dry runs their own config file
fixes #3279
port 1.6

(cherry picked from commit d57e32c5cb)
2026-09-04 14:37:56 +00:00
usoaraway0 f2114c1a01 brightness: retry initial ddc read (#3287)
port 1.6

(cherry picked from commit ec22e9e594)
2026-09-04 13:51:54 +00:00
枫莹 6ed79d06df fix(plugins): use plugin ids for management actions (#3284)
(cherry picked from commit 433b13cb0e)
2026-09-04 09:51:29 -04:00
usoaraway0 8ff677d2eb niri: create screenshot directory on demand (#3286)
port 1.6

(cherry picked from commit cbe572eea6)
2026-09-04 13:46:32 +00:00
purian23 700f43a194 feat(island): allow hiding clock or date options in compact pill
Port 1.6

(cherry picked from commit c1f1da1d26)
2026-09-04 04:47:35 +00:00
bbedward 53a5582858 build: remove install-shell from install in Makefile
(cherry picked from commit 2008c39af3)
2026-09-03 18:28:38 -04:00
161 changed files with 42624 additions and 7245 deletions
+34 -34
View File
@@ -520,37 +520,37 @@ jobs:
# Pin the satellite repos to this release's version. Each dispatch target
# decides for itself whether to tag: X.Y.0 always tags, X.Y.Z skips when the
# repo has no commits of its own since its last tag.
fanout:
name: Pin ${{ matrix.repo }}
runs-on: ubuntu-latest
needs: [release]
if: ${{ !contains(inputs.tag, '-') }}
strategy:
fail-fast: false
matrix:
repo:
- dgop
- dankgo
- dank-qml-common
- dankcalendar
- danksearch
- dank-greeter
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ matrix.repo }}
- name: Dispatch pin-release
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
# fanout:
# name: Pin ${{ matrix.repo }}
# runs-on: ubuntu-latest
# needs: [release]
# if: ${{ !contains(inputs.tag, '-') }}
# strategy:
# fail-fast: false
# matrix:
# repo:
# - dgop
# - dankgo
# - dank-qml-common
# - dankcalendar
# - danksearch
# - dank-greeter
# steps:
# - name: Create GitHub App token
# id: app_token
# uses: actions/create-github-app-token@v3
# with:
# app-id: ${{ secrets.APP_ID }}
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
# owner: ${{ github.repository_owner }}
# repositories: ${{ matrix.repo }}
#
# - name: Dispatch pin-release
# env:
# GH_TOKEN: ${{ steps.app_token.outputs.token }}
# REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
# TAG: ${{ inputs.tag }}
# run: |
# set -euo pipefail
# gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
# echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
+1 -1
View File
@@ -1,4 +1,4 @@
[submodule "dank-qml-common"]
path = dank-qml-common
url = https://github.com/AvengeMedia/dank-qml-common.git
branch = master
branch = stable-1.6
+5 -5
View File
@@ -79,11 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
primaryLight = primaryColor
}
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
BackgroundDark: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors))
+25 -2
View File
@@ -95,6 +95,8 @@ var (
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
scrollVersionRegex = regexp.MustCompile(`scroll version (\d+\.\d+)`)
aqueousVersionRegex = regexp.MustCompile(`(?i)aqueous v?(\d+\.\d+(?:\.\d+)?)`)
)
var doctorCmd = &cobra.Command{
@@ -739,6 +741,8 @@ func checkWindowManagers() []checkResult {
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
{"Scroll", "scroll", "--version", scrollVersionRegex, []string{"scroll"}},
{"Aqueous", "aqueous", "-version", aqueousVersionRegex, []string{"aqueous"}},
}
var results []checkResult
@@ -771,7 +775,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
"Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, or Aqueous",
doctorDocsURL + "#compositor-checks",
})
}
@@ -780,11 +784,28 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
}
if os.Getenv("AQUEOUS_SOCKET") != "" {
results = append(results, checkAqueousConfigHelper())
}
results = append(results, checkCompositorBlurSupport())
return results
}
func checkAqueousConfigHelper() checkResult {
url := doctorDocsURL + "#compositor-checks"
path, err := exec.LookPath("aqueous-config")
if err != nil {
return checkResult{catCompositor, "aqueous-config", statusWarn, "Not found", "Keybind and display settings edits from the shell need the aqueous-config helper", url}
}
details := "Keybind and display settings edits from the shell"
if doctorVerbose {
details = path
}
return checkResult{catCompositor, "aqueous-config", statusOK, "Installed", details, url}
}
func checkCompositorBlurSupport() checkResult {
supported, err := blur.ProbeSupport()
if err != nil {
@@ -817,6 +838,8 @@ func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
func detectRunningWM() string {
switch {
case os.Getenv("AQUEOUS_SOCKET") != "":
return "Aqueous"
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
@@ -911,7 +934,7 @@ ShellRoot {
{"Polkit", "Authentication prompts"},
{"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"ShortcutInhibitor", "Allow shortcut management (niri, Aqueous)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
}
+3
View File
@@ -35,6 +35,7 @@ var (
ssJSON bool
ssGeometry bool
ssAllowMulti bool
ssHUD string
)
type screenshotMetadata struct {
@@ -181,6 +182,7 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
screenshotCmd.PersistentFlags().StringVar(&ssHUD, "hud", "auto", "HUD overlay scale in region selector (auto, off/0, or integer scale 1-4)")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
@@ -212,6 +214,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.Stdout = ssStdout
config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti
config.HUD = ssHUD
if ssGeometry {
config.Clipboard = false
+1 -1
View File
@@ -58,7 +58,7 @@ func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
+8 -9
View File
@@ -3,7 +3,7 @@ module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.5
require (
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287
github.com/Nadim147c/material/v3 v3.1.1
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.27.0
@@ -18,7 +18,7 @@ require (
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.8.4
@@ -71,23 +71,23 @@ require (
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
)
require (
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-alpha.4
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -98,13 +98,12 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.0.0 // indirect
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
golang.org/x/text v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
+22 -24
View File
@@ -1,9 +1,9 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e h1:AUCBM5EF+hxCG55uJl30waRQHhR5+Wij9u8AfoTkQvs=
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577 h1:z7daEy56wLH6R7PGS3MKIVoDHNdVoDptY/f2i61EDCQ=
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea h1:A2IuXIzhTQwvBZiS8pxU5RmWSszMd16Oh11gUlJ9OeU=
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287 h1:wzQqP16zxfUcfFltJelKg6pTdRyiLY2l2NJLT0Par+Q=
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287/go.mod h1:lwzO5Y4BO5z0I7cLMp5A7sw/THliwGz1TNv6xoUehY4=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nadim147c/material/v3 v3.1.1 h1:3ZuTPWBJWUpzzWW1rtqe28RMZXhA4f0/mg/Jk3JG94Y=
@@ -38,8 +38,8 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -61,8 +61,6 @@ github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daT
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
@@ -85,8 +83,8 @@ github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ=
@@ -166,8 +164,6 @@ github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -192,8 +188,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
@@ -204,8 +200,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
@@ -219,18 +215,20 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -243,10 +241,10 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
+51 -26
View File
@@ -661,9 +661,12 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
if existingData != "" {
existingConfig = existingData
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
}
// A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland
// watches the live config and regenerates a stub one if it disappears first.
if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") {
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData))
if err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
@@ -711,11 +714,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
}
}
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
if err != nil {
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
return result, result.Error
}
if mainConfBackup != "" && result.BackupPath == "" {
result.BackupPath = mainConfBackup
cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup))
}
if movedLegacy > 0 {
if result.BackupPath == "" {
result.BackupPath = backupDir
@@ -737,42 +744,53 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, nil
}
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
func uniqueBackupPath(dst string) string {
if _, err := os.Lstat(dst); err != nil {
return dst
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
if removeSource {
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
return err
for i := 1; i < 1000; i++ {
candidate := fmt.Sprintf("%s.%d", dst, i)
if _, err := os.Lstat(candidate); err != nil {
return candidate
}
}
return nil
return fmt.Sprintf("%s.%d", dst, time.Now().UnixNano())
}
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
func backupHyprlandConfigFile(dst string, data []byte) (string, error) {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", err
}
dst = uniqueBackupPath(dst)
if err := os.WriteFile(dst, data, 0o644); err != nil {
return "", err
}
return dst, nil
}
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) {
mainConf := filepath.Join(configDir, "hyprland.conf")
legacyPaths := []string{mainConf}
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
if err != nil {
return 0, err
return 0, "", err
}
legacyPaths = append(legacyPaths, dmsConfPaths...)
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
if err != nil {
return 0, err
return 0, "", err
}
legacyPaths = append(legacyPaths, backupPaths...)
moved := 0
mainConfBackup := ""
for _, src := range legacyPaths {
info, err := os.Lstat(src)
if os.IsNotExist(err) {
continue
}
if err != nil {
return moved, err
return moved, mainConfBackup, err
}
if info.IsDir() {
continue
@@ -782,21 +800,28 @@ func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, er
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(backupDir, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
return moved, err
dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel))
if err != nil {
return moved, mainConfBackup, err
}
if src == mainConf {
mainConfBackup = dst
}
moved++
}
return moved, nil
return moved, mainConfBackup, nil
}
func moveHyprlandConfigFile(src, dst string) error {
func moveHyprlandConfigFile(src, dst string) (string, error) {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
return "", err
}
return os.Rename(src, dst)
dst = uniqueBackupPath(dst)
if err := os.Rename(src, dst); err != nil {
return "", err
}
return dst, nil
}
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
+70 -1
View File
@@ -557,7 +557,7 @@ func TestKittyConfigStructure(t *testing.T) {
func TestKittyThemeConfigStructure(t *testing.T) {
assert.Contains(t, KittyThemeConfig, "foreground #e0e2e8")
assert.Contains(t, KittyThemeConfig, "background #101418")
assert.Contains(t, KittyThemeConfig, "cursor #e0e2e8")
assert.Contains(t, KittyThemeConfig, "cursor #9dcbfb")
assert.Contains(t, KittyThemeConfig, "color0 #101418")
assert.Contains(t, KittyThemeConfig, "color15 #ffffff")
}
@@ -890,3 +890,72 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}
})
}
func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) {
td := t.TempDir()
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05")
require.NoError(t, os.MkdirAll(backupDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644))
autogenerated := "autogenerated = 1\n$mainMod = SUPER\n"
require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644))
moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
require.NoError(t, err)
assert.Equal(t, 1, moved)
assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup)
kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf"))
require.NoError(t, err)
assert.Equal(t, userConfig, string(kept))
stray, err := os.ReadFile(mainConfBackup)
require.NoError(t, err)
assert.Equal(t, autogenerated, string(stray))
}
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
backupDir := t.TempDir()
dst := filepath.Join(backupDir, "hyprland.lua")
require.NoError(t, os.WriteFile(dst, []byte("-- first\n"), 0o644))
written, err := backupHyprlandConfigFile(dst, []byte("-- second\n"))
require.NoError(t, err)
assert.Equal(t, dst+".1", written)
first, err := os.ReadFile(dst)
require.NoError(t, err)
assert.Equal(t, "-- first\n", string(first))
}
func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644))
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
go func() {
for range logChan {
}
}()
defer close(logChan)
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
require.NoError(t, err)
assert.NoFileExists(t, confPath)
backup, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, userConfig, string(backup))
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
}
@@ -1,5 +1,5 @@
cursor #e0e2e8
cursor_text_color #c2c7cf
cursor #9dcbfb
cursor_text_color #003354
foreground #e0e2e8
background #101418
+2 -2
View File
@@ -179,8 +179,8 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
if err != nil {
if logFn != nil {
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
}
+19 -11
View File
@@ -3,21 +3,32 @@ package config
import (
"os"
"path/filepath"
"slices"
)
// Mirrors Hyprland's shipped target. Before=graphical-session.target would cycle with the add-wants implicit After=dms.service.
const hyprlandSessionTargetUnit = `[Unit]
Description=Hyprland Session Target
BindsTo=graphical-session.target
Wants=graphical-session-pre.target
After=graphical-session-pre.target
PropagatesStopTo=graphical-session.target
`
var staleHyprlandSessionTargetUnits = []string{
`[Unit]
Description=Hyprland Session Target
Requires=graphical-session.target
After=graphical-session.target
`,
`[Unit]
Description=Hyprland Session Target
BindsTo=graphical-session.target
Before=graphical-session.target
Wants=graphical-session-pre.target
After=graphical-session-pre.target
`
const legacyHyprlandSessionTargetUnit = `[Unit]
Description=Hyprland Session Target
Requires=graphical-session.target
After=graphical-session.target
`
`,
}
// Only a missing or dankinstall-written unit is replaced; hand-written units are left alone.
func EnsureHyprlandSessionTarget() (string, error) {
@@ -30,10 +41,7 @@ func EnsureHyprlandSessionTarget() (string, error) {
existing, err := os.ReadFile(targetPath)
if err == nil {
content := string(existing)
if content == hyprlandSessionTargetUnit {
return targetPath, nil
}
if content != legacyHyprlandSessionTargetUnit {
if content == hyprlandSessionTargetUnit || !slices.Contains(staleHyprlandSessionTargetUnits, content) {
return targetPath, nil
}
}
@@ -31,23 +31,25 @@ func TestEnsureHyprlandSessionTargetWritesMissing(t *testing.T) {
}
}
func TestEnsureHyprlandSessionTargetUpgradesLegacy(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
path := targetPathFor(t, home)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(legacyHyprlandSessionTargetUnit), 0o644); err != nil {
t.Fatal(err)
}
func TestEnsureHyprlandSessionTargetUpgradesStale(t *testing.T) {
for _, stale := range staleHyprlandSessionTargetUnits {
home := t.TempDir()
t.Setenv("HOME", home)
path := targetPathFor(t, home)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(stale), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnsureHyprlandSessionTarget(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if string(data) != hyprlandSessionTargetUnit {
t.Fatalf("legacy unit not upgraded:\n%s", data)
if _, err := EnsureHyprlandSessionTarget(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if string(data) != hyprlandSessionTargetUnit {
t.Fatalf("stale unit not upgraded:\n%s", data)
}
}
}
+40 -24
View File
@@ -455,6 +455,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
type PaletteOptions struct {
IsLight bool
Background string
Container string
UseDPS bool
}
@@ -520,8 +521,18 @@ func DeriveContainer(primary string, isLight bool) string {
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
// DeriveContainer only approximates the Material tone pairing, so a caller that
// has the real primary_container should pass it: color5 and color6 are the
// primary/container pair and their contrast with each other is the invariant.
func resolveContainer(primaryColor string, opts PaletteOptions) string {
if opts.Container != "" {
return opts.Container
}
return DeriveContainer(primaryColor, opts.IsLight)
}
func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
baseColor := DeriveContainer(primaryColor, opts.IsLight)
baseColor := resolveContainer(primaryColor, opts)
rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb)
@@ -531,13 +542,20 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
var palette Palette
var normalTextTarget, secondaryTarget float64
if opts.UseDPS {
// Delta Phi Star is not symmetric across polarity: on a light background the
// same Lc buys far less real contrast, so the light targets are the numbers
// that land on the same measured WCAG ratios the dark ones already deliver.
var normalTextTarget, accentTarget float64
switch {
case opts.UseDPS && opts.IsLight:
normalTextTarget = 70.0
accentTarget = 58.0
case opts.UseDPS:
normalTextTarget = 40.0
secondaryTarget = 35.0
} else {
accentTarget = 24.5
default:
normalTextTarget = 4.5
secondaryTarget = 3.0
accentTarget = 2.1
}
var bgColor string
@@ -557,8 +575,6 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
greenH := blendHue(0.33, ph.H, 0.10)
yellowH := blendHue(0.14, ph.H, 0.04)
accentTarget := secondaryTarget * 0.7
if opts.IsLight {
redS := math.Min(baseSat*1.2, 1.0)
redV := baseVal * 0.95
@@ -570,15 +586,13 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
yellowS := math.Min(baseSat*1.5, 1.0)
yellowV := math.Min(baseVal*1.2, 1.0)
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts))
blueS := math.Min(ph.S*1.05, 1.0)
blueV := math.Min(ph.V*1.05, 1.0)
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
// Color5 matches primary_container exactly (light container in light mode)
container5 := DeriveContainer(primaryColor, true)
palette.Color5 = NewColorInfo(container5)
palette.Color5 = NewColorInfo(baseColor)
palette.Color6 = NewColorInfo(primaryColor)
@@ -604,8 +618,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
brightBlueV := math.Min(ph.V*1.15, 1.0)
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
lightContainer := DeriveContainer(primaryColor, true)
palette.Color13 = NewColorInfo(lightContainer)
color13S := ph.S * 0.7
color13V := math.Min(ph.V*1.3, 1.0)
palette.Color13 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})), bgColor, accentTarget, opts))
brightCyanS := ph.S * 0.5
brightCyanV := math.Min(ph.V*1.3, 1.0)
@@ -632,9 +647,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
blueV := ph.V * 0.95
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
// Color5 matches primary_container exactly (dark container in dark mode)
darkContainer := DeriveContainer(primaryColor, false)
palette.Color5 = NewColorInfo(darkContainer)
palette.Color5 = NewColorInfo(baseColor)
palette.Color6 = NewColorInfo(primaryColor)
@@ -681,11 +694,14 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
}
type VariantOptions struct {
PrimaryDark string
PrimaryLight string
Background string
UseDPS bool
IsLightMode bool
PrimaryDark string
PrimaryLight string
BackgroundDark string
BackgroundLight string
ContainerDark string
ContainerLight string
UseDPS bool
IsLightMode bool
}
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
@@ -702,8 +718,8 @@ func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
}
func GenerateVariantPalette(opts VariantOptions) VariantPalette {
darkOpts := PaletteOptions{IsLight: false, Background: opts.Background, UseDPS: opts.UseDPS}
lightOpts := PaletteOptions{IsLight: true, Background: opts.Background, UseDPS: opts.UseDPS}
darkOpts := PaletteOptions{IsLight: false, Background: opts.BackgroundDark, Container: opts.ContainerDark, UseDPS: opts.UseDPS}
lightOpts := PaletteOptions{IsLight: true, Background: opts.BackgroundLight, Container: opts.ContainerLight, UseDPS: opts.UseDPS}
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
light := GeneratePalette(opts.PrimaryLight, lightOpts)
+111 -1
View File
@@ -701,7 +701,8 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
bgColor := result.Color0.Hex
for i := 1; i < 8; i++ {
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
// Color5 is the container and Color6 the exact primary: they pair
// with each other, not with the background
if i == 5 || i == 6 {
continue
}
@@ -862,3 +863,112 @@ func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
}
func TestGeneratePaletteLightYellowMeetsNormalTextContrast(t *testing.T) {
primaries := []string{"#63568f", "#7d5800", "#116d2b", "#1c5070", "#8b3f68"}
for _, primary := range primaries {
t.Run(primary, func(t *testing.T) {
palette := GeneratePalette(primary, PaletteOptions{IsLight: true, Background: "#f5fafc", UseDPS: true})
ratio := ContrastRatio(palette.Color3.Hex, palette.Color0.Hex)
if ratio < 4.5 {
t.Errorf("light Color3 %s vs bg %s ratio %.2f, expected >= 4.5 (yellow must clear the normal-text target)",
palette.Color3.Hex, palette.Color0.Hex, ratio)
}
})
}
}
func TestGenerateVariantPaletteUsesPerVariantBackground(t *testing.T) {
const darkBg = "#0f1417"
const lightBg = "#f5fafc"
variants := GenerateVariantPalette(VariantOptions{
PrimaryDark: "#8ecff2",
PrimaryLight: "#1c5070",
BackgroundDark: darkBg,
BackgroundLight: lightBg,
UseDPS: true,
})
if variants.Color0.Dark.Hex != darkBg {
t.Errorf("dark Color0 = %s, expected the dark background %s", variants.Color0.Dark.Hex, darkBg)
}
if variants.Color0.Light.Hex != lightBg {
t.Errorf("light Color0 = %s, expected the light background %s", variants.Color0.Light.Hex, lightBg)
}
lightDim := variants.Color8.Light.Hex
if lstar := getLstar(lightDim); lstar < 60.0 {
t.Errorf("light Color8 %s has L* %.2f, expected >= 60 (derived from the dark surface it collapses to near-black)",
lightDim, lstar)
}
if ratio := ContrastRatio(lightDim, lightBg); ratio > 3.0 {
t.Errorf("light Color8 %s vs bg %s ratio %.2f, expected <= 3.0 (bright black stays dim)", lightDim, lightBg, ratio)
}
darkDim := variants.Color8.Dark.Hex
if ratio := ContrastRatio(darkDim, darkBg); ratio > 3.0 {
t.Errorf("dark Color8 %s vs bg %s ratio %.2f, expected <= 3.0", darkDim, darkBg, ratio)
}
}
func TestGeneratePaletteColor5PairsWithColor6(t *testing.T) {
tests := []struct {
name string
primary string
container string
bg string
isLight bool
}{
{"violet dark", "#cdbdff", "#4b3e76", "#141318", false},
{"violet light", "#63568f", "#e8deff", "#fdf7ff", true},
{"green dark", "#9ed49d", "#1f5027", "#101510", false},
{"green light", "#38693c", "#b9f0b8", "#f7fbf2", true},
{"amber light", "#805610", "#ffddb3", "#fff8f4", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
palette := GeneratePalette(tt.primary, PaletteOptions{
IsLight: tt.isLight,
Background: tt.bg,
Container: tt.container,
UseDPS: true,
})
if palette.Color5.Hex != tt.container {
t.Errorf("Color5 = %s, expected the supplied container %s", palette.Color5.Hex, tt.container)
}
if palette.Color6.Hex != tt.primary {
t.Errorf("Color6 = %s, expected the primary %s", palette.Color6.Hex, tt.primary)
}
pair := ContrastRatio(palette.Color5.Hex, palette.Color6.Hex)
if pair < 4.5 {
t.Errorf("Color5 %s vs Color6 %s ratio %.2f, expected >= 4.5 (the container/primary pair carries the prompt themes that use ANSI 5 on 6)",
palette.Color5.Hex, palette.Color6.Hex, pair)
}
if palette.Color5.Hex == palette.Color13.Hex {
t.Errorf("Color5 and Color13 are both %s, expected a distinct bright variant", palette.Color5.Hex)
}
if sep := ContrastRatio(palette.Color5.Hex, palette.Color13.Hex); sep < 2.0 {
t.Errorf("Color5 %s vs Color13 %s ratio %.2f, expected >= 2.0 (normal and bright must be tellable apart)",
palette.Color5.Hex, palette.Color13.Hex, sep)
}
})
}
}
func TestGeneratePaletteFallsBackToDerivedContainer(t *testing.T) {
const primary = "#cdbdff"
palette := GeneratePalette(primary, PaletteOptions{Background: "#141318", UseDPS: true})
want := DeriveContainer(primary, false)
if palette.Color5.Hex != want {
t.Errorf("Color5 = %s with no container supplied, expected the derived %s", palette.Color5.Hex, want)
}
}
+20 -11
View File
@@ -343,7 +343,7 @@ func buildOnce(opts *Options) (bool, error) {
oldColors, _ := os.ReadFile(opts.ColorsOutput())
var primaryDark, primaryLight, surface string
var primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string
var dank16JSON string
var importArgs []string
var sourceImage string
@@ -373,7 +373,10 @@ func buildOnce(opts *Options) (bool, error) {
log.Info("Using stock/custom theme colors with matugen base")
primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
surface = extractNestedColor(opts.StockColors, "surface", "dark")
surfaceDark = extractNestedColor(opts.StockColors, "surface", "dark")
surfaceLight = extractNestedColor(opts.StockColors, "surface", "light")
containerDark = extractNestedColor(opts.StockColors, "primary_container", "dark")
containerLight = extractNestedColor(opts.StockColors, "primary_container", "light")
if primaryDark == "" {
return false, fmt.Errorf("failed to extract primary dark from stock colors")
@@ -382,7 +385,7 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
importArgs = []string{"--import-json-string", importData}
@@ -403,7 +406,10 @@ func buildOnce(opts *Options) (bool, error) {
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
primaryLight = extractMatugenColor(matJSON, "primary", "light")
surface = extractMatugenColor(matJSON, "surface", "dark")
surfaceDark = extractMatugenColor(matJSON, "surface", "dark")
surfaceLight = extractMatugenColor(matJSON, "surface", "light")
containerDark = extractMatugenColor(matJSON, "primary_container", "dark")
containerLight = extractMatugenColor(matJSON, "primary_container", "light")
if primaryDark == "" {
return false, fmt.Errorf("failed to extract primary color")
@@ -412,7 +418,7 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)}
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
@@ -1125,13 +1131,16 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
return color
}
func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string, mode ColorMode) string {
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: surface,
UseDPS: true,
IsLightMode: mode == ColorModeLight,
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
BackgroundDark: surfaceDark,
BackgroundLight: surfaceLight,
ContainerDark: containerDark,
ContainerLight: containerLight,
UseDPS: true,
IsLightMode: mode == ColorModeLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
return dank16.GenerateVariantJSON(variantColors)
@@ -3,6 +3,7 @@ package qmlchecks
import (
"os"
"regexp"
"strconv"
"strings"
"testing"
)
@@ -55,3 +56,51 @@ func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
}
}
func TestLockScreenFprintProfileMatchesPamQml(t *testing.T) {
profile, err := os.ReadFile("../../../quickshell/assets/pam/fprint")
if err != nil {
t.Fatalf("read fprint PAM profile: %v", err)
}
qml, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
if err != nil {
t.Fatalf("read lock screen PAM QML: %v", err)
}
// Pam.qml tells a pam_fprintd timeout expiry apart from a device fault by
// how long the session ran, so a profile change that is not mirrored in
// sessionTimeoutMs makes real faults look like planned renewals.
profileTimeout := regexp.MustCompile(`pam_fprintd\.so[^\n]*\btimeout=(\d+)`).FindSubmatch(profile)
if profileTimeout == nil {
t.Fatalf("fprint profile must set an explicit pam_fprintd timeout")
}
qmlTimeout := regexp.MustCompile(`sessionTimeoutMs:\s*(\d+)`).FindSubmatch(qml)
if qmlTimeout == nil {
t.Fatalf("Pam.qml must declare sessionTimeoutMs")
}
seconds, err := strconv.Atoi(string(profileTimeout[1]))
if err != nil {
t.Fatalf("parse profile timeout: %v", err)
}
millis, err := strconv.Atoi(string(qmlTimeout[1]))
if err != nil {
t.Fatalf("parse sessionTimeoutMs: %v", err)
}
if seconds*1000 != millis {
t.Fatalf("sessionTimeoutMs is %dms but the fprint profile sets timeout=%d", millis, seconds)
}
// fprintd 1.94.0 and older ignore this option when it is longer than two
// digits and silently fall back to 30 seconds.
if len(profileTimeout[1]) > 2 {
t.Fatalf("pam_fprintd timeout=%s is too long for fprintd <= 1.94.0, which would ignore it", profileTimeout[1])
}
// One finished session must equal one wrong finger, otherwise
// SettingsData.maxFprintTries silently means a multiple of the scans it names.
if !regexp.MustCompile(`pam_fprintd\.so[^\n]*\bmax-tries=1\b`).Match(profile) {
t.Fatalf("fprint profile must use max-tries=1 so the shell counts wrong scans one for one")
}
}
+19 -7
View File
@@ -85,9 +85,10 @@ type OutputSurface struct {
slots [3]*RenderSlot
slotsReady bool
shown *overlay
framePending bool
redrawQueued bool
shown *overlay
framePending bool
redrawQueued bool
committedCursor bool
}
type PreCapture struct {
@@ -108,6 +109,7 @@ type RegionSelector struct {
shm *client.Shm
seat *client.Seat
pointer *client.Pointer
touch *client.Touch
keyboard *client.Keyboard
keymap *keymap.Keymap
layerShell *wlr_layer_shell.ZwlrLayerShellV1
@@ -138,6 +140,8 @@ type RegionSelector struct {
selection SelectionState
pointerX float64
pointerY float64
hasTouchPoint bool
touchPointId int32
preSelect Region
showCapturedCursor bool
shiftHeld bool
@@ -969,11 +973,12 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
slot.shm.CopyFrom(srcBuf)
r.dimBackground(slot.shm)
scale := 1
effectiveScale := 1.0
if os.output != nil {
scale = scaleFactor(os.output.effectiveScale())
effectiveScale = os.output.effectiveScale()
}
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, scale)
hudScale := r.hudScale(effectiveScale)
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, hudScale)
slot.backgroundInitialized = true
slot.backgroundSource = srcBuf
slot.backgroundDragging = r.selection.dragging
@@ -985,7 +990,10 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
case r.compositorVersion >= 4:
fullDamage = false
damage = overlayDamage(os.shown, cur)
if len(damage) == 0 {
if os.committedCursor != r.showCapturedCursor {
fullDamage = true
damage = nil
} else if len(damage) == 0 {
return
}
}
@@ -1029,6 +1037,7 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
})
}
_ = os.wlSurface.Commit()
os.committedCursor = r.showCapturedCursor
// Mark this slot as busy until compositor releases it
slot.busy = true
@@ -1103,6 +1112,9 @@ func (r *RegionSelector) cleanup() {
if r.pointer != nil {
r.pointer.Release()
}
if r.touch != nil {
_ = r.touch.Release()
}
if r.keyboard != nil {
r.keyboard.Release()
}
+143
View File
@@ -19,6 +19,12 @@ func (r *RegionSelector) setupInput() {
r.setupPointerHandlers()
}
}
if e.Capabilities&uint32(client.SeatCapabilityTouch) != 0 && r.touch == nil {
if touch, err := r.seat.GetTouch(); err == nil {
r.touch = touch
r.setupTouchHandlers()
}
}
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil {
if keyboard, err := r.seat.GetKeyboard(); err == nil {
r.keyboard = keyboard
@@ -146,6 +152,143 @@ func (r *RegionSelector) setupPointerHandlers() {
})
}
func (r *RegionSelector) setupTouchHandlers() {
r.touch.SetDownHandler(func(e client.TouchDownEvent) {
r.handleTouchDown(e.Surface, e.Id, e.X, e.Y)
})
r.touch.SetMotionHandler(func(e client.TouchMotionEvent) {
r.handleTouchMotion(e.Id, e.X, e.Y)
})
r.touch.SetUpHandler(func(e client.TouchUpEvent) {
r.handleTouchUp(e.Id)
})
r.touch.SetCancelHandler(func(e client.TouchCancelEvent) {
r.handleTouchCancel()
})
}
func (r *RegionSelector) handleTouchDown(surface *client.Surface, touchId int32, x, y float64) {
if r.hasTouchPoint {
return
}
r.activeSurface = nil
for _, os := range r.surfaces {
if os.wlSurface != nil && surface != nil && os.wlSurface.ID() == surface.ID() {
r.activeSurface = os
break
}
}
if r.activeSurface == nil {
return
}
r.hasTouchPoint = true
r.touchPointId = touchId
if r.phase == phaseScroll {
if r.activeSurface != r.selection.surface {
return
}
switch r.scrollBarHit(x, y) {
case "done", "preview":
r.finishScroll()
case "cancel":
r.cancelled = true
r.running = false
}
return
}
touchX := x + float64(r.activeSurface.output.x)
touchY := y + float64(r.activeSurface.output.y)
if r.ctrlHeld && r.selection.hasSelection {
if handle := r.resizeHandleAt(touchX, touchY); handle != handleNone {
if r.beginSelectionResize(handle, touchX, touchY) {
return
}
}
if r.beginSelectionMove(touchX, touchY) {
r.selection.dragging = true
return
}
}
r.preSelect = Region{}
r.movingSelection = false
r.resizingHandle = handleNone
r.selection.hasSelection = true
r.selection.dragging = true
r.selection.surface = r.activeSurface
r.selection.anchorX = touchX
r.selection.anchorY = touchY
r.selection.currentX = r.selection.anchorX
r.selection.currentY = r.selection.anchorY
for _, os := range r.surfaces {
r.redrawSurface(os)
}
}
func (r *RegionSelector) handleTouchMotion(touchId int32, x, y float64) {
if !r.hasTouchPoint || touchId != r.touchPointId || r.activeSurface == nil {
return
}
if r.phase == phaseScroll {
return
}
if !r.selection.dragging {
return
}
r.updateSelectionCurrent(r.activeSurface, x, y)
}
func (r *RegionSelector) handleTouchUp(touchId int32) {
if !r.hasTouchPoint || touchId != r.touchPointId {
return
}
r.hasTouchPoint = false
r.selection.dragging = false
r.movingSelection = false
r.resizingHandle = handleNone
if r.phase == phaseScroll {
return
}
for _, os := range r.surfaces {
r.redrawSurface(os)
}
if r.screenshoter != nil && r.screenshoter.config.NoConfirm && r.selection.hasSelection {
r.finishSelection()
}
}
func (r *RegionSelector) handleTouchCancel() {
if !r.hasTouchPoint {
return
}
r.hasTouchPoint = false
r.selection.dragging = false
r.movingSelection = false
r.resizingHandle = handleNone
for _, os := range r.surfaces {
r.redrawSurface(os)
}
}
func (r *RegionSelector) updateSelectionCurrent(os *OutputSurface, surfaceX, surfaceY float64) {
if os == nil || os.output == nil || !r.selection.dragging {
return
+32 -1
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"math"
"slices"
"strconv"
"strings"
)
const (
@@ -630,6 +632,32 @@ func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, form
style.TextR, style.TextG, style.TextB, format)
}
const (
minHUDScale = 1
maxHUDScale = 4
)
func (r *RegionSelector) hudScale(effectiveScale float64) int {
hudOpt := "auto"
if r.screenshoter != nil && r.screenshoter.config.HUD != "" {
hudOpt = strings.ToLower(strings.TrimSpace(r.screenshoter.config.HUD))
}
switch hudOpt {
case "off", "none", "false", "0":
return 0
case "", "auto":
return min(scaleFactor(effectiveScale), maxHUDScale)
default:
if val, err := strconv.ParseFloat(hudOpt, 64); err == nil {
if val <= 0 {
return 0
}
return min(max(scaleFactor(val), minHUDScale), maxHUDScale)
}
return min(scaleFactor(effectiveScale), maxHUDScale)
}
}
type hudItem struct {
key, desc string
}
@@ -657,6 +685,9 @@ func (r *RegionSelector) hudItems() []hudItem {
}
func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW, hudH int) {
if scale <= 0 {
return 0, 0, 0, 0
}
scale = scaleFactor(float64(scale))
charH := fontCharH * scale
padding, itemSpacing := 12*scale, 24*scale
@@ -680,7 +711,7 @@ func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW,
}
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32, scale int) {
if r.selection.dragging {
if r.selection.dragging || scale <= 0 {
return
}
@@ -238,6 +238,12 @@ func TestHUDDimensionsContainText(t *testing.T) {
},
}
// When scale <= 0, hudDimensions should return zero
hx, hy, hw, hh := r.hudDimensions(bufW, bufH, 0)
if hx != 0 || hy != 0 || hw != 0 || hh != 0 {
t.Fatalf("expected 0 dimensions for scale 0, got %d, %d, %d, %d", hx, hy, hw, hh)
}
for _, scale := range []int{1, 2} {
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
data := make([]byte, bufH*stride)
@@ -260,6 +266,47 @@ func TestHUDDimensionsContainText(t *testing.T) {
}
}
func TestHUDScale(t *testing.T) {
tests := []struct {
hudOpt string
effectiveScale float64
expectedScale int
}{
{"auto", 1.0, 1},
{"auto", 1.8, 2},
{"auto", 2.4, 2},
{"", 1.0, 1},
{"", 2.0, 2},
{"off", 1.0, 0},
{"OFF", 2.0, 0},
{"none", 1.5, 0},
{"false", 1.0, 0},
{"0", 2.0, 0},
{"0.0", 1.0, 0},
{"-1", 1.0, 0},
{"1", 1.0, 1},
{"1.5", 1.0, 2},
{"2", 1.0, 2},
{"3", 1.0, 3},
{"4", 1.0, 4},
{"1000", 1.0, 4},
{"0.2", 1.0, 1},
{"invalid", 1.7, 2},
}
for _, tc := range tests {
r := &RegionSelector{
screenshoter: &Screenshoter{
config: Config{HUD: tc.hudOpt},
},
}
got := r.hudScale(tc.effectiveScale)
if got != tc.expectedScale {
t.Errorf("hudScale(%q, %v) = %d, expected %d", tc.hudOpt, tc.effectiveScale, got, tc.expectedScale)
}
}
}
func TestOverlayDeltaHandlesClampedMonitorEdge(t *testing.T) {
prev := &overlay{
interior: dirtyRect{100, 100, 1920, 1080},
@@ -0,0 +1,152 @@
package screenshot
import (
"testing"
"github.com/AvengeMedia/dankgo/wayland/client"
)
func newTestSurface(id uint32) *client.Surface {
s := &client.Surface{}
s.SetID(id)
return s
}
func TestTouchInputSequence(t *testing.T) {
surface := newTestSurface(100)
os := &OutputSurface{
output: &WaylandOutput{
x: 0,
y: 0,
},
logicalW: 1920,
logicalH: 1080,
wlSurface: surface,
}
r := &RegionSelector{
running: true,
surfaces: []*OutputSurface{os},
}
// 1. Touch Down
r.handleTouchDown(surface, 1, 100, 200)
if !r.hasTouchPoint || r.touchPointId != 1 {
t.Fatalf("expected hasTouchPoint=true, touchPointId=1, got hasTouchPoint=%v, id=%d", r.hasTouchPoint, r.touchPointId)
}
if !r.selection.dragging || !r.selection.hasSelection {
t.Fatalf("expected dragging=true, hasSelection=true")
}
if r.selection.anchorX != 100 || r.selection.anchorY != 200 {
t.Fatalf("expected anchor (100, 200), got (%.1f, %.1f)", r.selection.anchorX, r.selection.anchorY)
}
// 2. Ignore secondary touch point while primary is active
surface2 := newTestSurface(200)
r.handleTouchDown(surface2, 2, 500, 600)
if r.touchPointId != 1 {
t.Fatalf("expected touchPointId to stay 1, got %d", r.touchPointId)
}
// 3. Touch Motion with matching id
r.handleTouchMotion(1, 400, 500)
if r.selection.currentX != 400 || r.selection.currentY != 500 {
t.Fatalf("expected current (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
}
// 4. Touch Motion with mismatched id should be ignored
r.handleTouchMotion(2, 900, 900)
if r.selection.currentX != 400 || r.selection.currentY != 500 {
t.Fatalf("expected current to remain (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
}
// 5. Touch Up with mismatched id should not release
r.handleTouchUp(2)
if !r.hasTouchPoint || !r.selection.dragging {
t.Fatalf("expected touch point to remain active on mismatched touch up")
}
// 6. Touch Up with matching id
r.handleTouchUp(1)
if r.hasTouchPoint || r.selection.dragging {
t.Fatalf("expected hasTouchPoint=false, dragging=false after touch up")
}
if !r.selection.hasSelection {
t.Fatalf("expected selection to be preserved after touch up")
}
}
func TestTouchCancel(t *testing.T) {
surface := newTestSurface(100)
os := &OutputSurface{
output: &WaylandOutput{
x: 0,
y: 0,
},
logicalW: 1920,
logicalH: 1080,
wlSurface: surface,
}
r := &RegionSelector{
running: true,
surfaces: []*OutputSurface{os},
}
r.handleTouchDown(surface, 5, 50, 60)
if !r.hasTouchPoint || !r.selection.dragging {
t.Fatalf("expected active touch point")
}
r.handleTouchCancel()
if r.hasTouchPoint || r.selection.dragging {
t.Fatalf("expected touch point and dragging to be cleared on cancel")
}
}
func TestTouchUpScrollPhase(t *testing.T) {
surface := newTestSurface(100)
os := &OutputSurface{
output: &WaylandOutput{
x: 0,
y: 0,
},
logicalW: 1920,
logicalH: 1080,
wlSurface: surface,
}
sc := &Screenshoter{
config: Config{NoConfirm: true},
}
r := &RegionSelector{
screenshoter: sc,
phase: phaseScroll,
running: true,
surfaces: []*OutputSurface{os},
selection: SelectionState{
hasSelection: true,
surface: os,
},
}
r.hasTouchPoint = true
r.touchPointId = 3
r.selection.dragging = true
r.handleTouchUp(3)
if r.hasTouchPoint || r.selection.dragging {
t.Fatalf("expected touch point and dragging cleared")
}
// In scroll phase with NoConfirm, handleTouchUp must not call finishSelection / change running state
if !r.running {
t.Fatalf("expected running to stay true, did not expect finishSelection side-effects")
}
}
+2
View File
@@ -73,6 +73,7 @@ type Config struct {
Geometry bool
AllowMultiple bool
IntervalMs int
HUD string
// SelectorHook runs as the interactive selector starts (true) and ends (false).
SelectorHook func(begin bool)
}
@@ -90,5 +91,6 @@ func DefaultConfig() Config {
Clipboard: true,
SaveFile: true,
Notify: true,
HUD: "auto",
}
}
+5 -3
View File
@@ -18,10 +18,12 @@ const (
propertiesIface = "org.freedesktop.DBus.Properties"
)
// Sender-scoped: the system bus is shared process-wide, so unscoped rules wake
// every other subsystem's signal channel too.
var bluezMatchRules = [][]dbus.MatchOption{
{dbus.WithMatchInterface(propertiesIface), dbus.WithMatchMember("PropertiesChanged")},
{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")},
{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesRemoved")},
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(propertiesIface), dbus.WithMatchMember("PropertiesChanged")},
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")},
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesRemoved")},
}
func NewManager() (*Manager, error) {
+1 -1
View File
@@ -19,7 +19,7 @@ func WaitForAdapter() error {
conn.Signal(signals)
defer conn.RemoveSignal(signals)
adapterAdded := []dbus.MatchOption{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")}
adapterAdded := []dbus.MatchOption{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")}
if err := conn.AddMatchSignal(adapterAdded...); err != nil {
return err
}
+266 -73
View File
@@ -2,6 +2,7 @@ package brightness
import (
"encoding/binary"
"errors"
"fmt"
"math"
"os"
@@ -20,13 +21,40 @@ const (
DDCCI_VCP_SET = 0x03
VCP_BRIGHTNESS = 0x10
DDC_SOURCE_ADDR = 0x51
EDID_ADDR = 0x50
)
// ddcutil's DEFAULT_FLOCK_POLL_MILLISEC and DEFAULT_FLOCK_MAX_WAIT_MILLISEC (src/base/parms.h).
const (
ddcBusLockPoll = 100 * time.Millisecond
ddcBusLockWait = 3 * time.Second
)
var (
errDDCDisabled = errors.New("disabled via DMS_NO_DDC")
errInvalidEDID = errors.New("invalid edid")
errDisplayAsleep = errors.New("display asleep")
errNoMonitor = errors.New("no monitor on bus")
errBusReused = errors.New("bus belongs to another adapter")
)
var ddcRereadDelays = []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second}
func NewDDCBackend() (*DDCBackend, error) {
return newDDCBackend(nil)
}
func newDDCBackend(onReread func()) (*DDCBackend, error) {
if os.Getenv("DMS_NO_DDC") != "" {
return nil, errDDCDisabled
}
b := &DDCBackend{
scanInterval: 30 * time.Second,
debounceTimers: make(map[string]*time.Timer),
debouncePending: make(map[string]ddcPendingSet),
onReread: onReread,
stop: make(chan struct{}),
}
if err := b.scanI2CDevices(); err != nil {
@@ -53,6 +81,7 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
}
activeBuses := make(map[int]bool)
connectors := drmConnectorsByBus()
for i := range 32 {
busPath := fmt.Sprintf("/dev/i2c-%d", i)
@@ -70,18 +99,36 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
// Don't re-probe identified monitors: DDC traffic during a wake
// sequence can disturb some monitors' own brightness handling.
if _, ok := b.devices.Load(id); ok {
if known, ok := b.devices.Load(id); ok {
if known.adapter == getI2CDeviceSysfsName(i) {
continue
}
b.devices.Delete(id)
log.Debugf("removed DDC device %s (bus number reused by another adapter)", id)
}
verdict := ddcBusVerdictFor(i, connectors)
if verdict == ddcBusSkip {
log.Debugf("i2c-%d: no monitor per DRM sysfs, not probing", i)
continue
}
if ddcDisplayAsleep(i, connectors) {
log.Debugf("i2c-%d: display asleep, not probing", i)
continue
}
dev, err := b.probeDDCDevice(i)
dev, err := b.probeDDCDevice(i, verdict == ddcBusNeedsEDIDRead)
if err != nil || dev == nil {
continue
}
dev.id = id
unread := dev.max == 0
b.devices.Store(id, dev)
log.Debugf("found DDC device on i2c-%d", i)
if unread && b.onReread != nil {
go b.rereadUntilKnown(dev)
}
}
b.devices.Range(func(id string, dev *ddcDevice) bool {
@@ -97,53 +144,119 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
return nil
}
func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
busPath := fmt.Sprintf("/dev/i2c-%d", bus)
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
func (b *DDCBackend) probeDDCDevice(bus int, readEDID bool) (*ddcDevice, error) {
fd, err := openDDCBus(bus)
if err != nil {
return nil, err
}
defer syscall.Close(fd)
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
return nil, errno
}
dummy := make([]byte, 32)
syscall.Read(fd, dummy) //nolint:errcheck
writebuf := []byte{DDC_SOURCE_ADDR, 0x80}
writebuf = append(writebuf, ddcciChecksum(writebuf))
n, err := syscall.Write(fd, writebuf)
if err == nil && n == len(writebuf) {
name := b.getDDCName(bus)
dev := &ddcDevice{
bus: bus,
addr: DDCCI_ADDR,
name: name,
if readEDID {
edid, err := readBusEDID(fd)
if err != nil {
return nil, err
}
if isLaptopEDID(edid) {
return nil, errors.New("laptop panel")
}
b.readInitialBrightness(fd, dev)
return dev, nil
}
readbuf := make([]byte, 4)
n, err = syscall.Read(fd, readbuf)
if err != nil || n == 0 {
if err := setI2CAddr(fd, DDCCI_ADDR); err != nil {
return nil, err
}
if !detectX37(fd) {
return nil, fmt.Errorf("x37 unresponsive")
}
name := b.getDDCName(bus)
dev := &ddcDevice{
bus: bus,
addr: DDCCI_ADDR,
name: name,
bus: bus,
addr: DDCCI_ADDR,
name: b.getDDCName(bus),
adapter: getI2CDeviceSysfsName(bus),
}
b.readInitialBrightness(fd, dev)
return dev, nil
}
// Based on ddcutil's i2c_open_bus() cross-instance lock (src/base/flock.c): ddcutil, other dms
// processes and this one take turns on a bus.
func openDDCBus(bus int) (int, error) {
// Without O_CLOEXEC a process spawned mid-probe inherits the fd and the flock with it.
fd, err := syscall.Open(fmt.Sprintf("/dev/i2c-%d", bus), syscall.O_RDWR|syscall.O_CLOEXEC, 0)
if err != nil {
return -1, err
}
deadline := time.Now().Add(ddcBusLockWait)
for {
err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
return fd, nil
}
if !errors.Is(err, unix.EWOULDBLOCK) || time.Now().After(deadline) {
syscall.Close(fd)
return -1, fmt.Errorf("lock i2c-%d: %w", bus, err)
}
time.Sleep(ddcBusLockPoll)
}
}
func setI2CAddr(fd, addr int) error {
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(addr)); errno != 0 {
return errno
}
return nil
}
// Based on ddcutil's i2c_detect_x37() (src/i2c/i2c_x37.c).
func detectX37(fd int) bool {
if n, err := syscall.Read(fd, make([]byte, 1)); err == nil && n == 1 {
return true
}
_, err := syscall.Write(fd, nil)
return err == nil
}
// Based on ddcutil's i2c_get_raw_edid_by_fd() with the fileio reader (src/i2c/i2c_edid.c).
func readBusEDID(fd int) ([]byte, error) {
if err := setI2CAddr(fd, EDID_ADDR); err != nil {
return nil, err
}
var err error
consecutiveEIO := 0
for _, size := range []int{128, 128, 256, 256} {
edid := make([]byte, size)
if err = readEDIDOnce(fd, edid); err == nil {
return edid[:128], nil
}
switch {
case errors.Is(err, syscall.ENXIO), errors.Is(err, syscall.EOPNOTSUPP), errors.Is(err, syscall.ETIMEDOUT):
return nil, err
case errors.Is(err, syscall.EIO):
consecutiveEIO++
if consecutiveEIO >= 2 {
return nil, err
}
default:
consecutiveEIO = 0
}
}
return nil, err
}
func readEDIDOnce(fd int, edid []byte) error {
if _, err := syscall.Write(fd, []byte{0x00}); err != nil {
return err
}
n, err := syscall.Read(fd, edid)
if err != nil {
return err
}
if n < 128 || !isValidEDID(edid[:128]) {
return errInvalidEDID
}
return nil
}
func (b *DDCBackend) getDDCName(bus int) string {
sysfsPath := fmt.Sprintf("/sys/class/i2c-adapter/i2c-%d/name", bus)
data, err := os.ReadFile(sysfsPath)
@@ -176,6 +289,63 @@ func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
}
}
// Bounded and spaced: extra DDC traffic can upset some monitors (#2049).
func (b *DDCBackend) rereadUntilKnown(dev *ddcDevice) {
for _, delay := range ddcRereadDelays {
select {
case <-b.stop:
return
case <-time.After(delay):
}
read, retry := b.rereadBrightness(dev)
if read {
b.onReread()
return
}
if !retry {
return
}
}
log.Debugf("giving up on reading brightness for %s", dev.id)
}
func (b *DDCBackend) rereadBrightness(dev *ddcDevice) (read, retry bool) {
b.ioMutex.Lock()
defer b.ioMutex.Unlock()
if current, ok := b.devices.Load(dev.id); !ok || current != dev || dev.max > 0 {
return false, false
}
switch err := b.checkBusAlive(dev); {
case errors.Is(err, errBusReused):
return false, false
case err != nil:
return false, true
}
fd, err := openDDCBus(dev.bus)
if err != nil {
log.Debugf("brightness reread skipped for %s: %v", dev.id, err)
return false, true
}
defer syscall.Close(fd)
if err := setI2CAddr(fd, dev.addr); err != nil {
return false, false
}
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
if err != nil {
log.Debugf("brightness reread failed for %s: %v", dev.id, err)
return false, true
}
dev.max = cap.max
dev.lastBrightness = cap.current
log.Debugf("read %s brightness %d/%d", dev.id, cap.current, cap.max)
return true, false
}
func (b *DDCBackend) GetDevices() ([]Device, error) {
if err := b.scanI2CDevices(); err != nil {
log.Debugf("DDC scan error: %v", err)
@@ -183,6 +353,8 @@ func (b *DDCBackend) GetDevices() ([]Device, error) {
devices := make([]Device, 0)
b.ioMutex.Lock()
defer b.ioMutex.Unlock()
b.devices.Range(func(id string, dev *ddcDevice) bool {
devices = append(devices, Device{
Class: ClassDDC,
@@ -190,7 +362,7 @@ func (b *DDCBackend) GetDevices() ([]Device, error) {
Name: dev.name,
Current: dev.lastBrightness,
Max: dev.max,
CurrentPercent: dev.lastBrightness,
CurrentPercent: ddcPercentFromRaw(dev.lastBrightness, dev.max),
Backend: "ddc",
})
return true
@@ -274,6 +446,9 @@ func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) er
return fmt.Errorf("device not found: %s", id)
}
b.ioMutex.Lock()
defer b.ioMutex.Unlock()
busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
if _, err := os.Stat(busPath); os.IsNotExist(err) {
@@ -282,52 +457,85 @@ func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) er
return fmt.Errorf("device disconnected: %s", id)
}
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
if err != nil {
switch err := b.checkBusAlive(dev); {
case errors.Is(err, errBusReused):
b.devices.Delete(id)
log.Debugf("removed DDC device %s (bus number reused by another adapter)", id)
return err
case err != nil:
return err
}
fd, err := openDDCBus(dev.bus)
switch {
case errors.Is(err, unix.EWOULDBLOCK):
return err
case err != nil:
b.devices.Delete(id)
log.Debugf("removed DDC device %s (open failed: %v)", id, err)
return fmt.Errorf("open i2c device: %w", err)
}
defer syscall.Close(fd)
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(dev.addr)); errno != 0 {
return fmt.Errorf("set i2c slave addr: %w", errno)
if err := setI2CAddr(fd, dev.addr); err != nil {
return fmt.Errorf("set i2c slave addr: %w", err)
}
max := dev.max
if max == 0 {
maxValue := dev.max
if maxValue == 0 {
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
if err != nil {
return fmt.Errorf("get current capability: %w", err)
}
max = cap.max
dev.max = max
b.devices.Store(id, dev)
maxValue = cap.max
}
if maxValue <= 0 {
return fmt.Errorf("%s reported max brightness %d", id, maxValue)
}
if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, value); err != nil {
raw := ddcRawFromPercent(value, maxValue)
if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, raw); err != nil {
return fmt.Errorf("set vcp feature: %w", err)
}
log.Debugf("set %s to %d/%d", id, value, max)
log.Debugf("set %s to %d/%d", id, raw, maxValue)
dev.max = max
dev.lastBrightness = value
dev.max = maxValue
dev.lastBrightness = raw
b.devices.Store(id, dev)
return nil
}
func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
for range 3 {
dummy := make([]byte, 32)
n, _ := syscall.Read(fd, dummy)
if n == 0 {
break
}
time.Sleep(20 * time.Millisecond)
// Based on ddcutil's i2c_check_open_bus_alive() (src/i2c/i2c_bus_core.c), sysfs side only. Known
// devices are keyed by bus number, which the kernel hands to the next adapter once one goes away.
func (b *DDCBackend) checkBusAlive(dev *ddcDevice) error {
if isIgnorableI2CBus(dev.bus) || getI2CDeviceSysfsName(dev.bus) != dev.adapter {
return errBusReused
}
connectors := drmConnectorsByBus()
if ddcBusVerdictFor(dev.bus, connectors) == ddcBusSkip {
return errNoMonitor
}
if ddcDisplayAsleep(dev.bus, connectors) {
return errDisplayAsleep
}
return nil
}
func ddcRawFromPercent(percent, maxValue int) int {
percent = min(max(percent, 0), 100)
return int(math.Round(float64(percent*maxValue) / 100))
}
func ddcPercentFromRaw(raw, maxValue int) int {
if maxValue <= 0 {
return 0
}
return min(int(math.Round(float64(raw*100)/float64(maxValue))), 100)
}
func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
data := []byte{
DDCCI_VCP_GET,
vcp,
@@ -347,25 +555,6 @@ func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
time.Sleep(50 * time.Millisecond)
pollFds := []unix.PollFd{
{
Fd: int32(fd),
Events: unix.POLLIN,
},
}
pollTimeout := 200
pollResult, err := unix.Poll(pollFds, pollTimeout)
if err != nil {
return nil, fmt.Errorf("poll i2c: %w", err)
}
if pollResult == 0 {
return nil, fmt.Errorf("poll timeout after %dms", pollTimeout)
}
if pollFds[0].Revents&unix.POLLIN == 0 {
return nil, fmt.Errorf("poll returned but POLLIN not set")
}
response := make([]byte, 12)
n, err = syscall.Read(fd, response)
if err != nil || n < 8 {
@@ -520,4 +709,8 @@ func (b *DDCBackend) WaitPending() {
}
func (b *DDCBackend) Close() {
if b.stop == nil {
return
}
b.closeOnce.Do(func() { close(b.stop) })
}
+192 -55
View File
@@ -1,9 +1,12 @@
package brightness
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
@@ -11,9 +14,12 @@ import (
)
// isIgnorableI2CBus checks if an I2C bus should be skipped during DDC probing.
// Based on ddcutil's sysfs_is_ignorable_i2c_device() (sysfs_base.c:1441)
// Based on ddcutil's sysfs_is_ignorable_i2c_device() (src/sysfs/sysfs_simple.c)
func isIgnorableI2CBus(busno int) bool {
name := getI2CDeviceSysfsName(busno)
if name == "DPMST" {
return false
}
driver := getI2CSysfsDriver(busno)
if name != "" && isIgnorableI2CDeviceName(name, driver) {
@@ -23,19 +29,20 @@ func isIgnorableI2CBus(busno int) bool {
// Only probe display adapters (0x03xxxx) and docking stations (0x0axxxx)
class := getI2CDeviceSysfsClass(busno)
if class != 0 {
classHigh := class & 0xFFFF0000
ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
if ignorable {
log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
}
return ignorable
if class == 0 {
// No PCI class says nothing about a platform adapter, but a real adapter always has a name.
return name == ""
}
return false
classHigh := class & 0xFFFF0000
ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
if ignorable {
log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
}
return ignorable
}
// Based on ddcutil's ignorable_i2c_device_sysfs_name() (sysfs_base.c:1408)
// Based on ddcutil's ignorable_i2c_device_sysfs_name() (src/sysfs/sysfs_simple.c)
func isIgnorableI2CDeviceName(name, driver string) bool {
ignorablePrefixes := []string{
"SMBus",
@@ -44,7 +51,8 @@ func isIgnorableI2CDeviceName(name, driver string) bool {
"smu",
"mac-io",
"u4",
"AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
"AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
"AMDGPU DM i2c OEM bus", // RGB controllers, not displays
}
for _, prefix := range ignorablePrefixes {
@@ -71,65 +79,194 @@ func getI2CDeviceSysfsName(busno int) string {
return strings.TrimSpace(string(data))
}
// Based on ddcutil's get_i2c_device_sysfs_class() (sysfs_base.c:1380)
// Based on ddcutil's get_i2c_device_sysfs_class() (src/sysfs/sysfs_simple.c)
func getI2CDeviceSysfsClass(busno int) uint32 {
classPath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/class", busno)
data, err := os.ReadFile(classPath)
if err != nil {
classPath = fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/device/device/class", busno)
data, err = os.ReadFile(classPath)
paths := []string{
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno),
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device", busno),
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/i2c-dev/i2c-%d/device", busno, busno),
}
for _, path := range paths {
adapter := findI2CAdapter(path)
if adapter == "" {
continue
}
data, err := os.ReadFile(filepath.Join(adapter, "class"))
if err != nil {
return 0
continue
}
class, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimSpace(string(data)), "0x"), 16, 32)
if err == nil && class != 0 {
return uint32(class)
}
}
classStr := strings.TrimSpace(string(data))
classStr = strings.TrimPrefix(classStr, "0x")
class, err := strconv.ParseUint(classStr, 16, 32)
if err != nil {
return 0
}
return uint32(class)
return 0
}
// Based on ddcutil's get_i2c_sysfs_driver_by_busno() (sysfs_base.c:1284)
// Based on ddcutil's get_driver_for_busno() (src/sysfs/sysfs_simple.c)
func getI2CSysfsDriver(busno int) string {
devicePath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno)
adapterPath, err := findI2CAdapter(devicePath)
adapter := findI2CAdapter(fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno))
if adapter == "" {
return ""
}
module, err := filepath.EvalSymlinks(filepath.Join(adapter, "driver", "module"))
if err != nil {
return ""
}
return filepath.Base(module)
}
driverLink := filepath.Join(adapterPath, "driver")
target, err := os.Readlink(driverLink)
// Based on ddcutil's sysfs_find_adapter() (src/sysfs/sysfs_simple.c): the nearest
// ancestor under /sys/devices carrying a class attribute.
func findI2CAdapter(path string) string {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return ""
}
return filepath.Base(target)
}
func findI2CAdapter(devicePath string) (string, error) {
currentPath := devicePath
for range 10 {
if _, err := os.Stat(filepath.Join(currentPath, "name")); err == nil {
return currentPath, nil
for strings.HasPrefix(resolved, "/sys/devices") {
if info, err := os.Stat(filepath.Join(resolved, "class")); err == nil && info.Mode().IsRegular() {
return resolved
}
deviceLink := filepath.Join(currentPath, "device")
target, err := os.Readlink(deviceLink)
if err != nil {
break
}
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(currentPath), target)
}
currentPath = filepath.Clean(target)
resolved = filepath.Dir(resolved)
}
return ""
}
return "", fmt.Errorf("could not find adapter for %s", devicePath)
// ddcutil's known_reliable_drivers (src/sysfs/sysfs_base.c): drivers that keep the DRM connector
// edid, status and dpms attributes current.
var sysfsReliableDrivers = []string{"i915", "xe", "amdgpu", "radeon", "nouveau"}
var (
drmConnectorPattern = regexp.MustCompile(`^card[0-9]+-`)
i2cBusPattern = regexp.MustCompile(`^i2c-([0-9]+)$`)
)
type ddcBusVerdict int
const (
ddcBusSkip ddcBusVerdict = iota
ddcBusHasEDID
ddcBusNeedsEDIDRead
)
// Based on ddcutil's i2c_edid_exists() and its laptop panel exclusion (src/i2c/i2c_bus_core.c).
func ddcBusVerdictFor(busno int, connectors map[int]string) ddcBusVerdict {
name := getI2CDeviceSysfsName(busno)
displayLink := name == "DisplayLink I2C Adapter"
reliable := slices.Contains(sysfsReliableDrivers, getI2CSysfsDriver(busno))
connector, mapped := connectors[busno]
if !mapped {
if reliable && !displayLink && name != "DPMST" && len(connectors) > 0 {
return ddcBusSkip
}
return ddcBusNeedsEDIDRead
}
if strings.Contains(connector, "-eDP-") || strings.Contains(connector, "-LVDS-") {
return ddcBusSkip
}
if reliable || displayLink {
if len(drmConnectorAttr(connector, "edid")) >= 128 {
return ddcBusHasEDID
}
return ddcBusSkip
}
if strings.TrimSpace(drmConnectorAttr(connector, "status")) == "disconnected" {
return ddcBusSkip
}
return ddcBusNeedsEDIDRead
}
// Based on ddcutil's dpms_check_drm_asleep_by_businfo() (src/sysfs/sysfs_dpms.c).
func ddcDisplayAsleep(busno int, connectors map[int]string) bool {
connector, mapped := connectors[busno]
if !mapped || !slices.Contains(sysfsReliableDrivers, getI2CSysfsDriver(busno)) {
return false
}
return strings.TrimSpace(drmConnectorAttr(connector, "dpms")) != "On"
}
// Based on ddcutil's get_connector_bus_numbers() (src/sysfs/sysfs_simple.c): a DP connector's own
// i2c-N directory, any other connector's ddc/i2c-dev/i2c-N.
func drmConnectorsByBus() map[int]string {
connectors := map[int]string{}
entries, err := os.ReadDir("/sys/class/drm")
if err != nil {
return connectors
}
for _, entry := range entries {
name := entry.Name()
if !drmConnectorPattern.MatchString(name) {
continue
}
dir := filepath.Join("/sys/class/drm", name)
if !strings.Contains(name, "-DP-") {
dir = filepath.Join(dir, "ddc", "i2c-dev")
}
if busno, ok := i2cSubdirBus(dir); ok {
connectors[busno] = name
}
}
return connectors
}
func i2cSubdirBus(dir string) (int, bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, false
}
for _, entry := range entries {
match := i2cBusPattern.FindStringSubmatch(entry.Name())
if match == nil {
continue
}
if busno, err := strconv.Atoi(match[1]); err == nil {
return busno, true
}
}
return 0, false
}
func drmConnectorAttr(connector, attr string) string {
data, err := os.ReadFile(filepath.Join("/sys/class/drm", connector, attr))
if err != nil {
return ""
}
return string(data)
}
var edidHeader = []byte{0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00}
// Based on ddcutil's is_valid_raw_edid() (src/util/edid.c).
func isValidEDID(edid []byte) bool {
if len(edid) < 128 || !bytes.Equal(edid[:8], edidHeader) {
return false
}
var sum byte
for _, b := range edid[:128] {
sum += b
}
return sum == 0
}
// Based on ddcutil's is_laptop_parsed_edid() (src/util/edid.c).
func isLaptopEDID(edid []byte) bool {
return edidDescriptorText(edid, 0xfc) == "" && edidDescriptorText(edid, 0xff) == ""
}
// Based on ddcutil's get_edid_descriptor_strings() (src/util/edid.c).
func edidDescriptorText(edid []byte, tag byte) string {
text := ""
for i := range 4 {
descriptor := edid[54+i*18 : 72+i*18]
if descriptor[0] != 0 || descriptor[1] != 0 || descriptor[2] != 0 || descriptor[4] != 0 || descriptor[3] != tag {
continue
}
raw := descriptor[5:18]
if end := bytes.IndexByte(raw, 0x0a); end >= 0 {
raw = raw[:end]
}
text = strings.TrimRight(string(raw), " \t\n\v\f\r")
}
return text
}
+5 -5
View File
@@ -1,8 +1,8 @@
package brightness
import (
"errors"
"fmt"
"os"
"sort"
"strings"
"time"
@@ -42,12 +42,12 @@ func (m *Manager) initLogind() {
}
func (m *Manager) initDDC() {
if os.Getenv("DMS_NO_DDC") != "" {
ddc, err := newDDCBackend(m.updateState)
switch {
case errors.Is(err, errDDCDisabled):
log.Info("DDC backend disabled via DMS_NO_DDC")
return
}
ddc, err := NewDDCBackend()
if err != nil {
case err != nil:
log.Debugf("Failed to initialize DDC backend: %v", err)
return
}
+6
View File
@@ -96,6 +96,11 @@ type DDCBackend struct {
debounceTimers map[string]*time.Timer
debouncePending map[string]ddcPendingSet
debounceWg sync.WaitGroup
ioMutex sync.Mutex
onReread func()
stop chan struct{}
closeOnce sync.Once
}
type ddcPendingSet struct {
@@ -108,6 +113,7 @@ type ddcDevice struct {
addr int
id string
name string
adapter string
max int
lastBrightness int
}
@@ -49,6 +49,8 @@ func HandleRequest(conn *models.Conn, req models.Request, m *Manager) {
handlePinEntry(conn, req, m)
case "clipboard.unpinEntry":
handleUnpinEntry(conn, req, m)
case "clipboard.editEntry":
handleEditEntry(conn, req, m)
case "clipboard.getPinnedEntries":
handleGetPinnedEntries(conn, req, m)
case "clipboard.getPinnedCount":
@@ -422,3 +424,24 @@ func handleCopyFile(conn *models.Conn, req models.Request, m *Manager) {
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
}
func handleEditEntry(conn *models.Conn, req models.Request, m *Manager) {
id, err := params.Int(req.Params, "id")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
text, err := params.String(req.Params, "text")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.EditEntry(uint64(id), text); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry updated"})
}
+114
View File
@@ -795,6 +795,13 @@ func selectAltTextMimeType(mimes []string) string {
return ""
}
func isTextMimeType(mime string) bool {
if mime == "" || strings.HasPrefix(mime, "text/plain") {
return true
}
return slices.Contains(altTextMimeTypes, mime)
}
func (m *Manager) isImageMimeType(mime string) bool {
return strings.HasPrefix(mime, "image/")
}
@@ -1963,6 +1970,113 @@ func (m *Manager) UnpinEntry(id uint64) error {
return err
}
func (m *Manager) EditEntry(id uint64, text string) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
data := []byte(text)
mimeType := "text/plain;charset=utf-8"
if len(bytes.TrimSpace(data)) == 0 {
return fmt.Errorf("cannot save empty entry")
}
cfg := m.getConfig()
if int64(len(data)) > cfg.MaxEntrySize {
return fmt.Errorf("data too large")
}
newHash := computeHash(data)
preview := m.textPreview(data)
err := m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return fmt.Errorf("clipboard bucket missing")
}
oldKey := itob(id)
v := b.Get(oldKey)
if v == nil {
return errEntryNotFound
}
existing, err := decodeEntry(v)
if err != nil {
return err
}
if existing.IsImage {
return errors.New("cannot edit image entry")
}
if !isTextMimeType(existing.MimeType) {
return errors.New("cannot edit non-text entry")
}
wasPinned := existing.Pinned
if err := b.Delete(oldKey); err != nil {
return err
}
if err := m.deduplicateInTx(b, newHash); err != nil {
return err
}
if wasPinned {
c := b.Cursor()
for k, val := c.First(); k != nil; k, val = c.Next() {
if extractHash(val) == newHash {
meta, err := decodeEntryMeta(val)
if err == nil && meta.Pinned {
return nil
}
}
}
}
newID, err := b.NextSequence()
if err != nil {
return err
}
newEntry := Entry{
ID: newID,
Data: data,
MimeType: mimeType,
Preview: preview,
Size: len(data),
Timestamp: time.Now(),
IsImage: false,
Hash: newHash,
Pinned: wasPinned,
}
encoded, err := encodeEntry(newEntry)
if err != nil {
return err
}
if err := b.Put(itob(newID), encoded); err != nil {
return err
}
return m.trimLengthInTx(b)
})
if err == nil {
if clipErr := m.SetClipboard(data, mimeType); clipErr != nil {
log.Errorf("Failed to set clipboard selection: %v", clipErr)
}
m.updateState()
m.notifySubscribers()
}
return err
}
func (m *Manager) GetPinnedEntries() []Entry {
if m.db == nil {
return nil
@@ -45,9 +45,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
db.Close()
})
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
fn()
}).Maybe()
return &Manager{
config: DefaultConfig(),
db: db,
wlCtx: mockCtx,
}
}
@@ -578,6 +584,175 @@ func TestCreateHistoryEntryFromPinned_KeepsLatestUnpinnedDuplicate(t *testing.T)
assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID)
}
func TestEditEntry_UnpinnedEntry(t *testing.T) {
m := newTestManagerWithDB(t)
id := storeTestEntry(t, m, "original unpinned")
require.NoError(t, m.EditEntry(id, "edited unpinned"))
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "edited unpinned", history[0].Preview)
assert.False(t, history[0].Pinned)
assert.NotEqual(t, id, history[0].ID)
// Old entry should not exist
oldEntry, err := m.GetEntry(id)
assert.ErrorIs(t, err, errEntryNotFound)
assert.Nil(t, oldEntry)
}
func TestEditEntry_PinnedEntryRemainsPinned(t *testing.T) {
m := newTestManagerWithDB(t)
id := storeTestEntry(t, m, "original pinned")
require.NoError(t, m.PinEntry(id))
assert.Equal(t, 1, m.GetPinnedCount())
require.NoError(t, m.EditEntry(id, "edited pinned"))
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "edited pinned", history[0].Preview)
assert.True(t, history[0].Pinned)
pinnedEntries := m.GetPinnedEntries()
require.Len(t, pinnedEntries, 1)
assert.Equal(t, "edited pinned", pinnedEntries[0].Preview)
assert.Equal(t, 1, m.GetPinnedCount())
// Old pinned entry should be deleted
oldEntry, err := m.GetEntry(id)
assert.ErrorIs(t, err, errEntryNotFound)
assert.Nil(t, oldEntry)
}
func TestEditEntry_NotFound(t *testing.T) {
m := newTestManagerWithDB(t)
err := m.EditEntry(99999, "new text")
assert.ErrorIs(t, err, errEntryNotFound)
}
func TestEditEntry_ImageReturnsError(t *testing.T) {
m := newTestManagerWithDB(t)
imgEntry := Entry{
Data: []byte{0x89, 0x50, 0x4E, 0x47},
MimeType: "image/png",
Preview: "[[ image ]]",
Size: 4,
Timestamp: time.Now().Truncate(time.Second),
IsImage: true,
}
require.NoError(t, m.storeEntry(imgEntry))
history := m.GetHistory()
require.Len(t, history, 1)
id := history[0].ID
err := m.EditEntry(id, "replacement text")
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot edit image entry")
}
func TestEditEntry_NonTextReturnsError(t *testing.T) {
m := newTestManagerWithDB(t)
uriEntry := Entry{
Data: []byte("file:///path/to/file\n"),
MimeType: "text/uri-list",
Preview: "file:///path/to/file",
Size: 21,
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
}
require.NoError(t, m.storeEntry(uriEntry))
history := m.GetHistory()
require.Len(t, history, 1)
id := history[0].ID
err := m.EditEntry(id, "replacement text")
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot edit non-text entry")
}
func TestEditEntry_AltTextMimeTypesAllowed(t *testing.T) {
for _, mime := range []string{"UTF8_STRING", "STRING", "TEXT", "text/plain;charset=utf-8", "text/plain"} {
t.Run(mime, func(t *testing.T) {
m := newTestManagerWithDB(t)
entry := Entry{
Data: []byte("old text"),
MimeType: mime,
Preview: "old text",
Size: 8,
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
}
require.NoError(t, m.storeEntry(entry))
history := m.GetHistory()
require.Len(t, history, 1)
id := history[0].ID
err := m.EditEntry(id, "replacement text")
assert.NoError(t, err)
})
}
}
func TestEditEntry_EmptyOrWhitespaceReturnsError(t *testing.T) {
m := newTestManagerWithDB(t)
id := storeTestEntry(t, m, "keep me")
for _, badText := range []string{"", " ", "\t\n\r"} {
err := m.EditEntry(id, badText)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot save empty entry")
}
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "keep me", history[0].Preview)
}
func TestEditEntry_DataTooLargeReturnsError(t *testing.T) {
m := newTestManagerWithDB(t)
m.config.MaxEntrySize = 10
id := storeTestEntry(t, m, "small")
err := m.EditEntry(id, "this text exceeds the 10-byte limit")
assert.Error(t, err)
assert.Contains(t, err.Error(), "data too large")
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "small", history[0].Preview)
}
func TestHandleEditEntry_SuccessAndValidation(t *testing.T) {
m := newTestManagerWithDB(t)
id := storeTestEntry(t, m, "before edit")
mc := newClipboardTestConn()
conn := models.NewConn(mc)
handleEditEntry(conn, models.Request{
ID: 1,
Params: map[string]any{
"id": float64(id),
"text": "after edit",
},
}, m)
var resp models.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.True(t, resp.Result.Success)
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "after edit", history[0].Preview)
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{
subscribers: make(map[string]chan State),
+2 -2
View File
@@ -126,7 +126,7 @@ func findKeyboards() ([]EvdevDevice, error) {
var keyboards []EvdevDevice
for _, path := range matches {
device, err := evdev.Open(path)
device, err := evdev.OpenWithFlags(path, os.O_RDONLY)
if err != nil {
continue
}
@@ -207,7 +207,7 @@ func (m *Manager) watchForNewKeyboards() {
continue
}
device, err := evdev.Open(event.Name)
device, err := evdev.OpenWithFlags(event.Name, os.O_RDONLY)
if err != nil {
m.devicesMutex.Unlock()
continue
+42 -17
View File
@@ -154,23 +154,7 @@ func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]
}
if _, hasDevice := interfaces[iwdDeviceInterface]; hasDevice {
b.devicePath = path
if devProps, ok := interfaces[iwdDeviceInterface]; ok {
if nameVar, ok := devProps["Name"]; ok {
if name, ok := nameVar.Value().(string); ok {
b.stateMutex.Lock()
b.state.WiFiDevice = name
b.stateMutex.Unlock()
}
}
if poweredVar, ok := devProps["Powered"]; ok {
if powered, ok := poweredVar.Value().(bool); ok {
b.stateMutex.Lock()
b.state.WiFiEnabled = powered
b.stateMutex.Unlock()
}
}
}
b.applyDeviceProps(interfaces[iwdDeviceInterface])
}
if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter {
b.adapterPath = path
@@ -193,6 +177,47 @@ func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]
return nil
}
func (b *IWDBackend) applyDeviceProps(props map[string]dbus.Variant) {
if nameVar, ok := props["Name"]; ok {
if name, ok := nameVar.Value().(string); ok {
b.stateMutex.Lock()
b.state.WiFiDevice = name
b.stateMutex.Unlock()
}
}
if poweredVar, ok := props["Powered"]; ok {
if powered, ok := poweredVar.Value().(bool); ok {
b.stateMutex.Lock()
b.state.WiFiEnabled = powered
b.stateMutex.Unlock()
}
}
}
// setDevice records a device that appeared at a new path, which is what
// happens when iwd restarts and recreates wlan0. It returns the path it
// replaced so the caller can move the signal match over.
func (b *IWDBackend) setDevice(path dbus.ObjectPath, props map[string]dbus.Variant) (prev dbus.ObjectPath, changed bool) {
if path == "" || path == b.devicePath {
return "", false
}
prev = b.devicePath
b.devicePath = path
b.applyDeviceProps(props)
return prev, true
}
func (b *IWDBackend) clearDevice(path dbus.ObjectPath) bool {
if path == "" || path != b.devicePath {
return false
}
b.devicePath = ""
b.stateMutex.Lock()
b.state.WiFiEnabled = false
b.stateMutex.Unlock()
return true
}
func (b *IWDBackend) GetCurrentState() (*BackendState, error) {
b.stateMutex.RLock()
defer b.stateMutex.RUnlock()
@@ -26,12 +26,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
b.conn.Signal(sigChan)
if b.devicePath != "" {
err := b.conn.AddMatchSignal(
dbus.WithMatchObjectPath(b.devicePath),
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
if err != nil {
if err := b.addDeviceSignalMatch(b.devicePath); err != nil {
return fmt.Errorf("failed to add device signal match: %w", err)
}
}
@@ -86,6 +81,48 @@ func (b *IWDBackend) addStationSignalMatch(path dbus.ObjectPath) error {
)
}
func (b *IWDBackend) addDeviceSignalMatch(path dbus.ObjectPath) error {
return b.conn.AddMatchSignal(
dbus.WithMatchObjectPath(path),
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
func (b *IWDBackend) removeDeviceSignalMatch(path dbus.ObjectPath) {
err := b.conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(path),
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
if err != nil {
log.Debugf("Failed to remove iwd device signal match for %s: %v", path, err)
}
}
func (b *IWDBackend) handleDeviceAdded(path dbus.ObjectPath, props map[string]dbus.Variant) bool {
prev, changed := b.setDevice(path, props)
if !changed {
return false
}
if prev != "" {
b.removeDeviceSignalMatch(prev)
}
if err := b.addDeviceSignalMatch(path); err != nil {
log.Warnf("Failed to add iwd device signal match for %s: %v", path, err)
}
return true
}
func (b *IWDBackend) handleDeviceRemoved(path dbus.ObjectPath) bool {
if !b.clearDevice(path) {
return false
}
b.removeDeviceSignalMatch(path)
return true
}
func (b *IWDBackend) handleStationAdded(path dbus.ObjectPath) bool {
if path == "" || path == b.stationPath {
return false
@@ -139,6 +176,12 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
if len(sig.Body) >= 2 {
path, _ := sig.Body[0].(dbus.ObjectPath)
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
// Device before Station: handleStationAdded reads Powered from devicePath.
if props, ok := interfaces[iwdDeviceInterface]; ok {
if b.handleDeviceAdded(path, props) && b.onStateChange != nil {
b.onStateChange()
}
}
if _, ok := interfaces[iwdStationInterface]; ok {
if b.handleStationAdded(path) && b.onStateChange != nil {
b.onStateChange()
@@ -159,17 +202,19 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
path, _ := sig.Body[0].(dbus.ObjectPath)
if interfaces, ok := sig.Body[1].([]string); ok {
for _, iface := range interfaces {
if iface == iwdStationInterface {
switch iface {
case iwdDeviceInterface:
if b.handleDeviceRemoved(path) && b.onStateChange != nil {
b.onStateChange()
}
case iwdStationInterface:
if b.handleStationRemoved(path) && b.onStateChange != nil {
b.onStateChange()
}
break
}
if iface == iwdKnownNetworkInterface {
case iwdKnownNetworkInterface:
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange()
}
break
}
}
}
@@ -460,3 +460,71 @@ func TestConnectAttempt_DoubleFinalization(t *testing.T) {
assert.Equal(t, "bad-credentials", backend.state.LastError)
backend.stateMutex.RUnlock()
}
func TestIWDBackend_SetDevice_FollowsRecreatedDevice(t *testing.T) {
backend, _ := NewIWDBackend()
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/0/3": {
iwdDeviceInterface: {"Name": dbus.MakeVariant("wlan0"), "Powered": dbus.MakeVariant(true)},
iwdStationInterface: {},
},
})
assert.NoError(t, err)
// iwd restarts: the old objects go away and wlan0 comes back with a new ifindex.
assert.True(t, backend.handleStationRemoved("/net/connman/iwd/0/3"))
assert.True(t, backend.clearDevice("/net/connman/iwd/0/3"))
prev, changed := backend.setDevice("/net/connman/iwd/0/7", map[string]dbus.Variant{
"Name": dbus.MakeVariant("wlan0"),
"Powered": dbus.MakeVariant(true),
})
assert.True(t, changed)
assert.Empty(t, prev)
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/7"), backend.devicePath)
enabled, _ := backend.GetWiFiEnabled()
assert.True(t, enabled, "Wi-Fi must be enabled again once the recreated device reports Powered")
}
func TestIWDBackend_SetDevice_ReplacesPathWithoutRemovalSignal(t *testing.T) {
backend, _ := NewIWDBackend()
backend.devicePath = "/net/connman/iwd/0/3"
prev, changed := backend.setDevice("/net/connman/iwd/0/7", map[string]dbus.Variant{
"Powered": dbus.MakeVariant(true),
})
assert.True(t, changed)
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/3"), prev)
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/7"), backend.devicePath)
}
func TestIWDBackend_SetDevice_SamePathIsNoop(t *testing.T) {
backend, _ := NewIWDBackend()
backend.devicePath = "/net/connman/iwd/0/3"
_, changed := backend.setDevice("/net/connman/iwd/0/3", map[string]dbus.Variant{
"Powered": dbus.MakeVariant(false),
})
assert.False(t, changed)
_, changed = backend.setDevice("", nil)
assert.False(t, changed)
}
func TestIWDBackend_ClearDevice(t *testing.T) {
backend, _ := NewIWDBackend()
backend.devicePath = "/net/connman/iwd/0/3"
assert.False(t, backend.clearDevice("/net/connman/iwd/0/9"), "unrelated path must be ignored")
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/3"), backend.devicePath)
assert.True(t, backend.clearDevice("/net/connman/iwd/0/3"))
assert.Empty(t, backend.devicePath)
enabled, _ := backend.GetWiFiEnabled()
assert.False(t, enabled)
assert.ErrorContains(t, backend.SetWiFiEnabled(true), "no WiFi device available")
}
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net"
"os"
"strings"
"sync"
@@ -30,12 +31,54 @@ func (l *linkInfo) isWired() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType == "bridge" {
return hasPhysicalMember(l.name)
}
if l.linkType != "" {
return l.linkType == "ether"
return isWiredLinkType(l.linkType)
}
return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
}
// isWiredLinkType reports whether a networkd link Type can carry the wired
// uplink. Link aggregates and bridges hold the IP configuration while the
// enslaved member NIC stays in the "enslaved" state, same as #1581 for
// NetworkManager. Bridges are further checked in isWired.
func isWiredLinkType(t string) bool {
switch t {
case "ether", "bridge", "bond", "team", "vlan":
return true
}
return false
}
// bridgeMembers lists the interfaces enslaved to a bridge. It is a variable so
// tests can stub the sysfs lookup.
var bridgeMembers = func(bridge string) []string {
entries, err := os.ReadDir("/sys/class/net/" + bridge + "/brif")
if err != nil {
return nil
}
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
return names
}
// hasPhysicalMember reports whether a bridge has at least one member that is
// not virtual (veth, tap, ...). This tells a host bridge carrying the uplink
// apart from container/VM bridges (lxc, waydroid, ...) that only hold virtual
// members.
func hasPhysicalMember(bridge string) bool {
for _, m := range bridgeMembers(bridge) {
if !looksVirtual(m) {
return true
}
}
return false
}
func (l *linkInfo) isWireless() bool {
if looksVirtual(l.name) {
return false
@@ -166,6 +166,15 @@ func TestLinkInfo_Classify(t *testing.T) {
{"veth ether excluded", "veth1234", "ether", false, false},
{"podman bridge ether excluded", "podman3", "ether", false, false},
{"docker bridge ether excluded", "docker0", "ether", false, false},
// The IP lives on the aggregate while the member NIC is "enslaved" (#3463).
{"bridge type", "br0", "bridge", true, false},
{"bond type", "bond0", "bond", true, false},
{"team type", "team0", "team", true, false},
{"vlan type", "vlan10", "vlan", true, false},
{"docker user bridge excluded", "br-1a2b3c", "bridge", false, false},
{"libvirt bridge excluded", "virbr0", "bridge", false, false},
{"bridge with only virtual members excluded", "lxcbr0", "bridge", false, false},
{"bridge without members excluded", "waydroid0", "bridge", false, false},
// Fallback path: linkType unavailable, name-prefix heuristic applies.
{"fallback enp wired", "enp141s0", "", true, false},
{"fallback wlan wireless", "wlan0", "", false, true},
@@ -174,6 +183,15 @@ func TestLinkInfo_Classify(t *testing.T) {
{"fallback docker skipped", "docker0", "", false, false},
{"fallback tun skipped", "tun0", "", false, false},
}
members := map[string][]string{
"br0": {"enp42s0", "vnet0"},
"lxcbr0": {"vethAbCd12"},
"waydroid0": nil,
}
orig := bridgeMembers
bridgeMembers = func(b string) []string { return members[b] }
t.Cleanup(func() { bridgeMembers = orig })
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := &linkInfo{name: tc.ifname, linkType: tc.linkType}
@@ -254,7 +272,7 @@ func TestLooksVirtual(t *testing.T) {
for _, n := range virtual {
assert.True(t, looksVirtual(n), "%s should look virtual", n)
}
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0"}
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0", "br0", "bond0", "team0", "vlan10"}
for _, n := range real {
assert.False(t, looksVirtual(n), "%s should not look virtual", n)
}
+14 -9
View File
@@ -16,7 +16,8 @@ const (
metricPreferred = int64(100)
metricNonPreferred = int64(300)
metricDefault = int64(100)
// -1 lets NetworkManager pick the metric by device type (nm-settings ipv4.route-metric)
metricDefault = int64(-1)
)
func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
@@ -46,7 +47,7 @@ func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
case PreferenceCellular:
return m.prioritizeCellular()
case PreferenceAuto:
return m.balancePriorities()
return m.restoreDefaultPriorities()
}
return nil
@@ -112,7 +113,7 @@ func (m *Manager) prioritizeCellular() error {
return nil
}
func (m *Manager) balancePriorities() error {
func (m *Manager) restoreDefaultPriorities() error {
if err := m.setConnectionPriority("802-3-ethernet", priorityDefault, metricDefault); err != nil {
log.Warnf("Failed to reset Ethernet priority: %v", err)
}
@@ -242,16 +243,20 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int
}
func priorityMatches(variant dbus.Variant, expected int64) bool {
value, ok := variantInt64(variant)
return ok && value == expected
return settingInt64(variant, int64(priorityDefault)) == expected
}
func routeMetricMatches(section map[string]dbus.Variant, expected int64) bool {
if section == nil {
return false
return settingInt64(section["route-metric"], metricDefault) == expected
}
// GetSettings omits properties at their default, so a missing key is the default value
func settingInt64(variant dbus.Variant, def int64) int64 {
value, ok := variantInt64(variant)
if !ok {
return def
}
value, ok := variantInt64(section["route-metric"])
return ok && value == expected
return value
}
func variantInt64(variant dbus.Variant) (int64, bool) {
@@ -3,6 +3,7 @@ package network
import (
"testing"
"github.com/godbus/dbus/v5"
"github.com/stretchr/testify/assert"
)
@@ -45,7 +46,10 @@ func TestManager_GetConnectionPreference(t *testing.T) {
}
}
// Note: Full testing of priority operations would require mocking NetworkManager
// D-Bus interfaces. The tests above cover the basic logic and error handling.
// Integration tests would be needed for complete coverage of network connection
// priority updates and reactivation.
func TestProfileAtDefaultsMatchesAutoTargets(t *testing.T) {
assert.True(t, priorityMatches(dbus.Variant{}, int64(priorityDefault)))
assert.True(t, routeMetricMatches(nil, metricDefault))
assert.True(t, routeMetricMatches(map[string]dbus.Variant{"method": dbus.MakeVariant("auto")}, metricDefault))
assert.False(t, routeMetricMatches(map[string]dbus.Variant{"route-metric": dbus.MakeVariant(int64(100))}, metricDefault))
assert.False(t, priorityMatches(dbus.MakeVariant(int32(100)), int64(priorityDefault)))
}
@@ -17,6 +17,7 @@ import (
func init() {
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
RegisterSystemBackend(func() Backend { return &shellyBackend{} })
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
}
@@ -53,9 +54,7 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
}
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
// AUR packages are exempt — holding those cannot break the repo dependency graph.
// Arch repository packages must upgrade together; AUR packages may be held.
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
if len(ignored) == 0 {
return ignored
@@ -81,7 +80,7 @@ func isPacmanFamily(b Backend) bool {
return false
}
switch b.ID() {
case "pacman", "paru", "yay":
case "pacman", "paru", "yay", "shelly":
return true
default:
return false
@@ -0,0 +1,168 @@
package sysupdate
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"slices"
"strings"
"time"
)
type shellyBackend struct{}
func (shellyBackend) ID() string { return "shelly" }
func (shellyBackend) DisplayName() string { return "Shelly (AUR)" }
func (shellyBackend) Repo() RepoKind { return RepoSystem }
func (shellyBackend) NeedsAuth() bool { return true }
func (shellyBackend) RunsInTerminal() bool {
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
}
func (shellyBackend) IsAvailable(ctx context.Context) bool {
if !commandExists("shelly") {
return false
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
out, err := Capture(ctx, []string{"shelly", "--version", "--json"})
if err != nil {
return false
}
var version struct {
Name string `json:"name"`
SchemaVersion int `json:"schemaVersion"`
}
// Shelly 3.1.3 introduced this CLI identity response.
return json.Unmarshal([]byte(out), &version) == nil && version.Name == "shelly" && version.SchemaVersion == 1
}
func (b shellyBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
pkgs, err := b.checkUpdates(ctx, RepoSystem)
if err != nil {
return nil, err
}
aur, err := b.checkUpdates(ctx, RepoAUR)
if err != nil {
return nil, err
}
return append(pkgs, aur...), nil
}
func (b shellyBackend) checkUpdates(ctx context.Context, repo RepoKind) ([]Package, error) {
kind := "standard"
if repo == RepoAUR {
kind = "aur"
}
out, err := Capture(ctx, []string{"shelly", "list-updates", kind, "--json"})
if ctx.Err() != nil {
return nil, ctx.Err()
}
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return nil, fmt.Errorf("shelly %s update check: %w: %s", kind, err, strings.TrimSpace(string(exitErr.Stderr)))
}
return nil, fmt.Errorf("shelly %s update check: %w", kind, err)
}
return parseShellyUpdates(out, repo)
}
func parseShellyUpdates(out string, repo RepoKind) ([]Package, error) {
var updates []struct {
Name string
CurrentVersion string
Version string
NewVersion string
DownloadSize int64
PackageBase string
}
if err := json.Unmarshal([]byte(out), &updates); err != nil {
return nil, fmt.Errorf("shelly %s updates: invalid JSON: %w", repo, err)
}
if updates == nil {
return nil, fmt.Errorf("shelly %s updates: expected a JSON array", repo)
}
var pkgs []Package
for _, update := range updates {
fromVersion := update.CurrentVersion
if repo == RepoAUR {
fromVersion = update.Version
}
if update.Name == "" || fromVersion == "" || update.NewVersion == "" {
return nil, fmt.Errorf("shelly %s updates: missing package name or version", repo)
}
pkg := Package{
Name: update.Name,
Backend: "shelly",
Repo: repo,
FromVersion: fromVersion,
ToVersion: update.NewVersion,
SizeBytes: max(0, update.DownloadSize),
}
if repo == RepoAUR {
base := update.PackageBase
if base == "" {
base = update.Name
}
pkg.ChangelogURL = "https://aur.archlinux.org/packages/" + url.PathEscape(base)
}
pkgs = append(pkgs, pkg)
}
return pkgs, nil
}
func (b shellyBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
var aur []Package
if opts.IncludeAUR && (opts.DryRun || len(opts.Ignored) > 0) {
// The manager has already removed held packages from opts.Targets.
var err error
aur, err = b.checkUpdates(ctx, RepoAUR)
if err != nil {
return err
}
for _, pkg := range aur {
if slices.Contains(opts.Ignored, pkg.Name) {
return fmt.Errorf("shelly cannot exclude held AUR package %q; disable AUR updates or remove its DMS hold before updating", pkg.Name)
}
}
}
if opts.DryRun {
pkgs, err := b.checkUpdates(ctx, RepoSystem)
if err != nil {
return err
}
pkgs = append(pkgs, aur...)
for _, pkg := range pkgs {
if onLine != nil {
onLine(fmt.Sprintf("%s %s -> %s", pkg.Name, pkg.FromVersion, pkg.ToVersion))
}
}
return nil
}
argv := shellyUpgradeArgv(opts.IncludeAUR)
if opts.AttachStdio {
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: true})
}
if !b.RunsInTerminal() {
return Run(ctx, argv, RunOptions{OnLine: onLine, Env: []string{"SHELLY_ELEVATOR=pkexec"}})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
return Run(ctx, wrapInTerminal(term, "DMS — System Update (shelly)", strings.Join(argv, " "), opts.TerminalArgs), RunOptions{OnLine: onLine})
}
func shellyUpgradeArgv(includeAUR bool) []string {
if !includeAUR {
return []string{"shelly", "upgrade", "standard", "--no-confirm"}
}
return []string{"shelly", "upgrade", "all", "--no-flatpak", "--no-appimage", "--no-confirm"}
}
@@ -0,0 +1,298 @@
package sysupdate
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
const shellyRepoFixture = `[{"Name":"linux","CurrentVersion":"2:6.18-1","NewVersion":"2:6.19-1","DownloadSize":1024,"Repository":"core"}]`
const shellyAURFixture = `[{"Name":"example-git","Version":"r1-1","NewVersion":"r2-1","DownloadSize":-1,"PackageBase":"example"}]`
func TestParseShellyUpdates(t *testing.T) {
tests := []struct {
name string
out string
repo RepoKind
want []Package
}{
{"repository", shellyRepoFixture, RepoSystem, []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem, FromVersion: "2:6.18-1", ToVersion: "2:6.19-1", SizeBytes: 1024}}},
{"aur package base", shellyAURFixture, RepoAUR, []Package{{Name: "example-git", Backend: "shelly", Repo: RepoAUR, FromVersion: "r1-1", ToVersion: "r2-1", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
{"aur name fallback", `[{"Name":"example","Version":"1","NewVersion":"2"}]`, RepoAUR, []Package{{Name: "example", Backend: "shelly", Repo: RepoAUR, FromVersion: "1", ToVersion: "2", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
{"empty", "[]\n", RepoSystem, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseShellyUpdates(tt.out, tt.repo)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("packages = %#v, want %#v", got, tt.want)
}
})
}
for _, out := range []string{"", "null", "{}", "[", "[] trailing", "[null]", `[{"Name":"linux"}]`, `[{"Name":"linux","CurrentVersion":"1","NewVersion":"2","DownloadSize":"large"}]`} {
t.Run(out, func(t *testing.T) {
if _, err := parseShellyUpdates(out, RepoSystem); err == nil {
t.Fatalf("expected error for %q", out)
}
})
}
}
func writeUpdateExecutable(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
func TestShellySelection(t *testing.T) {
for _, tt := range []struct {
name string
bins []string
version string
want string
}{
{"shelly only", []string{"shelly"}, `{"schemaVersion":1,"name":"shelly","version":"3.1.3"}`, "shelly"},
{"shelly before pacman", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly"}`, "shelly"},
{"paru preferred", []string{"paru", "yay", "shelly", "pacman"}, "", "paru"},
{"yay preferred", []string{"yay", "shelly", "pacman"}, "", "yay"},
{"legacy shelly", []string{"shelly", "pacman"}, "2.4.1", "pacman"},
{"wrong binary", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly-ui"}`, "pacman"},
{"unknown schema", []string{"shelly", "pacman"}, `{"schemaVersion":2,"name":"shelly"}`, "pacman"},
{"pacman only", []string{"pacman"}, "", "pacman"},
} {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
for _, bin := range tt.bins {
writeUpdateExecutable(t, dir, bin, "printf '%s\\n' '"+tt.version+"'")
}
selection := Select(t.Context())
if selection.System == nil || selection.System.ID() != tt.want {
t.Fatalf("selection = %#v, want %s", selection.System, tt.want)
}
})
}
}
func fakeShelly(t *testing.T) (string, string) {
t.Helper()
dir := t.TempDir()
log := filepath.Join(dir, "calls")
t.Setenv("PATH", dir)
t.Setenv("TERMINAL", "")
t.Setenv("DMS_FORCE_PKEXEC", "")
t.Setenv("DMS_TEST_SHELLY_LOG", log)
t.Setenv("DMS_TEST_SHELLY_EXIT", "0")
t.Setenv("SHELLY_ELEVATOR", "")
writeUpdateExecutable(t, dir, "shelly", `printf '%s|%s\n' "$*" "$SHELLY_ELEVATOR" >> "$DMS_TEST_SHELLY_LOG"
case "$*" in
'list-updates standard --json') printf '%s\n' '`+shellyRepoFixture+`' ;;
'list-updates aur --json') printf '%s\n' '`+shellyAURFixture+`' ;;
'upgrade standard --no-confirm'|'upgrade all --no-flatpak --no-appimage --no-confirm') exit "$DMS_TEST_SHELLY_EXIT" ;;
*) echo "unexpected arguments: $*" >&2; exit 99 ;;
esac`)
return dir, log
}
func readUpdateCalls(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatal(err)
}
return string(data)
}
func TestShellyCheckUpdates(t *testing.T) {
_, log := fakeShelly(t)
pkgs, err := (shellyBackend{}).CheckUpdates(t.Context())
if err != nil {
t.Fatal(err)
}
if len(pkgs) != 2 || pkgs[0].Repo != RepoSystem || pkgs[1].Repo != RepoAUR {
t.Fatalf("unexpected packages: %#v", pkgs)
}
if got, want := readUpdateCalls(t, log), "list-updates standard --json|\nlist-updates aur --json|\n"; got != want {
t.Fatalf("calls = %q, want %q", got, want)
}
}
func TestShellyCheckFailures(t *testing.T) {
for _, code := range []string{"1", "2"} {
t.Run(code, func(t *testing.T) {
dir, _ := fakeShelly(t)
writeUpdateExecutable(t, dir, "shelly", "echo 'database unavailable' >&2; exit "+code)
if _, err := (shellyBackend{}).CheckUpdates(t.Context()); err == nil || !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("expected query failure with stderr, got %v", err)
}
})
}
t.Run("cancellation", func(t *testing.T) {
dir, _ := fakeShelly(t)
writeUpdateExecutable(t, dir, "shelly", "exec /bin/sleep 30")
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
if _, err := (shellyBackend{}).CheckUpdates(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected deadline error, got %v", err)
}
})
}
func TestShellyUpgrade(t *testing.T) {
for _, tt := range []struct {
name string
includeAUR bool
attached bool
terminal bool
}{
{"pkexec repository", false, false, false},
{"pkexec aur", true, false, false},
{"attached repository", false, true, false},
{"attached aur", true, true, false},
{"terminal repository", false, false, true},
{"terminal aur", true, false, true},
} {
t.Run(tt.name, func(t *testing.T) {
dir, log := fakeShelly(t)
opts := UpgradeOptions{IncludeAUR: tt.includeAUR, AttachStdio: tt.attached, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
elevator := ""
if tt.terminal {
opts.Terminal = "fake-terminal"
writeUpdateExecutable(t, dir, opts.Terminal, `while [ "$1" != sh ] && [ "$#" -gt 0 ]; do shift; done
shift
exec /bin/sh "$@"`)
} else if !tt.attached {
t.Setenv("DMS_FORCE_PKEXEC", "1")
elevator = "pkexec"
}
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err != nil {
t.Fatal(err)
}
command := "upgrade standard --no-confirm"
if tt.includeAUR {
command = "upgrade all --no-flatpak --no-appimage --no-confirm"
}
if got, want := readUpdateCalls(t, log), command+"|"+elevator+"\n"; got != want {
t.Fatalf("calls = %q, want %q", got, want)
}
})
}
}
func TestShellyUpgradeHolds(t *testing.T) {
for _, tt := range []struct {
name string
includeAUR bool
ignored []string
wantError bool
dryRun bool
}{
{"held aur", true, []string{"example-git"}, true, false},
{"repository only", false, []string{"example-git"}, false, false},
{"repository hold cannot exclude system upgrade", true, []string{"linux"}, false, false},
{"unrelated hold", true, []string{"org.example.Flatpak"}, false, false},
{"dry held aur", true, []string{"example-git"}, true, true},
{"dry repository only", false, []string{"example-git"}, false, true},
{"dry repository hold", true, []string{"linux"}, false, true},
{"dry unrelated hold", true, []string{"org.example.Flatpak"}, false, true},
} {
t.Run(tt.name, func(t *testing.T) {
_, log := fakeShelly(t)
t.Setenv("DMS_FORCE_PKEXEC", "1")
opts := UpgradeOptions{DryRun: tt.dryRun, IncludeAUR: tt.includeAUR, Ignored: tt.ignored, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
calls := readUpdateCalls(t, log)
if strings.Count(calls, "list-updates aur --json") > 1 {
t.Fatalf("duplicate AUR check: %q", calls)
}
if tt.wantError {
if err == nil || err.Error() != `shelly cannot exclude held AUR package "example-git"; disable AUR updates or remove its DMS hold before updating` || strings.Contains(calls, "upgrade ") {
t.Fatalf("held AUR package must block upgrade, got %v, calls %q", err, readUpdateCalls(t, log))
}
return
}
if err != nil || strings.Contains(calls, "upgrade ") == tt.dryRun {
t.Fatalf("unexpected result for dryRun=%v: %v, calls %q", tt.dryRun, err, calls)
}
})
}
if !isPacmanFamily(shellyBackend{}) {
t.Fatal("Shelly must enforce repository hold restrictions")
}
}
func TestShellyHoldCheckFailure(t *testing.T) {
for _, dryRun := range []bool{false, true} {
dir, log := fakeShelly(t)
writeUpdateExecutable(t, dir, "shelly", `printf '%s\n' "$*" >> "$DMS_TEST_SHELLY_LOG"
echo 'AUR unavailable' >&2
exit 1`)
opts := UpgradeOptions{DryRun: dryRun, IncludeAUR: true, Ignored: []string{"example-git"}, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
if err == nil || !strings.Contains(err.Error(), "AUR unavailable") {
t.Fatalf("dryRun=%v: expected AUR query error, got %v", dryRun, err)
}
if calls := readUpdateCalls(t, log); calls != "list-updates aur --json\n" {
t.Fatalf("query failure must stop the operation: %q", calls)
}
}
}
func TestShellyDryRun(t *testing.T) {
for _, includeAUR := range []bool{false, true} {
_, log := fakeShelly(t)
var lines []string
opts := UpgradeOptions{DryRun: true, IncludeAUR: includeAUR, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
if err := (shellyBackend{}).Upgrade(t.Context(), opts, func(line string) { lines = append(lines, line) }); err != nil {
t.Fatal(err)
}
wantCalls := "list-updates standard --json|\n"
wantLines := []string{"linux 2:6.18-1 -> 2:6.19-1"}
if includeAUR {
wantCalls = "list-updates aur --json|\n" + wantCalls
wantLines = append(wantLines, "example-git r1-1 -> r2-1")
}
if got := readUpdateCalls(t, log); got != wantCalls || !reflect.DeepEqual(lines, wantLines) {
t.Fatalf("calls = %q, lines = %q", got, lines)
}
}
}
func TestShellySkipsExcludedTargets(t *testing.T) {
for _, targets := range [][]Package{
nil,
{{Name: "example-git", Backend: "shelly", Repo: RepoAUR}},
{{Name: "org.example.App", Backend: "flatpak", Repo: RepoFlatpak}},
{{Name: "linux", Backend: "pacman", Repo: RepoSystem}},
} {
_, log := fakeShelly(t)
if err := (shellyBackend{}).Upgrade(t.Context(), UpgradeOptions{Targets: targets, IncludeFlatpak: true}, nil); err != nil {
t.Fatal(err)
}
if got := readUpdateCalls(t, log); got != "" {
t.Fatalf("excluded targets executed commands: %q", got)
}
}
}
func TestShellyUpgradeFailure(t *testing.T) {
_, _ = fakeShelly(t)
t.Setenv("DMS_FORCE_PKEXEC", "1")
t.Setenv("DMS_TEST_SHELLY_EXIT", "126")
opts := UpgradeOptions{Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err == nil {
t.Fatal("authentication or upgrade failure must propagate")
}
}
+9 -1
View File
@@ -35,6 +35,7 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error {
}
return cmd.Process.Kill()
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
@@ -125,7 +126,14 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
title, shellCmd,
)
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
closer := `dms_update_status=$?
if [ "$dms_update_status" -eq 0 ]; then
printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'
else
printf '\n\033[1;31m=== Update failed (exit %s). Press Enter to close. ===\033[0m\n' "$dms_update_status"
fi
read -r dms_update_reply
exit "$dms_update_status"`
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer
@@ -0,0 +1,58 @@
package sysupdate
import (
"errors"
"os"
"os/exec"
"strings"
"testing"
)
func TestTerminalWrapperPreservesExitStatus(t *testing.T) {
for _, tt := range []struct {
command string
status int
message string
}{
{"true", 0, "Done."},
{"false", 1, "Update failed (exit 1)"},
{"sh -c 'exit 42'", 42, "Update failed (exit 42)"},
} {
t.Run(tt.command, func(t *testing.T) {
argv := wrapInTerminal("xterm", "Test", tt.command, nil)
cmd := exec.Command("sh", "-c", argv[len(argv)-1])
cmd.Stdin = strings.NewReader("\n")
out, err := cmd.CombinedOutput()
if tt.status == 0 && err != nil {
t.Fatal(err)
}
if tt.status != 0 {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || exitErr.ExitCode() != tt.status {
t.Fatalf("expected exit %d, got %v (%s)", tt.status, err, out)
}
}
if !strings.Contains(string(out), tt.message) {
t.Fatalf("missing status message %q: %s", tt.message, out)
}
})
}
}
func TestRunAttachesStdin(t *testing.T) {
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
if _, err := writer.WriteString("confirmation\n"); err != nil {
t.Fatal(err)
}
writer.Close()
previous := os.Stdin
os.Stdin = reader
defer func() { os.Stdin = previous }()
if err := Run(t.Context(), []string{"sh", "-c", `read -r answer && [ "$answer" = confirmation ]`}, RunOptions{AttachStdio: true}); err != nil {
t.Fatalf("attached command did not receive stdin: %v", err)
}
}
+2 -2
View File
@@ -420,8 +420,8 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
m.failCustomUpgrade(ErrCodeCancelled, err)
return
}
// exit status reflects the trailing `read`, not the update command
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
m.failCustomUpgrade(ErrCodeBackendFailed, err)
return
}
m.finishSuccessfulUpgrade(false)
@@ -0,0 +1,52 @@
package sysupdate
import (
"context"
"slices"
"strings"
"testing"
)
type customUpgradeTestBackend struct {
pacmanBackend
checks int
}
func (b *customUpgradeTestBackend) CheckUpdates(context.Context) ([]Package, error) {
b.checks++
return nil, nil
}
func TestCustomUpgradeFailure(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
writeUpdateExecutable(t, dir, "fake-terminal", `while [ "$#" -gt 0 ] && [ "$1" != sh ]; do shift; done
[ "$#" -gt 0 ] || exit 99
shift
exec /bin/sh "$@"`)
backend := &customUpgradeTestBackend{}
m := &Manager{
selection: Selection{System: backend},
notifyDirty: make(chan struct{}, 1),
stopChan: make(chan struct{}),
}
m.runCustomUpgrade(t.Context(), UpgradeOptions{
Terminal: "fake-terminal",
CustomCommand: "false",
})
state := m.GetState()
if state.Phase != PhaseError {
t.Errorf("phase = %s, want %s", state.Phase, PhaseError)
}
if state.Error == nil || state.Error.Code != ErrCodeBackendFailed || !strings.Contains(state.Error.Message, "exit status 1") {
t.Errorf("error = %#v, want backend-failed with exit status 1", state.Error)
}
if slices.Contains(state.RecentLog, "Upgrade complete.") {
t.Error("failed custom upgrade logged success")
}
if backend.checks != 0 {
t.Errorf("failed custom upgrade refreshed %d times", backend.checks)
}
}
+1
View File
@@ -33,6 +33,7 @@ func convertStatus(status *ipnstate.Status) *TailscaleState {
if status.Self != nil {
state.Self = convertPeerStatus(status.Self, users)
state.Self.Online = true
}
if len(status.Peer) > 0 {
+7 -5
View File
@@ -95,7 +95,7 @@ func newManager(client tailscaleClient) *Manager {
func (m *Manager) watchLoop(ctx context.Context) {
defer m.watchWG.Done()
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
mask := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
backoff := time.Second
unreachableSent := false
@@ -122,8 +122,13 @@ func (m *Manager) watchLoop(ctx context.Context) {
log.Info("[Tailscale] Connected to IPN bus")
m.markAvailable()
select {
case m.dirty <- struct{}{}:
default:
}
for {
notify, err := watcher.Next()
_, err := watcher.Next()
if err != nil {
log.Warnf("[Tailscale] IPN bus error: %v", err)
break
@@ -131,9 +136,6 @@ func (m *Manager) watchLoop(ctx context.Context) {
backoff = time.Second
if notify.State == nil && notify.NetMap == nil { //nolint:staticcheck // NetMap is deprecated upstream but still the only activity signal on some platforms
continue
}
select {
case m.dirty <- struct{}{}:
default:
@@ -418,3 +418,55 @@ func TestManager_Actions_PropagateError(t *testing.T) {
assert.Error(t, m.SetExitNode("nABC123"))
assert.Error(t, m.SetAllowLANAccess(true))
}
type dynamicWatcher struct {
ctx context.Context
ch chan ipn.Notify
}
func (w *dynamicWatcher) Next() (ipn.Notify, error) {
select {
case n, ok := <-w.ch:
if !ok {
return ipn.Notify{}, fmt.Errorf("watcher closed")
}
return n, nil
case <-w.ctx.Done():
return ipn.Notify{}, w.ctx.Err()
}
}
func (w *dynamicWatcher) Close() error {
return nil
}
func TestWatchLoop_NotifyWithoutStateOrNetMap(t *testing.T) {
var statusCalls atomic.Int32
notifyCh := make(chan ipn.Notify, 1)
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
return &dynamicWatcher{ctx: ctx, ch: notifyCh}, nil
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
statusCalls.Add(1)
return runningStatus(), nil
},
}
m := newManager(client)
defer m.Close()
// Wait for the initial connect-time status call to complete.
require.Eventually(t, func() bool {
return statusCalls.Load() == 1
}, 2*time.Second, 10*time.Millisecond)
// Send an event with State == nil and NetMap == nil.
notifyCh <- ipn.Notify{Engine: &ipn.EngineStatus{}}
// Verify that the notification without State or NetMap triggers a second status call.
require.Eventually(t, func() bool {
return statusCalls.Load() >= 2
}, 2*time.Second, 10*time.Millisecond)
}
Generated
+4 -3
View File
@@ -3,15 +3,16 @@
"dank-qml-common": {
"flake": false,
"locked": {
"lastModified": 1788376015,
"narHash": "sha256-/tT8rTznLADndTwLBzdgzzgfJH1ZhYQibYo5v/F8U+U=",
"lastModified": 1791392812,
"narHash": "sha256-id6Y5XNO5K3KfXSOsEYVUwiq1ygYGNX5LeKSgHM3GR8=",
"owner": "AvengeMedia",
"repo": "dank-qml-common",
"rev": "26396ce432d6c71c3f5367438f96f4a8d667e160",
"rev": "1ae722232da7110d26cd2c355b6615e078e94197",
"type": "github"
},
"original": {
"owner": "AvengeMedia",
"ref": "stable-1.6",
"repo": "dank-qml-common",
"type": "github"
}
+2 -2
View File
@@ -8,7 +8,7 @@
flake = false;
};
dank-qml-common = {
url = "github:AvengeMedia/dank-qml-common";
url = "github:AvengeMedia/dank-qml-common/stable-1.6";
flake = false;
};
};
@@ -111,7 +111,7 @@
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-nswS/ygp0oYMXp6YHbbIxBha4xvzjKAxcAS5yOpbslg=";
vendorHash = "sha256-ejDi+TzstUtxtWpcl5yYw4UZuTnZraOsIdJuXPKxJ6s=";
subPackages = [ "cmd/dms" ];
+1 -1
View File
@@ -1 +1 @@
Heisenberg
Marble Tabby
+21 -5
View File
@@ -189,6 +189,26 @@ Singleton {
}
}
readonly property var _termIndexes: new WeakMap()
function _termIndex(table) {
const cached = _termIndexes.get(table);
if (cached)
return cached;
const index = {};
for (const c in table) {
const bucket = table[c];
if (!bucket)
continue;
for (const term in bucket) {
if (!index[term] && bucket[term])
index[term] = bucket[term];
}
}
_termIndexes.set(table, index);
return index;
}
function _lookup(table, term, context) {
if (!table)
return "";
@@ -196,11 +216,7 @@ Singleton {
return table[context][term];
if (table[term] && table[term][term])
return table[term][term];
for (const c in table) {
if (table[c] && table[c][term])
return table[c][term];
}
return "";
return _termIndex(table)[term] || "";
}
// isRealContext is consumed by translations/extract_translations.py only:
+10 -1
View File
@@ -3,6 +3,7 @@ import Quickshell
QtObject {
required property Singleton service
property var modules: null
property bool active: true
property bool _held: false
@@ -11,7 +12,15 @@ QtObject {
if (wanted === _held)
return;
_held = wanted;
service.refCount += wanted ? 1 : -1;
if (modules === null) {
service.refCount += wanted ? 1 : -1;
return;
}
if (wanted) {
service.addRef(modules);
return;
}
service.removeRef(modules);
}
onActiveChanged: sync(active)
+1 -1
View File
@@ -14,7 +14,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SessionData")
readonly property int sessionConfigVersion: 4
readonly property int sessionConfigVersion: 5
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
+37 -15
View File
@@ -1988,8 +1988,11 @@ Singleton {
function saveSettings() {
if (_loading || _parseError || !_hasLoaded)
return;
const json = JSON.stringify(Store.toJson(root), null, 2);
if (json === settingsFile.text())
return;
_selfWrite = true;
settingsFile.setText(JSON.stringify(Store.toJson(root), null, 2));
settingsFile.setText(json);
if (_isReadOnly)
_checkSettingsWritable();
}
@@ -3225,10 +3228,11 @@ Singleton {
if (!app && !desktop)
return -1;
return rules.findIndex(rule => {
if (!predicate(rule))
if (!predicate(rule) || (rule.matchType || "contains").toString().toLowerCase() !== "exact")
return false;
const pattern = (rule.pattern || "").toString().toLowerCase();
return pattern !== "" && (pattern === app || pattern === desktop);
const value = !rule.field || rule.field === "appName" ? app : rule.field === "desktopEntry" ? desktop : "";
return pattern !== "" && pattern === value;
});
}
@@ -3246,20 +3250,31 @@ Singleton {
saveSettings();
}
function _removeAppRule(appName, desktopEntry, predicate) {
function _hasNoAction(rule) {
return (rule.action || "default").toString().toLowerCase() === "default";
}
// Edits the first enabled matching rule and drops it once it no longer does anything.
function _updateAppRule(appName, desktopEntry, predicate, changes) {
var rules = JSON.parse(JSON.stringify(notificationRules || []));
const index = _appRuleIndex(rules, appName, desktopEntry, predicate);
const index = _appRuleIndex(rules, appName, desktopEntry, rule => rule.enabled !== false && predicate(rule));
if (index === -1)
return;
rules.splice(index, 1);
return false;
const rule = Object.assign(rules[index], changes);
if (_hasNoAction(rule) && (rule.urgency || "default").toString().toLowerCase() === "default" && !_isDndBypassRule(rule))
rules.splice(index, 1);
notificationRules = rules;
saveSettings();
return true;
}
function addMuteRuleForApp(appName, desktopEntry) {
_addAppRule(appName, desktopEntry, {
action: "mute"
});
if (!_updateAppRule(appName, desktopEntry, _hasNoAction, {
action: "mute"
}))
_addAppRule(appName, desktopEntry, {
action: "mute"
});
}
function isAppMuted(appName, desktopEntry) {
@@ -3267,7 +3282,9 @@ Singleton {
}
function removeMuteRuleForApp(appName, desktopEntry) {
_removeAppRule(appName, desktopEntry, _isMuteRule);
_updateAppRule(appName, desktopEntry, _isMuteRule, {
action: "default"
});
}
function isAppDndBypassed(appName, desktopEntry) {
@@ -3276,14 +3293,19 @@ Singleton {
function setAppDndBypass(appName, desktopEntry, enabled) {
if (!enabled) {
_removeAppRule(appName, desktopEntry, _isDndBypassRule);
_updateAppRule(appName, desktopEntry, _isDndBypassRule, {
bypassDnd: false
});
return;
}
if (isAppDndBypassed(appName, desktopEntry))
return;
_addAppRule(appName, desktopEntry, {
bypassDnd: true
});
if (!_updateAppRule(appName, desktopEntry, () => true, {
bypassDnd: true
}))
_addAppRule(appName, desktopEntry, {
bypassDnd: true
});
}
function updateNotificationRule(index, ruleData) {
+29
View File
@@ -0,0 +1,29 @@
.pragma library
// Qt Wayland sends scroll phases only for the finger axis source; pixelDelta also comes with wheels from Qt 6.12 (qtbase d19bd66b4)
const Kind = Object.freeze({
none: "none",
touchpad: "touchpad",
wheel: "wheel",
highResWheel: "highResWheel"
});
function isTouchpad(wheel) {
if (wheel.phase === Qt.NoScrollPhase)
return false;
return wheel.pixelDelta.x !== 0 || wheel.pixelDelta.y !== 0;
}
function verticalKind(wheel) {
if (isTouchpad(wheel))
return Kind.touchpad;
const angle = wheel.angleDelta.y;
if (angle === 0)
return Kind.none;
return angle % 120 === 0 ? Kind.wheel : Kind.highResWheel;
}
function anyAxisDelta(wheel) {
const point = isTouchpad(wheel) ? wheel.pixelDelta : wheel.angleDelta;
return point.x || point.y;
}
@@ -83,5 +83,29 @@ function migrateToVersion(obj, targetVersion, settingsData) {
session.configVersion = 4;
}
if (currentVersion < 5) {
console.info("SessionData: Migrating session to version 5");
console.info("SessionData: Tray keys now use a stable id (or id::instance) instead of id::tooltipTitle");
function stripTrayKeys(list) {
if (!list || list.constructor !== Array) return list;
var out = [];
for (var i = 0; i < list.length; i++) {
var raw = list[i];
if (typeof raw !== "string") continue;
var key = raw.includes("::") ? raw.split("::")[0] : raw;
var hasDup = false;
for (var j = 0; j < out.length; j++) {
if (out[j] === key) { hasDup = true; break; }
}
if (key && !hasDup) out.push(key);
}
return out;
}
if (session.hiddenTrayIds !== undefined) session.hiddenTrayIds = stripTrayKeys(session.hiddenTrayIds);
if (session.trayItemOrder !== undefined) session.trayItemOrder = stripTrayKeys(session.trayItemOrder);
session.configVersion = 5;
}
return session;
}
+2 -2
View File
@@ -398,8 +398,8 @@ Item {
IpcHandler {
function getFocusedScreenName() {
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
return Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
return Hyprland.focusedMonitor.name;
}
if (CompositorService.isNiri && NiriService.currentOutput) {
return NiriService.currentOutput;
@@ -28,7 +28,7 @@ Item {
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
readonly property bool canEditEntry: !!entry && !(entry.isImage ?? false)
readonly property bool canEditEntry: ClipboardService.canEditEntry(entry)
readonly property bool hasTextAlternative: !!entry && (entry.altMimeType ?? "") !== ""
readonly property string pinText: entry?.pinned || hasPinnedDuplicate ? I18n.tr("Unpin") : I18n.tr("Pin")
readonly property string pinIcon: entry?.pinned || hasPinnedDuplicate ? "keep_off" : "push_pin"
+88 -29
View File
@@ -14,6 +14,20 @@ Item {
property var entry: null
property string editorText: ""
property bool textLoaded: false
property bool loadFailed: false
Timer {
id: loadTimeoutTimer
interval: 5000
repeat: false
onTriggered: {
if (!root.textLoaded) {
root.loadFailed = true;
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
}
}
}
function releaseTextInputFocus() {
if (editField) {
@@ -68,35 +82,22 @@ Item {
}
}
function setEntry(newEntry) {
entry = newEntry;
editorText = newEntry?.text ?? newEntry?.preview ?? "";
if (editField) {
editField.text = editorText;
}
Qt.callLater(function () {
if (editField) {
editField.forceActiveFocus();
editField.cursorPosition = editField.text.length;
}
});
if (!newEntry || newEntry.isImage) {
return;
}
const requestedId = newEntry.id;
function fetchEntry(requestedId) {
loadFailed = false;
loadTimeoutTimer.restart();
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
}, function (response) {
if (response.error) {
return;
}
loadTimeoutTimer.stop();
if (!root.entry || root.entry.id !== requestedId) {
return;
}
if (!response.result) {
ClipboardService.refresh();
if (response.error || !response.result) {
root.loadFailed = true;
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
if (!response.result) {
ClipboardService.refresh();
}
return;
}
const result = response.result;
@@ -108,8 +109,12 @@ Item {
}
if (!fullText || fullText.length === 0) {
root.loadFailed = true;
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
return;
}
root.loadFailed = false;
root.textLoaded = true;
root.editorText = fullText;
if (editField) {
if (fullText.length > 50000) {
@@ -127,15 +132,47 @@ Item {
});
}
function setEntry(newEntry) {
entry = newEntry;
loadFailed = false;
const hasFullText = typeof newEntry?.text === "string";
textLoaded = !newEntry || !ClipboardService.canEditEntry(newEntry) || !(newEntry.id > 0) || hasFullText;
editorText = newEntry?.text ?? newEntry?.preview ?? "";
if (editField) {
editField.text = editorText;
}
Qt.callLater(function () {
if (editField) {
editField.forceActiveFocus();
editField.cursorPosition = editField.text.length;
}
});
if (hasFullText || !newEntry || !ClipboardService.canEditEntry(newEntry) || !(newEntry.id > 0)) {
loadTimeoutTimer.stop();
return;
}
fetchEntry(newEntry.id);
}
function saveEntry(action) {
const saveAction = action ?? "history";
DMSService.sendRequest("clipboard.copy", {
"text": root.editorText
}, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
const entryId = root.entry?.id ?? 0;
if (entryId > 0 && !root.textLoaded) {
if (root.loadFailed) {
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
root.fetchEntry(entryId);
} else {
ToastService.showWarning(I18n.tr("Loading full text, please wait...", "clipboard editor: save blocked while the entry's full text is still being fetched"));
}
return;
}
loadTimeoutTimer.stop();
const onComplete = function () {
if (saveAction === "history") {
modal.mode = "history";
Qt.callLater(function () {
@@ -154,6 +191,27 @@ Item {
if (saveAction === "paste") {
ClipboardService.pasteClipboard(modal.hide);
}
};
if (entryId > 0) {
ClipboardService.editEntry(root.entry, root.editorText, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
}
onComplete();
});
return;
}
DMSService.sendRequest("clipboard.copy", {
"text": root.editorText
}, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
}
onComplete();
});
}
@@ -292,6 +350,7 @@ Item {
width: cancelButton.width
height: buttonHeight
opacity: root.textLoaded ? 1 : 0.6
Rectangle {
anchors.fill: parent
@@ -181,11 +181,44 @@ FocusScope {
}
function editEntry(entry) {
if (!entry || entry.isImage) {
if (!ClipboardService.canEditEntry(entry)) {
return;
}
editorView.setEntry(entry);
mode = "editor";
const requestedId = entry.id;
if (!requestedId) {
editorView.setEntry(entry);
mode = "editor";
return;
}
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
}, function (response) {
if (response.error || !response.result) {
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
if (!response.result) {
ClipboardService.refresh();
}
return;
}
const result = response.result;
let fullText = "";
if (result?.data) {
fullText = editorView.decodeEntryData(result.data);
} else {
fullText = result?.preview ?? "";
}
if (!fullText && (entry.preview ?? "").length > 0) {
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
return;
}
const entryWithText = Object.assign({}, entry, { "text": fullText });
editorView.setEntry(entryWithText);
mode = "editor";
});
}
function resetState() {
@@ -37,7 +37,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && effectiveModalLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
function _dockOccupiesSide(side) {
if (!SettingsData.showDock)
@@ -83,6 +83,12 @@ Item {
property bool keepPopoutsOpen: false
property var customKeyboardFocus: null
property bool useOverlayLayer: false
readonly property var effectiveModalLayer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
property real frozenMotionOffsetX: 0
property real frozenMotionOffsetY: 0
readonly property alias contentWindow: contentWindow
@@ -140,7 +146,7 @@ Item {
"phase": phase,
"visible": presented,
"presented": presented,
"layer": root.useOverlayLayer ? "overlay" : "top",
"layer": root.effectiveModalLayer === WlrLayer.Overlay ? "overlay" : "top",
"barSide": resolvedConnectedBarSide,
"bodyRect": bodyRect,
"animationOffset": animationOffset,
@@ -397,12 +403,7 @@ Item {
}
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
WlrLayershell.layer: root.effectiveModalLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldHaveFocus, customKeyboardFocus)
@@ -821,24 +821,15 @@ Item {
}
if (isCategoryFiltered) {
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
if (categoryPluginId) {
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
for (var i = 0; i < pluginCategoryItems.length; i++) {
allItems.push(pluginCategoryItems[i]);
}
} else {
var rawApps = AppSearchService.getAppsInCategory(appCategory);
for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
// Also include core apps (DMS Settings etc.) that match this category
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
}
var rawApps = AppSearchService.getAppsInCategory(appCategory);
for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
}
} else {
var apps = searchApps(searchQuery);
@@ -85,7 +85,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && effectiveLauncherLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
function _dockOccupiesSide(side) {
@@ -246,7 +246,7 @@ Item {
"phase": phase,
"visible": presented,
"presented": presented,
"layer": root.usesOverlayLayer ? "overlay" : "top",
"layer": root.effectiveLauncherLayer === WlrLayer.Overlay ? "overlay" : "top",
"barSide": resolvedConnectedBarSide,
"bodyRect": bodyRect,
"animationOffset": animationOffset,
@@ -47,23 +47,20 @@ Rectangle {
Row {
id: leftContent
anchors.left: parent.left
anchors.leftMargin: Theme.spacingXS
anchors.leftMargin: hasAppCategories ? 0 : Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
// Whether the apps category picker should replace the plain title
readonly property bool hasAppCategories: root.section?.id === "apps" && (root.controller?.appCategories?.length ?? 0) > 0
DankIcon {
anchors.verticalCenter: parent.verticalCenter
// Hide section icon when the category chip already shows one
visible: !leftContent.hasAppCategories
name: root.section?.icon ?? "folder"
size: 16
color: Theme.surfaceVariantText
}
// Plain title — hidden when the category chip is shown
StyledText {
anchors.verticalCenter: parent.verticalCenter
visible: !leftContent.hasAppCategories
@@ -73,12 +70,10 @@ Rectangle {
color: Theme.surfaceVariantText
}
// Compact inline category chip — only visible on the apps section
Item {
id: categoryChip
visible: leftContent.hasAppCategories
anchors.verticalCenter: parent.verticalCenter
// Size to content with a fixed-min width so it doesn't jump around
width: chipRow.implicitWidth + Theme.spacingM * 2
height: 24
@@ -237,7 +232,6 @@ Rectangle {
}
}
// Size to list content, cap at 10 visible items
height: Math.min((root.controller?.appCategories?.length ?? 0) * 34, 10 * 34) + Theme.spacingS * 2 + 4
}
}
@@ -46,6 +46,8 @@ DankFloatingWindow {
}
function submitAndClose() {
if (!root.visible)
return;
if (aqueousWorkspace || CompositorService.isAqueous) {
if (!aqueousWorkspace || renaming)
return;
@@ -34,6 +34,10 @@ PluginComponent {
TailscaleService.connectTailscale(null);
}
onCcWidgetExpanded: {
TailscaleService.refresh(null);
}
ccDetailContent: Component {
Rectangle {
id: detailRoot
@@ -19,8 +19,11 @@ Rectangle {
signal settingsButtonClicked
signal headerTapped
Component.onCompleted: DgopService.addRef("system")
Component.onDestruction: DgopService.removeRef("system")
Ref {
service: DgopService
modules: "system"
active: root.visible && (root.Window.window?.visible ?? false)
}
implicitHeight: 70
radius: Theme.cornerRadius
@@ -19,7 +19,10 @@ Rectangle {
signal showPortSelector(var node)
Component.onCompleted: AudioService.refreshSinkPorts()
Component.onCompleted: {
AudioService.refreshSinkPorts();
AudioService.refreshCards();
}
implicitHeight: headerRow.height + (!hasVolumeSliderInCC ? volumeSlider.height : 0) + audioContent.height + Theme.spacingM
radius: Theme.cornerRadius
@@ -365,6 +368,78 @@ Rectangle {
}
}
}
Repeater {
model: ScriptModel {
values: AudioService.switchableOutputPorts
}
delegate: Rectangle {
id: portDelegate
required property var modelData
width: parent.width
height: 50
radius: Theme.cornerRadius
color: portMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
border.color: Theme.outlineLight
border.width: 1
DankRipple {
id: portRipple
cornerRadius: portDelegate.radius
}
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingS
DankIcon {
name: portDelegate.modelData.icon
size: Theme.iconSize - 4
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Theme.iconSize - parent.spacing
StyledText {
text: portDelegate.modelData.title
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
elide: Text.ElideRight
width: parent.width
wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: portDelegate.modelData.cardDescription
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft
}
}
}
MouseArea {
id: portMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: mouse => portRipple.trigger(mouse.x, mouse.y)
onClicked: AudioService.activateOutputPort(portDelegate.modelData, (ok, message) => ToastService.showToast(message, ok ? ToastService.levelInfo : ToastService.levelError))
}
}
}
Row {
id: playbackHeaderRow
anchors.left: parent.left
@@ -20,12 +20,10 @@ Rectangle {
border.color: Theme.outlineMedium
border.width: Theme.layerOutlineWidth
Component.onCompleted: {
DgopService.addRef(["diskmounts"]);
}
Component.onDestruction: {
DgopService.removeRef(["diskmounts"]);
Ref {
service: DgopService
modules: ["diskmounts"]
active: root.visible && (root.Window.window?.visible ?? false)
}
DankFlickable {
@@ -67,11 +67,10 @@ CompoundPill {
return Theme.surfaceText;
}
Component.onCompleted: {
DgopService.addRef(["diskmounts"]);
}
Component.onDestruction: {
DgopService.removeRef(["diskmounts"]);
Ref {
service: DgopService
modules: ["diskmounts"]
active: root.visible && (root.Window.window?.visible ?? false)
}
onToggled: {
@@ -117,10 +117,9 @@ Rectangle {
onClicked: root.clicked()
}
Component.onCompleted: {
DgopService.addRef(["diskmounts"]);
}
Component.onDestruction: {
DgopService.removeRef(["diskmounts"]);
Ref {
service: DgopService
modules: ["diskmounts"]
active: root.visible && (root.Window.window?.visible ?? false)
}
}
+4 -4
View File
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import "../../Common/WheelInput.js" as WheelInput
MouseArea {
id: root
@@ -93,16 +94,15 @@ MouseArea {
const deltaY = wheel.angleDelta.y;
const deltaX = wheel.angleDelta.x;
const isTouchpadY = wheel.pixelDelta && wheel.pixelDelta.y !== 0;
const isTouchpadX = wheel.pixelDelta && wheel.pixelDelta.x !== 0;
const isTouchpad = WheelInput.isTouchpad(wheel);
if (CompositorService.isNiri && xBehavior !== "none" && Math.abs(deltaX) > Math.abs(deltaY)) {
accumulateX(isTouchpadX, deltaX, xBehavior);
accumulateX(isTouchpad, deltaX, xBehavior);
return;
}
if (yBehavior === "none")
return;
accumulateY(isTouchpadY, deltaY, yBehavior);
accumulateY(isTouchpad, deltaY, yBehavior);
}
onWheel: wheel => processWheel(wheel)
@@ -39,6 +39,7 @@ Item {
applyIndexLayout();
}
updateContentExtent();
blurBarWindow?.refreshBlurRegion?.();
}
function updateContentExtent() {
+4 -4
View File
@@ -103,8 +103,8 @@ Item {
function triggerControlCenterOnFocusedScreen() {
let focusedScreenName = "";
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
focusedScreenName = Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
focusedScreenName = Hyprland.focusedMonitor.name;
} else if (CompositorService.isNiri && NiriService.currentOutput) {
focusedScreenName = NiriService.currentOutput;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
@@ -132,8 +132,8 @@ Item {
function triggerWallpaperBrowserOnFocusedScreen() {
let focusedScreenName = "";
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
focusedScreenName = Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
focusedScreenName = Hyprland.focusedMonitor.name;
} else if (CompositorService.isNiri && NiriService.currentOutput) {
focusedScreenName = NiriService.currentOutput;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
+27 -20
View File
@@ -164,6 +164,12 @@ Item {
_blurRebuildTimer.restart();
}
function refreshBlurRegion() {
if (!blurRegion)
return;
blurRegion.changed();
}
Timer {
id: _blurRebuildTimer
interval: 1
@@ -386,12 +392,26 @@ Item {
Connections {
target: topBarSlide
function onXChanged() {
if (barWindow.blurRegion)
barWindow.blurRegion.changed();
barWindow.refreshBlurRegion();
}
function onYChanged() {
if (barWindow.blurRegion)
barWindow.blurRegion.changed();
barWindow.refreshBlurRegion();
}
}
Connections {
target: barUnitInset
function onXChanged() {
barWindow.refreshBlurRegion();
}
function onYChanged() {
barWindow.refreshBlurRegion();
}
function onWidthChanged() {
barWindow.refreshBlurRegion();
}
function onHeightChanged() {
barWindow.refreshBlurRegion();
}
}
@@ -401,8 +421,7 @@ Item {
_blurRebuildTimer.restart();
}
function onWingChanged() {
if (barWindow.blurRegion)
barWindow.blurRegion.changed();
barWindow.refreshBlurRegion();
}
}
@@ -1149,20 +1168,8 @@ Item {
HoverHandler {
id: hoverPopoutHandler
enabled: (barConfig?.hoverPopouts ?? false) && !barWindow.clickThroughEnabled
property real lastGlobalX: 0
property real lastGlobalY: 0
onPointChanged: {
const gp = barUnitInset.mapToItem(null, point.position.x, point.position.y);
lastGlobalX = gp.x;
lastGlobalY = gp.y;
topBarContent.queueHoverPopout(gp.x, gp.y);
}
onHoveredChanged: {
topBarContent.updateHoverBarHovered(hovered);
}
onPointChanged: topBarContent.queueHoverFromItem(barUnitInset, point)
onHoveredChanged: topBarContent.updateHoverBarHovered(hovered)
}
}
}
+64 -11
View File
@@ -19,6 +19,8 @@ Item {
property var leftWidgetsModel
property var centerWidgetsModel
property var rightWidgetsModel
property var widgetOwner: null
property var hoverSections: null
property bool _animateFrameInsets: false
readonly property real innerPadding: barConfig?.innerPadding ?? 4
@@ -85,6 +87,34 @@ Item {
property alias vCenterSection: vCenterSection
property alias vRightSection: vRightSection
readonly property var _defaultHoverSections: barWindow.isVertical ? [
{
section: vLeftSection,
name: "left"
},
{
section: vCenterSection,
name: "center"
},
{
section: vRightSection,
name: "right"
}
] : [
{
section: hLeftSection,
name: "left"
},
{
section: hCenterSection,
name: "center"
},
{
section: hRightSection,
name: "right"
}
]
anchors.fill: parent
anchors.leftMargin: _leftMargin
anchors.rightMargin: _rightMargin
@@ -413,12 +443,8 @@ Item {
barContent: topBarContent
barWindow: topBarContent.barWindow
barConfig: topBarContent.barConfig
hLeftSection: topBarContent.hLeftSection
hCenterSection: topBarContent.hCenterSection
hRightSection: topBarContent.hRightSection
vLeftSection: topBarContent.vLeftSection
vCenterSection: topBarContent.vCenterSection
vRightSection: topBarContent.vRightSection
widgetOwner: topBarContent.widgetOwner
sections: topBarContent.hoverSections || topBarContent._defaultHoverSections
leftWidgetsModel: topBarContent.leftWidgetsModel
centerWidgetsModel: topBarContent.centerWidgetsModel
rightWidgetsModel: topBarContent.rightWidgetsModel
@@ -427,6 +453,28 @@ Item {
readonly property string activeHoverTrigger: hoverController.activeHoverTrigger
readonly property bool hoverPopoutsEnabled: hoverController.hoverPopoutsEnabled
function mapItemToScreen(item, x, y) {
if (!item || typeof item.mapToItem !== "function")
return null;
try {
const raw = item.mapToItem(null, x ?? 0, y ?? 0);
if (!raw)
return null;
return Qt.point(raw.x + (barWindow?.hostOriginX ?? 0), raw.y + (barWindow?.hostOriginY ?? 0));
} catch (e) {
return null;
}
}
function queueHoverFromItem(item, point) {
if (!point)
return;
const gp = mapItemToScreen(item, point.position.x, point.position.y);
if (!gp)
return;
queueHoverPopout(gp.x, gp.y);
}
function queueHoverPopout(gx, gy) {
hoverController.queueHoverPoint(gx, gy);
}
@@ -447,6 +495,10 @@ Item {
hoverController.updateBarHovered(hovered);
}
function invalidateHoverCandidateCache() {
hoverController.invalidateCandidateCache();
}
function resetHoverForBarGeometryChange() {
hoverController.resetForBarGeometryChange();
}
@@ -465,14 +517,13 @@ Item {
const centerSection = barWindow.isVertical ? vCenterSection : hCenterSection;
if (centerSection) {
if (barWindow.isVertical) {
const centerY = centerSection.height / 2;
return {
triggerPos: centerSection.mapToItem(null, 0, centerY),
triggerPos: mapItemToScreen(centerSection, 0, centerSection.height / 2),
triggerWidth: centerSection.height
};
}
return {
triggerPos: centerSection.mapToItem(null, 0, 0),
triggerPos: mapItemToScreen(centerSection, 0, 0),
triggerWidth: centerSection.width
};
}
@@ -480,7 +531,7 @@ Item {
const ref = opts.visualItem || widgetItem.visualContent || widgetItem;
const w = opts.triggerWidth !== undefined ? opts.triggerWidth : (widgetItem.visualWidth !== undefined ? widgetItem.visualWidth : widgetItem.width);
return {
triggerPos: ref.mapToItem(null, 0, 0),
triggerPos: mapItemToScreen(ref, 0, 0),
triggerWidth: w
};
}
@@ -987,7 +1038,9 @@ Item {
if (loader.item.setBarContext)
loader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0);
if (loader.item.setTriggerPosition) {
const globalPos = launcherButton.visualContent.mapToItem(null, 0, 0);
const globalPos = topBarContent.mapItemToScreen(launcherButton.visualContent, 0, 0);
if (!globalPos)
return false;
const currentScreen = barWindow.screen;
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barWindow.effectiveBarThickness, launcherButton.visualWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig);
loader.item.setTriggerPosition(pos.x, pos.y, pos.width, launcherButton.section, currentScreen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig);
@@ -10,12 +10,8 @@ Item {
required property var barContent
required property var barWindow
required property var barConfig
required property var hLeftSection
required property var hCenterSection
required property var hRightSection
required property var vLeftSection
required property var vCenterSection
required property var vRightSection
property var widgetOwner: null
property var sections: []
property var leftWidgetsModel
property var centerWidgetsModel
@@ -46,6 +42,7 @@ Item {
onLeftWidgetsModelChanged: invalidateCandidateCache()
onCenterWidgetsModelChanged: invalidateCandidateCache()
onRightWidgetsModelChanged: invalidateCandidateCache()
onSectionsChanged: invalidateCandidateCache()
onHoverPopoutsEnabledChanged: {
if (hoverPopoutsEnabled)
@@ -62,7 +59,7 @@ Item {
Component.onDestruction: _disconnectCandidateWatchers()
Connections {
target: root.barContent
target: root.widgetOwner || root.barContent
function onWidthChanged() {
root.invalidateCandidateCache();
@@ -227,41 +224,12 @@ Item {
}
function _getBarSections() {
if (barWindow.isVertical) {
return [
{
section: vLeftSection,
name: "left"
},
{
section: vCenterSection,
name: "center"
},
{
section: vRightSection,
name: "right"
}
];
}
return [
{
section: hLeftSection,
name: "left"
},
{
section: hCenterSection,
name: "center"
},
{
section: hRightSection,
name: "right"
}
];
return root.sections || [];
}
// The widget registry is keyed by (widgetId, screenName)
function _itemBelongsToThisBar(item) {
const owner = barContent;
const owner = root.widgetOwner || barContent;
if (!owner || !item)
return true;
let node = item;
@@ -489,17 +457,15 @@ Item {
}
function _globalItemBounds(item) {
try {
const topLeft = item.mapToItem(null, 0, 0);
return {
x: topLeft.x,
y: topLeft.y,
width: item.width,
height: item.height
};
} catch (e) {
const topLeft = barContent.mapItemToScreen(item, 0, 0);
if (!topLeft)
return null;
}
return {
x: topLeft.x,
y: topLeft.y,
width: item.width,
height: item.height
};
}
function _hitBoundsForWidget(widgetItem, wrapper) {
@@ -25,6 +25,13 @@ Item {
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
property alias widgetLayoutLoader: layoutLoader
function refreshBlur() {
blurBarWindow?.refreshBlurRegion?.();
}
onXChanged: refreshBlur()
onYChanged: refreshBlur()
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
@@ -52,6 +59,7 @@ Item {
visible: widgetLoader.active && widgetLoader.widgetEnabled
width: widgetLoader.item ? widgetLoader.item.width : 0
height: widgetLoader.item ? widgetLoader.item.height : 0
onXChanged: root.refreshBlur()
WidgetHost {
id: widgetLoader
anchors.verticalCenter: parent.verticalCenter
@@ -100,6 +108,7 @@ Item {
property var itemData: modelData
visible: widgetLoader.active && widgetLoader.widgetEnabled
height: widgetLoader.item ? widgetLoader.item.height : 0
onYChanged: root.refreshBlur()
WidgetHost {
id: widgetLoader
anchors.horizontalCenter: parent.horizontalCenter
@@ -25,6 +25,13 @@ Item {
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
property alias widgetLayoutLoader: layoutLoader
function refreshBlur() {
blurBarWindow?.refreshBlurRegion?.();
}
onXChanged: refreshBlur()
onYChanged: refreshBlur()
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
@@ -54,6 +61,7 @@ Item {
visible: widgetLoader.active && widgetLoader.widgetEnabled
width: widgetLoader.item ? widgetLoader.item.width : 0
height: widgetLoader.item ? widgetLoader.item.height : 0
onXChanged: root.refreshBlur()
WidgetHost {
id: widgetLoader
anchors.verticalCenter: parent.verticalCenter
@@ -102,6 +110,7 @@ Item {
property var itemData: modelData
visible: widgetLoader.active && widgetLoader.widgetEnabled
height: widgetLoader.item ? widgetLoader.item.height : 0
onYChanged: root.refreshBlur()
WidgetHost {
id: widgetLoader
anchors.horizontalCenter: parent.horizontalCenter
@@ -10,7 +10,7 @@ import qs.Widgets
BasePill {
id: root
readonly property string focusedScreenName: (CompositorService.isHyprland && typeof Hyprland !== "undefined" && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor ? (Hyprland.focusedWorkspace.monitor.name || "") : CompositorService.isNiri && typeof NiriService !== "undefined" && NiriService.currentOutput ? NiriService.currentOutput : "")
readonly property string focusedScreenName: (CompositorService.isHyprland && typeof Hyprland !== "undefined" && Hyprland.focusedMonitor ? (Hyprland.focusedMonitor.name || "") : CompositorService.isNiri && typeof NiriService !== "undefined" && NiriService.currentOutput ? NiriService.currentOutput : "")
readonly property string targetScreenName: parentScreen?.name || focusedScreenName
function resolveNotepadInstance() {
@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Wayland
@@ -39,13 +40,33 @@ BasePill {
return !hiddenTrayIds.includes(itemId.toLowerCase());
});
}
property var _trayKeyByItem: new Map()
function getTrayItemKey(item) {
const id = item?.id || "";
const tooltipTitle = item?.tooltipTitle || "";
if (!tooltipTitle || tooltipTitle === id) {
return id;
if (!id)
return "";
if (root._trayKeyByItem.has(item))
return root._trayKeyByItem.get(item);
const taken = new Set();
for (const live of root.allTrayItems) {
if (live !== item && (live?.id || "") === id && root._trayKeyByItem.has(live))
taken.add(root._trayKeyByItem.get(live));
}
return `${id}::${tooltipTitle}`;
let key = id;
for (let n = 1; taken.has(key); n++)
key = `${id}::${n}`;
root._trayKeyByItem.set(item, key);
return key;
}
function resolveOrderIndex(key, orderMap) {
if (orderMap.has(key))
return orderMap.get(key);
return -1;
}
function isTrayIdHidden(key) {
return SessionData.isHiddenTrayId(key);
}
function trayIconSourceFor(trayItem) {
@@ -149,6 +170,13 @@ BasePill {
}
}
Connections {
target: SystemTray
function onItemUnregistered(item) {
root._trayKeyByItem.delete(item);
}
}
function sortByPreferredOrder(items, trigger) {
void trigger;
const savedOrder = SessionData.trayItemOrder || [];
@@ -158,15 +186,17 @@ BasePill {
return [...items].sort((a, b) => {
const keyA = getTrayItemKey(a);
const keyB = getTrayItemKey(b);
const orderA = orderMap.has(keyA) ? orderMap.get(keyA) : 10000 + items.indexOf(a);
const orderB = orderMap.has(keyB) ? orderMap.get(keyB) : 10000 + items.indexOf(b);
return orderA - orderB;
const orderA = resolveOrderIndex(keyA, orderMap);
const orderB = resolveOrderIndex(keyB, orderMap);
const posA = orderA >= 0 ? orderA : 10000 + items.indexOf(a);
const posB = orderB >= 0 ? orderB : 10000 + items.indexOf(b);
return posA - posB;
});
}
readonly property var allSortedTrayItems: sortByPreferredOrder(allTrayItems, _trayOrderTrigger)
readonly property var allSortedTrayItemKeys: allSortedTrayItems.map(item => getTrayItemKey(item))
readonly property var visibleSortedTrayItems: allSortedTrayItems.filter(item => !SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
readonly property var visibleSortedTrayItems: allSortedTrayItems.filter(item => !root.isTrayIdHidden(root.getTrayItemKey(item)))
readonly property int automaticVisibleItemLimit: {
if (!root.useAutomaticOverflow)
return root.visibleSortedTrayItems.length;
@@ -189,7 +219,7 @@ BasePill {
item: item
}))
readonly property var autoOverflowBarItems: visibleSortedTrayItems.slice(automaticVisibleItemLimit)
readonly property var manualHiddenBarItems: allSortedTrayItems.filter(item => SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
readonly property var manualHiddenBarItems: allSortedTrayItems.filter(item => root.isTrayIdHidden(root.getTrayItemKey(item)))
readonly property var hiddenBarItemKeys: manualHiddenBarItems.concat(autoOverflowBarItems).map(item => root.getTrayItemKey(item))
readonly property var hiddenBarItems: allSortedTrayItems.filter(item => hiddenBarItemKeys.indexOf(root.getTrayItemKey(item)) !== -1)
readonly property string trayIconTintMode: {
@@ -297,7 +327,7 @@ BasePill {
}
function isManualHiddenTrayItem(item) {
return SessionData.isHiddenTrayId(getTrayItemKey(item));
return root.isTrayIdHidden(root.getTrayItemKey(item));
}
function isAutoOverflowTrayItem(item) {
@@ -1899,6 +1929,8 @@ BasePill {
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
elide: Text.ElideMiddle
wrapMode: Text.NoWrap
maximumLineCount: 1
width: parent.width - Theme.spacingS * 2 - (Theme.iconSizeSmall + Theme.spacingS)
}
@@ -2026,7 +2058,7 @@ BasePill {
}
}
Row {
RowLayout {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
@@ -2036,9 +2068,9 @@ BasePill {
visible: !menuEntry?.isSeparator
Rectangle {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
radius: menuEntry?.buttonType === 2 ? width / 2 : 2
border.width: 1
@@ -2064,9 +2096,9 @@ BasePill {
}
Item {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: (menuEntry?.icon ?? "") !== ""
Image {
@@ -2084,22 +2116,23 @@ BasePill {
font.pixelSize: Theme.fontSizeSmall
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
width: Math.max(150, parent.width - 64)
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.minimumWidth: 0
wrapMode: Text.NoWrap
}
Item {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: menuEntry?.hasChildren ?? false
DankIcon {
anchors.centerIn: parent
name: "chevron_right"
size: Theme.iconSizeSmall - 2
color: Theme.widgetTextColor
visible: menuEntry?.hasChildren ?? false
}
}
}
@@ -8,6 +8,7 @@ import Quickshell.WindowManager
import qs.Common
import qs.Services
import qs.Widgets
import "../../../Common/WheelInput.js" as WheelInput
Item {
id: root
@@ -26,44 +27,19 @@ Item {
readonly property bool isMango: CompositorService.isMango
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1"
readonly property real _leftMargin: {
if (isVertical)
return 0;
root.x;
if (!root.parent)
return 0;
const gap = root.mapToItem(null, 0, 0).x;
return (gap > 0 && gap < 30) ? gap + 5 : 0;
}
readonly property real _rightMargin: {
if (isVertical)
return 0;
root.x;
root.width;
if (!root.parent || !blurBarWindow)
return 0;
const gap = blurBarWindow.width - root.mapToItem(null, root.width, 0).x;
return (gap > 0 && gap < 30) ? gap + 5 : 0;
}
readonly property real _topMargin: {
if (!isVertical)
return axis?.edge === "top" ? crossEdgeExtension : 0;
root.y;
if (!root.parent)
return 0;
const gap = root.mapToItem(null, 0, 0).y;
return (gap > 0 && gap < 30) ? gap + 5 : 0;
}
readonly property real _bottomMargin: {
if (!isVertical)
return axis?.edge === "bottom" ? crossEdgeExtension : 0;
root.y;
root.height;
if (!root.parent || !blurBarWindow)
return 0;
const gap = blurBarWindow.height - root.mapToItem(null, 0, root.height).y;
return (gap > 0 && gap < 30) ? gap + 5 : 0;
}
property bool isFirst: false
property bool isLast: false
property real sectionSpacing: 0
property bool isLeftBarEdge: false
property bool isRightBarEdge: false
property bool isTopBarEdge: false
property bool isBottomBarEdge: false
readonly property real barEdgeExtension: 1000
readonly property real _leftMargin: isVertical ? 0 : (isLeftBarEdge && isFirst ? barEdgeExtension : (isFirst ? sectionSpacing : sectionSpacing / 2))
readonly property real _rightMargin: isVertical ? 0 : (isRightBarEdge && isLast ? barEdgeExtension : (isLast ? sectionSpacing : sectionSpacing / 2))
readonly property real _topMargin: isVertical ? (isTopBarEdge && isFirst ? barEdgeExtension : (isFirst ? sectionSpacing : sectionSpacing / 2)) : (axis?.edge === "top" ? crossEdgeExtension : 0)
readonly property real _bottomMargin: isVertical ? (isBottomBarEdge && isLast ? barEdgeExtension : (isLast ? sectionSpacing : sectionSpacing / 2)) : (axis?.edge === "bottom" ? crossEdgeExtension : 0)
property int _desktopEntriesUpdateTrigger: 0
readonly property var sortedToplevels: {
@@ -1017,7 +993,7 @@ Item {
return;
const delta = wheel.angleDelta.y;
const isTouchpad = wheel.pixelDelta && wheel.pixelDelta.y !== 0;
const isTouchpad = WheelInput.isTouchpad(wheel);
const reverse = SettingsData.reverseScrolling ? -1 : 1;
if (isTouchpad) {
@@ -194,7 +194,6 @@ Item {
id: mediaExpandedLoader
activity: "media"
height: Math.max(target.height, root.height)
active: root.mediaSurfaceActive && (root.expanded || root.expandedFade > 0)
asynchronous: false
sourceComponent: root.mediaExpandedComponent
@@ -153,6 +153,9 @@ Item {
property bool hasAdjacentLeftBar: false
property bool hasAdjacentRightBar: false
property var hyprlandOverviewLoader: root.hyprlandOverviewLoader
property var hostWindow: root.hostWindow
readonly property int hostOriginX: hostWindow?.hostOriginX ?? 0
readonly property int hostOriginY: hostWindow?.hostOriginY ?? 0
property var controlCenterButtonRef: null
property var clockButtonRef: null
property var systemUpdateButtonRef: null
@@ -166,6 +169,9 @@ Item {
function unregisterBlurWidget(item) {
root.unregisterBlurWidget(item);
}
function refreshBlurRegion() {
root.kickBlur();
}
}
property var _blurWidgetItems: []
@@ -266,9 +272,16 @@ Item {
return;
blurRebuildTimer.restart();
blurTrailTimer.restart();
componentProvider.invalidateHoverCandidateCache();
}
}
function onSatelliteRowMoved() {
kickBlur();
if (!root.motionRunning)
componentProvider.invalidateHoverCandidateCache();
}
function kickBlur() {
if (root.motionRunning)
return;
@@ -409,11 +422,26 @@ Item {
barWindow: satelliteBarWindow
rootWindow: root.hostWindow
barConfig: root.satelliteConfig
widgetOwner: root
hoverSections: [
{
section: leadingWidgetSection,
name: "left"
},
{
section: trailingWidgetSection,
name: "right"
}
]
leftWidgetsModel: emptyWidgetsModel
centerWidgetsModel: emptyWidgetsModel
rightWidgetsModel: emptyWidgetsModel
}
readonly property bool satelliteHoverTracking: root.visible && componentProvider.hoverPopoutsEnabled && !root.controller.inputSuspended
readonly property bool satellitesHovered: satelliteHoverTracking && (leadingEnvelopeHoverHandler.hovered || trailingEnvelopeHoverHandler.hovered || leadingWidgetHoverHandler.hovered || trailingWidgetHoverHandler.hovered)
onSatellitesHoveredChanged: componentProvider.updateHoverBarHovered(satellitesHovered)
Item {
id: leadingInputEnvelope
@@ -426,6 +454,12 @@ Item {
y: root.isVertical ? alongPos : crossPos
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
HoverHandler {
id: leadingEnvelopeHoverHandler
enabled: root.satelliteHoverTracking
onPointChanged: componentProvider.queueHoverFromItem(leadingInputEnvelope, point)
}
}
Item {
@@ -441,6 +475,12 @@ Item {
y: root.isVertical ? alongPos : crossPos
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
HoverHandler {
id: trailingEnvelopeHoverHandler
enabled: root.satelliteHoverTracking
onPointChanged: componentProvider.queueHoverFromItem(trailingInputEnvelope, point)
}
}
IslandSatelliteChrome {
@@ -497,8 +537,10 @@ Item {
y: root.isVertical ? alongPos : root.rowCross
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
onAlongPosChanged: root.kickBlur()
onAlongSizeChanged: root.kickBlur()
onXChanged: root.onSatelliteRowMoved()
onYChanged: root.onSatelliteRowMoved()
onWidthChanged: root.onSatelliteRowMoved()
onHeightChanged: root.onSatelliteRowMoved()
LeftSection {
id: leadingWidgetSection
@@ -518,6 +560,12 @@ Item {
crossEdgeExtension: root.crossEdgeExtension
edgeIsScreenEdge: root.edgeAligned
}
HoverHandler {
id: leadingWidgetHoverHandler
enabled: root.satelliteHoverTracking
onPointChanged: componentProvider.queueHoverFromItem(leadingInput, point)
}
}
Item {
@@ -531,8 +579,10 @@ Item {
y: root.isVertical ? alongPos : root.rowCross
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
onAlongPosChanged: root.kickBlur()
onAlongSizeChanged: root.kickBlur()
onXChanged: root.onSatelliteRowMoved()
onYChanged: root.onSatelliteRowMoved()
onWidthChanged: root.onSatelliteRowMoved()
onHeightChanged: root.onSatelliteRowMoved()
RightSection {
id: trailingWidgetSection
@@ -552,5 +602,11 @@ Item {
crossEdgeExtension: root.crossEdgeExtension
edgeIsScreenEdge: root.edgeAligned
}
HoverHandler {
id: trailingWidgetHoverHandler
enabled: root.satelliteHoverTracking
onPointChanged: componentProvider.queueHoverFromItem(trailingInput, point)
}
}
}
+1 -1
View File
@@ -351,7 +351,7 @@ Item {
onTriggered: dock.startupRevealDone = true
}
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(screenName) && SettingsData.dockOpenOnOverview
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(dock._dockScreenName) && SettingsData.dockOpenOnOverview
readonly property bool hoverOrActive: dockMouseArea.containsMouse || dockApps.requestDockShow || contextMenuOpen || revealSticky
onOverviewRevealChanged: {
@@ -43,7 +43,7 @@ Variants {
if (launcherOpen || _openedForCurrentHover)
return;
_openedForCurrentHover = true;
PopoutService.openDankLauncherV2(CompositorService.framePeerSurfacesUseOverlayForScreen(zoneLoader.modelData), true);
PopoutService.openDankLauncherV2(false, true);
}
screen: zoneLoader.modelData
+2 -1
View File
@@ -272,7 +272,8 @@ Scope {
property bool isActiveScreen: {
if (Quickshell.screens.length <= 1)
return true;
return SettingsData.getFilteredScreens("lockScreen").includes(screen);
const screens = SettingsData.getFilteredScreens("lockScreen");
return screens.length === 0 || screens.includes(screen);
}
color: isActiveScreen ? "transparent" : SettingsData.lockScreenInactiveColor
+11 -2
View File
@@ -82,6 +82,8 @@ Item {
return I18n.tr("Too many attempts - locked out");
if (root.pamState === "fail")
return I18n.tr("Incorrect password - try again");
if (pam.fprint.status === "disabled")
return "";
if (pam.fprintState === "error")
return I18n.tr("Fingerprint error");
if (pam.fprintState === "max")
@@ -861,10 +863,15 @@ Item {
name: {
if (pam.u2fPending)
return "passkey";
if (pam.fprint.tries >= SettingsData.maxFprintTries)
switch (pam.fprint.status) {
case "max":
case "stopped":
return "fingerprint_off";
if (pam.fprint.active || pam.fprint.retrying)
case "active":
return "fingerprint";
case "retrying":
return "hourglass_empty";
}
if (pam.u2f.active)
return "passkey";
return "lock";
@@ -1005,6 +1012,8 @@ Item {
return;
}
root.pam.retryFprintOnActivity();
if (root.unlocking) {
event.accepted = true;
return;
+13
View File
@@ -16,7 +16,20 @@ FocusScope {
signal passwordChanged(string newPassword)
signal unlockRequested
HoverHandler {
enabled: root.pam.awaitingActivityRetry
onPointChanged: root.pam.retryFprintOnActivity()
}
PointHandler {
onActiveChanged: {
if (active)
root.pam.retryFprintOnActivity();
}
}
Keys.onPressed: event => {
root.pam.retryFprintOnActivity();
if (videoScreensaver.active && videoScreensaver.inputEnabled) {
videoScreensaver.dismiss();
event.accepted = true;
+154 -52
View File
@@ -32,9 +32,8 @@ Scope {
function resetAuthFlows(): void {
passwd.abort();
fprint.abort();
fprint.stop();
u2f.abort();
errorRetry.running = false;
u2fErrorRetry.running = false;
u2fPendingTimeout.running = false;
passwdActiveTimeout.running = false;
@@ -58,9 +57,8 @@ Scope {
if (!root.unlockInProgress) {
root.unlockInProgress = true;
passwd.abort();
fprint.abort();
fprint.stop();
u2f.abort();
errorRetry.running = false;
u2fErrorRetry.running = false;
u2fPendingTimeout.running = false;
root.u2fPending = false;
@@ -217,7 +215,7 @@ Scope {
if (res === PamResult.Success) {
if (!root.unlockInProgress) {
fprint.abort();
fprint.stop();
root.proceedAfterPrimaryAuth();
}
return;
@@ -260,69 +258,147 @@ Scope {
PamContext {
id: fprint
property bool available: SettingsData.lockFingerprintReady
property int tries
property int errorTries
property bool retrying: false
readonly property bool available: SettingsData.lockFingerprintReady
property int tries: 0
property int errorTries: 0
property double attemptStartedAt: 0
property bool completedDuringStart: false
readonly property int maxErrorTries: 200
readonly property int daemonIdleExitMs: 30000
// sessionTimeoutMs mirrors `timeout=` in assets/pam/fprint
readonly property int sessionTimeoutMs: 90000
readonly property int verifyStartSlackMs: 10000
readonly property bool retrying: errorRetry.running
readonly property int retryInterval: errorRetry.interval
readonly property bool allowed: available && SettingsData.enableFprint && root.lockSecured && !root.fprintSuppressedByPrimaryPam && !root.unlockInProgress && !root.u2fPending && !SessionService.preparingForSleep && !IdleService.monitorsOff && tries < SettingsData.maxFprintTries
readonly property string status: {
if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam)
return "disabled";
if (tries >= SettingsData.maxFprintTries)
return "max";
if (!allowed)
return "paused";
if (active)
return "active";
if (retrying)
return "retrying";
return errorTries >= maxErrorTries ? "stopped" : "idle";
}
function stop(): void {
errorRetry.stop();
attemptStartedAt = 0;
abort();
}
// pam_fprintd reports an expired timeout as PAM_AUTHINFO_UNAVAIL, the same
// result a device fault gives, and its "Verification timed out" message is
// translated, so age is the signal we can rely on. Its timeout runs from the
// verify rather than from the claim, which is why the age is measured from
// the message announcing the verify: a slow claim then cannot make a fault
// look old enough, and a session that never reached a verify never qualifies.
// That message lands after pam_fprintd has already armed its deadline, so the
// age read here runs short of the real timeout and the slack makes it up. The
// bias is deliberate: a timeout misread as a fault parks the reader behind the
// backoff, while a fault misread as a timeout costs one extra retry.
function attemptSettled(): bool {
return attemptStartedAt > 0 && Date.now() - attemptStartedAt >= sessionTimeoutMs - verifyStartSlackMs;
}
function checkAvail(): void {
if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam) {
retrying = false;
abort();
if (!allowed) {
stop();
return;
}
if (!active && !retrying)
startAttempt();
}
function startAttempt(): void {
if (!allowed || errorTries >= maxErrorTries) {
stop();
return;
}
if (active)
return;
errorRetry.stop();
if (root.fprintState === "error")
root.fprintState = "";
attemptStartedAt = 0;
completedDuringStart = false;
if (start())
return;
if (!completedDuringStart)
scheduleErrorRetry();
}
tries = 0;
function scheduleErrorRetry(): void {
stop();
if (!allowed)
return;
errorTries++;
root.fprintState = "error";
if (errorTries === 1)
root.flashMsg();
fprintStateReset.restart();
if (errorTries < maxErrorTries)
errorRetry.restart();
}
function recover(): void {
if (!allowed)
return;
errorTries = 0;
retrying = false;
start();
if (!active)
startAttempt();
}
onAllowedChanged: {
if (!allowed)
stop();
else
Qt.callLater(checkAvail);
}
config: "fprint"
configDirectory: Quickshell.shellDir + "/assets/pam"
onPamMessage: {
if (attemptStartedAt === 0)
attemptStartedAt = Date.now();
}
onCompleted: res => {
if (!available)
completedDuringStart = true;
if (!allowed)
return;
switch (res) {
case PamResult.Success:
retrying = false;
if (!root.unlockInProgress) {
passwd.abort();
root.proceedAfterPrimaryAuth();
}
stop();
passwd.abort();
root.proceedAfterPrimaryAuth();
return;
case PamResult.Error:
errorTries++;
if (errorTries < 200) {
retrying = true;
abort();
errorRetry.restart();
if (attemptSettled()) {
stop();
errorTries = 0;
startAttempt();
return;
}
retrying = false;
abort();
scheduleErrorRetry();
return;
case PamResult.Failed:
case PamResult.MaxTries:
retrying = false;
stop();
tries++;
if (tries < SettingsData.maxFprintTries) {
root.fprintState = "fail";
start();
} else {
root.fprintState = "max";
abort();
}
break;
default:
errorTries = 0;
root.fprintState = tries < SettingsData.maxFprintTries ? "fail" : "max";
root.flashMsg();
fprintStateReset.restart();
startAttempt();
return;
}
root.flashMsg();
fprintStateReset.start();
}
}
@@ -426,20 +502,35 @@ Scope {
Timer {
id: errorRetry
interval: Math.min(1500 * Math.pow(2, Math.max(0, fprint.errorTries - 1)), 30000)
onTriggered: fprint.start()
// A device wedged by a suspend mid-verify only comes back when fprintd
// restarts, and fprintd only exits after its idle timeout with no client
// attached, so the second retry outlasts that instead of climbing to it.
interval: {
if (fprint.errorTries <= 1)
return 1500;
if (fprint.errorTries === 2)
return fprint.daemonIdleExitMs + 5000;
return 60000;
}
onTriggered: fprint.startAttempt()
}
readonly property bool awaitingActivityRetry: fprint.errorTries > 0 && !fprint.retrying && !fprint.active
function retryFprintOnActivity(): void {
if (root.awaitingActivityRetry)
fprint.recover();
}
Connections {
target: SessionService
// timers run on monotonic time, so suspend leaves the backoff remainder to burn after wake (#3171)
function onSessionResumed() {
if (!fprint.available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam)
return;
fprint.errorTries = 0;
if (errorRetry.running)
errorRetry.restart();
fprint.recover();
}
function onLidOpened() {
fprint.recover();
}
}
@@ -491,7 +582,10 @@ Scope {
id: fprintStateReset
interval: 4000
onTriggered: root.fprintState = ""
onTriggered: {
if (root.fprintState !== "max")
root.fprintState = "";
}
}
onLockSecuredChanged: {
@@ -508,6 +602,8 @@ Scope {
root.attemptInfoMessages = [];
root.lockoutAnnouncedThisAttempt = false;
root.resetAuthFlows();
fprint.tries = 0;
fprint.errorTries = 0;
if (!SettingsData.lockPamExternallyManaged && !dankshellConfigWatcher.loaded && !userPamWatcher.loaded)
ensureUserPamConfig();
// FileView cannot watch a path that does not exist yet; re-read so a
@@ -521,11 +617,17 @@ Scope {
target: SettingsData
function onEnableFprintChanged(): void {
fprint.checkAvail();
if (SettingsData.enableFprint)
fprint.recover();
else
fprint.checkAvail();
}
function onLockFingerprintReadyChanged(): void {
fprint.checkAvail();
if (SettingsData.lockFingerprintReady)
fprint.recover();
else
fprint.checkAvail();
}
function onEnableU2fChanged(): void {
@@ -698,7 +698,7 @@ Column {
color: Theme.surfaceTextSecondary
font.family: textArea.font.family
font.pixelSize: textArea.font.pixelSize
visible: textArea.text.length === 0
visible: textArea.text.length === 0 && !textArea.inputMethodComposing
anchors.left: textArea.left
anchors.top: textArea.top
anchors.leftMargin: textArea.leftPadding
+46 -10
View File
@@ -16,22 +16,43 @@ Item {
return Theme.surfaceText;
}
// The sysfs names DMS can write, in the order they are tried. Hardware that
// exposes none of them, a Lenovo IdeaPad on ideapad_laptop for instance,
// used to run the apply script to completion without writing anything, and
// sh exiting 0 was indistinguishable from a successful write.
readonly property string thresholdFileList: "charge_control_limit_max charge_stop_threshold charge_control_end_threshold"
readonly property int noThresholdExitCode: 2
property bool chargeLimitSupported: true
Process {
id: thresholdProbe
running: Qt.platform.os === "linux"
command: ["sh", "-c", "for bat in /sys/class/power_supply/BAT*; do for file in " + root.thresholdFileList + "; do [ -f \"$bat/$file\" ] && exit 0; done; done; exit 1"]
onExited: exitCode => root.chargeLimitSupported = exitCode === 0
}
Process {
id: applyLimitProcess
command: ["pkexec", "sh", "-c", "
found=0
for bat in /sys/class/power_supply/BAT*; do
if [ -f \"$bat/charge_control_limit_max\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_limit_max\"
elif [ -f \"$bat/charge_stop_threshold\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_stop_threshold\"
elif [ -f \"$bat/charge_control_end_threshold\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_end_threshold\"
fi
for file in " + root.thresholdFileList + "; do
if [ -f \"$bat/$file\" ]; then
found=1
echo " + SettingsData.batteryChargeLimit + " > \"$bat/$file\" || exit 1
break
fi
done
done
[ \"$found\" = 1 ] || exit " + root.noThresholdExitCode + "
"]
running: false
onExited: exitCode => {
if (exitCode !== 0) {
if (exitCode === root.noThresholdExitCode) {
root.chargeLimitSupported = false;
ToastService.showError(I18n.tr("Charge limit not supported on this hardware", "battery settings: toast title when sysfs has no charge threshold"), I18n.tr("No writable charge threshold file was found under /sys/class/power_supply.", "battery settings: why the charge limit could not be applied"));
} else if (exitCode !== 0) {
ToastService.showError(I18n.tr("Failed to apply charge limit to system"), I18n.tr("Process exited with code %1").arg(exitCode));
} else {
ToastService.showInfo(I18n.tr("Charge limit applied successfully"), I18n.tr("Limit set to %1%").arg(SettingsData.batteryChargeLimit));
@@ -214,9 +235,24 @@ done
onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue)
}
StyledText {
width: parent.width
// A machine without a battery has no charge threshold to
// write to either, but saying so there would be noise: the
// limit was never applicable in the first place.
visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && !root.chargeLimitSupported
text: I18n.tr("No writable charge threshold file was found under /sys/class/power_supply.", "battery settings: why the charge limit could not be applied")
wrapMode: Text.WordWrap
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeMedium
}
Row {
// charge_control_* live in Linux sysfs; no BSD equivalent
visible: Qt.platform.os === "linux"
// charge_control_* live in Linux sysfs; no BSD equivalent.
// Without a battery the apply button cannot do anything,
// and offering it anyway is what produced the false
// success this change is about.
visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && root.chargeLimitSupported
width: parent.width
height: applyButton.height
layoutDirection: I18n.isRtl ? Qt.LeftToRight : Qt.RightToLeft
@@ -1113,6 +1113,13 @@ Singleton {
}
}
Connections {
target: HyprlandService
function onMonitorLayoutChanged() {
root.outputs = root.buildOutputsMap();
}
}
Connections {
target: CompositorService
function onCompositorChanged() {
@@ -1806,6 +1813,13 @@ Singleton {
"transform": mapWlrTransform(output.transform)
}
};
const live = HyprlandService.liveMonitor(output.name);
if (!live)
continue;
map[output.name].logical.x = live.x;
map[output.name].logical.y = live.y;
map[output.name].logical.scale = live.scale || 1.0;
map[output.name].logical.transform = hyprlandToTransform(live.lastIpcObject?.transform ?? 0);
}
return map;
}
@@ -244,14 +244,13 @@ StyledRect {
width: parent.width
dropdownWidth: parent.width
visible: !scaleContainer.customMode
currentValue: scaleContainer.currentScale
options: scaleContainer.scaleOptionsData.labels
onValueChanged: value => {
if (value === I18n.tr("Custom...")) {
scaleContainer.customMode = true;
currentValue = scaleContainer.currentScale;
scaleInput.text = scaleContainer.currentScale;
scaleInput.forceActiveFocus();
scaleInput.selectAll();
scaleContainer.customMode = true;
customFocusTimer.restart();
return;
}
const mapped = scaleContainer.scaleOptionsData.valueByLabel[value];
@@ -259,6 +258,22 @@ StyledRect {
}
}
Binding {
target: scaleDropdown
property: "currentValue"
value: scaleContainer.currentScale
}
// the dropdown takes focus back when its menu finishes closing
Timer {
id: customFocusTimer
interval: Theme.shortDuration + 100
onTriggered: {
scaleInput.forceActiveFocus();
scaleInput.selectAll();
}
}
DankTextField {
id: scaleInput
width: parent.width
@@ -162,6 +162,7 @@ StyledRect {
spacing: Theme.spacingM
DankSpinner {
size: Theme.iconSize
running: root.isUpdating
anchors.verticalCenter: parent.verticalCenter
}

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