Compare commits

..
75 Commits
Author SHA1 Message Date
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
264 changed files with 48249 additions and 16764 deletions
+2 -2
View File
@@ -531,8 +531,8 @@ jobs:
repo:
- dgop
- dankgo
- dank-qml-common
- dankcalendar
# - dank-qml-common
# - dankcalendar
- danksearch
- dank-greeter
steps:
+4 -13
View File
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications
.PHONY: all build dev run clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
.PHONY: all build dev run clean lint-qml install install-bin install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
all: build
@@ -53,14 +53,6 @@ install-bin:
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Binary installed"
install-shell:
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
@test -e $(SHELL_DIR)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
@mkdir -p $(SHELL_INSTALL_DIR)
@cp -rL $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@echo "Shell files installed"
install-completions:
@echo "Installing shell completions..."
@mkdir -p $(DATA_DIR)/bash-completion/completions
@@ -98,7 +90,7 @@ install-desktop:
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed"
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
install: install-bin install-completions install-systemd install-icon install-desktop
@echo ""
@echo "Installation complete!"
@echo ""
@@ -111,7 +103,7 @@ uninstall-bin:
@echo "Binary removed"
uninstall-shell:
@echo "Removing shell files from $(SHELL_INSTALL_DIR)..."
@echo "Removing pre-1.6 shell files from $(SHELL_INSTALL_DIR)..."
@rm -rf $(SHELL_INSTALL_DIR)
@echo "Shell files removed"
@@ -159,7 +151,6 @@ help:
@echo "Install:"
@echo " install - Build and install everything (requires sudo)"
@echo " install-bin - Install only the binary"
@echo " install-shell - Install only shell files"
@echo " install-completions - Install only shell completions"
@echo " install-systemd - Install only systemd service"
@echo " install-icon - Install only icon"
@@ -168,7 +159,7 @@ help:
@echo "Uninstall:"
@echo " uninstall - Remove everything (requires sudo)"
@echo " uninstall-bin - Remove only the binary"
@echo " uninstall-shell - Remove only shell files"
@echo " uninstall-shell - Remove shell files left by pre-1.6 installs"
@echo " uninstall-completions - Remove only shell completions"
@echo " uninstall-systemd - Remove only systemd service"
@echo " uninstall-icon - Remove only icon"
+1 -1
View File
@@ -9,7 +9,7 @@ Type=dbus
BusName=org.freedesktop.Notifications
ExecStart=/usr/bin/dms run --session
ExecReload=/bin/kill -USR1 $MAINPID
LimitNOFILE=16384
LimitNOFILE=16384:infinity
Restart=on-failure
RestartForceExitStatus=TEMPFAIL
SuccessExitStatus=TEMPFAIL
+14 -10
View File
@@ -11,10 +11,11 @@ import (
)
var (
colorOutputFmt string
colorAutocopy bool
colorNotify bool
colorLowercase bool
colorOutputFmt string
colorAutocopy bool
colorNotify bool
colorLowercase bool
colorAllowMulti bool
)
var colorCmd = &cobra.Command{
@@ -54,7 +55,8 @@ Examples:
dms color pick --rgb # Output as RGB
dms color pick --json # Output all formats as JSON
dms color pick --hex -l # Output hex in lowercase
dms color pick -a # Auto-copy result to clipboard`,
dms color pick -a # Auto-copy result to clipboard
dms color pick --allow-multiple # Skip the one-picker-at-a-time guard`,
Run: runColorPick,
}
@@ -69,6 +71,7 @@ func init() {
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
colorPickCmd.Flags().BoolVar(&colorAllowMulti, "allow-multiple", false, "Open a picker even when another selection overlay is already open")
colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
@@ -90,11 +93,12 @@ func runColorPick(cmd *cobra.Command, args []string) {
}
config := colorpicker.Config{
Format: format,
CustomFormat: colorOutputFmt,
Lowercase: colorLowercase,
Autocopy: colorAutocopy,
Notify: colorNotify,
Format: format,
CustomFormat: colorOutputFmt,
Lowercase: colorLowercase,
Autocopy: colorAutocopy,
Notify: colorNotify,
AllowMultiple: colorAllowMulti,
}
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
+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"},
}
+94 -79
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -46,7 +47,7 @@ var keybindsSetCmd = &cobra.Command{
Short: "Set a keybind override",
Long: "Create or update a keybind override for the specified provider",
Args: cobra.ExactArgs(3),
Run: runKeybindsSet,
Run: runKeybindsEdit,
}
var keybindsRemoveCmd = &cobra.Command{
@@ -54,7 +55,7 @@ var keybindsRemoveCmd = &cobra.Command{
Short: "Remove a keybind",
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
Args: cobra.ExactArgs(2),
Run: runKeybindsRemove,
Run: runKeybindsEdit,
}
var keybindsResetCmd = &cobra.Command{
@@ -62,10 +63,14 @@ var keybindsResetCmd = &cobra.Command{
Short: "Reset a keybind override to its DMS default",
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
Args: cobra.ExactArgs(2),
Run: runKeybindsReset,
Run: runKeybindsEdit,
}
func init() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
command.Flags().Bool("json", false, "Return structured mutation results, including errors")
}
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
@@ -93,6 +98,9 @@ func initializeProviders() {
registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("")
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
log.Warnf("Failed to register Aqueous provider: %v", err)
}
if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err)
}
@@ -206,85 +214,92 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
printCheatSheet(provider)
}
func getWritableProvider(name string) keybinds.WritableProvider {
provider, err := keybinds.GetDefaultRegistry().Get(name)
func keybindEditFailure(err error) map[string]any {
code := "command_failed"
var helperError *providers.AqueousError
if errors.As(err, &helperError) {
code = helperError.Code
}
return map[string]any{"success": false, "code": code, "message": err.Error()}
}
func runKeybindsEdit(cmd *cobra.Command, args []string) {
result, err := editKeybind(cmd, args)
if err == nil {
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(result)
return
}
structured, _ := cmd.Flags().GetBool("json")
if !structured {
log.Fatalf("Failed to save keybind: %v", err)
return
}
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(keybindEditFailure(err))
os.Exit(1)
}
func editKeybind(cmd *cobra.Command, args []string) (map[string]any, error) {
provider, err := keybinds.GetDefaultRegistry().Get(args[0])
if err != nil {
log.Fatalf("Error: %v", err)
return nil, err
}
key := args[1]
if aqueous, ok := provider.(*providers.AqueousProvider); ok {
generation, _ := cmd.Flags().GetString("expected-generation")
edit := providers.AqueousBindEdit{Generation: generation, Key: key, Remove: cmd.Name() != "set"}
if !edit.Remove {
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
if cmd.Flags().Changed(flag) {
return nil, fmt.Errorf("aqueous does not support --%s in this provider", flag)
}
}
edit.Action = args[2]
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
}
result, err := aqueous.Edit(cmd.Context(), edit)
if err != nil {
return nil, err
}
return map[string]any{"success": true, "code": "applied", "generation": result.String("generation"), "key": key}, nil
}
writable, ok := provider.(keybinds.WritableProvider)
if !ok {
log.Fatalf("Provider %s does not support writing keybinds", name)
return nil, fmt.Errorf("provider %s does not support writing keybinds", args[0])
}
return writable
}
func runKeybindsSet(cmd *cobra.Command, args []string) {
providerName, key, action := args[0], args[1], args[2]
writable := getWritableProvider(providerName)
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
desc, _ := cmd.Flags().GetString("desc")
if err := writable.SetBind(key, action, desc, options); err != nil {
log.Fatalf("Error setting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"action": action,
"path": writable.GetOverridePath(),
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsRemove(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.RemoveBind(key); err != nil {
log.Fatalf("Error removing keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"removed": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsReset(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.ResetBind(key); err != nil {
log.Fatalf("Error resetting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"reset": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
result := map[string]any{"success": true, "key": key}
switch cmd.Name() {
case "set":
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
desc, _ := cmd.Flags().GetString("desc")
err = writable.SetBind(key, args[2], desc, options)
result["action"] = args[2]
result["path"] = writable.GetOverridePath()
case "remove":
err = writable.RemoveBind(key)
result["removed"] = true
case "reset":
err = writable.ResetBind(key)
result["reset"] = true
default:
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
}
return result, err
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"errors"
"fmt"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
)
func TestKeybindEditFailure(t *testing.T) {
for _, tc := range []struct {
err error
code string
}{
{&providers.AqueousError{Code: "external_change", Message: "changed"}, "external_change"},
{fmt.Errorf("helper: %w", &providers.AqueousError{Code: "read_only", Message: "denied"}), "read_only"},
{errors.New("transport failed"), "command_failed"},
} {
result := keybindEditFailure(tc.err)
if result["success"] != false || result["code"] != tc.code || result["message"] != tc.err.Error() {
t.Fatalf("unexpected failure result: %#v", result)
}
}
}
+13 -3
View File
@@ -20,6 +20,7 @@ import (
var (
ssOutputName string
ssSeat string
ssCursor string
ssFormat string
ssQuality int
@@ -33,6 +34,8 @@ var (
ssStdout bool
ssJSON bool
ssGeometry bool
ssAllowMulti bool
ssHUD string
)
type screenshotMetadata struct {
@@ -57,7 +60,7 @@ Modes:
full - Capture the focused output
all - Capture all outputs combined
output - Capture a specific output by name
window - Capture the focused window (Hyprland/Mango/niri)
window - Capture the focused window (Hyprland/Mango/niri/Aqueous)
last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
@@ -71,7 +74,7 @@ Examples:
dms screenshot full # Full screen of focused output
dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window (Hyprland)
dms screenshot window # Focused window
dms screenshot last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only
@@ -80,6 +83,7 @@ Examples:
dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON
dms screenshot --allow-multiple # Skip the one-selector-at-a-time guard
dms screenshot -g # Print selected region geometry (X,Y WxH) to stdout
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
@@ -120,7 +124,7 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{
Use: "window",
Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`,
Run: runScreenshotWindow,
}
@@ -163,6 +167,7 @@ var notifyActionCmd = &cobra.Command{
func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
@@ -176,6 +181,8 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
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)")
@@ -195,6 +202,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config := screenshot.DefaultConfig()
config.Mode = mode
config.OutputName = ssOutputName
config.Seat = ssSeat
if strings.EqualFold(ssCursor, "on") {
config.Cursor = screenshot.CursorOn
}
@@ -205,6 +213,8 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.Reset = ssReset
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)")
+2 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
@@ -96,10 +97,7 @@ func tryServerRequest(req models.Request) (*models.Response[any], bool) {
}
func getServerSocketPath() string {
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = os.TempDir()
}
runtimeDir := utils.RuntimeDir()
if sessionSock, ok := shellApp.SessionSocketPath(); ok {
return sessionSock
+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=
+12 -106
View File
@@ -2,28 +2,19 @@ package clipboard
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix"
)
const (
xkbKeymapFormatV1 = 1
keyStateReleased = 0
keyStatePressed = 1
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
shiftModMask = 1 << 0
ctrlModMask = 1 << 2
// evdev fallbacks for a standard pc105 map
fallbackCtrlKey = 29 // KEY_LEFTCTRL
fallbackShiftKey = 42 // KEY_LEFTSHIFT
fallbackVKey = 47 // KEY_V
)
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
@@ -50,25 +41,25 @@ func SendPasteKeystroke(withShift bool) (err error) {
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
var seatKeymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
if seatKeymap == nil {
seatKeymap = &e
}
})
s.display.Roundtrip()
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
if seatKeymap == nil || seatKeymap.Format != keymap.FormatXkbV1 {
return fmt.Errorf("no xkb keymap from seat")
}
defer unix.Close(keymap.Fd)
defer unix.Close(seatKeymap.Fd)
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
keymapText, err := keymap.Read(seatKeymap.Fd, seatKeymap.Size)
if err != nil {
return fmt.Errorf("read keymap: %w", err)
}
keys := resolveKeycodes(keymapText)
keys := keymap.Parse(keymapText)
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
if err != nil {
@@ -76,15 +67,15 @@ func SendPasteKeystroke(withShift bool) (err error) {
}
defer vk.Destroy()
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
if err := vk.Keymap(keymap.FormatXkbV1, seatKeymap.Fd, seatKeymap.Size); err != nil {
return fmt.Errorf("set keymap: %w", err)
}
mods := uint32(ctrlModMask)
held := []uint32{keys.ctrl}
held := []uint32{keys.Keycode("Control_L")}
if withShift {
mods |= shiftModMask
held = append(held, keys.shift)
held = append(held, keys.Keycode("Shift_L"))
}
t := uint32(0)
@@ -121,93 +112,8 @@ func SendPasteKeystroke(withShift bool) (err error) {
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
return fmt.Errorf("set modifiers: %w", err)
}
if err := press(keys.v); err != nil {
if err := press(keys.Keycode("v")); err != nil {
return fmt.Errorf("key press: %w", err)
}
return nil
}
func readKeymap(fd int, size uint32) (string, error) {
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
if err != nil {
return "", err
}
text := strings.TrimRight(string(data), "\x00")
return text, unix.Munmap(data)
}
type pasteKeycodes struct {
ctrl uint32
shift uint32
v uint32
}
var (
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
)
// xkbcommon may serialize keysyms as hex escapes instead of names
// (e.g. "0x76" for v, "0xffe3" for Control_L).
var keysymNames = map[uint32]string{
0x76: "v",
0xffe3: "Control_L",
0xffe1: "Shift_L",
}
func canonicalKeysym(sym string) string {
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
return sym
}
value, err := strconv.ParseUint(sym[2:], 16, 32)
if err != nil {
return sym
}
if name, ok := keysymNames[uint32(value)]; ok {
return name
}
return sym
}
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
// the seat keymap's first group, falling back to pc105 positions.
func resolveKeycodes(keymap string) pasteKeycodes {
codes := map[string]uint32{}
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
if code, err := strconv.Atoi(m[2]); err == nil {
codes[m[1]] = uint32(code)
}
}
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
want := map[string]*uint32{
"Control_L": &keys.ctrl,
"Shift_L": &keys.shift,
"v": &keys.v,
}
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
if group == nil {
continue
}
xkbCode, ok := codes[m[1]]
if !ok || xkbCode < 8 {
continue
}
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
target, wanted := want[level1]
if !wanted {
continue
}
*target = xkbCode - 8
delete(want, level1)
if len(want) == 0 {
break
}
}
return keys
}
+10 -12
View File
@@ -4,6 +4,7 @@ import (
"os"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix"
)
@@ -32,20 +33,20 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
var seatKeymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
if seatKeymap == nil {
seatKeymap = &e
}
})
s.display.Roundtrip()
if keymap == nil {
if seatKeymap == nil {
t.Fatal("no keymap event")
}
defer unix.Close(keymap.Fd)
defer unix.Close(seatKeymap.Fd)
text, err := readKeymap(keymap.Fd, keymap.Size)
text, err := keymap.Read(seatKeymap.Fd, seatKeymap.Size)
if err != nil {
t.Fatalf("read keymap: %v", err)
}
@@ -56,10 +57,7 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
}
}
keys := resolveKeycodes(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
}
keys := keymap.Parse(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", seatKeymap.Size,
keys.Keycode("Control_L"), keys.Keycode("Shift_L"), keys.Keycode("v"))
}
-80
View File
@@ -1,80 +0,0 @@
package clipboard
import "testing"
const qwertyKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
minimum = 8;
maximum = 708;
<ESC> = 9;
<AB04> = 55;
<LCTL> = 37;
<LFSH> = 50;
alias <AL01> = <AC01>;
indicator 1 = "Caps Lock";
};
xkb_types "(unnamed)" {
type "ALPHABETIC" {
modifiers = Shift+Lock;
map[Shift] = Level2;
level_name[Level1] = "Base";
};
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Escape ] };
key <AB04> { type= "ALPHABETIC", [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const hexKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB04> = 56;
<LCTL> = 38;
<LFSH> = 51;
};
xkb_symbols "(unnamed)" {
key <LCTL> { [ 0xffe3 ] };
key <LFSH> {
type= "PC_ALT_LEVEL2",
symbols[1]= [ 0xffe1, 0xfe08 ]
};
key <AB04> { [ 0x76, 0x56 ] };
};
};`
const dvorakKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB09> = 60;
<LCTL> = 37;
<LFSH> = 50;
};
xkb_symbols "(unnamed)" {
key <AB09> { [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
func TestResolveKeycodes(t *testing.T) {
tests := []struct {
name string
keymap string
want pasteKeycodes
}{
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveKeycodes(tt.keymap)
if got != tt.want {
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
}
})
}
}
+23 -6
View File
@@ -10,16 +10,19 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client"
)
type Config struct {
Format OutputFormat
CustomFormat string
Lowercase bool
Autocopy bool
Notify bool
Format OutputFormat
CustomFormat string
Lowercase bool
Autocopy bool
Notify bool
AllowMultiple bool
}
type Output struct {
@@ -62,6 +65,7 @@ type Picker struct {
seat *client.Seat
pointer *client.Pointer
keyboard *client.Keyboard
keymap *keymap.Keymap
layerShell *wlr_layer_shell.ZwlrLayerShellV1
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter
@@ -91,6 +95,14 @@ func New(config Config) *Picker {
}
func (p *Picker) Run() (*Color, error) {
if !p.config.AllowMultiple {
lock, err := utils.LockSelectionOverlay()
if err != nil {
return nil, err
}
defer lock.Release()
}
if err := p.connect(); err != nil {
return nil, fmt.Errorf("wayland connect: %w", err)
}
@@ -728,9 +740,14 @@ func (p *Picker) setupPointerHandlers() {
}
func (p *Picker) setupKeyboardHandlers() {
p.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
p.keymap = keymap.FromEvent(e)
})
p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
sym := p.keymap.Keysym(e.Key)
for _, ls := range p.surfaces {
ls.state.OnKey(e.Key, e.State)
ls.state.OnKey(sym, e.State)
}
})
}
+4 -4
View File
@@ -266,7 +266,7 @@ func (s *SurfaceState) OnPointerButton(button, state uint32) {
}
}
func (s *SurfaceState) OnKey(key, state uint32) {
func (s *SurfaceState) OnKey(sym string, state uint32) {
if state != 1 {
return
}
@@ -274,10 +274,10 @@ func (s *SurfaceState) OnKey(key, state uint32) {
s.mu.Lock()
defer s.mu.Unlock()
switch key {
case 1: // KEY_ESC
switch sym {
case "Escape":
s.cancelled = true
case 28: // KEY_ENTER
case "Return", "KP_Enter":
if s.readyForDisplay && s.screenBuf != nil {
s.colorPicked = true
}
+1 -1
View File
@@ -104,7 +104,7 @@ func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
for range goroutines / 3 {
wg.Go(func() {
for range iterations {
s.OnKey(1, 1)
s.OnKey("Escape", 1)
}
})
}
+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) {
+69
View File
@@ -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)
}
+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)
}
+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)
}
}
+178
View File
@@ -0,0 +1,178 @@
package providers
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
)
type AqueousProvider struct{}
func NewAqueousProvider() *AqueousProvider { return &AqueousProvider{} }
func (*AqueousProvider) Name() string { return "aqueous" }
func aqueousObjects(snapshot aqueousConfig, key string) []aqueousConfig {
values, _ := snapshot[key].([]any)
result := make([]aqueousConfig, 0, len(values))
for _, value := range values {
if object, ok := value.(map[string]any); ok {
result = append(result, aqueousConfig(object))
}
}
return result
}
func AqueousCheatSheet(snapshot aqueousConfig) (*keybinds.CheatSheet, error) {
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
return nil, err
}
sheet := &keybinds.CheatSheet{Title: "Aqueous", Provider: "aqueous", Generation: snapshot.String("generation"), ModKey: "Super", DMSBindsIncluded: true, Binds: map[string][]keybinds.Keybind{"Compositor": {}, "Custom": {}}}
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
values, _ := field["value"].([]any)
if len(values) == 0 {
values = []any{""}
}
for _, value := range values {
key, ok := value.(string)
if !ok {
return nil, errors.New("invalid keybinding list")
}
sheet.Binds["Compositor"] = append(sheet.Binds["Compositor"], keybinds.Keybind{Key: key, Action: field.String("id"), Description: field.String("label"), Source: "dms"})
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
sheet.Binds["Custom"] = append(sheet.Binds["Custom"], keybinds.Keybind{Key: binding.String("chord"), Action: "spawn " + binding.String("command"), Description: binding.String("command"), Source: "dms"})
}
seen := map[string]keybinds.Keybind{}
for _, category := range []string{"Compositor", "Custom"} {
for i, bind := range sheet.Binds[category] {
if bind.Key == "" {
continue
}
if previous, exists := seen[bind.Key]; exists {
sheet.Binds[category][i].Conflict = &previous
}
seen[bind.Key] = bind
}
}
return sheet, nil
}
func (*AqueousProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
snapshot, err := aqueousHelper(context.Background(), "snapshot", nil)
if err != nil {
return nil, err
}
return AqueousCheatSheet(snapshot)
}
type AqueousBindEdit struct {
Generation string
OriginalKey string
Key string
Action string
Remove bool
}
func AqueousBindRequest(snapshot aqueousConfig, edit AqueousBindEdit) (aqueousConfig, error) {
if edit.Generation == "" || edit.Generation != snapshot.String("generation") {
return nil, &AqueousError{Code: "external_change", Message: "reload and reconcile the retained keybind draft"}
}
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
return nil, err
}
if edit.Key == "" {
return nil, errors.New("missing key")
}
original := edit.OriginalKey
if original == "" {
original = edit.Key
}
originalCount := 0
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
values, _ := field["value"].([]any)
for _, value := range values {
if value == original {
originalCount++
}
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
if binding.String("chord") == original {
originalCount++
}
}
if originalCount > 1 {
return nil, &AqueousError{Code: "ambiguous_target", Message: "multiple bindings use the original shortcut"}
}
if originalCount == 0 && (edit.Remove || edit.OriginalKey != "") {
return nil, &AqueousError{Code: "target_removed", Message: "the original shortcut no longer exists"}
}
changes := []any{}
customChanges := []any{}
foundAction := false
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
old, _ := field["value"].([]any)
values := make([]any, 0, len(old)+1)
for _, value := range old {
if value == original {
continue
}
if !edit.Remove && value == edit.Key && field.String("id") != edit.Action {
return nil, fmt.Errorf("key conflict: %s", edit.Key)
}
values = append(values, value)
}
if field.String("id") == edit.Action && !edit.Remove {
foundAction = true
if !slices.Contains(values, any(edit.Key)) {
values = append(values, edit.Key)
}
}
if !slices.Equal(old, values) {
changes = append(changes, map[string]any{"id": field.String("id"), "value": values})
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
if binding.String("chord") == original {
customChanges = append(customChanges, map[string]any{"op": "delete", "id": binding.String("id")})
continue
}
if !edit.Remove && binding.String("chord") == edit.Key {
return nil, fmt.Errorf("key conflict: %s", edit.Key)
}
}
if !edit.Remove && !foundAction {
command, ok := strings.CutPrefix(edit.Action, "spawn ")
if !ok || strings.TrimSpace(command) == "" {
return nil, errors.New("unknown Aqueous action; custom commands use spawn")
}
customChanges = append(customChanges, map[string]any{"op": "add", "chord": edit.Key, "command": command})
}
return aqueousConfig{"expected_generation": edit.Generation, "changes": changes, "custom_keybind_changes": customChanges, "create_user_override": true}, nil
}
func (*AqueousProvider) Edit(ctx context.Context, edit AqueousBindEdit) (aqueousConfig, error) {
snapshot, err := aqueousHelper(ctx, "snapshot", nil)
if err != nil {
return nil, err
}
request, err := AqueousBindRequest(snapshot, edit)
if err != nil {
return nil, err
}
return aqueousHelper(ctx, "apply", request)
}
@@ -0,0 +1,115 @@
package providers
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func helperResult(ctx context.Context, verb string, input []byte) (aqueousConfig, error) {
var result aqueousConfig
args := []string{verb, "--shell", "dms"}
if input != nil {
args = append(args, "--request", "-")
}
err := utils.RunJSON(ctx, "aqueous-config", args, input, &result)
if err != nil || !result.Bool("ok") {
if result.String("code") != "" {
return result, &AqueousError{Code: result.String("code"), Message: result.String("message")}
}
if err != nil {
if verb == "apply" {
return result, &AqueousError{Code: "uncertain", Message: err.Error()}
}
return result, err
}
return result, errors.New("aqueous-config rejected the request")
}
return result, nil
}
type AqueousError struct {
Code string
Message string
}
func (e *AqueousError) Error() string { return e.Code + ": " + e.Message }
func requireAqueousCapabilities(result aqueousConfig, required ...string) error {
if result.Number("protocol") != 1 {
return errors.New("unsupported aqueous-config protocol")
}
values, ok := result["capabilities"].([]any)
if !ok {
return errors.New("unsupported aqueous-config: capability discovery required")
}
for _, capability := range required {
if !slices.Contains(values, any(capability)) {
return fmt.Errorf("unsupported aqueous-config capability: %s", capability)
}
}
return nil
}
func aqueousHelper(ctx context.Context, verb string, request aqueousConfig) (aqueousConfig, error) {
if verb == "snapshot" {
result, err := helperResult(ctx, verb, nil)
if err != nil {
return result, err
}
if err := requireAqueousCapabilities(result, "schema_fields", "shell_dms"); err != nil {
return result, err
}
if result.String("generation") == "" {
return result, errors.New("missing configuration generation")
}
return result, nil
}
if verb != "validate" && verb != "apply" {
return nil, errors.New("unsupported helper operation")
}
if request.String("expected_generation") == "" {
return nil, errors.New("missing expected_generation; reload configuration")
}
version, err := helperResult(ctx, "version", nil)
if err != nil {
return nil, err
}
required := []string{"keybinds", "validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"}
if err := requireAqueousCapabilities(version, required...); err != nil {
return nil, err
}
request["protocol"] = 1
if _, exists := request["backup_dir"]; !exists {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
request["backup_dir"] = filepath.Join(configDir, "DankMaterialShell", "aqueous-backups")
}
input, err := json.Marshal(request)
if err != nil {
return nil, err
}
validated, err := helperResult(ctx, "validate", input)
if err != nil || verb == "validate" {
return validated, err
}
return helperResult(ctx, "apply", input)
}
type aqueousConfig map[string]any
func (e aqueousConfig) String(key string) string { value, _ := e[key].(string); return value }
func (e aqueousConfig) Bool(key string) bool { value, _ := e[key].(bool); return value }
func (e aqueousConfig) Number(key string) float64 {
value, _ := e[key].(json.Number)
number, _ := value.Float64()
return number
}
@@ -0,0 +1,133 @@
package providers
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func aqueousFixture(t *testing.T) aqueousConfig {
t.Helper()
var snapshot aqueousConfig
err := utils.DecodeJSON([]byte(`{"ok":true,"protocol":1,"generation":"abc","capabilities":["keybinds"],"fields":[{"id":"spawn_terminal","category":"keybinds","type":"string_list","label":"Terminal","value":["Super+Return","Super+T"]},{"id":"close","category":"keybinds","type":"string_list","label":"Close","value":[]}],"custom_keybinds":[{"id":"custom:12","chord":"Super+R","command":"printf 'hello; $world'"}]}`), &snapshot)
if err != nil {
t.Fatal(err)
}
return snapshot
}
func TestAqueousCheatsheetIncludesUnboundAndCustom(t *testing.T) {
sheet, err := AqueousCheatSheet(aqueousFixture(t))
if err != nil {
t.Fatal(err)
}
if sheet.Generation != "abc" || len(sheet.Binds["Compositor"]) != 3 {
t.Fatal("lost generation or multiple bindings")
}
if sheet.Binds["Compositor"][2].Key != "" || sheet.Binds["Compositor"][2].Action != "close" {
t.Fatal("unbound action lost")
}
if sheet.Binds["Custom"][0].Action != "spawn printf 'hello; $world'" {
t.Fatal("custom command modified")
}
}
func TestAqueousBindingReplacementIsOneRequest(t *testing.T) {
snapshot := aqueousFixture(t)
request, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+Enter", Action: "spawn_terminal"})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(request["changes"])
if string(data) != `[{"id":"spawn_terminal","value":["Super+T","Super+Enter"]}]` {
t.Fatalf("wrong atomic replacement: %s", data)
}
if request["expected_generation"] != "abc" {
t.Fatal("generation replaced")
}
if !reflect.DeepEqual(snapshot, aqueousFixture(t)) {
t.Fatal("draft mutated snapshot")
}
}
func TestAqueousBindingConflictAndRemove(t *testing.T) {
for _, edit := range []AqueousBindEdit{
{Generation: "stale", Key: "Super+T", Remove: true},
{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+R", Action: "close"},
{Generation: "abc", Key: "Super+Q", Action: "invented_action"},
} {
if _, err := AqueousBindRequest(aqueousFixture(t), edit); err == nil {
t.Fatal("invalid edit accepted")
}
}
request, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: "abc", Key: "Super+R", Remove: true})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(request["custom_keybind_changes"])
if string(data) != `[{"id":"custom:12","op":"delete"}]` {
t.Fatalf("wrong removal: %s", data)
}
}
func TestAqueousGenerationConflictCode(t *testing.T) {
for _, generation := range []string{"", "stale"} {
_, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: generation, Key: "Super+R", Remove: true})
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "external_change" {
t.Fatalf("expected structured generation conflict, got %v", err)
}
}
}
func TestAqueousBindingRequiresUniqueOriginal(t *testing.T) {
for _, edit := range []AqueousBindEdit{
{Generation: "abc", Key: "Super+Missing", Remove: true},
{Generation: "abc", OriginalKey: "Super+Missing", Key: "Super+X", Action: "spawn_terminal"},
} {
_, err := AqueousBindRequest(aqueousFixture(t), edit)
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "target_removed" {
t.Fatalf("expected missing original, got %v", err)
}
}
snapshot := aqueousFixture(t)
snapshot["custom_keybinds"] = append(snapshot["custom_keybinds"].([]any), map[string]any{"id": "duplicate", "chord": "Super+T", "command": "echo duplicate"})
_, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", Key: "Super+T", Remove: true})
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "ambiguous_target" {
t.Fatalf("expected ambiguous original, got %v", err)
}
}
func TestAqueousHelperFailureCodes(t *testing.T) {
for _, tc := range []struct {
name string
script string
code string
}{
{"conflict", `printf '%s' '{"ok":false,"code":"external_change","message":"changed"}'; exit 1`, "external_change"},
{"missing_ack", "exit 0", "uncertain"},
{"failed_process", "exit 1", "uncertain"},
{"malformed_ack", "printf '{'", "uncertain"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "aqueous-config"), []byte("#!/bin/sh\n"+tc.script+"\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
_, err := helperResult(context.Background(), "apply", nil)
var failure *AqueousError
if !errors.As(err, &failure) || failure.Code != tc.code {
t.Fatalf("expected %s, got %v", tc.code, err)
}
})
}
}
+1
View File
@@ -30,6 +30,7 @@ type DMSBindsStatus struct {
}
type CheatSheet struct {
Generation string `json:"generation,omitempty"`
Title string `json:"title"`
Provider string `json:"provider"`
ModKey string `json:"modKey,omitempty"`
+62 -26
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)
@@ -500,23 +506,25 @@ func buildImportData(dank16JSON, image string) string {
return fmt.Sprintf(`{"dank16": %s, "image": %s}`, dank16JSON, path)
}
func userConfigSection(opts *Options) string {
if !opts.RunUserTemplates || opts.ConfigDir == "" {
return "[config]\n\n"
}
data, err := os.ReadFile(filepath.Join(opts.ConfigDir, "matugen", "config.toml"))
if err != nil {
return "[config]\n\n"
}
section := extractTOMLSection(string(data), "[config]", "[templates]")
if section == "" {
return "[config]\n\n"
}
return section + "\n"
}
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
wroteConfig := false
if opts.RunUserTemplates {
if data, err := os.ReadFile(userConfigPath); err == nil {
configSection := extractTOMLSection(string(data), "[config]", "[templates]")
if configSection != "" {
cfgFile.WriteString(configSection)
cfgFile.WriteString("\n")
wroteConfig = true
}
}
}
if !wroteConfig {
cfgFile.WriteString("[config]\n\n")
}
cfgFile.WriteString(userConfigSection(opts))
baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml")
if data, err := os.ReadFile(baseConfigPath); err == nil {
@@ -983,7 +991,32 @@ func runMatugenDryRun(opts *Options) (string, error) {
return execDryRun(opts, newFlags)
}
// matugen aborts on a config file it cannot deserialize, and without -c it
// reads the user's own ~/.config/matugen/config.toml, so a dry run gets a
// config of its own instead of inheriting whatever is there.
func writeDryRunConfig(opts *Options) (string, error) {
cfgFile, err := os.CreateTemp("", "matugen-dryrun-*.toml")
if err != nil {
return "", fmt.Errorf("failed to create dry-run config: %w", err)
}
defer cfgFile.Close()
section := userConfigSection(opts)
if idx := strings.Index(section, "\n[templates"); idx != -1 {
section = section[:idx+1]
}
cfgFile.WriteString(section)
cfgFile.WriteString("[templates]\n")
return cfgFile.Name(), nil
}
func execDryRun(opts *Options, flags matugenFlags) (string, error) {
cfgPath, err := writeDryRunConfig(opts)
if err != nil {
return "", err
}
defer os.Remove(cfgPath)
var baseArgs []string
switch opts.Kind {
case "hex":
@@ -991,7 +1024,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
default:
baseArgs = []string{opts.Kind, opts.Value}
}
baseArgs = append(baseArgs, "-m", string(opts.Mode), "-t", opts.MatugenType, "--json", "hex", "--dry-run")
baseArgs = append(baseArgs, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgPath, "--json", "hex", "--dry-run")
baseArgs = appendContrastArg(baseArgs, opts.Contrast)
if flags.isV4 {
baseArgs = append(baseArgs, sourceSelectionArgs(opts.SourceMode, flags.supportsPrefer)...)
@@ -1098,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)
+73
View File
@@ -1327,3 +1327,76 @@ func TestResolveSmartMode(t *testing.T) {
})
}
}
func writeUserMatugenConfig(t *testing.T, configDir, content string) {
t.Helper()
dir := filepath.Join(configDir, "matugen")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("failed to create matugen config dir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(content), 0o644); err != nil {
t.Fatalf("failed to write user config: %v", err)
}
}
func TestWriteDryRunConfigAlwaysDeclaresConfigAndTemplates(t *testing.T) {
tests := []struct {
name string
userConfig string
runUserTemplates bool
wantContains []string
wantNotContains []string
}{
{
name: "comment only user config",
userConfig: "# nothing here\n",
runUserTemplates: true,
wantNotContains: []string{"# nothing here"},
},
{
name: "user config section is carried over",
userConfig: "[config]\nfallback_color = \"#ff0000\"\n\n[templates.mine]\ninput_path = 'in'\noutput_path = 'out'\n",
runUserTemplates: true,
wantContains: []string{"fallback_color = \"#ff0000\""},
wantNotContains: []string{"[templates.mine]"},
},
{
name: "user templates disabled",
userConfig: "[config]\nfallback_color = \"#ff0000\"\n",
wantNotContains: []string{"fallback_color"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
configDir := filepath.Join(t.TempDir(), "config")
writeUserMatugenConfig(t, configDir, tc.userConfig)
opts := &Options{ConfigDir: configDir, RunUserTemplates: tc.runUserTemplates}
cfgPath, err := writeDryRunConfig(opts)
if err != nil {
t.Fatalf("writeDryRunConfig failed: %v", err)
}
defer os.Remove(cfgPath)
data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("failed to read dry-run config: %v", err)
}
content := string(data)
assert.Contains(t, content, "[config]")
assert.Contains(t, content, "[templates]")
for _, want := range tc.wantContains {
assert.Contains(t, content, want)
}
for _, unwanted := range tc.wantNotContains {
assert.NotContains(t, content, unwanted)
}
})
}
}
func TestUserConfigSectionWithoutConfigDir(t *testing.T) {
assert.Equal(t, "[config]\n\n", userConfigSection(&Options{RunUserTemplates: true}))
}
+3 -6
View File
@@ -8,10 +8,11 @@ import (
"fmt"
"io"
"net"
"os"
"path/filepath"
"time"
"unicode/utf16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -148,9 +149,5 @@ func readQString(r io.Reader) (string, error) {
// SocketPathForPID returns the instance IPC socket used by qs --pid.
func SocketPathForPID(pid int) string {
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
}
return filepath.Join(runtimeDir, "quickshell", "by-pid", fmt.Sprint(pid), "ipc.sock")
return filepath.Join(utils.RuntimeDir(), "quickshell", "by-pid", fmt.Sprint(pid), "ipc.sock")
}
+95
View File
@@ -0,0 +1,95 @@
package screenshot
import (
"context"
"errors"
"image"
"math"
)
func aqueousWindowGeometry(model aqueousSnapshotModel, seatName string) (*WindowGeometry, error) {
seat, err := model.Seat(seatName)
if err != nil {
return nil, err
}
if seat.FocusKind != "window" {
return nil, errors.New("no active window: seat focus is not a window")
}
window := model.Entities["window:"+seat.Window]
if window == nil || !window.Visible || window.Minimized || !window.CanActivate {
return nil, errors.New("no eligible active window")
}
output := model.Entities["output:"+window.Output]
if output == nil || !output.Enabled || !output.Powered {
return nil, errors.New("active window output is unavailable")
}
box, bounds := window.OuterGeometry, output.Bounds
if box == nil || bounds == nil || !box.valid() || !bounds.valid() || box.Width <= 0 || box.Height <= 0 || output.Scale <= 0 || math.IsInf(output.Scale, 0) || math.IsNaN(output.Scale) {
return nil, errors.New("invalid active window geometry")
}
return &WindowGeometry{X: int32(box.X), Y: int32(box.Y), Width: int32(box.Width), Height: int32(box.Height), Output: output.Name, Scale: output.Scale, OutputX: int32(bounds.X), OutputY: int32(bounds.Y)}, nil
}
func aqueousCropRect(geom *WindowGeometry, pixels image.Rectangle) image.Rectangle {
left := math.Floor((float64(geom.X) - float64(geom.OutputX)) * geom.Scale)
top := math.Floor((float64(geom.Y) - float64(geom.OutputY)) * geom.Scale)
right := math.Ceil((float64(geom.X) + float64(geom.Width) - float64(geom.OutputX)) * geom.Scale)
bottom := math.Ceil((float64(geom.Y) + float64(geom.Height) - float64(geom.OutputY)) * geom.Scale)
return image.Rect(int(left), int(top), int(right), int(bottom)).Intersect(pixels)
}
func aqueousFocusedOutput(seatName string) (string, error) {
model, err := aqueousSnapshot(context.Background())
if err != nil {
return "", err
}
seat, err := model.Seat(seatName)
if err != nil {
return "", err
}
output := model.Entities["output:"+seat.Output]
if output == nil || !output.Enabled || !output.Powered {
return "", errors.New("selected output is unavailable")
}
return output.Name, nil
}
func (s *Screenshoter) captureAqueousWindow() (*CaptureResult, error) {
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
geom, err := aqueousWindowGeometry(model, s.config.Seat)
if err != nil {
return nil, err
}
region := Region{X: geom.X, Y: geom.Y, Width: geom.Width, Height: geom.Height, Output: geom.Output}
if s.config.Geometry {
return geometryResult(region), nil
}
output := s.findOutputByName(geom.Output)
if output == nil {
return nil, errors.New("active window output disappeared")
}
result, err := s.captureWholeOutput(output)
if err != nil {
return nil, err
}
defer result.Buffer.Close()
rect := aqueousCropRect(geom, image.Rect(0, 0, result.Buffer.Width, result.Buffer.Height))
if rect.Empty() {
return nil, errors.New("active window is outside the output")
}
bpp := PixelFormat(result.Format).BytesPerPixel()
cropped, err := CreateShmBuffer(rect.Dx(), rect.Dy(), rect.Dx()*bpp)
if err != nil {
return nil, err
}
src, dst := result.Buffer.Data(), cropped.Data()
for y := 0; y < rect.Dy(); y++ {
start := (rect.Min.Y+y)*result.Buffer.Stride + rect.Min.X*bpp
copy(dst[y*cropped.Stride:y*cropped.Stride+rect.Dx()*bpp], src[start:start+rect.Dx()*bpp])
}
cropped.Format = PixelFormat(result.Format)
return &CaptureResult{Buffer: cropped, Region: region, Format: result.Format, Scale: geom.Scale, CICP: result.CICP}, nil
}
@@ -0,0 +1,119 @@
package screenshot
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"regexp"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func aqueousSnapshot(ctx context.Context) (aqueousSnapshotModel, error) {
executable, err := os.Executable()
if err != nil {
return aqueousSnapshotModel{}, err
}
var data json.RawMessage
if err := utils.RunJSON(ctx, executable, []string{"ipc", "call", "aqueous", "snapshot", os.Getenv("AQUEOUS_SOCKET")}, nil, &data); err != nil {
return aqueousSnapshotModel{}, fmt.Errorf("get Aqueous state from DMS; a shell must be running in this session: %w", err)
}
return parseAqueousSnapshot(data)
}
type aqueousBox struct {
X, Y, Width, Height int64
}
func (b aqueousBox) valid() bool {
for _, value := range []int64{b.X, b.Y, b.Width, b.Height} {
if value < math.MinInt32 || value > math.MaxInt32 {
return false
}
}
return true
}
type aqueousCaptureEntity struct {
Kind string `json:"kind"`
ID string `json:"id"`
Name string `json:"name"`
Output string `json:"output"`
Window string `json:"window"`
FocusKind string `json:"focus_kind"`
Visible bool `json:"visible"`
Minimized bool `json:"minimized"`
CanActivate bool `json:"can_activate"`
Enabled bool `json:"enabled"`
Powered bool `json:"powered"`
Scale float64 `json:"scale"`
Bounds *aqueousBox `json:"bounds"`
OuterGeometry *aqueousBox `json:"outer_geometry"`
}
type aqueousSnapshotModel struct {
Entities map[string]*aqueousCaptureEntity
}
func (m aqueousSnapshotModel) Seat(name string) (*aqueousCaptureEntity, error) {
if name != "" {
if seat := m.Entities["seat:"+name]; seat != nil {
return seat, nil
}
return nil, errors.New("not_found: seat")
}
var seat *aqueousCaptureEntity
for _, entity := range m.Entities {
if entity.Kind != "seat" {
continue
}
if seat != nil {
return nil, errors.New("ambiguous_seat")
}
seat = entity
}
if seat == nil {
return nil, errors.New("unavailable: no seat")
}
return seat, nil
}
func parseAqueousSnapshot(data []byte) (aqueousSnapshotModel, error) {
var batch struct {
Error string `json:"error"`
Schema int `json:"schema"`
Session string `json:"session"`
Sequence string `json:"sequence"`
BaseSequence json.RawMessage `json:"base_sequence"`
Type string `json:"type"`
Upsert []aqueousCaptureEntity `json:"upsert"`
Removed []string `json:"removed"`
}
var model aqueousSnapshotModel
if err := utils.DecodeJSON(data, &batch); err != nil {
return model, err
}
if batch.Error != "" {
return model, errors.New("aqueous state is unavailable in the running DMS shell")
}
if batch.Schema != 1 || batch.Type != "snapshot" || !bytes.Equal(batch.BaseSequence, []byte("null")) || batch.Upsert == nil || batch.Removed == nil || len(batch.Removed) != 0 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(batch.Session) || !regexp.MustCompile(`^[0-9]+$`).MatchString(batch.Sequence) {
return model, errors.New("invalid Aqueous shell snapshot")
}
model.Entities = make(map[string]*aqueousCaptureEntity, len(batch.Upsert))
for i := range batch.Upsert {
entity := &batch.Upsert[i]
key := entity.Kind + ":" + entity.ID
if entity.ID == "" || entity.Kind == "" || model.Entities[key] != nil {
return aqueousSnapshotModel{}, errors.New("invalid or duplicate shell entity")
}
model.Entities[key] = entity
}
if model.Entities["session:session"] == nil {
return aqueousSnapshotModel{}, errors.New("missing session entity")
}
return model, nil
}
+79
View File
@@ -0,0 +1,79 @@
package screenshot
import (
"bytes"
"image"
"os"
"testing"
)
func TestAqueousCropRoundsOutwardAndClips(t *testing.T) {
for _, test := range []struct {
geometry WindowGeometry
pixels image.Rectangle
want image.Rectangle
}{
{WindowGeometry{X: -1919, Y: 1, Width: 101, Height: 51, OutputX: -1920, Scale: 1.25}, image.Rect(0, 0, 2400, 1350), image.Rect(1, 1, 128, 65)},
{WindowGeometry{X: -20, Y: -10, Width: 100, Height: 60, Scale: 1.5}, image.Rect(0, 0, 800, 600), image.Rect(0, 0, 120, 75)},
{WindowGeometry{X: 90, Y: 80, Width: 100, Height: 100, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rect(90, 80, 100, 100)},
{WindowGeometry{X: 200, Y: 200, Width: 20, Height: 20, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rectangle{}},
} {
if got := aqueousCropRect(&test.geometry, test.pixels); got != test.want {
t.Fatalf("got %v want %v", got, test.want)
}
}
}
func TestAqueousGeometryUsesSeatAndOuterBounds(t *testing.T) {
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
if err != nil {
t.Fatal(err)
}
model, err := parseAqueousSnapshot(data)
if err != nil {
t.Fatal(err)
}
geometry, err := aqueousWindowGeometry(model, "")
if err != nil {
t.Fatal(err)
}
seat, _ := model.Seat("")
window := model.Entities["window:"+seat.Window]
if geometry.Width != int32(window.OuterGeometry.Width) || geometry.X != int32(window.OuterGeometry.X) {
t.Fatal("wrong bounds")
}
seat.FocusKind = "layer_surface"
if _, err := aqueousWindowGeometry(model, ""); err == nil {
t.Fatal("layer focus captured")
}
seat.FocusKind = "window"
model.Entities["seat:second"] = &aqueousCaptureEntity{Kind: "seat", ID: "second"}
if _, err := aqueousWindowGeometry(model, ""); err == nil {
t.Fatal("ambiguous seat captured")
}
if _, err := aqueousWindowGeometry(model, seat.ID); err != nil {
t.Fatal(err)
}
delete(model.Entities, "window:"+seat.Window)
if _, err := aqueousWindowGeometry(model, seat.ID); err == nil {
t.Fatal("removed window captured")
}
}
func TestAqueousSnapshotRejectsInvalidInput(t *testing.T) {
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
if err != nil {
t.Fatal(err)
}
for _, input := range [][]byte{
[]byte(`{}`),
bytes.Replace(data, []byte(`"schema": 1`), []byte(`"schema": 2`), 1),
bytes.Replace(data, []byte(`"type": "snapshot"`), []byte(`"type": "delta"`), 1),
append(data, []byte(`{}`)...),
{0xff},
} {
if _, err := parseAqueousSnapshot(input); err == nil {
t.Fatal("invalid snapshot accepted")
}
}
}
+13 -1
View File
@@ -1,6 +1,7 @@
package screenshot
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -21,6 +22,7 @@ const (
CompositorScroll
CompositorMiracle
CompositorMango
CompositorAqueous
)
var detectedCompositor Compositor = -1
@@ -35,6 +37,7 @@ func DetectCompositor() Compositor {
needsStat bool
compositor Compositor
}{
{os.Getenv("AQUEOUS_SOCKET"), true, CompositorAqueous},
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
@@ -78,8 +81,14 @@ func GetActiveWindow() (*WindowGeometry, error) {
return getHyprlandActiveWindow()
case CompositorMango:
return getMangoActiveWindow()
case CompositorAqueous:
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
return aqueousWindowGeometry(model, "")
default:
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
return nil, fmt.Errorf("window capture requires Hyprland, Mango, niri, or Aqueous")
}
}
@@ -355,6 +364,9 @@ func getNiriFocusedMonitor() string {
func GetFocusedMonitor() string {
switch DetectCompositor() {
case CompositorAqueous:
name, _ := aqueousFocusedOutput("")
return name
case CompositorHyprland:
return getHyprlandFocusedMonitor()
case CompositorSway:
+3 -5
View File
@@ -10,6 +10,8 @@ import (
"os"
"path/filepath"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const niriScreenshotTimeout = 5 * time.Second
@@ -24,11 +26,7 @@ func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
return nil, fmt.Errorf("NIRI_SOCKET not set")
}
dir := os.Getenv("XDG_RUNTIME_DIR")
if dir == "" {
dir = os.TempDir()
}
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
path := filepath.Join(utils.RuntimeDir(), fmt.Sprintf("dms-window-%d.png", os.Getpid()))
events, err := subscribeNiriEvents(socket)
if err != nil {
+23 -5
View File
@@ -12,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_cursor_shape"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client"
)
@@ -84,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 {
@@ -107,7 +109,9 @@ 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
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter
@@ -136,6 +140,8 @@ type RegionSelector struct {
selection SelectionState
pointerX float64
pointerY float64
hasTouchPoint bool
touchPointId int32
preSelect Region
showCapturedCursor bool
shiftHeld bool
@@ -967,7 +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)
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat)
effectiveScale := 1.0
if os.output != nil {
effectiveScale = os.output.effectiveScale()
}
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
@@ -979,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
}
}
@@ -1023,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
@@ -1097,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()
}
+190 -38
View File
@@ -3,6 +3,7 @@ package screenshot
import (
"math"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client"
)
@@ -18,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
@@ -145,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
@@ -432,52 +576,60 @@ func (r *RegionSelector) setupKeyboardHandlers() {
}
})
r.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
r.keymap = keymap.FromEvent(e)
})
r.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
switch e.Key {
case 29, 97: // Ctrl left/right
ctrl := e.State != 0
if ctrl != r.ctrlHeld {
r.ctrlHeld = ctrl
r.refreshCursor()
if r.selection.hasSelection {
for _, os := range r.surfaces {
r.redrawSurface(os)
}
r.handleKey(r.keymap.Keysym(e.Key), e.State)
})
}
func (r *RegionSelector) handleKey(sym string, state uint32) {
held := state != 0
switch sym {
case "Control_L", "Control_R":
if held != r.ctrlHeld {
r.ctrlHeld = held
r.refreshCursor()
if r.selection.hasSelection {
for _, os := range r.surfaces {
r.redrawSurface(os)
}
}
case 56, 100: // Alt left/right
r.altHeld = e.State != 0
}
if e.State != 1 {
return
}
case "Alt_L", "Alt_R":
r.altHeld = held
}
if state != 1 {
return
}
if r.phase == phaseScroll {
switch e.Key {
case 1:
r.cancelled = true
r.running = false
case 28, 96:
r.finishScroll()
}
return
}
switch e.Key {
case 1:
if r.phase == phaseScroll {
switch sym {
case "Escape":
r.cancelled = true
r.running = false
case 25:
r.showCapturedCursor = !r.showCapturedCursor
for _, os := range r.surfaces {
r.redrawSurface(os)
}
case 28, 57, 96:
if r.selection.hasSelection {
r.finishSelection()
}
case "Return", "KP_Enter":
r.finishScroll()
}
})
return
}
switch sym {
case "Escape":
r.cancelled = true
r.running = false
case "p":
r.showCapturedCursor = !r.showCapturedCursor
for _, os := range r.surfaces {
r.redrawSurface(os)
}
case "Return", "space", "KP_Enter":
if r.selection.hasSelection {
r.finishSelection()
}
}
}
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
@@ -0,0 +1,48 @@
package screenshot
import "testing"
func TestHandleKeyEscapeCancels(t *testing.T) {
for _, phase := range []selectorPhase{phaseSelect, phaseScroll} {
r := &RegionSelector{running: true, phase: phase}
r.handleKey("Escape", 1)
if !r.cancelled || r.running {
t.Errorf("phase %v: cancelled=%v running=%v after Escape", phase, r.cancelled, r.running)
}
}
}
func TestHandleKeyIgnoresReleaseAndCapsLock(t *testing.T) {
r := &RegionSelector{running: true}
r.handleKey("Escape", 0)
r.handleKey("Caps_Lock", 1)
if r.cancelled || !r.running {
t.Errorf("cancelled=%v running=%v, want untouched", r.cancelled, r.running)
}
}
func TestHandleKeyTogglesCapturedCursor(t *testing.T) {
r := &RegionSelector{running: true, showCapturedCursor: true}
r.handleKey("p", 1)
if r.showCapturedCursor {
t.Error("p did not toggle captured cursor off")
}
r.handleKey("p", 1)
if !r.showCapturedCursor {
t.Error("p did not toggle captured cursor back on")
}
}
func TestHandleKeyTracksModifiers(t *testing.T) {
r := &RegionSelector{running: true}
r.handleKey("Control_R", 1)
r.handleKey("Alt_L", 1)
if !r.ctrlHeld || !r.altHeld {
t.Errorf("ctrl=%v alt=%v after press, want both held", r.ctrlHeld, r.altHeld)
}
r.handleKey("Control_R", 0)
r.handleKey("Alt_L", 0)
if r.ctrlHeld || r.altHeld {
t.Errorf("ctrl=%v alt=%v after release, want both released", r.ctrlHeld, r.altHeld)
}
}
+122 -58
View File
@@ -4,9 +4,25 @@ import (
"fmt"
"math"
"slices"
"strconv"
"strings"
)
var fontGlyphs = map[rune][12]uint8{
const (
fontCharW = 8
fontCharH = 12
fontStep = fontCharW + 1
)
func scaleFactor(scale float64) int {
s := int(math.Round(scale))
if s < 1 {
return 1
}
return s
}
var fontGlyphs = map[rune][fontCharH]uint8{
'0': {0x3C, 0x66, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
'1': {0x18, 0x38, 0x78, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00, 0x00},
'2': {0x3C, 0x66, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x66, 0x7E, 0x00, 0x00},
@@ -593,12 +609,11 @@ func (r *RegionSelector) drawScrollPreview(data []byte, stride, bufW, bufH int,
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
s := r.scroll
style := LoadOverlayStyle()
const charH = 12
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format)
labelY := s.doneY + (s.btnH-charH)/2
labelY := s.doneY + (s.btnH-fontCharH)/2
r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
style.AccentR, style.AccentG, style.AccentB, 255, format)
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
@@ -617,9 +632,41 @@ func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, form
style.TextR, style.TextG, style.TextB, format)
}
func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH int) {
const charW, charH, padding, itemSpacing = 8, 12, 12, 24
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
}
func (h hudItem) width(step int) int {
return (len(h.key) + 1 + len(h.desc)) * step
}
func (r *RegionSelector) hudItems() []hudItem {
cursorLabel := "hide"
if !r.showCapturedCursor {
cursorLabel = "show"
@@ -629,16 +676,28 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
captureKey = "Drag+Release"
}
items := []struct{ key, desc string }{
return []hudItem{
{captureKey, "capture"},
{"Ctrl", "resize/move"},
{"P", cursorLabel + " cursor"},
{"Esc", "cancel"},
}
}
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
margin := 20 * scale
items := r.hudItems()
totalW := 0
step := fontStep * scale
for i, item := range items {
totalW += len(item.key)*(charW+1) + (1+len(item.desc))*(charW+1)
totalW += item.width(step)
if i < len(items)-1 {
totalW += itemSpacing
}
@@ -647,46 +706,32 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
hudW = totalW + padding*2
hudH = charH + padding*2
hudX = (bufW - hudW) / 2
hudY = bufH - hudH - 20
hudY = bufH - hudH - margin
return hudX, hudY, hudW, hudH
}
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
if r.selection.dragging {
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32, scale int) {
if r.selection.dragging || scale <= 0 {
return
}
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH)
scale = scaleFactor(float64(scale))
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
style := LoadOverlayStyle()
r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH,
style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format)
const charW, padding, itemSpacing = 8, 12, 24
cursorLabel := "hide"
if !r.showCapturedCursor {
cursorLabel = "show"
}
captureKey := "Space/Enter"
if r.screenshoter != nil && r.screenshoter.config.NoConfirm {
captureKey = "Drag+Release"
}
items := []struct{ key, desc string }{
{captureKey, "capture"},
{"Ctrl", "resize/move"},
{"P", cursorLabel + " cursor"},
{"Esc", "cancel"},
}
padding, itemSpacing := 12*scale, 24*scale
step := fontStep * scale
items := r.hudItems()
tx, ty := hudX+padding, hudY+padding
for i, item := range items {
r.drawText(data, stride, bufW, bufH, tx, ty, item.key,
style.AccentR, style.AccentG, style.AccentB, format)
tx += len(item.key) * (charW + 1)
r.drawText(data, stride, bufW, bufH, tx, ty, " "+item.desc,
style.TextR, style.TextG, style.TextB, format)
tx += (1 + len(item.desc)) * (charW + 1)
r.drawTextScaled(data, stride, bufW, bufH, tx, ty, item.key,
style.AccentR, style.AccentG, style.AccentB, format, scale)
r.drawTextScaled(data, stride, bufW, bufH, tx+len(item.key)*step, ty, " "+item.desc,
style.TextR, style.TextG, style.TextB, format, scale)
tx += item.width(step)
if i < len(items)-1 {
tx += itemSpacing
@@ -741,24 +786,29 @@ func (r *RegionSelector) drawVLine(data []byte, stride, bufW, bufH, x, y, length
}
func labelRect(b selectionRenderBounds, bufW, bufH int) (dirtyRect, string) {
scale := scaleFactor(b.scaleX)
text := b.labelText
const charW, charH = 8, 12
textW := len(text) * (charW + 1)
charH := fontCharH * scale
step := fontStep * scale
textW := len(text) * step
padX := 4 * scale
padY := 2 * scale
offsetY := 8 * scale
tx := b.x + (b.w-textW)/2
ty := b.y + b.h + 8
ty := b.y + b.h + offsetY
if ty+charH > bufH {
ty = b.y - charH - 8
ty = b.y - charH - offsetY
}
tx = clamp(tx, 0, bufW-textW)
return dirtyRect{x1: tx - 4, y1: ty - 2, x2: tx + textW + 4, y2: ty + charH + 2}, text
return dirtyRect{x1: tx - padX, y1: ty - padY, x2: tx + textW + padX, y2: ty + charH + padY}, text
}
func (r *RegionSelector) drawLabel(data []byte, stride, bufW, bufH int, o *overlay, format uint32) {
scale := scaleFactor(o.scaleX)
l := o.label
r.fillRect(data, stride, bufW, bufH, l.x1, l.y1, l.x2-l.x1, l.y2-l.y1, 0, 0, 0, 200, format)
r.drawText(data, stride, bufW, bufH, l.x1+4, l.y1+2, o.text, 255, 255, 255, format)
r.drawTextScaled(data, stride, bufW, bufH, l.x1+4*scale, l.y1+2*scale, o.text, 255, 255, 255, format, scale)
}
func formatIsBGR(format uint32) bool {
@@ -795,41 +845,55 @@ func (r *RegionSelector) fillRect(data []byte, stride, bufW, bufH, x, y, w, h in
}
func (r *RegionSelector) drawText(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32) {
r.drawTextScaled(data, stride, bufW, bufH, x, y, text, cr, cg, cb, format, 1)
}
func (r *RegionSelector) drawTextScaled(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32, scale int) {
scale = scaleFactor(float64(scale))
step := fontStep * scale
for i, ch := range text {
r.drawChar(data, stride, bufW, bufH, x+i*9, y, ch, cr, cg, cb, format)
r.drawChar(data, stride, bufW, bufH, x+i*step, y, ch, cr, cg, cb, format, scale)
}
}
func (r *RegionSelector) drawChar(data []byte, stride, bufW, bufH, x, y int, ch rune, cr, cg, cb uint8, format uint32) {
func (r *RegionSelector) drawChar(data []byte, stride, bufW, bufH, x, y int, ch rune, cr, cg, cb uint8, format uint32, scale int) {
glyph, ok := fontGlyphs[ch]
if !ok {
return
}
scale = scaleFactor(float64(scale))
c0, c2 := cb, cr
if formatIsBGR(format) {
c0, c2 = cr, cb
}
for row := range 12 {
py := y + row
if py < 0 || py >= bufH {
continue
}
for row := range fontCharH {
bits := glyph[row]
for col := range 8 {
if (bits & (1 << (7 - col))) == 0 {
for dy := range scale {
py := y + row*scale + dy
if py < 0 || py >= bufH {
continue
}
px := x + col
if px < 0 || px >= bufW {
continue
rowOff := py * stride
for col := range fontCharW {
if (bits & (1 << (7 - col))) == 0 {
continue
}
baseX := x + col*scale
for dx := range scale {
px := baseX + dx
if px < 0 || px >= bufW {
continue
}
off := rowOff + px*4
if off+3 >= len(data) {
continue
}
data[off], data[off+1], data[off+2], data[off+3] = c0, cg, c2, 255
}
}
off := py*stride + px*4
if off+3 >= len(data) {
continue
}
data[off], data[off+1], data[off+2], data[off+3] = c0, cg, c2, 255
}
}
}
+60 -11
View File
@@ -238,18 +238,26 @@ func TestHUDDimensionsContainText(t *testing.T) {
},
}
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH)
data := make([]byte, bufH*stride)
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888))
// 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)
}
// Assert no lit pixel falls outside the pill boundaries
for y := range bufH {
for x := range bufW {
off := y*stride + x*4
if data[off] != 0 || data[off+1] != 0 || data[off+2] != 0 || data[off+3] != 0 {
if x < hudX || x >= hudX+hudW || y < hudY || y >= hudY+hudH {
t.Fatalf("noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]",
noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH)
for _, scale := range []int{1, 2} {
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
data := make([]byte, bufH*stride)
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888), scale)
// Assert no lit pixel falls outside the pill boundaries
for y := range bufH {
for x := range bufW {
off := y*stride + x*4
if data[off] != 0 || data[off+1] != 0 || data[off+2] != 0 || data[off+3] != 0 {
if x < hudX || x >= hudX+hudW || y < hudY || y >= hudY+hudH {
t.Fatalf("scale=%d noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]",
scale, noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH)
}
}
}
}
@@ -258,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")
}
}
+19
View File
@@ -8,6 +8,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/dankgo/wayland/client"
)
@@ -177,6 +178,14 @@ func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
if !s.config.AllowMultiple {
lock, err := utils.LockSelectionOverlay()
if err != nil {
return nil, err
}
defer lock.Release()
}
if s.config.Reset {
if err := SaveLastRegion(Region{}); err != nil {
log.Debug("failed to reset last region", "err", err)
@@ -211,6 +220,9 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
return s.captureAqueousWindow()
}
if DetectCompositor() == CompositorNiri {
if s.config.Geometry {
return nil, fmt.Errorf("window geometry mode is not supported on niri")
@@ -353,6 +365,13 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
}
func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
name, err := aqueousFocusedOutput(s.config.Seat)
if err != nil {
return nil, err
}
return s.captureOutput(name)
}
output := s.findFocusedOutput()
if output == nil {
s.outputsMu.Lock()
+1
View File
@@ -0,0 +1 @@
aqueous-snapshot.json contains focused-window state captured from Aqueous 2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb in an isolated session on 2026-09-05. The session token is sanitized.
@@ -0,0 +1 @@
{"schema": 1, "session": "0123456789abcdef0123456789abcdef", "sequence": "5", "base_sequence": null, "type": "snapshot", "upsert": [{"kind": "workspace", "id": "12", "output": "2", "name": "same \"name\"", "number": 3, "active": true, "urgent": false}, {"kind": "workspace", "id": "2", "output": "1", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "session", "id": "session", "locked": false, "default_seat": "default", "overview_output": null, "overview_window": null}, {"kind": "workspace", "id": "11", "output": "2", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "output", "id": "1", "name": "HEADLESS-2", "enabled": true, "powered": true, "bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "1"}, {"kind": "workspace", "id": "18", "output": "2", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "16", "output": "2", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "15", "output": "2", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "10", "output": "2", "name": "1", "number": 1, "active": false, "urgent": false}, {"kind": "workspace", "id": "5", "output": "1", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "9", "output": "1", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "4", "output": "1", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "seat", "id": "default", "output": "1", "window": "ca89b2eb6850b81d12b92763969d82ca", "focus_kind": "window", "keyboard": "1"}, {"kind": "workspace", "id": "3", "output": "1", "name": "3", "number": 3, "active": false, "urgent": false}, {"kind": "workspace", "id": "14", "output": "2", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "7", "output": "1", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "1", "output": "1", "name": "1", "number": 1, "active": true, "urgent": false}, {"kind": "workspace", "id": "6", "output": "1", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "8", "output": "1", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "workspace", "id": "13", "output": "2", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "workspace", "id": "17", "output": "2", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "output", "id": "2", "name": "HEADLESS-1", "enabled": true, "powered": true, "bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "12"}, {"kind": "window", "id": "ca89b2eb6850b81d12b92763969d82ca", "backend": "xdg", "app_id": "aq-shell-test", "class": null, "title": "same title", "workspace": "1", "output": "1", "geometry": {"x": 10, "y": 10, "width": 628, "height": 700}, "outer_geometry": {"x": 8, "y": 8, "width": 632, "height": 704}, "focused": true, "visible": true, "floating": false, "minimized": false, "maximized": false, "fullscreen": false, "skip_taskbar": false, "skip_switcher": false, "always_above": false, "always_below": false, "snapped": false, "fixed_position": false, "layout": "tiled", "can_minimize": false, "can_maximize": false, "can_activate": true}, {"kind": "keyboard", "id": "1", "seat": "default", "layouts": ["English (US)", "German"], "index": 0}, {"kind": "keyboard_device", "id": "1", "name": "wlr_virtual_keyboard_v1", "seat": "default", "group": "1", "virtual": true}], "removed": []}
+19 -15
View File
@@ -56,21 +56,24 @@ type Output struct {
}
type Config struct {
Mode Mode
OutputName string
Cursor CursorMode
NoConfirm bool
Reset bool
Format Format
Quality int
OutputDir string
Filename string
Clipboard bool
SaveFile bool
Notify bool
Stdout bool
Geometry bool
IntervalMs int
Seat string
Mode Mode
OutputName string
Cursor CursorMode
NoConfirm bool
Reset bool
Format Format
Quality int
OutputDir string
Filename string
Clipboard bool
SaveFile bool
Notify bool
Stdout bool
Geometry bool
AllowMultiple bool
IntervalMs int
HUD string
// SelectorHook runs as the interactive selector starts (true) and ends (false).
SelectorHook func(begin bool)
}
@@ -88,5 +91,6 @@ func DefaultConfig() Config {
Clipboard: true,
SaveFile: true,
Notify: true,
HUD: "auto",
}
}
+12 -7
View File
@@ -160,15 +160,20 @@ func (b *DDCBackend) getDDCName(bus int) string {
}
func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
if err != nil {
for attempt := 0; attempt < 3; attempt++ {
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
if err == nil {
dev.max = cap.max
dev.lastBrightness = cap.current
log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
return
}
if attempt < 2 {
time.Sleep(100 * time.Millisecond)
continue
}
log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
return
}
dev.max = cap.max
dev.lastBrightness = cap.current
log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
}
func (b *DDCBackend) GetDevices() ([]Device, error) {
@@ -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
@@ -1,6 +1,7 @@
package network
import (
"encoding/hex"
"fmt"
"sort"
@@ -21,15 +22,13 @@ const (
var _ HotspotBackend = (*NetworkManagerBackend)(nil)
// ConfigureHotspot validates only DMS-owned constraints: SSID presence, band
// names, device capability, and the not-active guard. SSID byte-length and
// WPA-PSK password policy are deliberately left to NetworkManager, whose
// AddConnection/Update errors are returned to the caller; duplicating its
// evolving rules here would only let them drift.
func (b *NetworkManagerBackend) ConfigureHotspot(req HotspotRequest) error {
if req.SSID == "" {
return fmt.Errorf("hotspot SSID cannot be empty")
}
if err := validateHotspotPassword(req.Password); err != nil {
return err
}
if err := validateHotspotBandName(req.Band); err != nil {
return err
}
@@ -495,6 +494,22 @@ func isAPCapableWiFiDevice(devInfo *wifiDeviceInfo) (bool, error) {
return caps&nmWiFiDeviceCapAP != 0, nil
}
// Mirrors NetworkManager's nm_utils_wpa_psk_valid: byte length, not rune count.
func validateHotspotPassword(password string) error {
if password == "" {
return nil
}
if len(password) == 64 {
if _, err := hex.DecodeString(password); err == nil {
return nil
}
}
if len(password) < 8 || len(password) > 63 {
return fmt.Errorf("hotspot password must be 8 to 63 characters, or a 64-digit hex key")
}
return nil
}
func validateHotspotBandName(band string) error {
if band != "" && band != "bg" && band != "a" {
return fmt.Errorf("unsupported hotspot band: %s", band)
@@ -1,6 +1,7 @@
package network
import (
"strings"
"testing"
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
@@ -916,3 +917,48 @@ func TestGetHotspotSecrets(t *testing.T) {
assert.Error(t, err)
})
}
func TestValidateHotspotPassword(t *testing.T) {
hex64 := strings.Repeat("0123456789abcdef", 4)
cases := []struct {
name string
password string
valid bool
}{
{"open network", "", true},
{"three digits", "123", false},
{"seven chars", "testing", false},
{"eight chars", "testing1", true},
{"sixty three chars", strings.Repeat("a", 63), true},
{"sixty four hex", hex64, true},
{"sixty four hex uppercase", strings.ToUpper(hex64), true},
{"sixty four non-hex", strings.Repeat("g", 64), false},
{"sixty five chars", strings.Repeat("a", 65), false},
{"multibyte counts bytes", "ääää", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateHotspotPassword(tc.password)
if tc.valid {
assert.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), "8 to 63 characters")
})
}
}
func TestConfigureHotspotRejectsShortPasswordBeforeTouchingNetworkManager(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
backend, err := NewNetworkManagerBackend(mockNM)
require.NoError(t, err)
backend.settings = mockSettings
err = backend.ConfigureHotspot(HotspotRequest{SSID: "DMS Hotspot", Password: "123", Device: "wlan0"})
require.Error(t, err)
assert.Contains(t, err.Error(), "8 to 63 characters")
}
@@ -183,6 +183,12 @@ func (b *NetworkManagerBackend) classifyNMStateReason(reason uint32) string {
}
}
// NM raises NO_SECRETS once auth retries run out on a saved profile too, not only
// on a dismissed prompt (nm-device-wifi.c handle_8021x_or_psk_auth_fail, #3313).
func forgetOnConnectFailure(reasonCode string, preExisting bool) bool {
return reasonCode == errdefs.ErrUserCanceled && !preExisting
}
// With several adapters, state must follow the associated device, not whichever
// enumerated first (#2460).
func (b *NetworkManagerBackend) wifiDeviceForState() (gonetworkmanager.Device, gonetworkmanager.DeviceWireless) {
@@ -321,7 +327,7 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
b.state.ConnectingSSID = ""
b.state.LastError = reasonCode
if reasonCode == errdefs.ErrUserCanceled {
if forgetOnConnectFailure(reasonCode, b.state.ConnectingPreExisting) {
forgetSSID = connectingSSID
}
@@ -146,3 +146,23 @@ func TestNetworkManagerBackend_UpdateWiFiState_PicksConnectedDevice(t *testing.T
assert.True(t, backend.state.WiFiConnected)
assert.Equal(t, "wlan1", backend.state.WiFiDevice)
}
func TestForgetOnConnectFailure(t *testing.T) {
tests := []struct {
name string
reasonCode string
preExisting bool
want bool
}{
{"cancelled prompt on freshly created profile", errdefs.ErrUserCanceled, false, true},
{"no-secrets failure on saved profile", errdefs.ErrUserCanceled, true, false},
{"bad credentials on freshly created profile", errdefs.ErrBadCredentials, false, false},
{"generic failure on saved profile", errdefs.ErrConnectionFailed, true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, forgetOnConnectFailure(tt.reasonCode, tt.preExisting))
})
}
}
@@ -8,6 +8,8 @@ import (
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -26,15 +28,8 @@ type wpaCtrlConn struct {
seq int
}
func wpaCtrlLocalSocketBase() string {
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return dir
}
return os.TempDir()
}
func newWpaCtrlConn(sockPath string) (*wpaCtrlConn, error) {
localDir, err := os.MkdirTemp(wpaCtrlLocalSocketBase(), "dms-wpa-")
localDir, err := os.MkdirTemp(utils.RuntimeDir(), "dms-wpa-")
if err != nil {
return nil, fmt.Errorf("create wpa_ctrl socket dir: %w", err)
}
+2 -2
View File
@@ -33,8 +33,8 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/dankgo/ipc"
"github.com/AvengeMedia/dankgo/paths"
"github.com/AvengeMedia/dankgo/syncmap"
)
@@ -86,7 +86,7 @@ var cupsMu sync.Mutex
var cupsSubscriberCount int
var cupsEverAvailable bool
var appPaths = paths.New("danklinux")
var appPaths = utils.App()
func GetSocketPath() string {
return appPaths.SocketPath()
@@ -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)
}
+69
View File
@@ -1,11 +1,19 @@
package utils
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"unicode/utf8"
)
type AppChecker interface {
@@ -115,3 +123,64 @@ func userBinDirs() []string {
dirs = append(dirs, "/usr/local/bin")
return dirs
}
const maxJSONProcessBytes = 4 * 1024 * 1024
type boundedWriter struct {
bytes.Buffer
limit int
}
func (w *boundedWriter) Write(p []byte) (int, error) {
if len(p) > w.limit-w.Len() {
return 0, errors.New("process output exceeds size bound")
}
return w.Buffer.Write(p)
}
func RunJSON(ctx context.Context, executable string, args []string, input []byte, result any) error {
if len(input) > maxJSONProcessBytes {
return errors.New("request exceeds size bound")
}
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, executable, args...)
cmd.WaitDelay = time.Second
stdout := &boundedWriter{limit: maxJSONProcessBytes + 1}
stderr := &boundedWriter{limit: 16 * 1024}
cmd.Stdout, cmd.Stderr = stdout, stderr
if input != nil {
cmd.Stdin = bytes.NewReader(input)
}
err := cmd.Run()
if ctx.Err() != nil {
return fmt.Errorf("unavailable: command completion uncertain: %w", ctx.Err())
}
if stdout.Len() != 0 {
if decodeErr := DecodeJSON(bytes.TrimSuffix(stdout.Bytes(), []byte{'\n'}), result); decodeErr != nil {
return errors.Join(err, fmt.Errorf("invalid process JSON: %w", decodeErr))
}
}
if err != nil {
return fmt.Errorf("%s: %w", executable, err)
}
if stdout.Len() == 0 {
return errors.New("missing command acknowledgement")
}
return nil
}
func DecodeJSON(data []byte, target any) error {
if len(data) > maxJSONProcessBytes || !utf8.Valid(data) {
return errors.New("invalid UTF-8 or oversized JSON record")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(new(any)); err != io.EOF {
return errors.New("trailing JSON data")
}
return nil
}
+58
View File
@@ -1,10 +1,13 @@
package utils
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -67,3 +70,58 @@ func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
assert.Contains(t, parts, "/usr/local/bin")
assert.Contains(t, env, "OTHER=value")
}
func TestJSONProcessFixture(t *testing.T) {
mode := os.Getenv("DMS_JSON_PROCESS_FIXTURE")
if mode == "" {
return
}
switch mode {
case "accepted":
fmt.Print(`{"ok":true,"status":"accepted","sequence":"9007199254740993"}`)
case "failure":
fmt.Print(`{"ok":false,"status":"locked","sequence":"1"}`)
os.Exit(1)
case "malformed":
fmt.Print("invalid JSON")
case "timeout":
time.Sleep(time.Minute)
case "oversize":
fmt.Print(strings.Repeat("x", maxJSONProcessBytes+2))
}
os.Exit(0)
}
func TestRunJSONResultsAndTimeout(t *testing.T) {
for _, mode := range []string{"accepted", "failure", "malformed", "oversize", "timeout"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("DMS_JSON_PROCESS_FIXTURE", mode)
limit := 4 * time.Second
if mode == "timeout" {
limit = 100 * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), limit)
defer cancel()
var result jsonProcessResult
err := RunJSON(ctx, os.Args[0], []string{"-test.run=^TestJSONProcessFixture$"}, nil, &result)
if mode == "accepted" {
if err != nil || !result.OK || result.Status != "accepted" || result.Sequence != "9007199254740993" {
t.Fatalf("lost acknowledgement: %v %#v", err, result)
}
return
}
if err == nil {
t.Fatal("failure accepted")
}
if mode == "failure" && result.Status != "locked" {
t.Fatal("operation failure lost")
}
})
}
}
type jsonProcessResult struct {
OK bool `json:"ok"`
Status string `json:"status"`
Sequence string `json:"sequence"`
}
+23 -9
View File
@@ -2,6 +2,7 @@ package utils
import (
"fmt"
"os"
"os/exec"
"strings"
)
@@ -10,22 +11,35 @@ func dconfPath(schema, key string) string {
return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key
}
// GsettingsGet reads a gsettings value, falling back to dconf read.
// gsettings reaches whichever backend its own GLib links, not necessarily the
// dconf store GTK apps read, and it exits 0 either way.
func dconfSelected() bool {
backend := os.Getenv("GSETTINGS_BACKEND")
return backend == "" || backend == "dconf"
}
// GsettingsGet reads a desktop setting from dconf, falling back to gsettings.
func GsettingsGet(schema, key string) (string, error) {
if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil {
return strings.TrimSpace(string(out)), nil
if dconfSelected() {
if out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output(); err == nil {
if value := strings.TrimSpace(string(out)); value != "" {
return value, nil
}
}
}
out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output()
out, err := exec.Command("gsettings", "get", schema, key).Output()
if err != nil {
return "", fmt.Errorf("gsettings/dconf get failed for %s %s: %w", schema, key, err)
return "", fmt.Errorf("dconf/gsettings get failed for %s %s: %w", schema, key, err)
}
return strings.TrimSpace(string(out)), nil
}
// GsettingsSet writes a gsettings value, falling back to dconf write.
// GsettingsSet writes a desktop setting to dconf, falling back to gsettings.
func GsettingsSet(schema, key, value string) error {
if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil {
return nil
if dconfSelected() {
if err := exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run(); err == nil {
return nil
}
}
return exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run()
return exec.Command("gsettings", "set", schema, key, value).Run()
}
+117
View File
@@ -0,0 +1,117 @@
package utils
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeTool struct {
name string
stdout string
exit int
}
func fakeTools(t *testing.T, tools ...fakeTool) string {
t.Helper()
binDir := t.TempDir()
logPath := filepath.Join(binDir, "calls.log")
for _, tool := range tools {
script := fmt.Sprintf("#!/bin/sh\necho \"%s $*\" >> %q\nprintf '%%s' %q\nexit %d\n", tool.name, logPath, tool.stdout, tool.exit)
require.NoError(t, os.WriteFile(filepath.Join(binDir, tool.name), []byte(script), 0o755))
}
t.Setenv("PATH", binDir)
return logPath
}
func toolCalls(t *testing.T, logPath string) string {
t.Helper()
data, err := os.ReadFile(logPath)
if os.IsNotExist(err) {
return ""
}
require.NoError(t, err)
return string(data)
}
func TestGsettingsSetWritesDconf(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
logPath := fakeTools(t,
fakeTool{name: "dconf"},
fakeTool{name: "gsettings"},
)
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
calls := toolCalls(t, logPath)
assert.Contains(t, calls, "dconf write /org/gnome/desktop/interface/gtk-theme 'adw-gtk3'")
assert.NotContains(t, calls, "gsettings")
}
func TestGsettingsSetFallsBackWhenDconfFails(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
logPath := fakeTools(t,
fakeTool{name: "dconf", exit: 1},
fakeTool{name: "gsettings"},
)
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
assert.Contains(t, toolCalls(t, logPath), "gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3")
}
func TestGsettingsSetHonorsBackendOverride(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "keyfile")
logPath := fakeTools(t,
fakeTool{name: "dconf"},
fakeTool{name: "gsettings"},
)
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
calls := toolCalls(t, logPath)
assert.Contains(t, calls, "gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3")
assert.NotContains(t, calls, "dconf write")
}
func TestGsettingsGetReadsDconf(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
fakeTools(t,
fakeTool{name: "dconf", stdout: "'adw-gtk3-dark'"},
fakeTool{name: "gsettings", stdout: "'Adwaita'"},
)
value, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
require.NoError(t, err)
assert.Equal(t, "'adw-gtk3-dark'", value)
}
func TestGsettingsGetFallsBackWhenDconfUnset(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
fakeTools(t,
fakeTool{name: "dconf"},
fakeTool{name: "gsettings", stdout: "'Adwaita'"},
)
value, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
require.NoError(t, err)
assert.Equal(t, "'Adwaita'", value)
}
func TestGsettingsGetFailsWhenNoToolWorks(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
fakeTools(t,
fakeTool{name: "dconf", exit: 1},
fakeTool{name: "gsettings", exit: 1},
)
_, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
require.ErrorContains(t, err, "gtk-theme")
}
+105
View File
@@ -0,0 +1,105 @@
package utils
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
const (
// Shared by the screenshot region selector and the color picker: one grabbing overlay at a time.
SelectionOverlayLock = "dms-selection-overlay"
// Matches what the wayland client dials when WAYLAND_DISPLAY is unset.
defaultWaylandDisplay = "wayland-0"
)
var (
ErrLockHeld = errors.New("lock is held by another process")
ErrOverlayActive = errors.New("another dms selection overlay is already open")
)
type FileLock struct {
file *os.File
}
func displayScope() string {
name := filepath.Base(os.Getenv("WAYLAND_DISPLAY"))
switch name {
case ".", "..", string(filepath.Separator):
return defaultWaylandDisplay
}
return name
}
// One user's concurrent sessions share XDG_RUNTIME_DIR, so the lock is per wayland display.
func lockPath(name string) string {
return filepath.Join(RuntimeDir(), fmt.Sprintf("%s-%d-%s.lock", name, os.Getuid(), displayScope()))
}
func lockHolderPID(name string) int {
data, err := os.ReadFile(lockPath(name))
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0
}
return pid
}
func TryLock(name string) (*FileLock, error) {
f, err := os.OpenFile(lockPath(name), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, ErrLockHeld
}
return nil, err
}
if err := f.Truncate(0); err == nil {
_, _ = f.WriteString(strconv.Itoa(os.Getpid()))
}
return &FileLock{file: f}, nil
}
// LockSelectionOverlay only refuses on contention: a lock file that cannot be created or
// flocked yields a nil lock and no error, so the overlay still runs unguarded.
func LockSelectionOverlay() (*FileLock, error) {
lock, err := TryLock(SelectionOverlayLock)
if err == nil {
return lock, nil
}
if !errors.Is(err, ErrLockHeld) {
log.Debug("selection overlay lock unavailable", "err", err)
return nil, nil
}
if pid := lockHolderPID(SelectionOverlayLock); pid > 0 {
return nil, fmt.Errorf("%w (pid %d)", ErrOverlayActive, pid)
}
return nil, ErrOverlayActive
}
func (l *FileLock) Release() {
if l == nil || l.file == nil {
return
}
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
l.file.Close()
l.file = nil
}
+130
View File
@@ -0,0 +1,130 @@
package utils
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTryLockRejectsSecondHolder(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
held, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
_, err = TryLock(SelectionOverlayLock)
assert.ErrorIs(t, err, ErrLockHeld)
held.Release()
reacquired, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
reacquired.Release()
}
func TestTryLockSeparatesNames(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
first, err := TryLock("dms-test-a")
require.NoError(t, err)
defer first.Release()
second, err := TryLock("dms-test-b")
require.NoError(t, err)
second.Release()
}
func TestTryLockSeparatesWaylandDisplays(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
t.Setenv("WAYLAND_DISPLAY", "wayland-1")
first, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
defer first.Release()
t.Setenv("WAYLAND_DISPLAY", "wayland-2")
second, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
second.Release()
}
func TestTryLockWithoutWaylandDisplay(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
t.Setenv("WAYLAND_DISPLAY", "")
os.Unsetenv("WAYLAND_DISPLAY")
unset, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("%s-%d-wayland-0.lock", SelectionOverlayLock, os.Getuid()), filepath.Base(lockPath(SelectionOverlayLock)))
t.Setenv("WAYLAND_DISPLAY", "wayland-1")
other, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
other.Release()
os.Unsetenv("WAYLAND_DISPLAY")
_, err = TryLock(SelectionOverlayLock)
assert.ErrorIs(t, err, ErrLockHeld)
unset.Release()
}
func TestDisplayScopeStaysASingleName(t *testing.T) {
cases := []struct{ display, want string }{
{"", "wayland-0"},
{"/", "wayland-0"},
{"..", "wayland-0"},
{"wayland-1", "wayland-1"},
{"/run/user/1000/wayland-2", "wayland-2"},
{"/run/user/1000/wayland-3/", "wayland-3"},
}
for _, tc := range cases {
t.Setenv("WAYLAND_DISPLAY", tc.display)
assert.Equal(t, tc.want, displayScope(), tc.display)
}
}
func TestTryLockExcludesOtherProcesses(t *testing.T) {
if _, err := exec.LookPath("flock"); err != nil {
t.Skip("flock not available")
}
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
held, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
path := lockPath(SelectionOverlayLock)
assert.Error(t, exec.Command("flock", "-n", "-x", path, "-c", "true").Run())
held.Release()
assert.NoError(t, exec.Command("flock", "-n", "-x", path, "-c", "true").Run())
}
func TestLockSelectionOverlayReportsHolder(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
held, err := LockSelectionOverlay()
require.NoError(t, err)
require.NotNil(t, held)
defer held.Release()
lock, err := LockSelectionOverlay()
assert.Nil(t, lock)
assert.ErrorIs(t, err, ErrOverlayActive)
assert.Contains(t, err.Error(), fmt.Sprintf("pid %d", os.Getpid()))
}
func TestLockSelectionOverlayFallsBackWhenUnavailable(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "missing"))
lock, err := LockSelectionOverlay()
assert.NoError(t, err)
assert.Nil(t, lock)
lock.Release()
}
+6
View File
@@ -8,6 +8,12 @@ import (
"github.com/AvengeMedia/dankgo/paths"
)
var app = paths.New("danklinux")
func App() paths.App { return app }
func RuntimeDir() string { return app.SocketDir() }
func XDGStateHome() string { return paths.XDGStateHome() }
func XDGDataHome() string { return paths.XDGDataHome() }
+155
View File
@@ -0,0 +1,155 @@
package keymap
import (
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix"
)
const FormatXkbV1 = 1
// wl_keyboard.key carries evdev scancodes; xkb keycodes sit 8 above them.
const evdevOffset = 8
var pc105Codes = map[string]uint32{
"Escape": 1,
"p": 25,
"Return": 28,
"Control_L": 29,
"Shift_L": 42,
"v": 47,
"Alt_L": 56,
"space": 57,
"KP_Enter": 96,
"Control_R": 97,
"Alt_R": 100,
}
var pc105Syms = invert(pc105Codes)
// xkbcommon may serialize keysyms as hex instead of names.
var keysymNames = map[uint32]string{
0x20: "space",
0x70: "p",
0x76: "v",
0xff0d: "Return",
0xff1b: "Escape",
0xff8d: "KP_Enter",
0xffe1: "Shift_L",
0xffe3: "Control_L",
0xffe4: "Control_R",
0xffe9: "Alt_L",
0xffea: "Alt_R",
}
var (
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
)
// Keymap maps evdev scancodes to their first-group, first-level keysym names.
// A nil Keymap answers with pc105 positions.
type Keymap struct {
symByCode map[uint32]string
codeBySym map[string]uint32
}
// FromEvent owns and closes the keymap fd; nil means no usable xkb keymap.
func FromEvent(e client.KeyboardKeymapEvent) *Keymap {
defer unix.Close(e.Fd)
if e.Format != FormatXkbV1 {
return nil
}
text, err := Read(e.Fd, e.Size)
if err != nil {
return nil
}
return Parse(text)
}
func Read(fd int, size uint32) (string, error) {
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
if err != nil {
return "", err
}
text := strings.TrimRight(string(data), "\x00")
return text, unix.Munmap(data)
}
func Parse(text string) *Keymap {
xkbCodes := map[string]uint32{}
for _, m := range keycodeDefRe.FindAllStringSubmatch(text, -1) {
if code, err := strconv.Atoi(m[2]); err == nil {
xkbCodes[m[1]] = uint32(code)
}
}
k := &Keymap{symByCode: map[uint32]string{}, codeBySym: map[string]uint32{}}
for _, m := range keySymbolsRe.FindAllStringSubmatch(text, -1) {
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
if group == nil {
continue
}
xkbCode, ok := xkbCodes[m[1]]
if !ok || xkbCode < evdevOffset {
continue
}
sym := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
if sym == "" {
continue
}
code := xkbCode - evdevOffset
k.symByCode[code] = sym
if _, seen := k.codeBySym[sym]; !seen {
k.codeBySym[sym] = code
}
}
return k
}
func (k *Keymap) Keysym(code uint32) string {
if k == nil {
return pc105Syms[code]
}
if sym, ok := k.symByCode[code]; ok {
return sym
}
return pc105Syms[code]
}
func (k *Keymap) Keycode(sym string) uint32 {
if k == nil {
return pc105Codes[sym]
}
if code, ok := k.codeBySym[sym]; ok {
return code
}
return pc105Codes[sym]
}
func canonicalKeysym(sym string) string {
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
return sym
}
value, err := strconv.ParseUint(sym[2:], 16, 32)
if err != nil {
return sym
}
if name, ok := keysymNames[uint32(value)]; ok {
return name
}
return sym
}
func invert(m map[string]uint32) map[uint32]string {
out := make(map[uint32]string, len(m))
for sym, code := range m {
out[code] = sym
}
return out
}
+140
View File
@@ -0,0 +1,140 @@
package keymap
import "testing"
const qwertyKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
minimum = 8;
maximum = 708;
<ESC> = 9;
<AB04> = 55;
<LCTL> = 37;
<LFSH> = 50;
alias <AL01> = <AC01>;
indicator 1 = "Caps Lock";
};
xkb_types "(unnamed)" {
type "ALPHABETIC" {
modifiers = Shift+Lock;
map[Shift] = Level2;
level_name[Level1] = "Base";
};
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Escape ] };
key <AB04> { type= "ALPHABETIC", [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const hexKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB04> = 56;
<LCTL> = 38;
<LFSH> = 51;
};
xkb_symbols "(unnamed)" {
key <LCTL> { [ 0xffe3 ] };
key <LFSH> {
type= "PC_ALT_LEVEL2",
symbols[1]= [ 0xffe1, 0xfe08 ]
};
key <AB04> { [ 0x76, 0x56 ] };
};
};`
const dvorakKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB09> = 60;
<LCTL> = 37;
<LFSH> = 50;
};
xkb_symbols "(unnamed)" {
key <AB09> { [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const swapEscapeKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<ESC> = 9;
<RTRN> = 36;
<CAPS> = 66;
<KPEN> = 104;
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Caps_Lock ] };
key <RTRN> { [ Return ] };
key <CAPS> {
type= "ONE_LEVEL",
symbols[1]= [ Escape ]
};
key <KPEN> { [ KP_Enter ] };
};
};`
func TestKeycode(t *testing.T) {
tests := []struct {
name string
keymap string
sym string
want uint32
}{
{"qwerty v", qwertyKeymap, "v", 47},
{"qwerty ctrl", qwertyKeymap, "Control_L", 29},
{"qwerty shift", qwertyKeymap, "Shift_L", 42},
{"dvorak v", dvorakKeymap, "v", 52},
{"hex v", hexKeymap, "v", 48},
{"hex ctrl", hexKeymap, "Control_L", 30},
{"hex shift", hexKeymap, "Shift_L", 43},
{"swapescape escape", swapEscapeKeymap, "Escape", 58},
{"missing falls back to pc105", dvorakKeymap, "Escape", 1},
{"empty falls back to pc105", "", "v", 47},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Parse(tt.keymap).Keycode(tt.sym); got != tt.want {
t.Errorf("Keycode(%q) = %d, want %d", tt.sym, got, tt.want)
}
})
}
}
func TestKeysym(t *testing.T) {
tests := []struct {
name string
keymap string
code uint32
want string
}{
{"swapescape caps yields Escape", swapEscapeKeymap, 58, "Escape"},
{"swapescape esc yields Caps_Lock", swapEscapeKeymap, 1, "Caps_Lock"},
{"swapescape return", swapEscapeKeymap, 28, "Return"},
{"swapescape keypad enter", swapEscapeKeymap, 96, "KP_Enter"},
{"qwerty escape", qwertyKeymap, 1, "Escape"},
{"hex ctrl", hexKeymap, 30, "Control_L"},
{"unmapped code falls back to pc105", qwertyKeymap, 28, "Return"},
{"unknown code is empty", qwertyKeymap, 200, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Parse(tt.keymap).Keysym(tt.code); got != tt.want {
t.Errorf("Keysym(%d) = %q, want %q", tt.code, got, tt.want)
}
})
}
}
func TestNilKeymapUsesPc105(t *testing.T) {
var k *Keymap
if got := k.Keysym(1); got != "Escape" {
t.Errorf("Keysym(1) = %q, want Escape", got)
}
if got := k.Keycode("Control_L"); got != 29 {
t.Errorf("Keycode(Control_L) = %d, want 29", got)
}
}
+1 -1
View File
@@ -111,7 +111,7 @@
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-nswS/ygp0oYMXp6YHbbIxBha4xvzjKAxcAS5yOpbslg=";
vendorHash = "sha256-ejDi+TzstUtxtWpcl5yYw4UZuTnZraOsIdJuXPKxJ6s=";
subPackages = [ "cmd/dms" ];
+107
View File
@@ -0,0 +1,107 @@
function socketPath(path, runtime) {
if (!runtime.startsWith("/") || runtime.endsWith("/") || !path.startsWith(runtime + "/aqueous/"))
return false;
if (/[\r\n\u0000]/.test(path) || path.includes("//") || path.split("/").some(part => part === "." || part === ".."))
return false;
return /^\/aqueous\/[^/]+\/ipc\.sock$/.test(path.slice(runtime.length));
}
function byteLength(text) {
return encodeURIComponent(text).replace(/%[0-9A-F]{2}/g, "x").length;
}
function object(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function decimal(value) {
return typeof value === "string" && /^[0-9]{1,20}$/.test(value);
}
function nextId(value) {
const digits = value.split("");
for (let i = digits.length - 1; i >= 0; i--) {
if (digits[i] !== "9") {
digits[i] = String(Number(digits[i]) + 1);
return digits.join("");
}
digits[i] = "0";
}
if (digits.length === 20)
throw new Error("IPC request IDs exhausted");
return "1" + digits.join("");
}
function hello(value) {
if (!object(value) || value.schema !== 1 || typeof value.session !== "string" || !/^[0-9a-f]{32}$/.test(value.session) || value.max_pending_requests !== 1)
throw new Error("unsupported: Aqueous IPC hello");
const limits = {max_request_bytes: 65536, max_frame_bytes: 4259840, max_batch_bytes: 4194304};
for (const key of Object.keys(limits)) {
if (!Number.isInteger(value[key]) || value[key] <= 0 || value[key] > limits[key])
throw new Error("unsupported: IPC " + key);
}
if (!object(value.capabilities))
throw new Error("unsupported: IPC capabilities");
for (const name of ["state", "commands", "keyboard", "overview", "shortcut_inhibition"]) {
if (typeof value.capabilities[name] !== "boolean")
throw new Error("unsupported: missing capability " + name);
}
if (!value.capabilities.state)
throw new Error("unsupported: shell state");
return value;
}
function envelope(line, limit) {
if (byteLength(line) > limit)
throw new Error("IPC frame exceeds size bound");
let depth = 0, quoted = false, escaped = false;
for (const c of line) {
if (quoted) {
if (escaped) {
escaped = false;
continue;
}
switch (c) {
case "\\":
escaped = true;
break;
case '"':
quoted = false;
break;
}
continue;
}
switch (c) {
case '"':
quoted = true;
break;
case "{":
case "[":
depth++;
break;
case "}":
case "]":
depth--;
break;
}
if (depth > 32)
throw new Error("IPC nesting exceeds limit");
}
const value = JSON.parse(line);
if (!object(value) || value.ipc !== 1)
throw new Error("unsupported: IPC envelope");
return value;
}
function response(value, id) {
if (value.id !== id || !decimal(value.id) || typeof value.ok !== "boolean" || value.event !== undefined)
throw new Error("invalid IPC response correlation");
if (value.ok) {
if (!object(value.result) || value.error !== undefined)
throw new Error("invalid IPC result");
return "";
}
if (!object(value.error) || typeof value.error.code !== "string" || !value.error.code || value.error.code.length > 128 || typeof value.error.message !== "string" || byteLength(value.error.message) > 4096 || value.result !== undefined)
throw new Error("malformed IPC error");
return value.error.code + ": " + value.error.message;
}
+68
View File
@@ -0,0 +1,68 @@
.pragma library
// A calendar date with no time and no timezone.
//
// Arithmetic runs through Date.UTC, which has no DST gaps, so day and month
// rollover is exact. Instances are immutable: every operation returns a new one.
// month is 0-based, matching Javascripts' Date behavior.
class DateOnly {
constructor(year, month, day) {
this.year = year;
this.month = month;
this.day = day;
}
addDays(days) {
return _fromUtc(new Date(Date.UTC(this.year, this.month, this.day + days)));
}
addMonths(months) {
const target = new Date(Date.UTC(this.year, this.month + months, 1));
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
return new DateOnly(target.getUTCFullYear(), target.getUTCMonth(), Math.min(this.day, lastDay));
}
// 0 = Sunday, matching Date.getDay().
dayOfWeek() {
return new Date(Date.UTC(this.year, this.month, this.day)).getUTCDay();
}
daysUntil(other) {
const from = Date.UTC(this.year, this.month, this.day);
const to = Date.UTC(other.year, other.month, other.day);
return Math.round((to - from) / 86400000);
}
equals(other) {
if (!other)
return false;
return this.year === other.year && this.month === other.month && this.day === other.day;
}
// Local noon, so consumers that read local calendar fields land on this date
// even on days where local midnight does not exist.
toDate() {
return new Date(this.year, this.month, this.day, 12);
}
}
function _fromUtc(date) {
return new DateOnly(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
}
function of(year, month, day) {
return new DateOnly(year, month, day);
}
function fromDate(date) {
return new DateOnly(date.getFullYear(), date.getMonth(), date.getDate());
}
function firstOfMonth(year, month) {
return new DateOnly(year, month, 1);
}
function lastOfMonth(year, month) {
return _fromUtc(new Date(Date.UTC(year, month + 1, 0)));
}
+19
View File
@@ -0,0 +1,19 @@
.pragma library
function path(schema, key) {
return "/" + schema.replace(/\./g, "/") + "/" + key;
}
// gsettings reaches whichever backend its own GLib links, not necessarily the
// dconf store GTK apps read, and it exits 0 either way.
function backendIsDconf() {
return "[ \"${GSETTINGS_BACKEND:-dconf}\" = dconf ]";
}
function getCmd(schema, key) {
return "{ { " + backendIsDconf() + " && dconf read " + path(schema, key) + " 2>/dev/null | grep .; } || gsettings get " + schema + " " + key + " 2>/dev/null; } | tr -d \"'\"";
}
function setCmd(schema, key, value) {
return "{ " + backendIsDconf() + " && dconf write " + path(schema, key) + " \"'" + value + "'\" 2>/dev/null; } || gsettings set " + schema + " " + key + " '" + value + "'";
}
+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)
+147 -115
View File
@@ -8,6 +8,7 @@ import Quickshell.Io
import qs.Common
import qs.Common.settings
import qs.Services
import "GSettings.js" as GSettings
import "settings/SettingsSpec.js" as Spec
import "settings/SettingsStore.js" as Store
@@ -15,7 +16,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 17
readonly property int settingsConfigVersion: 18
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -987,37 +988,78 @@ Singleton {
property var notificationRules: []
property bool notificationDndAllowCritical: true
property bool notificationFocusedMonitor: false
// Names the bar config rendered as Dank Island instead of a DankBar. Empty = no island.
property string dankIslandBarId: ""
readonly property var islandBarConfig: dankIslandBarId ? (barConfigs.find(cfg => cfg.id === dankIslandBarId) || null) : null
readonly property bool dankIslandEnabled: !!islandBarConfig && (islandBarConfig.enabled ?? false)
readonly property int dankIslandPosition: islandBarConfig?.position ?? SettingsData.Position.Top
readonly property string dankIslandEdge: dankIslandPosition === SettingsData.Position.Bottom ? "bottom" : "top"
readonly property int dankIslandReservedStripHeight: {
const reserve = Math.max(24, Math.min(128, dankIslandReserveHeight));
const compact = Math.max(24, Math.min(72, dankIslandCompactHeight));
const gap = Math.max(0, Math.min(48, dankIslandOuterGap));
// Island is a per-instance render mode: any bar config with island:true draws as an island
// instead of a DankBar, and carries its own island* look settings.
readonly property var islandBarConfigs: {
barConfigs;
return (barConfigs || []).filter(cfg => cfg && cfg.island === true);
}
readonly property bool dankIslandEnabled: islandBarConfigs.some(cfg => cfg.enabled ?? false)
readonly property var islandDefaults: ({
"islandFloating": false,
"islandUseOverlayLayer": false,
"islandReserveThickness": 40,
"islandCompactThickness": 38,
"islandOuterGap": 4,
"islandAlongOffset": 0,
"islandInteractionMode": "hybrid",
"islandHoverOpenDelay": 150,
"islandHoverCloseDelay": 150,
"islandPalette": "default",
"islandTransparency": 1,
"islandCornerRadius": 34,
"islandHighContrast": false,
"islandMediaClockVisible": true,
"islandNotificationBadgeClearOnOpen": false,
"islandNotificationExpand": false,
"islandHomeCompactTight": false,
"islandHomeClockDisplay": "both",
"islandHomeVolumeDisplay": "both",
"islandHomeBrightnessDisplay": "both",
"islandBatteryStyle": "solid",
"islandSatellitesEnabled": true,
"islandSatellitePosition": "edges",
"islandSatelliteGap": 12,
"islandSatelliteBackground": false,
"islandSatelliteGothCorners": true,
"islandSatelliteTransparency": 1,
"islandSatelliteSwoopRadius": 24,
"islandReducedMotion": false,
"islandSpringStiffness": 560,
"islandSpringDamping": 37,
"islandSpringMass": 1
})
function islandSetting(bc, key) {
const value = bc?.[key];
return value === undefined || value === null ? islandDefaults[key] : value;
}
function islandLevelDisplay(bc, key) {
const value = islandSetting(bc, key);
return value === "icon" || value === "percentage" || value === "both" ? value : "both";
}
function islandClockDisplay(bc) {
const value = islandSetting(bc, "islandHomeClockDisplay");
return value === "time" || value === "date" || value === "both" ? value : "both";
}
function islandEdge(bc) {
return positionToSide(bc?.position ?? SettingsData.Position.Top) || "top";
}
function islandVertical(bc) {
const edge = islandEdge(bc);
return edge === "left" || edge === "right";
}
function islandStripThickness(bc) {
const reserve = Math.max(24, Math.min(128, islandSetting(bc, "islandReserveThickness")));
const compact = Math.max(24, Math.min(72, islandSetting(bc, "islandCompactThickness")));
const gap = Math.max(0, Math.min(48, islandSetting(bc, "islandOuterGap")));
return Math.max(reserve, gap + compact);
}
property bool dankIslandFloating: false
property bool dankIslandUseOverlayLayer: false
property int dankIslandReserveHeight: 40
property int dankIslandCompactHeight: 38
property int dankIslandOuterGap: 4
property int dankIslandHorizontalOffset: 0
property string dankIslandInteractionMode: "hybrid"
property int dankIslandHoverOpenDelay: 150
property int dankIslandHoverCloseDelay: 150
property string dankIslandPalette: "default"
property real dankIslandTransparency: 1
property int dankIslandCornerRadius: 34
property bool dankIslandHighContrast: false
property bool dankIslandMediaClockVisible: true
property bool dankIslandNotificationBadgeClearOnOpen: false
property bool dankIslandNotificationExpand: false
property bool dankIslandHomeCompactTight: false
property string dankIslandHomeVolumeDisplay: "both"
property string dankIslandHomeBrightnessDisplay: "both"
readonly property var _islandHomeGroupIds: ["media", "clock", "weather", "status", "volume", "brightness", "notifications"]
readonly property var _islandHomeLayoutDefault: [
{
@@ -1049,10 +1091,8 @@ Singleton {
"enabled": true
}
]
property var dankIslandHomeLayout: _islandHomeLayoutDefault
function getIslandHomeLayout() {
const stored = Array.isArray(dankIslandHomeLayout) ? dankIslandHomeLayout : [];
function getIslandHomeLayout(bc) {
const stored = Array.isArray(bc?.islandHomeLayout) ? bc.islandHomeLayout : [];
const result = [];
const seen = {};
for (const entry of stored) {
@@ -1075,41 +1115,33 @@ Singleton {
return result;
}
function islandHomeGroupEnabled(id) {
const entry = getIslandHomeLayout().find(g => g.id === id);
function islandHomeGroupEnabled(bc, id) {
const entry = getIslandHomeLayout(bc).find(g => g.id === id);
return entry ? entry.enabled : false;
}
function setIslandHomeLayoutOrder(ids) {
const current = getIslandHomeLayout();
function setIslandHomeLayoutOrder(barId, ids) {
const current = getIslandHomeLayout(getBarConfig(barId));
const ordered = ids.map(id => current.find(g => g.id === id)).filter(g => g);
for (const entry of current) {
if (ids.indexOf(entry.id) < 0)
ordered.push(entry);
}
set("dankIslandHomeLayout", ordered);
updateBarConfig(barId, {
islandHomeLayout: ordered
});
}
function setIslandHomeGroupEnabled(id, on) {
function setIslandHomeGroupEnabled(barId, id, on) {
if (id === "clock")
return;
set("dankIslandHomeLayout", getIslandHomeLayout().map(g => g.id === id ? {
"id": g.id,
"enabled": on
} : g));
updateBarConfig(barId, {
islandHomeLayout: getIslandHomeLayout(getBarConfig(barId)).map(g => g.id === id ? {
"id": g.id,
"enabled": on
} : g)
});
}
property string dankIslandBatteryStyle: "solid"
property bool dankIslandSatellitesEnabled: true
property string dankIslandSatellitePosition: "edges"
property int dankIslandSatelliteGap: 12
property bool dankIslandSatelliteBackground: false
property bool dankIslandSatelliteGothCorners: true
property real dankIslandSatelliteTransparency: 1
property int dankIslandSatelliteSwoopRadius: 24
property bool dankIslandReducedMotion: false
property real dankIslandSpringStiffness: 560
property real dankIslandSpringDamping: 37
property real dankIslandSpringMass: 1
property bool osdAlwaysShowValue: false
property int osdPosition: SettingsData.Position.BottomCenter
@@ -1523,11 +1555,7 @@ Singleton {
return;
if (!SessionData.lastAppliedIconTheme)
return;
const script = `if command -v gsettings >/dev/null 2>&1; then
gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g"
elif command -v dconf >/dev/null 2>&1; then
dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g"
fi`;
const script = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => {
const platform = (output || "").trim();
@@ -1565,11 +1593,7 @@ Singleton {
const resolved = resolveIconTheme();
let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved;
if (!cosmicThemeName || cosmicThemeName === "System Default") {
const detectScript = `if command -v gsettings >/dev/null 2>&1; then
gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g"
elif command -v dconf >/dev/null 2>&1; then
dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g"
fi`;
const detectScript = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => {
if (exitCode !== 0)
@@ -1631,11 +1655,7 @@ Singleton {
fi
done
if command -v gsettings >/dev/null 2>&1; then
gsettings set org.gnome.desktop.interface icon-theme '${gtkThemeName}' 2>/dev/null || true
elif command -v dconf >/dev/null 2>&1; then
dconf write /org/gnome/desktop/interface/icon-theme "'${gtkThemeName}'" 2>/dev/null || true
fi
${GSettings.setCmd("org.gnome.desktop.interface", "icon-theme", gtkThemeName)} || true
pkill -HUP -f 'gtk' 2>/dev/null || true`;
@@ -1738,13 +1758,21 @@ Singleton {
})
function set(key, value) {
if (key === "dankIslandInteractionMode" && value !== "click" && value !== "hybrid")
value = "hybrid";
Spec.set(root, key, value, saveSettings, _hooks);
if (key === "dankIslandBarId" && value && frameEnabled)
set("frameEnabled", false);
if (key === "frameEnabled" && value && dankIslandBarId)
set("dankIslandBarId", "");
if (key === "frameEnabled" && value)
clearIslandBars();
}
// Frame mode hosts bars inside the frame surface, which has nowhere to put an island.
function clearIslandBars() {
const islands = islandBarConfigs;
if (islands.length === 0)
return;
const configs = JSON.parse(JSON.stringify(barConfigs));
for (const cfg of configs)
delete cfg.island;
barConfigs = configs;
updateBarConfigs();
}
function loadSettings() {
@@ -1799,8 +1827,8 @@ Singleton {
Store.parse(root, obj);
// set() enforces this pair, but a hand-edited settings.json bypasses set() entirely.
if (frameEnabled && dankIslandBarId)
dankIslandBarId = "";
if (frameEnabled && islandBarConfigs.length > 0)
clearIslandBars();
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
frameMode = "connected";
@@ -2131,7 +2159,7 @@ Singleton {
const pathsArg = iconPaths.join(" ");
const script = `
echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g" || echo '')"
echo "SYSDEFAULT:$(${GSettings.getCmd("org.gnome.desktop.interface", "icon-theme")})"
for dir in ${pathsArg}; do
[ -d "$dir" ] || continue
for theme in "$dir"/*/; do
@@ -2170,7 +2198,7 @@ Singleton {
const pathsArg = cursorPaths.join(" ");
const script = `
echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface cursor-theme 2>/dev/null | sed "s/'//g" || echo '')"
echo "SYSDEFAULT:$(${GSettings.getCmd("org.gnome.desktop.interface", "cursor-theme")})"
for dir in ${pathsArg}; do
[ -d "$dir" ] || continue
for theme in "$dir"/*/; do
@@ -2329,12 +2357,10 @@ Singleton {
// The island is not a bar, but it is chrome: popouts still have to clear its strip.
if (!isIslandBarConfig(barConfig)) {
const islandTop = dankIslandEdgeOffset(screen, "top");
const islandBottom = dankIslandEdgeOffset(screen, "bottom");
if (islandTop > 0)
topBar = Math.max(topBar, islandTop);
if (islandBottom > 0)
bottomBar = Math.max(bottomBar, islandBottom);
topBar = Math.max(topBar, dankIslandEdgeOffset(screen, "top"));
bottomBar = Math.max(bottomBar, dankIslandEdgeOffset(screen, "bottom"));
leftBar = Math.max(leftBar, dankIslandEdgeOffset(screen, "left"));
rightBar = Math.max(rightBar, dankIslandEdgeOffset(screen, "right"));
}
return {
@@ -2470,21 +2496,20 @@ Singleton {
return barConfigs.find(cfg => cfg.id === barId) || null;
}
function setIslandBarId(barId) {
function setBarIsland(barId, on) {
const config = getBarConfig(barId);
if (!config)
if (!config || (config.island === true) === (on === true))
return;
set("dankIslandBarId", barId);
const updates = {};
if (!config.enabled)
updates.enabled = true;
if ((config.screenPreferences ?? []).length === 0)
updates.screenPreferences = ["all"];
const position = config.position ?? SettingsData.Position.Top;
if (position !== SettingsData.Position.Top && position !== SettingsData.Position.Bottom)
updates.position = SettingsData.Position.Top;
if (Object.keys(updates).length > 0)
updateBarConfig(barId, updates);
const updates = {
island: on === true
};
if (on === true) {
if (!config.enabled)
updates.enabled = true;
if ((config.screenPreferences ?? []).length === 0)
updates.screenPreferences = ["all"];
}
updateBarConfig(barId, updates);
}
function isBarIpcRevealed(barId) {
@@ -2494,7 +2519,7 @@ Singleton {
}
function setBarIpcReveal(barId, revealed) {
if (!barId || barId === dankIslandBarId)
if (!barId || isIslandBarConfig(getBarConfig(barId)))
return;
const nextRevealed = !!revealed;
if (!!barIpcRevealStates[barId] === nextRevealed)
@@ -2528,8 +2553,6 @@ Singleton {
const index = configs.findIndex(cfg => cfg.id === barId);
if (index === -1)
return;
if (isIslandBarConfig(configs[index]) && updates.position !== undefined && updates.position !== SettingsData.Position.Top && updates.position !== SettingsData.Position.Bottom)
delete updates.position;
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
if (updates.autoHide === false || updates.visible === false)
setBarIpcReveal(barId, false);
@@ -2548,8 +2571,6 @@ Singleton {
return;
const configs = barConfigs.filter(cfg => cfg.id !== barId);
barConfigs = configs;
if (dankIslandBarId === barId)
dankIslandBarId = "";
if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
delete nextBackups[barId];
@@ -2559,7 +2580,7 @@ Singleton {
updateBarConfigs();
}
// Bar-kind instances only. The island reserves its own edge via dankIslandOwnsEdge.
// Bar-kind instances only. Islands reserve their own edges via dankIslandOwnsEdge.
function getEnabledBarConfigs() {
return barConfigs.filter(cfg => cfg.enabled && !isIslandBarConfig(cfg));
}
@@ -2728,34 +2749,45 @@ Singleton {
}
function isIslandBarConfig(bc) {
return !!bc && !!dankIslandBarId && bc.id === dankIslandBarId;
return !!bc && bc.island === true;
}
function getIslandScreens() {
const cfg = islandBarConfig;
if (!cfg || !(cfg.enabled ?? false))
function activeIslandConfigsForScreen(screen) {
if (!screen)
return [];
return Quickshell.screens.filter(screen => barConfigCoversScreen(cfg, screen));
return islandBarConfigs.filter(cfg => (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen));
}
// Only the first island claiming an edge renders there; the rest would stack on top of it.
function islandConfigForEdge(screen, edge) {
return activeIslandConfigsForScreen(screen).find(cfg => islandEdge(cfg) === edge) ?? null;
}
function dankIslandCoversScreen(screen) {
const cfg = islandBarConfig;
return !!screen && !!cfg && (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen);
return activeIslandConfigsForScreen(screen).length > 0;
}
function dankIslandOwnsEdge(screen, edge) {
return dankIslandCoversScreen(screen) && dankIslandEdge === edge;
return islandConfigForEdge(screen, edge) !== null;
}
// Painted strip thickness on `edge`, including when floating drops the exclusive zone.
function dankIslandEdgeOffset(screen, edge) {
return dankIslandOwnsEdge(screen, edge) ? dankIslandReservedStripHeight : 0;
const cfg = islandConfigForEdge(screen, edge);
return cfg ? islandStripThickness(cfg) : 0;
}
function dankIslandIsSoleBarForScreen(screen) {
return dankIslandCoversScreen(screen) && getActiveBarEdgesForScreen(screen).length === 0;
}
// Edges an island already holds on every screen this config covers, so a second island
// instance cannot be dropped on top of it.
function islandEdgeTakenFor(bc, screen, edge) {
const owner = islandConfigForEdge(screen, edge);
return !!owner && owner.id !== bc?.id;
}
function getActiveBarEdgesForScreen(screen) {
return ["top", "bottom", "left", "right"].filter(edge => getActiveBarConfigsForEdge(screen, edge).length > 0);
}
+11 -70
View File
@@ -10,6 +10,7 @@ import qs.DankCommon.Common as DankCommon
import qs.Services
import qs.Modules.Greetd
import "StockThemes.js" as StockThemes
import "GSettings.js" as GSettings
Singleton {
id: root
@@ -144,10 +145,6 @@ Singleton {
Component.onCompleted: {
Quickshell.execDetached(["mkdir", "-p", stateDir]);
// shellDir may be an embedded-UI extraction, which is read-only and
// unexecutable (dankgo shellapp/shellfs makeReadOnly chmods 0444)
if (typeof SessionData === "undefined" || !SessionData.isGreeterMode)
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "assets", "", shellDir]);
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
matugenAvailable = (code === 0) && !envDisableMatugen;
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
@@ -297,32 +294,7 @@ Singleton {
if (currentTheme === "custom") {
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
} else if (currentTheme === dynamic) {
return {
"primary": getMatugenColor("primary", "#42a5f5"),
"primaryText": getMatugenColor("on_primary", "#ffffff"),
"primaryContainer": getMatugenColor("primary_container", "#1976d2"),
"secondary": getMatugenColor("secondary", "#8ab4f8"),
"secondaryContainer": getMatugenColor("secondary_container", getMatugenColor("surface_container_high", "#292b2f")),
"tertiary": getMatugenColor("tertiary", "#efb8c8"),
"tertiaryContainer": getMatugenColor("tertiary_container", getMatugenColor("surface_container_high", "#292b2f")),
"surface": getMatugenColor("surface", "#1a1c1e"),
"surfaceText": getMatugenColor("on_background", "#e3e8ef"),
"surfaceVariant": getMatugenColor("surface_variant", "#44464f"),
"surfaceVariantText": getMatugenColor("on_surface_variant", "#c4c7c5"),
"surfaceTint": getMatugenColor("surface_tint", "#8ab4f8"),
"background": getMatugenColor("background", "#1a1c1e"),
"backgroundText": getMatugenColor("on_background", "#e3e8ef"),
"outline": getMatugenColor("outline", "#8e918f"),
"surfaceContainerLowest": getMatugenColor("surface_container_lowest", "#0e1013"),
"surfaceContainerLow": getMatugenColor("surface_container_low", "#181a1d"),
"surfaceContainer": getMatugenColor("surface_container", "#1e2023"),
"surfaceContainerHigh": getMatugenColor("surface_container_high", "#292b2f"),
"surfaceContainerHighest": getMatugenColor("surface_container_highest", "#343740"),
"error": "#F2B8B5",
"warning": "#FF9800",
"info": "#2196F3",
"success": "#4CAF50"
};
return buildExtractedDynamicMode(isLightMode ? "light" : "dark", "Dynamic");
} else {
return StockThemes.getThemeByName(currentTheme, isLightMode);
}
@@ -1525,30 +1497,10 @@ Singleton {
const dankBarScale = fontScale !== undefined ? fontScale : 1.0;
const maxScale = (maximizeText ?? false) ? 1.5 : 1.0;
if (scale <= 0.75)
return evenLineBoxSize(Math.round(fontSizeSmall * 0.9 * dankBarScale * maxScale));
return Math.round(fontSizeSmall * 0.9 * dankBarScale * maxScale);
if (scale >= 1.25)
return evenLineBoxSize(Math.round(fontSizeMedium * dankBarScale * maxScale));
return evenLineBoxSize(Math.round(fontSizeSmall * dankBarScale * maxScale));
}
FontMetrics {
id: lineBoxProbe
}
// Text.implicitHeight is ceil(FontMetrics.height); probe.font is written whole and read via boundingRect() so bindings capture no dependency on the probe
function lineBoxHeight(pixelSize) {
lineBoxProbe.font = Qt.font({
family: fontFamily,
weight: fontWeight,
pixelSize: pixelSize
});
return Math.ceil(lineBoxProbe.boundingRect("0").height);
}
function evenLineBoxSize(pixelSize) {
if (lineBoxHeight(pixelSize) % 2 === 0)
return pixelSize;
return lineBoxHeight(pixelSize + 1) % 2 === 0 ? pixelSize + 1 : pixelSize;
return Math.round(fontSizeMedium * dankBarScale * maxScale);
return Math.round(fontSizeSmall * dankBarScale * maxScale);
}
function getBatteryIcon(level, isCharging, batteryAvailable) {
@@ -1918,30 +1870,19 @@ Singleton {
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
const schema = "org.gnome.desktop.interface";
const key = "gtk-theme";
const reset = GSettings.setCmd(schema, key, "");
const apply = GSettings.setCmd(schema, key, theme);
const makeCmd = (tool, schema, val) => {
if (tool === "gsettings") {
return `gsettings set ${schema} ${key} '' && gsettings set ${schema} ${key} ${val}`;
} else {
const dconfPath = `/${schema.replace(/\./g, "/")}`;
return `dconf write ${dconfPath}/${key} "''" && dconf write ${dconfPath}/${key} "'${val}'"`;
}
};
Proc.runCommand("gtkRefresher", ["sh", "-c", makeCmd("gsettings", schema, theme)], (output, exitCode) => {
Proc.runCommand("gtkRefresher", ["sh", "-c", `${reset}; ${apply}`], (output, exitCode) => {
if (exitCode !== 0) {
Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => {
if (exitCode !== 0) {
log.warn("Failed to refresh gtk-theme");
}
});
log.warn("Failed to refresh gtk-theme");
}
});
}
function patchGtk3colors() {
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight, shellDir], (output, exitCode) => {
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight], (output, exitCode) => {
switch (exitCode) {
case 0:
refreshGtkTheme();
@@ -1963,7 +1904,7 @@ Singleton {
}
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "true" : "false";
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight, shellDir], (output, exitCode) => {
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight], (output, exitCode) => {
if (exitCode === 0) {
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
@@ -534,39 +534,6 @@ var SPEC = {
notificationDndAllowCritical: { def: true },
notificationFocusedMonitor: { def: false },
dankIslandBarId: { def: "", onChange: "updateBarConfigs" },
dankIslandFloating: { def: false },
dankIslandUseOverlayLayer: { def: false },
dankIslandReserveHeight: { def: 40 },
dankIslandCompactHeight: { def: 38 },
dankIslandOuterGap: { def: 4 },
dankIslandHorizontalOffset: { def: 0 },
dankIslandInteractionMode: { def: "hybrid" },
dankIslandHoverOpenDelay: { def: 150 },
dankIslandHoverCloseDelay: { def: 150 },
dankIslandPalette: { def: "default" },
dankIslandTransparency: { def: 1 },
dankIslandCornerRadius: { def: 34 },
dankIslandHighContrast: { def: false },
dankIslandMediaClockVisible: { def: true },
dankIslandNotificationBadgeClearOnOpen: { def: false },
dankIslandNotificationExpand: { def: false },
dankIslandHomeCompactTight: { def: false },
dankIslandHomeVolumeDisplay: { def: "both" },
dankIslandHomeBrightnessDisplay: { def: "both" },
dankIslandHomeLayout: { def: [{ id: "media", enabled: true }, { id: "clock", enabled: true }, { id: "weather", enabled: false }, { id: "status", enabled: false }, { id: "volume", enabled: false }, { id: "brightness", enabled: false }, { id: "notifications", enabled: true }] },
dankIslandBatteryStyle: { def: "solid" },
dankIslandSatellitesEnabled: { def: true },
dankIslandSatellitePosition: { def: "edges" },
dankIslandSatelliteGap: { def: 12 },
dankIslandSatelliteBackground: { def: false },
dankIslandSatelliteGothCorners: { def: true },
dankIslandSatelliteTransparency: { def: 1 },
dankIslandSatelliteSwoopRadius: { def: 24 },
dankIslandReducedMotion: { def: false },
dankIslandSpringStiffness: { def: 560 },
dankIslandSpringDamping: { def: 37 },
dankIslandSpringMass: { def: 1 },
osdAlwaysShowValue: { def: false },
osdPosition: { def: 5 },
@@ -14,6 +14,43 @@ var STALE_WIDGET_KEYS = ["desktopClockEnabled", "desktopClockStyle", "desktopClo
var BAR_WIDGET_LIST_KEYS = ["leftWidgets", "centerWidgets", "rightWidgets"];
// v18: the shell-wide island settings became per-bar-config island* keys
var ISLAND_KEY_MOVES = {
dankIslandFloating: "islandFloating",
dankIslandUseOverlayLayer: "islandUseOverlayLayer",
dankIslandReserveHeight: "islandReserveThickness",
dankIslandCompactHeight: "islandCompactThickness",
dankIslandOuterGap: "islandOuterGap",
dankIslandHorizontalOffset: "islandAlongOffset",
dankIslandInteractionMode: "islandInteractionMode",
dankIslandHoverOpenDelay: "islandHoverOpenDelay",
dankIslandHoverCloseDelay: "islandHoverCloseDelay",
dankIslandPalette: "islandPalette",
dankIslandTransparency: "islandTransparency",
dankIslandCornerRadius: "islandCornerRadius",
dankIslandHighContrast: "islandHighContrast",
dankIslandMediaClockVisible: "islandMediaClockVisible",
dankIslandNotificationBadgeClearOnOpen: "islandNotificationBadgeClearOnOpen",
dankIslandNotificationExpand: "islandNotificationExpand",
dankIslandHomeCompactTight: "islandHomeCompactTight",
dankIslandHomeClockDisplay: "islandHomeClockDisplay",
dankIslandHomeVolumeDisplay: "islandHomeVolumeDisplay",
dankIslandHomeBrightnessDisplay: "islandHomeBrightnessDisplay",
dankIslandHomeLayout: "islandHomeLayout",
dankIslandBatteryStyle: "islandBatteryStyle",
dankIslandSatellitesEnabled: "islandSatellitesEnabled",
dankIslandSatellitePosition: "islandSatellitePosition",
dankIslandSatelliteGap: "islandSatelliteGap",
dankIslandSatelliteBackground: "islandSatelliteBackground",
dankIslandSatelliteGothCorners: "islandSatelliteGothCorners",
dankIslandSatelliteTransparency: "islandSatelliteTransparency",
dankIslandSatelliteSwoopRadius: "islandSatelliteSwoopRadius",
dankIslandReducedMotion: "islandReducedMotion",
dankIslandSpringStiffness: "islandSpringStiffness",
dankIslandSpringDamping: "islandSpringDamping",
dankIslandSpringMass: "islandSpringMass"
};
function migrateBatteryPillStyle(target) {
if (!target || typeof target !== "object" || target.batteryPillStyle === undefined)
return;
@@ -433,5 +470,28 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 17;
}
if (currentVersion < 18) {
console.info("Migrating settings from version", currentVersion, "to version 18");
console.info("Moving the shell-wide Dank Island onto its bar config as a per-instance mode");
var islandId = settings.dankIslandBarId;
var islandBars = Array.isArray(settings.barConfigs) ? settings.barConfigs : [];
for (var ib = 0; ib < islandBars.length; ib++) {
if (!islandBars[ib] || islandBars[ib].id !== islandId)
continue;
islandBars[ib].island = true;
for (var oldKey in ISLAND_KEY_MOVES) {
if (!(oldKey in settings))
continue;
islandBars[ib][ISLAND_KEY_MOVES[oldKey]] = settings[oldKey];
}
}
for (var dropKey in ISLAND_KEY_MOVES)
delete settings[dropKey];
delete settings.dankIslandBarId;
settings.configVersion = 18;
}
return settings;
}
+1 -1
View File
@@ -37,7 +37,7 @@ Item {
property bool osdSurfacesLoaded: false
property int pendingOsdResumeReloads: 0
readonly property var dankIslandScreens: SettingsData.getIslandScreens()
readonly property var dankIslandScreens: Quickshell.screens.filter(screen => SettingsData.dankIslandCoversScreen(screen))
readonly property var notificationPopupScreens: {
const screens = SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications");
return root.withoutDankIslandScreens(screens);
+6 -5
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;
@@ -964,9 +964,6 @@ Item {
const posValue = positionMap[position.toLowerCase()];
if (posValue === undefined)
return "BAR_INVALID_POSITION";
// The island silhouette only anchors top or bottom.
if (SettingsData.isIslandBarConfig(barConfig) && posValue !== SettingsData.Position.Top && posValue !== SettingsData.Position.Bottom)
return "BAR_IS_ISLAND";
SettingsData.updateBarConfig(barConfig.id, {
position: posValue
});
@@ -1866,6 +1863,8 @@ Item {
function open(): string {
root.workspaceRenameModalLoader.active = true;
if (root.workspaceRenameModalLoader.item) {
if (CompositorService.isAqueous)
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
@@ -1888,6 +1887,8 @@ Item {
root.workspaceRenameModalLoader.item.hide();
return "WORKSPACE_RENAME_MODAL_CLOSED";
}
if (CompositorService.isAqueous)
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
@@ -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)
+67 -31
View File
@@ -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);
@@ -1688,18 +1679,60 @@ Item {
return result;
}
function _diskCacheSectionDef(sectionId) {
for (var i = 0; i < sectionDefinitions.length; i++) {
if (sectionDefinitions[i].id === sectionId)
return sectionDefinitions[i];
}
return null;
}
function _diskCacheSection(def, items) {
return {
id: def.id || "",
title: def.title || "",
icon: def.icon || "",
priority: def.priority || 0,
items: items,
collapsed: false,
flatStartIndex: 0
};
}
function _loadDiskCache() {
var cached = CacheData.loadLauncherCache();
if (!cached || !Array.isArray(cached) || cached.length === 0)
return null;
var browseItems = getPluginBrowseItems();
var liveCoreItems = {};
var liveCoreApps = AppSearchService.getCoreApps("");
for (var c = 0; c < liveCoreApps.length; c++) {
var coreItem = transformCoreApp(liveCoreApps[c]);
liveCoreItems[coreItem.id] = coreItem;
}
var sectionsData = [];
var browseSectionSeen = false;
for (var i = 0; i < cached.length; i++) {
var s = cached[i];
if (s.id === "browse_plugins") {
browseSectionSeen = true;
if (browseItems.length > 0)
sectionsData.push(_diskCacheSection(_diskCacheSectionDef(s.id) || s, browseItems));
continue;
}
var items = [];
var srcItems = s.items || [];
for (var j = 0; j < srcItems.length; j++) {
var it = srcItems[j];
if (it.isCore) {
if (liveCoreItems[it.id])
items.push(liveCoreItems[it.id]);
continue;
}
if (it.type === "plugin_browse" || it.isBuiltInLauncher || it.pluginId)
continue;
items.push({
id: it.id || "",
type: it.type || "app",
@@ -1709,15 +1742,12 @@ Item {
iconType: it.iconType || "image",
iconFull: it.iconFull || "",
section: it.section || "",
isCore: it.isCore || false,
isBuiltInLauncher: it.isBuiltInLauncher || false,
pluginId: it.pluginId || "",
source: it.source || "",
data: {
id: it.id
},
actions: _actionsFromDesktopEntry(it.id),
primaryAction: it.type === "app" && !it.isCore ? {
primaryAction: it.type === "app" ? {
name: I18n.tr("Launch"),
icon: "open_in_new",
action: "launch"
@@ -1729,16 +1759,22 @@ Item {
_preScored: undefined
});
}
sectionsData.push({
id: s.id || "",
title: s.title || "",
icon: s.icon || "",
priority: s.priority || 0,
items: items,
collapsed: false,
flatStartIndex: 0
});
if (items.length > 0)
sectionsData.push(_diskCacheSection(s, items));
}
if (!browseSectionSeen && browseItems.length > 0) {
var browseDef = _diskCacheSectionDef("browse_plugins");
if (browseDef)
sectionsData.push(_diskCacheSection(browseDef, browseItems));
}
if (sectionsData.length === 0)
return null;
sectionsData.sort(function (a, b) {
return a.priority - b.priority;
});
return sectionsData;
}
@@ -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
}
}
@@ -193,7 +193,7 @@ Item {
if (modelData.repo)
Qt.openUrlExternally(modelData.repo);
}
onInstallRequested: PluginService.installFromRegistry(modelData.name, modelData.type === "desktop")
onInstallRequested: PluginService.installFromRegistry(modelData.id, modelData.name, modelData.type === "desktop")
}
}
}
+5
View File
@@ -51,9 +51,14 @@ DankModal {
const topChrome = Math.max(bar && barPosition === SettingsData.Position.Top ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "top"));
const bottomChrome = Math.max(bar && barPosition === SettingsData.Position.Bottom ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "bottom"));
const leftChrome = Math.max(bar && barPosition === SettingsData.Position.Left ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "left"));
const rightChrome = Math.max(bar && barPosition === SettingsData.Position.Right ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "right"));
const minY = topChrome + margin;
const maxY = screenH - modalHeight - bottomChrome - margin;
const minX = leftChrome + margin;
const maxX = screenW - modalWidth - rightChrome - margin;
targetX = Math.max(minX, Math.min(maxX, targetX));
targetY = Math.max(minY, Math.min(maxY, targetY));
return Qt.point(targetX, targetY);
@@ -92,8 +92,8 @@ Rectangle {
root.leaveHiddenTab(33);
}
function onDankIslandBarIdChanged() {
if (!SettingsData.dankIslandBarId)
function onIslandBarConfigsChanged() {
if (SettingsData.islandBarConfigs.length === 0)
root.leaveHiddenTab(46);
}
}
@@ -125,7 +125,7 @@ Rectangle {
return false;
if (item.frameOnly && !SettingsData.frameEnabled)
return false;
if (item.islandOnly && !SettingsData.dankIslandBarId)
if (item.islandOnly && SettingsData.islandBarConfigs.length === 0)
return false;
if (item.cellularOnly && (NetworkService.cellularDevices?.length ?? 0) === 0)
return false;
+39 -1
View File
@@ -9,6 +9,8 @@ DankFloatingWindow {
readonly property var log: Log.scoped("WorkspaceRenameModal")
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
property var aqueousWorkspace: null
property bool renaming: false
objectName: "workspaceRenameModal"
title: I18n.tr("Rename Workspace")
@@ -19,9 +21,24 @@ DankFloatingWindow {
onClosed: hide()
function show(name) {
if (CompositorService.isAqueous) {
if (renaming)
return false;
const workspace = AqueousService.workspaces.find(ws => ws.output === AqueousService.seat?.output && ws.active);
if (!AqueousService.available || !AqueousService.seat || !workspace || !AqueousService.capabilities.commands || AqueousService.locked) {
ToastService.showError(I18n.tr("Error"), I18n.tr("Unavailable"));
return false;
}
aqueousWorkspace = {
id: workspace.id,
session: workspace.aqueousSession
};
name = workspace.name;
}
nameInput.text = name;
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
return true;
}
function hide() {
@@ -29,6 +46,24 @@ DankFloatingWindow {
}
function submitAndClose() {
if (aqueousWorkspace || CompositorService.isAqueous) {
if (!aqueousWorkspace || renaming)
return;
const target = aqueousWorkspace;
renaming = true;
AqueousService.command("workspace.rename", {
id: target.id,
session: target.session,
name: nameInput.text
}, success => {
if (root.aqueousWorkspace !== target)
return;
root.renaming = false;
if (success)
root.hide();
});
return;
}
renameWorkspace(nameInput.text);
hide();
}
@@ -49,6 +84,8 @@ DankFloatingWindow {
return;
}
nameInput.text = "";
aqueousWorkspace = null;
renaming = false;
}
FocusScope {
@@ -133,7 +170,7 @@ DankFloatingWindow {
textColor: Theme.surfaceText
placeholderText: I18n.tr("Workspace name")
backgroundColor: "transparent"
enabled: root.visible
enabled: root.visible && !root.renaming
onAccepted: submitAndClose()
}
}
@@ -190,6 +227,7 @@ DankFloatingWindow {
MouseArea {
id: renameArea
enabled: !root.renaming
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
@@ -34,6 +34,10 @@ PluginComponent {
TailscaleService.connectTailscale(null);
}
onCcWidgetExpanded: {
TailscaleService.refresh(null);
}
ccDetailContent: Component {
Rectangle {
id: detailRoot

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