1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-10 15:38:31 -04:00

Compare commits

..

89 Commits

Author SHA1 Message Date
purian23 6cc574ea5b refactor: unify media control calls to MprisController sync 2026-07-15 14:15:30 -04:00
bbedward cdaedad969 media: throttle frame rate of media-related animations
port 1.5

related #2869
2026-07-15 12:57:25 -04:00
bbedward bf408f8d00 i18n: cut down terms and sync
port 1.5
2026-07-15 11:05:07 -04:00
purian23 b169fe0d77 feat: new PAM auth management & settings in greeter/lockscreen
- Introduce external management of greetd PAM
- New functionality to validate and apply custom PAM service paths in lockscreen

Port 1.5
2026-07-15 10:38:06 -04:00
bbedward 9b67cedaa1 battery: dont scale values and skew the actual battery values
fixes #2867

port 1.5
2026-07-15 10:20:59 -04:00
bbedward 2edf70a144 tailscale: fix inconsistency in styling of control center widget
port 1.5
2026-07-15 10:15:32 -04:00
bbedward 72a71cacba hyprland: scale overview displays relative to their actual scale
port 1.5
2026-07-15 10:10:33 -04:00
bbedward 0440e40a82 compositor: improve compositor detection
port 1.5
2026-07-15 09:45:25 -04:00
bbedward ec27d4643c qs: improve initial load of wallpaper, dock, and desktop plugins 2026-07-15 09:38:19 -04:00
bbedward f18d36f6c2 screenshot: add --json flag to capture metadata
fixes #2852

port 1.5
2026-07-15 09:17:55 -04:00
purian23 3254cc6a1e update release & changelog docs formatting
port 1.5
2026-07-15 00:57:26 -04:00
bbedward 4af3225f21 network: avoid dropping to legacy service when loading 2026-07-14 22:56:28 -04:00
bbedward 3c5245914f qs: use asynchronous loaders to load shell core 2026-07-14 22:35:58 -04:00
bbedward 89814a2c65 i18n: sync 2026-07-14 22:28:58 -04:00
bbedward 5d2093e54a gamma: fix scheduler losing track during suspend
port 1.5
2026-07-14 22:26:44 -04:00
purian23 3cd52ca327 greeter: update success message 2026-07-14 20:01:06 -04:00
purian23 5b41d699fa fix(greeter): reimplement regression to hide debug logs during login
- Provide more status feedback
- Allow 2 fprint tries within 10 seconds for DMS-managed PAM
- Updated dms greeter docs

Note: distro-managed PAM takes precedence and may use different limits.
DMS auth changes remain conditional on greetd being installed.

Fixes #2853
Port 1.5
2026-07-14 19:41:36 -04:00
bbedward 544599bf1c media: fix track art flash from chrome changing media art sizes on track
change

port 1.5
2026-07-14 18:44:42 -04:00
bbedward 296b3a3d7e mango: add separate dispatch socket
port 1.5
2026-07-14 17:09:34 -04:00
bbedward 729a990fa7 dbar: missing show on overview from search index
port 1.5
2026-07-14 16:35:26 -04:00
bbedward c3fa7b2e1d cava: more optimizations for visualizer
related #2863
2026-07-14 14:29:18 -04:00
bbedward 2c5a1a2804 cava: optimize CPU burn
related #631
2026-07-14 14:16:05 -04:00
bbedward 1973526c4e plugins: prevent churn of daemon plugins by not re-creating the entire map
fixes #2860

port 1.5
2026-07-14 11:46:40 -04:00
Callum Wong bba5502960 Smooth battery time remaining estimate (#2854)
* feat(battery): smooth time remaining estimate with moving average

* feat(battery): use a time-weighted EMA for time remaining estimate

port 1.5
2026-07-14 11:32:48 -04:00
Alexis Corporal 52740290b2 chore: fix indentation in niri media key bindings. (#2858) 2026-07-14 11:26:11 -04:00
bbedward 27703575bc plugins: use grid-style plugin browser
port 1.5
2026-07-14 11:25:10 -04:00
bbedward e6504add7b core/cli: expose QR code CLI
port 1.5
2026-07-14 10:22:17 -04:00
Huỳnh Thiện Lộc f1e9121295 fix(Modal): respect targetScreen property instead of always using focused screen (#2861)
The open() method in both DankModalStandalone and DankModalConnected unconditionally set contentWindow.screen to the focused screen, ignoring the targetScreen property.

Fix: use root.targetScreen ?? CompositorService.getFocusedScreen() so the modal appears on the configured screen when set, and falls back to focused screen otherwise.
2026-07-14 08:42:08 -04:00
bbedward 9ff751b82a dock: fix dock s howing with no apps
port 1.5
2026-07-13 22:47:22 -04:00
Kilian Mio 21eaaef056 Feature/split move size hyprland windowrules (#2824)
* windowrules: add split move/size fields for Lua table syntax

* windowrules: remove deprecated Move/Size fields, switch QML to split fields

* fix: use resolved dms binary path in Proc.runCommand
2026-07-13 18:30:26 -04:00
bbedward bb0be2b215 clipboard: store text alongside image when both are offered
related #2849
port 1.5
2026-07-13 18:23:00 -04:00
bbedward 25847c3f03 config/hyprland: set empty kb layout string by default
fixes #2851
port 1.5
2026-07-13 17:43:09 -04:00
bbedward 7b5c25c50f launcher/spotlight: improve height-change animation
port 1.5
2026-07-13 16:38:40 -04:00
bbedward 7535b70fa6 dash: allow hiding all tabs
port 1.5
2026-07-13 16:11:15 -04:00
bbedward e3034e4e94 Merge branch 'master' of github.com:AvengeMedia/DankMaterialShell 2026-07-13 16:03:56 -04:00
bbedward 3da19e5c15 launcher: add option to choose spotlight style on niri overview 2026-07-13 16:01:08 -04:00
Rafi ca89e12963 core: fix security and concurrency issues found in a backend audit (#2805)
* core: fix security and concurrency issues found in backend audit

Security:
- privesc: pipe the sudo password via stdin (sudo -S) instead of
  embedding it in the command string, so it no longer appears in argv
  (readable by any local user via /proc/<pid>/cmdline or ps)
- greeter: tokenize a session .desktop Exec= line into argv and execve
  directly instead of running it through /bin/sh -c, closing a command-
  injection path via user-writable ~/.local/share/wayland-sessions
- plugins: reject path-separator/.. in plugin id/name before joining
  into a filesystem path, closing an arbitrary-directory-delete in the
  uninstall/update fallback
- keybinds/hyprland: always quote unrecognized bind actions/keys when
  writing generated Lua; only re-emit genuine round-tripped custom Lua
  verbatim (tracked via an explicit flag), closing a Lua-injection path
- desktop/mimeapps: reject newline/bracket in mime/desktop-id fields so
  they can't inject fake sections into the shared mimeapps.list

Robustness / concurrency:
- server: recover panics in the request-dispatch path so one bad
  handler can't crash the daemon and drop every client
- go-wayland: recover panics in the shared dispatch choke point so a
  malformed compositor event can't crash CLI tools / the daemon
- server: per-connection D-Bus client ID instead of a shared constant,
  fixing cross-client signal delivery and subscription teardown
- network: guard the NetworkManager device maps with a mutex (a
  concurrent map read/write here is an unrecoverable fatal error)
- cups: close the event channel on Stop() so Unsubscribe() of the last
  subscriber no longer deadlocks; allocate the fresh channel in Start()
- freedesktop: reuse the shared session conn for the settings watcher
  and tear it down in Close(), fixing a per-Manager conn+goroutine leak
- clipboard: mutex-guard lazy dbusConn creation
- geolocation: use WithMatchMember for the GeoClue2 LocationUpdated
  signal (was WithMatchSender with an interface.member string, so the
  match never fired and live location updates never arrived)
- screenshot: set failed=true on buffer/pool creation errors so the
  dispatch loop doesn't wait forever for a ready/failed that never comes

* apply code review comments

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-07-13 15:44:42 -04:00
arfan 4fb6995796 workspace switcher: fix apps icon won't re focus (#2830)
* fix(workspace): update delegate data on window focus change

* fix(workspace): update delegate data handling specific for hyprland event
2026-07-13 14:59:53 -04:00
Huỳnh Thiện Lộc 3f5a54aa88 fix(dash): resolve pointer cursor hover issue in connected mode (#2845)
* fix(dash): cursor not changing to pointer in Connected Mode (#2831)

Replaces the full-screen background dismissal MouseArea in
DankPopoutConnected.qml with four edge strips that exclude
the popup body. The full-screen MouseArea at z:-1 was
suppressing child cursorShape propagation on Wayland when
combined with the full-screen input mask.

Blame: the connected popout architecture itself (the issue
does not repro in Separate Mode where background dismissal
lives in a separate PanelWindow).

* fix(dash): resolve pointer cursor hover issue in connected mode
2026-07-13 14:58:02 -04:00
Arsenijs Kitajevs d379d251b9 Fixed bluetooth UI bug mentioned in #2627 (#2850) 2026-07-13 14:57:41 -04:00
purian23 7ab0e01573 refactor(dms-updater): remove hidden updates display in popout view 2026-07-13 12:37:04 -04:00
bbedward 2cb48aaf6b listview: workaround delegates overlapping and make spotlight launcher
have a stable anchor

port 1.5
2026-07-13 12:30:49 -04:00
purian23 e4657aa5f9 feat(dms updater): add support for ignoring specific packages during system updates
- Added UI component & popout settings to manage ignored packages
- Added CLI support available in danklinux docs

Fixes: #2827
Closes: #2344, #1741
Port 1.5
2026-07-13 12:02:13 -04:00
bbedward 197d17ac4e launcher: add IPC feature-parity to spotlight-bar and improve list view
transitions

port 1.5
2026-07-13 11:25:01 -04:00
bbedward 31ea83584b i18n: add arabic 2026-07-13 10:46:38 -04:00
bbedward ea66b136ba launcher: don't select pre-filled queries, exclude from memory
port 1.5
2026-07-13 10:31:17 -04:00
bbedward 06c0ea2afb i18n: sync 2026-07-13 09:47:21 -04:00
bbedward f4f47c0bc5 time: add follow-locale option and squash separate greeter time options
port 1.5
2026-07-13 08:53:37 -04:00
purian23 8a0ed8a50f fix(port-audit): update git fetch command to include prune opt
port 1.5
2026-07-13 01:14:33 -04:00
purian23 63eea01243 refactor(debian): prefer native Debian Quickshell stable package support in dankinstall
Port 1.5
2026-07-13 00:39:06 -04:00
purian23 f590a2965a fix(dash-tabs): update visibility logic for dash tab elements
Fixes #2822

Port 1.5
2026-07-12 23:57:29 -04:00
purian23 4ab03deded refactor(settings): allow search settings to remain on sidebar until dismissed
Closes #2780

Port 1.5
2026-07-12 23:30:40 -04:00
sweenu 846d07d86a feat(ipc): add settings dump to print full live config as JSON (#2817)
Adds `dms ipc call settings dump`, returning the complete live
configuration via SettingsData.getCurrentSettingsJson() — the same
serialization used by the read-only banner's copy button.

Useful when settings.json is a read-only symlink (NixOS/home-manager):
changes made in the Settings UI only exist in the running shell's
memory, and this makes them retrievable from the CLI instead of only
through the clipboard button in the Settings modal.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Port 1.5
2026-07-12 22:57:51 -04:00
AiraNadih 55d88d8dfb fix(process-list): defer IPC actions until modal is loaded (#2829)
Port 1.5
2026-07-12 22:57:24 -04:00
Mati7235 a803f96f41 dms doctor: detect missing XDG_MENU_PREFIX when KDE apps are installed (#2840)
* dms doctor: warn when XDG_MENU_PREFIX is missing with KDE apps

Under non-Plasma compositors (niri, Hyprland, Sway), Dolphin's
'Open with...' dialog appears empty because KService/KApplicationTrader
requires a populated XDG menu tree, which depends on XDG_MENU_PREFIX.

This check warns the user when kde-cli-tools (keditfiletype) is
installed but XDG_MENU_PREFIX is not set.

Ref: https://specifications.freedesktop.org/menu-spec/latest/

* fix(dms doctor): improve warning message for missing XDG_MENU_PREFIX

Port 1.5

---------

Co-authored-by: Matias <git@matias7235.com.ar>
2026-07-12 22:54:28 -04:00
A042 da7cc5648b fix: use sleep_monitor/wakeup_monitor for DPMS to preserve monitor layout (#2842)
Since Mango v0.15.0, disable_monitor sets only_sleep=0 which removes the
output from the layout entirely. On NVIDIA (proprietary driver), the DRM
handshake fails on re-enable, leaving a black screen recoverable only via
TTY switch.

sleep_monitor/wakeup_monitor set only_sleep=1, keeping the output in the
layout while powered off. This matches the pre-v0.15 behavior and fixes
DPMS wake on NVIDIA.

These commands are available since Mango v0.15.0 (commit b0326d7).
2026-07-12 18:28:56 -04:00
bbedward 75443758f9 fix(printers): fix add by address with manually entered host
port 1.5
2026-07-12 18:27:51 -04:00
purian23 eaecff93a4 add(workflows): update notify_issues input to release flows
Port 1.5
2026-07-12 16:27:32 -04:00
purian23 6e7c20c59c feat(release): add issue notification script for retesting on new releases
Port 1.5
2026-07-12 15:58:17 -04:00
purian23 406dcfe110 refactor(workflows): update release refs to stable in CI configurations
Port 1.5
2026-07-12 15:23:04 -04:00
Huỳnh Thiện Lộc e2b3a2e3ca feat(media): add toggle for album art accent colors (#2831) (#2832)
* fix(media): use system colours in player instead of album cover accent

Closes #2831

Removes ColorQuantizer-based album art accent extraction from
MediaAccentService. All accent properties now return Theme.primary
and Theme.onPrimary directly, so the Dank Dash player always
matches the system colour scheme.

Blame: ee6f7b47 (introduced MediaAccentService & ColorQuantizer)
       d799175c (tweaked seekbar accent colours)
       a62ae336 (further integrated accent into player/album art)
       c44ffae7 (monochrome art edge case fixes)

* feat(media): add toggle for album art accent colours (#2831)

Adds mediaUseAlbumArtAccent setting (default: off) to Settings.
When enabled, MediaAccentService extracts accent from album art
via ColorQuantizer. When disabled, uses Theme.primary.
Toggle is in Settings > Media Player.

* fix: use american english spelling (colour -> color)

Port 1.5
2026-07-11 22:29:35 -04:00
purian23 71ab752e1b fix(void): updated dms-greeter stability support
- Updates will land in DMS v1.5.1 and users are advised to hold off on v1.5.0

Related #2788

Port 1.5
2026-07-11 01:07:00 -04:00
purian23 204ecd0461 feat(void): switch package repositories to R2 2026-07-10 18:14:13 -04:00
purian23 0fdfac366e ci(void): publish XBPS repository to R2 2026-07-10 17:57:28 -04:00
bbedward 52123458c7 widgets: fix dropdown/icon picker referencing a dead window
port 1.5
2026-07-10 17:21:10 -04:00
Artem Timofeev 26b2955cf3 fix(notifications): dismiss popups when senders close notifications (#2815)
When a sender calls CloseNotification (per freedesktop spec) for a
persistent notification (expire-timeout=0), the onDropped handler
removed the wrapper from internal arrays but did not dismiss the
visible popup. The popup remained on screen forever for non-critical
notifications like YubiKey touch prompts.

Set wrapper.popup=false in onDropped so the popup exits via the
normal signal chain. This is minimal; it does not change timeout
handling, queue management, or the isPersistent marker.

Also reverts the previous workaround that special-cased non-critical
expireTimeout=0 to use DMS timeout. Upstream spec-correct behavior
is to honor the sender timeout, and fix the dismissal bug instead.

Closes #2814
2026-07-10 17:08:03 -04:00
bbedward d82d86df5c theme: prevent failed portal writes from reverting theme mode
related: #2786

port 1.5
2026-07-10 15:44:36 -04:00
Kangheng Liu c445597f83 fix(player): combine trackid with text identity (#2808)
fixes #2807
2026-07-10 13:37:16 -04:00
bbedward 6a58adfb29 osd/media playback: delay showing until album art is ready and avoid
re-showing when duplicate metadata comes from mpris
related #2787
port 1.5
2026-07-10 13:35:51 -04:00
bbedward 05feb211ba audio: only show mic volume OSD through DMS IPCs, not external source
changes
fixes #2790
port 1.5
2026-07-10 12:43:40 -04:00
purian23 a3b2167e58 workflow: enhance porting logic to support inline verbiage & audits 2026-07-10 11:57:36 -04:00
bbedward 0b69feaa1a settings: restore lost geometric centering option
port 1.5
2026-07-10 11:12:08 -04:00
purian23 494144a7c7 workflow: updated target extraction regex options 2026-07-10 11:09:05 -04:00
bbedward 2a2c1ca9e6 calendar: add action button to events for opening links
fixes #2799
port 1.5
2026-07-10 11:02:25 -04:00
bbedward 56b7ecb008 qs/common: fix path decoding
fixes #2802
port 1.5
2026-07-10 10:49:50 -04:00
bbedward 867102b82c network/iwd: improve bad credential handling
related #2804

port: 1.5
2026-07-10 10:36:22 -04:00
Scott McKendry 4bdb7d17b2 fix(settings): battery tab items not in search (#2794) 2026-07-10 10:17:51 -04:00
bbedward c44ffae751 fix(media): resolve monochrome album art accents
port: 1.5
2026-07-09 19:40:21 -04:00
14Do ce1595d62d fix(ListViewTransitions): null transitions when duration truncates to 0ms (#2791)
A zero-duration ViewTransition still engages ListView's transition
machinery but resolves within the same frame, so removed delegates are
never released and displaced items are never repositioned. On a filtered
ScriptModel that re-filters on every keystroke this leaves stale rows and
empty gaps behind, visible in any DankListView-backed list.

The shortest sub-duration (remove/add = expressiveDurations.fast =
base * 0.4) is coerced to an int, so it truncates to 0ms for any
animation base duration below 3: animation speed None (0), or a Custom
duration of 1-2ms (1*0.4=0.4->0, 2*0.4=0.8->0, only 3*0.4=1.2->1
survives). Presets (250/500/750) are unaffected, which is why this only
reproduces with animations disabled or a very small custom value.

Gate the four transitions to null whenever that shortest sub-duration
would truncate to 0, so the view takes the correct instant path instead
of a broken 0ms transition. Base >= 3 is unchanged.
2026-07-09 18:47:26 -04:00
Evan Maddock 45f6232e32 build: Add support for DESTDIR (#2783)
This makes it easier for distros to use the Makefile when creating
packages. It enables us to specify a base destination directory to
install the project files to. E.g., on Solus, when creating eopkgs, files
must be installed to a special directory path, which becomes the package
files. I believe Fedora packages, and others, are the same.

Signed-off-by: Evan Maddock <maddock.evan@vivaldi.net>
2026-07-09 18:39:06 -04:00
bbedward 9cf2ca7196 auth: add some more intelligent pam config resolution for lock screen
and greeter
related #2789

port: 1.5
2026-07-09 15:07:22 -04:00
purian23 c0eeed4e89 workflows: update deps
port/1.5
2026-07-09 12:53:57 -04:00
bbedward d0a4c1c56e default apps: add configuration for geo: URIs
port: 1.5
2026-07-09 12:23:44 -04:00
purian23 cb0dc9c68d workflow: update porting logic 2026-07-09 12:02:14 -04:00
bbedward 0439d017b9 fix(notifications): handle sound-name hint 2026-07-09 12:00:36 -04:00
purian23 8008238ca0 Add GitHub workflows for release management 2026-07-09 11:26:30 -04:00
bbedward a095d0ed90 changelog: disable 2026-07-08 16:42:30 -04:00
purian23 a48cce59d4 fix(workflow): void stable packages 2026-07-08 13:52:12 -04:00
bbedward 4c806f83f1 bump to 1.6-beta 2026-07-08 13:10:39 -04:00
182 changed files with 2071 additions and 7609 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -51,7 +51,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
+2 -11
View File
@@ -43,7 +43,7 @@ jobs:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -55,15 +55,6 @@ jobs:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Preflight — verify Release workflow is dispatchable
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/${{ github.repository }}/actions/workflows/release.yml" \
--jq '.state' | grep -qx active ||
{ echo "::error::release.yml is not dispatchable; aborting before any push"; exit 1; }
- name: Port audit (informational)
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
@@ -92,7 +83,7 @@ jobs:
- name: Dispatch Release workflow
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
-f tag="${{ steps.derive.outputs.tag }}" \
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
+4 -18
View File
@@ -91,30 +91,21 @@ var authListServicesCmd = &cobra.Command{
var authValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate a PAM service file for use by the DMS lock screen",
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
Short: "Validate a PAM service file for use as the DMS lock-screen password stack",
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password stack. Exits 1 when the file is not usable.",
Run: func(cmd *cobra.Command, args []string) {
path, _ := cmd.Flags().GetString("path")
service, _ := cmd.Flags().GetString("service")
purpose, _ := cmd.Flags().GetString("purpose")
asJSON, _ := cmd.Flags().GetBool("json")
if (path == "") == (service == "") {
log.Fatalf("Error: exactly one of --path or --service is required")
}
if purpose != "password" && purpose != "u2f" {
log.Fatalf("Error: --purpose must be password or u2f")
}
var result sharedpam.LockscreenPamValidation
switch {
case service != "":
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamService(service)
} else {
result = sharedpam.ValidateLockscreenPamService(service)
}
result = sharedpam.ValidateLockscreenPamService(service)
case !filepath.IsAbs(path):
result = sharedpam.LockscreenPamValidation{
Path: path,
@@ -123,11 +114,7 @@ var authValidateCmd = &cobra.Command{
Errors: []string{"--path must be an absolute file path"},
}
default:
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamPath(path)
} else {
result = sharedpam.ValidateLockscreenPamPath(path)
}
result = sharedpam.ValidateLockscreenPamPath(path)
}
if asJSON {
@@ -172,7 +159,6 @@ func init() {
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
}
+82 -160
View File
@@ -449,7 +449,12 @@ func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) {
func checkDMSInstallation() []checkResult {
var results []checkResult
dmsPath := resolveDoctorShellPath()
dmsPath := ""
if err := findConfig(nil, nil); err == nil && configPath != "" {
dmsPath = configPath
} else if path, err := config.LocateDMSConfig(); err == nil {
dmsPath = path
}
if dmsPath == "" {
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
@@ -1162,182 +1167,99 @@ func formatResultsPlain(results []checkResult) string {
return sb.String()
}
const (
defaultDoctorFontFamily = "Inter Variable"
defaultDoctorMonoFontFamily = "Fira Code"
)
// bundledFontRelPaths maps settings/default family names to font files shipped with
// the shell and loaded via Qt FontLoader (not registered with fontconfig).
var bundledFontRelPaths = map[string][]string{
"inter variable": {
"DankCommon/assets/fonts/inter/InterVariable.ttf",
"assets/fonts/inter/InterVariable.ttf",
},
"fira code": {
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
},
"firacode nerd font": {
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
},
}
func resolveDoctorShellPath() string {
if err := findConfig(nil, nil); err == nil && configPath != "" {
return configPath
}
if path, err := config.LocateDMSConfig(); err == nil {
return path
}
return ""
}
func findBundledFontFile(shellPath, family string) string {
if shellPath == "" {
return ""
}
relPaths, ok := bundledFontRelPaths[strings.ToLower(strings.TrimSpace(family))]
if !ok {
return ""
}
for _, rel := range relPaths {
path := filepath.Join(shellPath, rel)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path
}
}
return ""
}
func isBundledDefaultFont(family string) bool {
_, ok := bundledFontRelPaths[strings.ToLower(strings.TrimSpace(family))]
return ok
}
func fontInFcList(name, cacheLower string) bool {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
return false
}
for _, line := range strings.Split(cacheLower, "\n") {
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailable bool, url string) checkResult {
if bundled := findBundledFontFile(shellPath, family); bundled != "" {
details := "Bundled (Qt FontLoader)"
if doctorVerbose {
details = bundled
}
return checkResult{catFonts, label, statusOK, family, details, url}
}
if isBundledDefaultFont(family) {
if shellPath == "" {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not verified", family),
"Could not locate shell config to verify bundled font files.",
url,
}
}
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' bundled file missing", family),
"Expected font file missing from shell install. Reinstall DMS or check DankCommon assets.",
url,
}
}
if !fcListAvailable {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not verified", family),
"fc-list not installed; cannot verify custom fonts in fontconfig.",
url,
}
}
if fcCache == "" {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not found", family),
"Fontconfig cache is empty or unreadable. Try running 'fc-cache -fv'.",
url,
}
}
if fontInFcList(family, fcCache) {
return checkResult{catFonts, label, statusOK, family, "Available via fontconfig", url}
}
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not found", family),
"Font is not registered with fontconfig. Try running 'fc-cache -fv' or install the font.",
url,
}
}
func checkFonts() []checkResult {
var results []checkResult
url := doctorDocsURL + "#fonts"
fontFamily := defaultDoctorFontFamily
monoFontFamily := defaultDoctorMonoFontFamily
configDir, err := os.UserConfigDir()
if err != nil {
return nil
}
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
if configDir, err := os.UserConfigDir(); err == nil {
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
fontFamily := "Inter Variable"
monoFontFamily := "Fira Code"
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
fontFamily = settings.FontFamily
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
fontFamily = settings.FontFamily
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
}
}
shellPath := resolveDoctorShellPath()
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily)
if !utils.CommandExists("fc-list") {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify if fonts are cached.", url})
return results
}
fcListAvailable := utils.CommandExists("fc-list")
fcCache := ""
// Retrieve font list
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
return results
}
if needFontconfig {
if !fcListAvailable {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify custom fonts in fontconfig cache.", url})
} else {
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
} else {
fcCache = strings.ToLower(string(output))
if len(strings.TrimSpace(fcCache)) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
outStr := string(output)
if len(strings.TrimSpace(outStr)) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
return results
}
lowerFonts := strings.ToLower(outStr)
// Helper to check if a font exists
hasFont := func(name string) bool {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
return false
}
for _, line := range strings.Split(lowerFonts, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Each line can have comma-separated families
families := strings.Split(line, ",")
for _, fam := range families {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
results = append(results,
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
)
// Normal Font Check
if hasFont(fontFamily) {
results = append(results, checkResult{catFonts, "Normal Font", statusOK, fontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Normal Font", statusWarn,
fmt.Sprintf("'%s' not found", fontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
// Monospace Font Check
if hasFont(monoFontFamily) {
results = append(results, checkResult{catFonts, "Monospace Font", statusOK, monoFontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Monospace Font", statusWarn,
fmt.Sprintf("'%s' not found", monoFontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
return results
}
+4 -39
View File
@@ -51,9 +51,8 @@ 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)
last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
Output format (--format):
png - PNG format (default)
@@ -73,9 +72,7 @@ Examples:
dms screenshot --no-confirm # Region capture on mouse release
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 scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
dms screenshot --json # Print capture metadata as JSON`,
}
var ssRegionCmd = &cobra.Command{
@@ -113,33 +110,10 @@ 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 and Mango.`,
Run: runScreenshotWindow,
}
var ssScrollInterval int
var ssScrollCmd = &cobra.Command{
Use: "scroll",
Short: "Capture a scrolling region stitched into one tall image",
Long: `Select a region, then scroll the content beneath with the mouse wheel or
touchpad while frames are captured and stitched vertically. Finish with the
on-screen done button; cancel with the cancel button. Enter and Esc work
everywhere: most compositors hold the keyboard on the overlay (keyboard
scrolling does not reach the app there), while Hyprland leaves the keyboard
with the application — keyboard scrolling works, and Enter/Esc act through
temporary global binds for the session. The cursor is never included in
frames.
Frames are stitched continuously while scrolling, and revisited content is
never duplicated — scrolling up past the starting point extends the image
upward. Content jumped past faster than capture can follow is skipped rather
than stitched incorrectly.
Rotated outputs are not supported.`,
Run: runScreenshotScroll,
}
var ssListCmd = &cobra.Command{
Use: "list",
Short: "List available outputs",
@@ -169,10 +143,7 @@ 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")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
screenshotCmd.AddCommand(ssRegionCmd)
screenshotCmd.AddCommand(ssScrollCmd)
screenshotCmd.AddCommand(ssFullCmd)
screenshotCmd.AddCommand(ssAllCmd)
screenshotCmd.AddCommand(ssOutputCmd)
@@ -278,7 +249,7 @@ func runScreenshot(config screenshot.Config) {
// Region select needs the keyboard; drop popout grabs for its duration.
result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
@@ -466,12 +437,6 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
runScreenshot(config)
}
func runScreenshotScroll(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeScroll)
config.IntervalMs = min(max(ssScrollInterval, 30), 1000)
runScreenshot(config)
}
func runScreenshotFull(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeFullScreen)
runScreenshot(config)
+7 -22
View File
@@ -313,7 +313,6 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
Lf, af, bf := cf.Lab()
Lf *= 100.0
dir := 1.0
if isLightMode {
@@ -342,7 +341,6 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
origL, af, bf := cf.Lab()
origL *= 100.0
var darkerResult, lighterResult string
darkerL, lighterL := origL, origL
@@ -422,24 +420,6 @@ func blendHue(base, target, factor float64) float64 {
return result
}
// color8 sits a fixed L* offset from the background so it keeps its dim role
// regardless of primary brightness (conventional palettes put ANSI bright
// black ~2-2.5:1 from the background, e.g. catppuccin-mocha #585b70 on #1e1e2e)
func DeriveDim(bgHex string, hue, sat float64, isLight bool) string {
offset := 22.0
if isLight {
offset = -offset
}
bgL := getLstar(bgHex)
targetL := math.Max(0, math.Min(100, bgL+offset))
tint := HSVToRGB(HSV{H: hue, S: sat, V: 0.5})
c := colorful.Color{R: tint.R, G: tint.G, B: tint.B}
_, af, bf := c.Lab()
return labToHex(targetL, af, bf)
}
func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
@@ -520,7 +500,10 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := baseVal * 0.28
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
gray8S := baseSat * 0.05
gray8V := baseVal * 0.85
dimTarget := secondaryTarget * 0.5
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
brightRedS := math.Min(baseSat*1.0, 1.0)
brightRedV := math.Min(baseVal*1.2, 1.0)
@@ -576,7 +559,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := math.Min(baseVal*1.05, 1.0)
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
gray8S := baseSat * 0.15
gray8V := baseVal * 0.65
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
brightRedS := math.Min(baseSat*0.75, 1.0)
brightRedV := math.Min(baseVal*1.35, 1.0)
-70
View File
@@ -679,73 +679,3 @@ func TestContrastAlgorithmComparison(t *testing.T) {
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
}
func TestEnsureContrastDPSLightModeStaysLight(t *testing.T) {
tests := []struct {
name string
result string
bg string
minLstar float64
}{
{
name: "bidirectional adjustment",
result: EnsureContrastDPSBidirectional("#d0ccc6", "#f8f8f8", 17.5, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
{
name: "lstar adjustment",
result: EnsureContrastDPSLstar("#c0c0c0", "#f8f8f8", 30.0, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lstar := getLstar(tt.result)
if lstar < tt.minLstar {
t.Errorf("result %s has L* %.2f on light bg %s, expected >= %.2f (collapsed to near-black)",
tt.result, lstar, tt.bg, tt.minLstar)
}
})
}
}
func TestGeneratePaletteColor8Dim(t *testing.T) {
hues := []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#ef4444"}
for _, base := range hues {
t.Run(base, func(t *testing.T) {
palette := GeneratePalette(base, PaletteOptions{IsLight: false, UseDPS: true})
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio < 1.5 || bgRatio > 3.0 {
t.Errorf("Color8 %s vs bg %s ratio %.2f, expected 1.5-3.0 (bright black stays near bg)",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
sepRatio := ContrastRatio(palette.Color4.Hex, palette.Color8.Hex)
if sepRatio < 2.0 {
t.Errorf("Color4 %s vs Color8 %s ratio %.2f, expected >= 2.0 (blue must not collide with bright black)",
palette.Color4.Hex, palette.Color8.Hex, sepRatio)
}
})
}
}
func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
palette := GeneratePalette("#f59e0b", PaletteOptions{IsLight: true, UseDPS: true})
lstar := getLstar(palette.Color8.Hex)
if lstar < 60.0 {
t.Errorf("light mode Color8 %s has L* %.2f, expected >= 60 (dim grey, not near-black)",
palette.Color8.Hex, lstar)
}
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio > 3.0 {
t.Errorf("light mode Color8 %s vs bg %s ratio %.2f, expected <= 3.0",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
}
+7 -9
View File
@@ -53,11 +53,6 @@ func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDi
}
}
func emergeInstallArgs(packages []string) []string {
args := []string{"emerge", "--ask=n", "--quiet", "--autounmask-continue=y", "--autounmask-keep-keywords=y", "--autounmask-keep-masks=y"}
return append(args, packages...)
}
func (g *GentooDistribution) getArchKeyword() string {
arch := runtime.GOARCH
switch arch {
@@ -324,7 +319,6 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
}
g.log("Portage tree synced successfully")
args := emergeInstallArgs(missingPkgs)
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
@@ -332,10 +326,12 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
@@ -525,7 +521,8 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
}
}
args := emergeInstallArgs(packageNames)
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, packageNames...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
@@ -716,7 +713,8 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
guruPackages[i] = pkg + "::guru"
}
args := emergeInstallArgs(guruPackages)
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, guruPackages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
+1 -1
View File
@@ -7,7 +7,7 @@ type minimalInstallGroup struct {
func shouldPreferMinimalInstall(pkg string) bool {
switch pkg {
case "niri", "niri-git", "hyprland", "hyprland-git":
case "niri", "niri-git":
return true
default:
return false
+31 -1
View File
@@ -6,7 +6,37 @@ func NewClient() Client {
geoclueClient, err := newGeoClueClient()
if err != nil {
log.Warnf("GeoClue2 unavailable: %v", err)
return newIpClient()
return newSeededIpClient()
}
loc, _ := geoclueClient.GetLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
log.Info("Using GeoClue2 location")
return geoclueClient
}
log.Info("GeoClue2 has no fix yet, seeding with IP location")
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location seed failed: %v", err)
return geoclueClient
}
log.Info("Seeded GeoClue2 with IP location")
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
return geoclueClient
}
func newSeededIpClient() *IpClient {
client := newIpClient()
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location also failed: %v", err)
return client
}
log.Info("Using IP location")
client.currLocation.Latitude = ipLoc.Latitude
client.currLocation.Longitude = ipLoc.Longitude
return client
}
+6 -22
View File
@@ -34,7 +34,6 @@ const (
type GeoClueClient struct {
currLocation *Location
locationMutex sync.RWMutex
seedOnce sync.Once
dbusConn *dbus.Conn
clientPath dbus.ObjectPath
@@ -231,29 +230,14 @@ func (c *GeoClueClient) SeedLocation(loc Location) {
}
func (c *GeoClueClient) GetLocation() (Location, error) {
loc := c.currentLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
return loc, nil
}
c.seedOnce.Do(func() {
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("GeoClue2 has no fix, IP location seed failed: %v", err)
return
}
log.Info("Seeded GeoClue2 with IP location")
c.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
})
return c.currentLocation(), nil
}
func (c *GeoClueClient) currentLocation() Location {
c.locationMutex.RLock()
defer c.locationMutex.RUnlock()
if c.currLocation == nil {
return Location{}
return Location{
Latitude: 0.0,
Longitude: 0.0,
}, nil
}
return *c.currLocation
stateCopy := *c.currLocation
return stateCopy, nil
}
+2 -7
View File
@@ -124,9 +124,7 @@ func GetLogger() *Logger {
logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
logMu.Lock()
_ = setLogFile(logger, path)
logMu.Unlock()
_ = SetLogFile(path)
}
})
return logger
@@ -147,18 +145,15 @@ func SetLevel(level string) {
// profile when stderr is a TTY and route the file through ansiStripWriter so
// the file stays plain while stderr keeps its colors.
func SetLogFile(path string) error {
l := GetLogger()
logMu.Lock()
defer logMu.Unlock()
return setLogFile(l, path)
}
func setLogFile(l *Logger, path string) error {
if logFile != nil {
logFile.Close()
logFile = nil
}
l := GetLogger()
if path == "" {
l.SetOutput(logStderr)
applyColorProfile(l, logStderr)
+9 -25
View File
@@ -44,7 +44,6 @@ type TemplateDef struct {
ID string
Commands []string
Flatpaks []string
ConfigDirs []string
ConfigFile string
Kind TemplateKind
RunUnconditionally bool
@@ -61,9 +60,9 @@ var templateRegistry = []TemplateDef{
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigDirs: []string{"vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigDirs: []string{"Vencord"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigDirs: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
@@ -460,9 +459,9 @@ output_path = '%s'
case TemplateKindGTK:
switch opts.Mode {
case ColorModeLight:
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-light.toml")
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
default:
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-dark.toml")
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
}
case TemplateKindTerminal:
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
@@ -475,10 +474,10 @@ output_path = '%s'
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
}
default:
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
}
}
@@ -513,14 +512,13 @@ func appendConfig(
cfgFile *os.File,
checkCmd []string,
checkFlatpaks []string,
checkConfigDirs []string,
fileName string,
) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil {
return
}
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) && !configDirExists(checkConfigDirs) {
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
return
}
data, err := os.ReadFile(configPath)
@@ -601,20 +599,6 @@ func templateSessionActive(tmpl TemplateDef) bool {
return err == nil
}
func configDirExists(names []string) bool {
configHome := utils.XDGConfigHome()
if configHome == "" {
return false
}
for _, name := range names {
info, err := os.Stat(filepath.Join(configHome, name))
if err == nil && info.IsDir() {
return true
}
}
return false
}
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
// Both nil is treated as "skip check" / unconditionally run
if checkCmd == nil && checkFlatpaks == nil {
@@ -1112,7 +1096,7 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
case tmpl.Kind == TemplateKindEmacs:
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
default:
detected = (appExists(checker, tmpl.Commands, tmpl.Flatpaks) || configDirExists(tmpl.ConfigDirs)) && templateSessionActive(tmpl)
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && templateSessionActive(tmpl)
}
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
+8 -95
View File
@@ -38,7 +38,7 @@ func TestAppendConfigBinaryExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, nil, nil, "test.toml")
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -82,7 +82,7 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, nil, "test.toml")
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -122,7 +122,7 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, nil, "test.toml")
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -163,7 +163,7 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -203,7 +203,7 @@ func TestAppendConfigBothExist(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, nil, "test.toml")
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -244,7 +244,7 @@ func TestAppendConfigNeitherExists(t *testing.T) {
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -281,7 +281,7 @@ func TestAppendConfigNoChecks(t *testing.T) {
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, nil, "test.toml")
appendConfig(opts, cfgFile, nil, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -312,7 +312,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, nil, "nonexistent.toml")
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
@@ -487,90 +487,3 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
}
assert.NotContains(t, string(output), "[templates.dmsmango]")
}
func TestAppendConfigConfigDirExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "vencord config content"
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
configHome := filepath.Join(tempDir, "config")
if err := os.MkdirAll(filepath.Join(configHome, "Vencord"), 0o755); err != nil {
t.Fatalf("failed to create Vencord config dir: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", configHome)
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
assert.Equal(t, testConfig+"\n", string(output))
}
func TestAppendConfigConfigDirDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte("vencord config content"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
configHome := filepath.Join(tempDir, "config")
if err := os.MkdirAll(configHome, 0o755); err != nil {
t.Fatalf("failed to create config home: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", configHome)
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
assert.Empty(t, string(output))
}
+1 -71
View File
@@ -646,7 +646,6 @@ type lockscreenPamAnalysis struct {
inlineFingerprint bool
inlineU2f bool
modules []string
authModules []string
unknownDirectives []string
err error
}
@@ -731,9 +730,6 @@ func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack
}
if !foundModule && strings.HasSuffix(field, ".so") {
acc.modules = append(acc.modules, field)
if lineType == "auth" {
acc.authModules = append(acc.authModules, field)
}
foundModule = true
}
}
@@ -778,14 +774,6 @@ func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
return validateLockscreenPam("", path, defaultValidateDeps())
}
func ValidateLockscreenU2fPamService(name string) LockscreenPamValidation {
return validateLockscreenU2fPam(name, "", defaultValidateDeps())
}
func ValidateLockscreenU2fPamPath(path string) LockscreenPamValidation {
return validateLockscreenU2fPam("", path, defaultValidateDeps())
}
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
result := LockscreenPamValidation{
MissingModules: []string{},
@@ -850,64 +838,6 @@ func validateLockscreenPam(serviceName string, path string, deps lockscreenPamVa
return result
}
func validateLockscreenU2fPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
result := validateLockscreenPam(serviceName, path, deps)
if result.Path == "" {
return result
}
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
analysis := resolver.analyzePath(result.Path)
if analysis.err != nil {
return result
}
filteredWarnings := result.Warnings[:0]
for _, warning := range result.Warnings {
if strings.Contains(warning, "pam_u2f is present") && strings.Contains(warning, "double-prompt") {
continue
}
filteredWarnings = append(filteredWarnings, warning)
}
result.Warnings = filteredWarnings
hasU2fAuth := false
unsafeModules := []string{}
unsafeSeen := map[string]bool{}
for _, ref := range analysis.authModules {
name := filepath.Base(ref)
if name == "pam_u2f.so" {
hasU2fAuth = true
continue
}
switch name {
case "pam_env.so", "pam_faildelay.so", "pam_nologin.so":
continue
default:
if !unsafeSeen[name] {
unsafeSeen[name] = true
unsafeModules = append(unsafeModules, name)
}
}
}
if !hasU2fAuth {
result.Errors = append(result.Errors, "no pam_u2f auth directive found; select a dedicated security-key PAM service")
}
for _, name := range unsafeModules {
result.Errors = append(result.Errors, fmt.Sprintf("additional auth module %s is not allowed in a dedicated security-key PAM service", name))
}
for _, name := range result.MissingModules {
if strings.Contains(name, "pam_u2f") {
result.Errors = append(result.Errors, fmt.Sprintf("%s is not installed or its configured path is unavailable", name))
break
}
}
result.Valid = len(result.Errors) == 0
return result
}
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
if filepath.IsAbs(ref) {
_, err := deps.stat(ref)
@@ -965,7 +895,7 @@ func buildManagedLockscreenU2FPamContent() string {
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
if deps.isNixOS() {
logFunc(" NixOS detected. DMS does not write /etc/pam.d/dankshell; the lock screen uses a sanitized password-only service in the user state directory unless you select a custom PAM source.")
logFunc(" NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.")
return nil
}
+2 -80
View File
@@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
}
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") {
t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs)
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") {
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
}
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
@@ -994,84 +994,6 @@ func TestValidateLockscreenPam(t *testing.T) {
})
}
func TestValidateLockscreenU2fPam(t *testing.T) {
t.Parallel()
t.Run("accepts a dedicated U2F stack with custom options", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue authfile=/etc/u2f-mappings\naccount required pam_permit.so\n")
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid dedicated U2F stack, got %+v", result)
}
if !result.InlineU2f {
t.Fatalf("expected inline U2F detection, got %+v", result)
}
if containsSubstr(result.Warnings, "double-prompt") {
t.Fatalf("dedicated U2F validation should not warn about its expected U2F module: %v", result.Warnings)
}
})
t.Run("rejects a primary login stack that also prompts for a password", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "login", "#%PAM-1.0\nauth required pam_unix.so\nauth required pam_u2f.so cue\naccount required pam_unix.so\n")
result := validateLockscreenU2fPam("login", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected mixed password/U2F stack to be rejected, got %+v", result)
}
if !containsSubstr(result.Errors, "pam_unix.so") || !containsSubstr(result.Errors, "dedicated security-key") {
t.Fatalf("expected actionable mixed-stack error, got %v", result.Errors)
}
})
t.Run("rejects a stack without pam_u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "password-only", "#%PAM-1.0\nauth required pam_unix.so\n")
result := validateLockscreenU2fPam("password-only", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "pam_u2f") {
t.Fatalf("expected missing-U2F error, got %+v", result)
}
})
t.Run("does not accept a similarly named module as pam_u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_u2f_helper.so"] = true
env.writePamFile(t, "not-u2f", "#%PAM-1.0\nauth required pam_u2f_helper.so\n")
result := validateLockscreenU2fPam("not-u2f", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "no pam_u2f auth directive") {
t.Fatalf("expected exact pam_u2f module validation, got %+v", result)
}
})
t.Run("rejects a missing pam_u2f module", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue\n")
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
if result.Valid || !containsSubstr(result.Errors, "pam_u2f.so is not installed") {
t.Fatalf("expected missing-module error, got %+v", result)
}
})
}
func containsSubstr(items []string, substr string) bool {
for _, item := range items {
if strings.Contains(item, substr) {
@@ -1,25 +0,0 @@
package qmlchecks
import (
"os"
"strings"
"testing"
)
func TestGreeterExternalAuthStatusUsesEffectiveFingerprintAvailability(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Greetd/GreeterContent.qml")
if err != nil {
t.Fatalf("read greeter QML: %v", err)
}
content := string(data)
for _, required := range []string{
"readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f",
"if (greeterPamHasFprint && greeterPamHasU2f)",
"if (greeterPamHasFprint)",
} {
if !strings.Contains(content, required) {
t.Fatalf("greeter external-auth status must contain %q", required)
}
}
}
@@ -23,51 +23,3 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
}
}
func TestLockScreenAuthenticationCardOwnsFactorControls(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Settings/LockScreenTab.qml")
if err != nil {
t.Fatalf("read lock screen settings QML: %v", err)
}
content := string(data)
authCard := strings.Index(content, `title: I18n.tr("Authentication")`)
behaviorCard := strings.Index(content, `title: I18n.tr("Behavior")`)
fingerprintToggle := strings.Index(content, `settingKey: "enableFprint"`)
u2fToggle := strings.Index(content, `settingKey: "enableU2f"`)
u2fSource := strings.Index(content, `settingKey: "lockU2fPamPath"`)
if authCard < 0 || behaviorCard < 0 || fingerprintToggle < 0 || u2fToggle < 0 || u2fSource < 0 {
t.Fatalf("expected authentication card, factor toggles, and U2F source setting")
}
for name, position := range map[string]int{
"fingerprint toggle": fingerprintToggle,
"U2F toggle": u2fToggle,
"U2F source": u2fSource,
} {
if position < authCard || position > behaviorCard {
t.Fatalf("%s must remain in the authentication card", name)
}
}
}
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
if err != nil {
t.Fatalf("read lock screen PAM QML: %v", err)
}
content := string(data)
for _, required := range []string{
"SettingsData.lockPamExternallyManaged",
"SettingsData.lockU2fPamPath",
"customU2fPamActive",
"u2fSuppressedByPrimaryPam",
} {
if !strings.Contains(content, required) {
t.Fatalf("lock screen PAM must contain %q", required)
}
}
if strings.Contains(content, "runningFromNixStore || resolveUserPam.running") {
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
}
}
+33 -22
View File
@@ -30,30 +30,41 @@ func DetectCompositor() Compositor {
return detectedCompositor
}
candidates := []struct {
socket string
needsStat bool
compositor Compositor
}{
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
{os.Getenv("MIRACLESOCK"), true, CompositorMiracle},
{os.Getenv("SWAYSOCK"), true, CompositorSway},
{os.Getenv("HYPRLAND_INSTANCE_SIGNATURE"), false, CompositorHyprland},
}
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
niriSocket := os.Getenv("NIRI_SOCKET")
swaySocket := os.Getenv("SWAYSOCK")
scrollSocket := os.Getenv("SCROLLSOCK")
miracleSocket := os.Getenv("MIRACLESOCK")
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE")
// A stale env var from a previous session must not mask the live compositor
for _, c := range candidates {
if c.socket == "" {
continue
switch {
case mangoSocket != "":
if _, err := os.Stat(mangoSocket); err == nil {
detectedCompositor = CompositorMango
return detectedCompositor
}
if c.needsStat {
if _, err := os.Stat(c.socket); err != nil {
continue
}
case niriSocket != "":
if _, err := os.Stat(niriSocket); err == nil {
detectedCompositor = CompositorNiri
return detectedCompositor
}
detectedCompositor = c.compositor
case scrollSocket != "":
if _, err := os.Stat(scrollSocket); err == nil {
detectedCompositor = CompositorScroll
return detectedCompositor
}
case miracleSocket != "":
if _, err := os.Stat(miracleSocket); err == nil {
detectedCompositor = CompositorMiracle
return detectedCompositor
}
case swaySocket != "":
if _, err := os.Stat(swaySocket); err == nil {
detectedCompositor = CompositorSway
return detectedCompositor
}
case hyprlandSig != "":
detectedCompositor = CompositorHyprland
return detectedCompositor
}
@@ -79,7 +90,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
case CompositorMango:
return getMangoActiveWindow()
default:
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
return nil, fmt.Errorf("window capture requires Hyprland or Mango")
}
}
-52
View File
@@ -54,58 +54,6 @@ func BufferToImageWithFormat(buf *ShmBuffer, format uint32) *image.RGBA {
return img
}
func ImageToBuffer(img image.Image) (*ShmBuffer, error) {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
buf, err := CreateShmBuffer(w, h, w*4)
if err != nil {
return nil, err
}
data := buf.Data()
switch src := img.(type) {
case *image.NRGBA:
for y := range h {
srcOff := y * src.Stride
dstOff := y * buf.Stride
for x := range w {
si, di := srcOff+x*4, dstOff+x*4
a := uint32(src.Pix[si+3])
data[di+0] = uint8(uint32(src.Pix[si+2]) * a / 255)
data[di+1] = uint8(uint32(src.Pix[si+1]) * a / 255)
data[di+2] = uint8(uint32(src.Pix[si+0]) * a / 255)
data[di+3] = uint8(a)
}
}
case *image.RGBA:
for y := range h {
srcOff := y * src.Stride
dstOff := y * buf.Stride
for x := range w {
si, di := srcOff+x*4, dstOff+x*4
data[di+0] = src.Pix[si+2]
data[di+1] = src.Pix[si+1]
data[di+2] = src.Pix[si+0]
data[di+3] = src.Pix[si+3]
}
}
default:
for y := range h {
dstOff := y * buf.Stride
for x := range w {
cr, cg, cb, ca := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
di := dstOff + x*4
data[di+0] = uint8(cb >> 8)
data[di+1] = uint8(cg >> 8)
data[di+2] = uint8(cr >> 8)
data[di+3] = uint8(ca >> 8)
}
}
}
buf.Format = FormatARGB8888
return buf, nil
}
func EncodePNG(w io.Writer, img image.Image) error {
enc := png.Encoder{CompressionLevel: png.BestSpeed}
return enc.Encode(w, img)
-164
View File
@@ -1,164 +0,0 @@
package screenshot
import (
"bufio"
"encoding/json"
"fmt"
"image"
"image/png"
"net"
"os"
"path/filepath"
"time"
)
const niriScreenshotTimeout = 5 * time.Second
// CaptureNiriWindowImage captures the focused window through niri's
// ScreenshotWindow action; niri replies before writing the file, so a second
// event-stream connection waits for ScreenshotCaptured. niri also copies the
// capture to its own clipboard, which cannot be disabled.
func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
socket := os.Getenv("NIRI_SOCKET")
if socket == "" {
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()))
events, err := subscribeNiriEvents(socket)
if err != nil {
return nil, err
}
defer events.Close()
if err := requestNiriWindowScreenshot(socket, path, showPointer); err != nil {
return nil, err
}
defer os.Remove(path)
if err := awaitNiriScreenshot(events, path); err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open niri screenshot: %w", err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode niri screenshot: %w", err)
}
return img, nil
}
func subscribeNiriEvents(socket string) (net.Conn, error) {
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
if err != nil {
return nil, fmt.Errorf("connect niri socket: %w", err)
}
_ = conn.SetDeadline(time.Now().Add(niriScreenshotTimeout))
if _, err := conn.Write([]byte("\"EventStream\"\n")); err != nil {
conn.Close()
return nil, fmt.Errorf("subscribe niri events: %w", err)
}
return conn, nil
}
func awaitNiriScreenshot(events net.Conn, path string) error {
scanner := bufio.NewScanner(events)
scanner.Buffer(make([]byte, 0, 64<<10), 1<<20)
for scanner.Scan() {
var event struct {
ScreenshotCaptured *struct {
Path string `json:"path"`
} `json:"ScreenshotCaptured"`
}
if json.Unmarshal(scanner.Bytes(), &event) != nil {
continue
}
if event.ScreenshotCaptured != nil && event.ScreenshotCaptured.Path == path {
return nil
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("await niri screenshot: %w", err)
}
return fmt.Errorf("niri event stream closed before screenshot completed")
}
func requestNiriWindowScreenshot(socket, path string, showPointer bool) error {
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
if err != nil {
return fmt.Errorf("connect niri socket: %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
request := map[string]any{
"Action": map[string]any{
"ScreenshotWindow": map[string]any{
"id": nil,
"write_to_disk": true,
"show_pointer": showPointer,
"path": path,
},
},
}
payload, err := json.Marshal(request)
if err != nil {
return err
}
if _, err := conn.Write(append(payload, '\n')); err != nil {
return fmt.Errorf("niri request: %w", err)
}
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return fmt.Errorf("niri reply: %w", err)
}
var reply map[string]json.RawMessage
if err := json.Unmarshal(line, &reply); err != nil {
return fmt.Errorf("parse niri reply: %w", err)
}
if raw, ok := reply["Err"]; ok {
var msg string
_ = json.Unmarshal(raw, &msg)
return fmt.Errorf("niri screenshot: %s", msg)
}
return nil
}
func (s *Screenshoter) captureNiriWindow() (*CaptureResult, error) {
img, err := CaptureNiriWindowImage(s.config.Cursor == CursorOn)
if err != nil {
return nil, err
}
buf, err := ImageToBuffer(img)
if err != nil {
return nil, err
}
scale := 1.0
if output := s.findOutputByName(GetFocusedMonitor()); output != nil {
scale = output.effectiveScale()
}
return &CaptureResult{
Buffer: buf,
YInverted: false,
Format: uint32(FormatARGB8888),
Scale: scale,
}, nil
}
+8 -27
View File
@@ -95,9 +95,6 @@ type RegionSelector struct {
showCapturedCursor bool
shiftHeld bool
phase selectorPhase
scroll *scrollSession
running bool
cancelled bool
result Region
@@ -170,15 +167,11 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
r.running = true
for r.running {
if err := r.dispatchOrTick(); err != nil {
if err := r.ctx.Dispatch(); err != nil {
return nil, false, fmt.Errorf("dispatch: %w", err)
}
}
if r.scroll != nil && r.scroll.abortErr != nil {
return nil, false, r.scroll.abortErr
}
if r.cancelled || r.capturedBuffer == nil {
return nil, r.cancelled, nil
}
@@ -193,10 +186,6 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
scale = s
}
}
if r.scroll != nil {
yInverted = false
format = uint32(r.scroll.format)
}
return &CaptureResult{
Buffer: r.capturedBuffer,
@@ -707,9 +696,7 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
}
slot.pool = pool
// niri latches surface opacity from the first buffer's format
// (observed), so slots are ARGB from the start with A=255 when opaque
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), alphaFormat(os.screenFormat))
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat)
if err != nil {
log.Error("create render slot wl_buffer failed", "err", err)
pool.Destroy()
@@ -751,9 +738,8 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
x1 := float64(r.preSelect.X-os.output.x) * scaleX
y1 := float64(r.preSelect.Y-os.output.y) * scaleY
// selection edges are inclusive; the exclusive width edge is one device px past it
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width)*scaleX - scaleX
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height)*scaleY - scaleY
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY
r.selection.hasSelection = true
r.selection.dragging = false
@@ -783,13 +769,10 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
return
}
switch r.phase {
case phaseScroll:
r.drawScrollOverlay(os, slot.shm)
default:
slot.shm.CopyFrom(srcBuf)
r.drawOverlay(os, slot.shm)
}
slot.shm.CopyFrom(srcBuf)
// Draw overlay (dimming + selection) into this slot
r.drawOverlay(os, slot.shm)
if os.viewport != nil {
_ = os.wlSurface.SetBufferScale(1)
@@ -825,8 +808,6 @@ func (r *RegionSelector) cleanup() {
r.cursorBuffer.Close()
}
r.cleanupScroll()
for _, os := range r.surfaces {
for _, slot := range os.slots {
if slot == nil {
+24 -49
View File
@@ -94,20 +94,6 @@ func (r *RegionSelector) setupPointerHandlers() {
return
}
if r.phase == phaseScroll {
if e.Button != 0x110 || e.State != 1 || r.activeSurface != r.selection.surface {
return
}
switch r.scrollBarHit(r.pointerX, r.pointerY) {
case "done":
r.finishScroll()
case "cancel":
r.cancelled = true
r.running = false
}
return
}
switch e.Button {
case 0x110: // BTN_LEFT
switch e.State {
@@ -149,17 +135,6 @@ func (r *RegionSelector) setupKeyboardHandlers() {
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:
r.cancelled = true
@@ -177,15 +152,17 @@ func (r *RegionSelector) setupKeyboardHandlers() {
})
}
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
func (r *RegionSelector) finishSelection() {
if r.selection.surface == nil {
return nil, 0, 0, 0, 0
r.running = false
return
}
os := r.selection.surface
srcBuf := r.getSourceBuffer(os)
if srcBuf == nil {
return nil, 0, 0, 0, 0
r.running = false
return
}
x1, y1 := r.selection.anchorX, r.selection.anchorY
@@ -204,10 +181,24 @@ func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, i
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
}
bx1 := clamp(int(x1*scaleX), 0, srcBuf.Width)
by1 := clamp(int(y1*scaleY), 0, srcBuf.Height)
bx2 := clamp(int(x2*scaleX), 0, srcBuf.Width)
by2 := clamp(int(y2*scaleY), 0, srcBuf.Height)
bx1 := int(x1 * scaleX)
by1 := int(y1 * scaleY)
bx2 := int(x2 * scaleX)
by2 := int(y2 * scaleY)
// Clamp to buffer bounds
if bx1 < 0 {
bx1 = 0
}
if by1 < 0 {
by1 = 0
}
if bx2 > srcBuf.Width {
bx2 = srcBuf.Width
}
if by2 > srcBuf.Height {
by2 = srcBuf.Height
}
w, h := bx2-bx1+1, by2-by1+1
if r.shiftHeld && w != h {
@@ -224,23 +215,7 @@ func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, i
h = 1
}
return os, bx1, by1, w, h
}
func (r *RegionSelector) finishSelection() {
os, bx1, by1, w, h := r.selectionDeviceRect()
if os == nil {
r.running = false
return
}
if r.screenshoter != nil && r.screenshoter.config.Mode == ModeScroll {
r.enterScrollPhase(os, bx1, by1, w, h)
return
}
srcBuf := r.getSourceBuffer(os)
// Create cropped buffer and copy pixels directly
cropped, err := CreateShmBuffer(w, h, w*4)
if err != nil {
r.running = false
+2 -78
View File
@@ -57,7 +57,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
w, h := renderBuf.Width, renderBuf.Height
format := os.screenFormat
// dim, forcing alpha: the X-format source's padding byte is undefined
// Dim the entire buffer
for y := 0; y < h; y++ {
off := y * stride
for x := 0; x < w; x++ {
@@ -68,7 +68,6 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data[i+0] = uint8(int(data[i+0]) * 3 / 5)
data[i+1] = uint8(int(data[i+1]) * 3 / 5)
data[i+2] = uint8(int(data[i+2]) * 3 / 5)
data[i+3] = 255
}
}
@@ -111,7 +110,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data[di+0] = srcData[si+0]
data[di+1] = srcData[si+1]
data[di+2] = srcData[si+2]
data[di+3] = 255
data[di+3] = srcData[si+3]
}
}
@@ -127,81 +126,6 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format)
}
func (r *RegionSelector) drawScrollOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data := renderBuf.Data()
stride := renderBuf.Stride
w, h := renderBuf.Width, renderBuf.Height
// 40% premultiplied scrim
for y := 0; y < h; y++ {
off := y * stride
for x := 0; x < w; x++ {
i := off + x*4
if i+3 >= len(data) {
continue
}
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 102
}
}
s := r.scroll
if s == nil || r.selection.surface != os {
return
}
// hole oversized 2px so overlay pixels never land in captured frames
holeX := s.holeX - 2
holeY := s.holeY - 2
holeW := s.holeW + 4
holeH := s.holeH + 4
x1 := clamp(holeX, 0, w)
y1 := clamp(holeY, 0, h)
x2 := clamp(holeX+holeW, 0, w)
y2 := clamp(holeY+holeH, 0, h)
for y := y1; y < y2; y++ {
off := y * stride
for x := x1; x < x2; x++ {
i := off + x*4
if i+3 >= len(data) {
continue
}
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 0
}
}
r.drawBorder(data, stride, w, h, holeX-1, holeY-1, holeW+2, holeH+2, os.screenFormat)
r.drawScrollBar(data, stride, w, h, os.screenFormat)
}
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
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)
r.fillRect(data, stride, bufW, bufH, s.cancelX, s.cancelY, s.cancelW, s.btnH,
70, 70, 70, 255, format)
r.drawText(data, stride, bufW, bufH, s.cancelX+12, labelY, "cancel",
style.TextR, style.TextG, style.TextB, format)
rows := 0
if s.st != nil {
rows = s.st.rows()
}
counter := fmt.Sprintf("%d shots %dpx", s.kept, rows)
r.drawText(data, stride, bufW, bufH, s.cancelX+s.cancelW+16, labelY, counter,
style.TextR, style.TextG, style.TextB, format)
}
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
if r.selection.dragging {
return
+1 -5
View File
@@ -92,7 +92,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
switch s.config.Mode {
case ModeLastRegion:
return s.captureLastRegion()
case ModeRegion, ModeScroll:
case ModeRegion:
return s.captureRegion()
case ModeWindow:
return s.captureWindow()
@@ -145,10 +145,6 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorNiri {
return s.captureNiriWindow()
}
geom, err := GetActiveWindow()
if err != nil {
return nil, err
-574
View File
@@ -1,574 +0,0 @@
package screenshot
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"os/signal"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
type selectorPhase int
const (
phaseSelect selectorPhase = iota
phaseScroll
)
const (
scrollMaxFailures = 5
scrollSeamTicks = 4
)
type scrollSession struct {
output *WaylandOutput
// wire coords for CaptureOutputRegion (logical or device px per compositor)
capX, capY, capW, capH int32
// device-pixel rect in the overlay buffer, for hole/border drawing
holeX, holeY, holeW, holeH int
interval time.Duration
nextTick time.Time
inFlight bool
failures int
kept int
abortErr error
buf *ShmBuffer
pool *client.ShmPool
wlBuf *client.Buffer
frame *wlr_screencopy.ZwlrScreencopyFrameV1
format PixelFormat
frameW, frameH int
yInverted bool
prevSig []float32
prevPlaced bool
unmatched bool
unmatchedTicks int
// control bar geometry in overlay buffer pixels
barX, barY, barW, barH int
doneX, doneY, doneW int
cancelX, cancelY int
cancelW int
btnH int
sigCh chan os.Signal
keysBound bool
st *stitcher
}
func (r *RegionSelector) dispatchOrTick() error {
timeout := -1
if s := r.scroll; r.phase == phaseScroll && s != nil && s.sigCh != nil {
select {
case sig := <-s.sigCh:
switch sig {
case unix.SIGUSR2:
r.cancelled = true
r.running = false
default:
r.finishScroll()
}
return nil
default:
}
}
if s := r.scroll; r.phase == phaseScroll && s.abortErr == nil && (s.st == nil || !s.st.full) {
timeout = max(int(time.Until(s.nextTick).Milliseconds()), 0)
}
fds := []unix.PollFd{{Fd: int32(r.ctx.Fd()), Events: unix.POLLIN}}
n, err := unix.Poll(fds, timeout)
switch {
case err == unix.EINTR:
return nil
case err != nil:
return err
case n > 0:
return r.ctx.Dispatch()
}
r.scrollTick()
return nil
}
func (r *RegionSelector) scrollTick() {
s := r.scroll
if s == nil {
return
}
if s.inFlight || (s.st != nil && s.st.full) {
s.nextTick = time.Now().Add(s.interval)
return
}
r.startScrollCapture()
}
func (r *RegionSelector) enterScrollPhase(os *OutputSurface, x, y, w, h int) {
switch {
case os.output.transform != TransformNormal:
r.abortScroll(fmt.Errorf("scroll capture does not support rotated outputs"))
return
case w < 1 || h < 1:
r.abortScroll(fmt.Errorf("empty scroll capture region"))
return
}
interval := 45
if r.screenshoter != nil && r.screenshoter.config.IntervalMs > 0 {
interval = r.screenshoter.config.IntervalMs
}
capX, capY, capW, capH := x, y, w, h
switch DetectCompositor() {
case CompositorHyprland, CompositorMango:
// both take device pixels, deviating from spec (observed)
default:
// spec: logical coordinates, scaled by the compositor
// https://wayland.app/protocols/wlr-screencopy-unstable-v1#zwlr_screencopy_manager_v1:request:capture_output_region
if scale := os.output.fractionalScale; scale > 1 {
capX = int(float64(x)/scale + 0.5)
capY = int(float64(y)/scale + 0.5)
capW = int(float64(w)/scale + 0.5)
capH = int(float64(h)/scale + 0.5)
}
}
r.scroll = &scrollSession{
output: os.output,
capX: int32(capX),
capY: int32(capY),
capW: int32(capW),
capH: int32(capH),
holeX: x,
holeY: y,
holeW: w,
holeH: h,
interval: time.Duration(interval) * time.Millisecond,
nextTick: time.Now(),
}
r.layoutScrollBar(os)
for _, surf := range r.surfaces {
r.setInputPassthrough(surf, surf == os)
}
// Hyprland routes all pointer input to exclusive-keyboard layers
// (https://github.com/hyprwm/Hyprland/discussions/14136), so the keyboard
// is released there and Enter/Esc come back via temporary global binds
if DetectCompositor() == CompositorHyprland {
r.enterHyprlandScrollInput(os)
}
r.phase = phaseScroll
for _, surf := range r.surfaces {
r.redrawSurface(surf)
}
}
// sized for the worst-case counter so the input region is set once
func (r *RegionSelector) layoutScrollBar(os *OutputSurface) {
s := r.scroll
const charAdv, pad, gap = 9, 12, 16
s.btnH = 24
s.doneW = len("done")*charAdv + 24
s.cancelW = len("cancel")*charAdv + 24
counterW := len("99999 shots 999999px") * charAdv
s.barW = pad + s.doneW + gap + s.cancelW + gap + counterW + pad
s.barH = s.btnH + 24
bufW, bufH := os.screenBuf.Width, os.screenBuf.Height
s.barX = (bufW - s.barW) / 2
s.barY = bufH - s.barH - 24
borderX1, borderY1 := s.holeX-3, s.holeY-3
borderX2, borderY2 := s.holeX+s.holeW+3, s.holeY+s.holeH+3
overlaps := s.barX < borderX2 && s.barX+s.barW > borderX1 &&
s.barY < borderY2 && s.barY+s.barH > borderY1
if overlaps {
s.barY = 24
}
s.doneX = s.barX + pad
s.doneY = s.barY + (s.barH-s.btnH)/2
s.cancelX = s.doneX + s.doneW + gap
s.cancelY = s.doneY
}
func (r *RegionSelector) setInputPassthrough(os *OutputSurface, withBar bool) {
reg, err := r.compositor.CreateRegion()
if err != nil {
return
}
if withBar && os.screenBuf != nil && os.logicalW > 0 {
s := r.scroll
scaleX := float64(os.logicalW) / float64(os.screenBuf.Width)
scaleY := float64(os.logicalH) / float64(os.screenBuf.Height)
_ = reg.Add(int32(float64(s.barX)*scaleX), int32(float64(s.barY)*scaleY),
int32(float64(s.barW)*scaleX)+1, int32(float64(s.barH)*scaleY)+1)
}
_ = os.wlSurface.SetInputRegion(reg)
_ = reg.Destroy()
}
func (r *RegionSelector) enterHyprlandScrollInput(osurf *OutputSurface) {
for _, surf := range r.surfaces {
_ = surf.layerSurf.SetKeyboardInteractivity(uint32(wlr_layer_shell.ZwlrLayerSurfaceV1KeyboardInteractivityNone))
}
if r.shortcutsInhibitor != nil {
_ = r.shortcutsInhibitor.Destroy()
r.shortcutsInhibitor = nil
}
s := r.scroll
scale := osurf.output.fractionalScale
if scale <= 0 {
scale = 1
}
cx := int(float64(osurf.output.x) + float64(s.holeX+s.holeW/2)/scale)
cy := int(float64(osurf.output.y) + float64(s.holeY+s.holeH/2)/scale)
hyprlandFocusWindowAt(cx, cy)
s.sigCh = make(chan os.Signal, 2)
signal.Notify(s.sigCh, unix.SIGUSR1, unix.SIGUSR2)
s.keysBound = hyprlandBindScrollKeys(os.Getpid())
}
func hyprlandFocusWindowAt(x, y int) {
out, err := exec.Command("hyprctl", "-j", "clients").Output()
if err != nil {
return
}
var clients []struct {
Address string `json:"address"`
At [2]int `json:"at"`
Size [2]int `json:"size"`
Mapped bool `json:"mapped"`
Hidden bool `json:"hidden"`
FocusHistoryID int `json:"focusHistoryID"`
}
if json.Unmarshal(out, &clients) != nil {
return
}
best := -1
for i, c := range clients {
if !c.Mapped || c.Hidden {
continue
}
if x < c.At[0] || x >= c.At[0]+c.Size[0] || y < c.At[1] || y >= c.At[1]+c.Size[1] {
continue
}
if best < 0 || c.FocusHistoryID < clients[best].FocusHistoryID {
best = i
}
}
if best < 0 {
return
}
_ = exec.Command("hyprctl", "dispatch", "focuswindow", "address:"+clients[best].Address).Run()
}
func hyprlandBindScrollKeys(pid int) bool {
batch := fmt.Sprintf("keyword bind ,Return,exec,kill -USR1 %d ; keyword bind ,Escape,exec,kill -USR2 %d", pid, pid)
return exec.Command("hyprctl", "--batch", batch).Run() == nil
}
func hyprlandUnbindScrollKeys() {
_ = exec.Command("hyprctl", "--batch", "keyword unbind ,Return ; keyword unbind ,Escape").Run()
}
func (r *RegionSelector) scrollBarHit(x, y float64) string {
s := r.scroll
os := r.selection.surface
if s == nil || os == nil || os.screenBuf == nil || os.logicalW == 0 {
return ""
}
bx := int(x * float64(os.screenBuf.Width) / float64(os.logicalW))
by := int(y * float64(os.screenBuf.Height) / float64(os.logicalH))
switch {
case bx >= s.doneX && bx < s.doneX+s.doneW && by >= s.doneY && by < s.doneY+s.btnH:
return "done"
case bx >= s.cancelX && bx < s.cancelX+s.cancelW && by >= s.cancelY && by < s.cancelY+s.btnH:
return "cancel"
default:
return ""
}
}
func alphaFormat(format uint32) uint32 {
switch format {
case uint32(FormatXRGB8888):
return uint32(FormatARGB8888)
case uint32(FormatXBGR8888):
return uint32(FormatABGR8888)
default:
return format
}
}
func (r *RegionSelector) startScrollCapture() {
s := r.scroll
frame, err := r.screencopy.CaptureOutputRegion(0, s.output.wlOutput, s.capX, s.capY, s.capW, s.capH)
if err != nil {
r.abortScroll(fmt.Errorf("scroll capture: %w", err))
return
}
s.inFlight = true
s.frame = frame
s.nextTick = time.Now().Add(s.interval)
frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) {
if err := s.ensureCaptureBuffer(r, e); err != nil {
r.abortScroll(err)
return
}
if err := frame.Copy(s.wlBuf); err != nil {
log.Error("scroll frame copy failed", "err", err)
}
})
frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) {
s.yInverted = (e.Flags & 1) != 0
})
frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) {
frame.Destroy()
s.frame = nil
s.inFlight = false
s.failures = 0
s.nextTick = time.Now().Add(s.interval)
r.handleScrollFrame()
})
frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) {
frame.Destroy()
s.frame = nil
s.inFlight = false
s.failures++
s.nextTick = time.Now().Add(s.interval)
if s.failures >= scrollMaxFailures {
r.abortScroll(fmt.Errorf("screencopy failed %d consecutive times", s.failures))
}
})
}
func (s *scrollSession) ensureCaptureBuffer(r *RegionSelector, e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) error {
if s.buf != nil {
if int(e.Width) != s.frameW || int(e.Height) != s.frameH || PixelFormat(e.Format) != s.format {
return fmt.Errorf("output changed during scroll capture")
}
return nil
}
format := PixelFormat(e.Format)
if int(e.Stride) < int(e.Width)*format.BytesPerPixel() {
return fmt.Errorf("invalid stride from compositor: %d for width %d", e.Stride, e.Width)
}
buf, err := CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
if err != nil {
return fmt.Errorf("create scroll buffer: %w", err)
}
buf.Format = format
pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size()))
if err != nil {
buf.Close()
return fmt.Errorf("create scroll pool: %w", err)
}
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), e.Format)
if err != nil {
pool.Destroy()
buf.Close()
return fmt.Errorf("create scroll wl_buffer: %w", err)
}
s.buf = buf
s.pool = pool
s.wlBuf = wlBuf
s.format = format
s.frameW = int(e.Width)
s.frameH = int(e.Height)
return nil
}
func (r *RegionSelector) handleScrollFrame() {
s := r.scroll
if s == nil || s.buf == nil {
return
}
rows, err := s.extractRows()
if err != nil {
r.abortScroll(err)
return
}
if s.st == nil {
s.st = newStitcher(s.frameW * 4)
}
cols := s.st.rowSamples(rows)
sig := s.st.frameSig(rows)
dup := duplicateFrame(sig, s.prevSig)
s.prevSig = sig
// moving content: recapture at compositor speed, the timer paces idle only
if !dup {
s.nextTick = time.Now()
}
var added int
switch {
case dup && s.unmatched:
// settled somewhere unreachable: seam a new segment after a few ticks
s.unmatchedTicks++
if s.unmatchedTicks < scrollSeamTicks {
return
}
var placed bool
added, placed = s.st.pushFrame(rows, cols)
if !placed {
added = s.st.seamAppend(rows, cols)
}
s.prevPlaced = true
s.unmatched = false
s.unmatchedTicks = 0
case dup && s.prevPlaced:
return
default:
var placed bool
added, placed = s.st.pushFrame(rows, cols)
s.prevPlaced = placed
s.unmatched = !placed
s.unmatchedTicks = 0
}
if scrollDebug {
log.Error("scroll frame", "dup", dup, "unmatched", s.unmatched,
"placed", s.prevPlaced, "added", added, "canvas", s.st.rows(), "kept", s.kept)
}
if added == 0 {
return
}
s.kept++
if r.selection.surface != nil {
r.redrawSurface(r.selection.surface)
}
}
var scrollDebug = os.Getenv("DMS_SCROLL_DEBUG") != ""
func (s *scrollSession) extractRows() ([]byte, error) {
src := s.buf
format := s.format
if format.Is24Bit() {
converted, newFormat, err := src.ConvertTo32Bit(format)
if err != nil {
return nil, fmt.Errorf("convert scroll frame: %w", err)
}
defer converted.Close()
src = converted
s.format = newFormat
}
rows := make([]byte, s.frameW*4*s.frameH)
data := src.Data()
for y := 0; y < s.frameH; y++ {
srcY := y
if s.yInverted {
srcY = s.frameH - 1 - y
}
srcOff := srcY * src.Stride
dstOff := y * s.frameW * 4
if srcOff+s.frameW*4 > len(data) {
continue
}
copy(rows[dstOff:dstOff+s.frameW*4], data[srcOff:srcOff+s.frameW*4])
}
return rows, nil
}
func (r *RegionSelector) finishScroll() {
s := r.scroll
if s == nil || s.st == nil || s.st.rows() == 0 {
r.cancelled = true
r.running = false
return
}
buf, err := CreateShmBuffer(s.frameW, s.st.rows(), s.frameW*4)
if err != nil {
r.abortScroll(fmt.Errorf("create stitched buffer: %w", err))
return
}
copy(buf.Data(), s.st.canvas)
buf.Format = s.format
r.capturedBuffer = buf
r.capturedRegion = Region{
X: int32(s.holeX),
Y: int32(s.holeY),
Width: int32(s.holeW),
Height: int32(s.holeH),
Output: s.output.name,
}
// same convention as finishSelection or preselect breaks on scaled outputs
r.result = Region{
X: int32(s.holeX) + s.output.x,
Y: int32(s.holeY) + s.output.y,
Width: int32(s.holeW),
Height: int32(s.holeH),
Output: s.output.name,
}
r.running = false
}
func (r *RegionSelector) abortScroll(err error) {
if r.scroll == nil {
r.scroll = &scrollSession{}
}
r.scroll.abortErr = err
r.running = false
}
func (r *RegionSelector) cleanupScroll() {
s := r.scroll
if s == nil {
return
}
if s.keysBound {
hyprlandUnbindScrollKeys()
}
if s.sigCh != nil {
signal.Stop(s.sigCh)
}
if s.frame != nil {
s.frame.Destroy()
}
if s.wlBuf != nil {
s.wlBuf.Destroy()
}
if s.pool != nil {
s.pool.Destroy()
}
if s.buf != nil {
s.buf.Close()
}
}
-278
View File
@@ -1,278 +0,0 @@
package screenshot
import (
"math/rand"
"slices"
"testing"
)
// mirrors handleScrollFrame's stitch logic so glides run without a compositor
type simSession struct {
prevSig []float32
prevPlaced bool
unmatched bool
unmatchedTicks int
st *stitcher
}
func (s *simSession) observe(rows []byte) {
cols := s.st.rowSamples(rows)
sig := s.st.frameSig(rows)
dup := duplicateFrame(sig, s.prevSig)
s.prevSig = sig
switch {
case dup && s.unmatched:
s.unmatchedTicks++
if s.unmatchedTicks < scrollSeamTicks {
return
}
if _, placed := s.st.pushFrame(rows, cols); !placed {
s.st.seamAppend(rows, cols)
}
s.prevPlaced = true
s.unmatched = false
s.unmatchedTicks = 0
case dup && s.prevPlaced:
return
default:
_, placed := s.st.pushFrame(rows, cols)
s.prevPlaced = placed
s.unmatched = !placed
s.unmatchedTicks = 0
}
}
// the page at a fractional scroll offset, as a compositor renders mid-glide
func fractionalFrame(page []byte, stride, frameH int, offset float64) []byte {
top := int(offset)
frac := offset - float64(top)
out := make([]byte, frameH*stride)
for y := 0; y < frameH; y++ {
a := page[(top+y)*stride : (top+y+1)*stride]
b := page[(top+y+1)*stride : (top+y+2)*stride]
row := out[y*stride : (y+1)*stride]
for x := range row {
row[x] = byte(float64(a[x])*(1-frac) + float64(b[x])*frac)
}
}
return out
}
// blank gaps between paragraphs plus identical card blocks repeated around
func webbyPage(rng *rand.Rand, stride, rows int) []byte {
page := make([]byte, rows*stride)
card := make([]byte, 40*stride)
rng.Read(card)
row := 0
for row < rows {
switch rng.Intn(4) {
case 0: // blank gap
row += 10 + rng.Intn(20)
case 1: // repeated card block
n := copy(page[row*stride:], card)
row += n / stride
default: // paragraph of distinct rows
n := (8 + rng.Intn(22)) * stride
if row*stride+n > len(page) {
n = len(page) - row*stride
}
rng.Read(page[row*stride : row*stride+n])
row += n / stride
}
}
return page
}
// screen-fixed sidebar in the unsampled outer 8% plus per-frame hover noise
func addFixedChrome(rng *rand.Rand, frame []byte, stride, frameH int, sidebar []byte) {
sbw := len(sidebar) / frameH
for y := 0; y < frameH; y++ {
copy(frame[y*stride:y*stride+sbw], sidebar[y*sbw:(y+1)*sbw])
}
hoverTop := 40 + rng.Intn(frameH-80)
for y := hoverTop; y < hoverTop+24; y++ {
off := y*stride + stride/3
for x := 0; x < 60; x++ {
frame[off+x] ^= 0x08
}
}
}
// starting at the page bottom and scrolling up must prepend, never stall
func TestScrollSimulationBottomUp(t *testing.T) {
const stride = 2048
const frameH = 240
rng := rand.New(rand.NewSource(99))
page := webbyPage(rng, stride, 4000)
st := newStitcher(stride)
sidebar := make([]byte, frameH*140)
rng.Read(sidebar)
sim := &simSession{st: st}
pos := 3700.0
capture := func() []byte {
f := fractionalFrame(page, stride, frameH, pos)
addFixedChrome(rng, f, stride, frameH, sidebar)
return f
}
glide := func(target float64) {
for i := 0; ; i++ {
step := (target - pos) * 0.45
if step > -1 && step < 1 {
break
}
pos += step
if i%4 != 3 {
pos = float64(int(pos))
}
sim.observe(capture())
}
pos = target
sim.observe(capture())
sim.observe(capture())
}
sim.observe(capture())
for _, target := range []float64{3640, 3560, 3460, 3340, 3240} {
glide(target)
}
wantRows := (3700 + frameH) - 3240
got := sim.st.rows()
if got < wantRows-stitchMinAppend || got > wantRows+2 {
t.Fatalf("canvas has %d rows, want ~%d (upward scrolling must prepend)", got, wantRows)
}
topPage := 3240 + (wantRows - got)
for _, cr := range []int{0, 100, 300} {
if !rowMatchesPage(sim.st.canvas, page, stride, cr, topPage+cr) {
t.Fatalf("canvas row %d does not map onto page row %d", cr, topPage+cr)
}
}
}
// exact page row or a blend of neighbors, allowing a one-row offset
func rowMatchesPage(canvas, page []byte, stride, canvasRow, pageRow int) bool {
for x := 200; x < stride-1400; x++ {
c := int(canvas[canvasRow*stride+x])
lo, hi := 255, 0
for k := pageRow - 1; k <= pageRow+1; k++ {
v := int(page[k*stride+x])
lo, hi = min(lo, v), max(hi, v)
}
if c < lo-1 || c > hi+1 {
return false
}
}
return true
}
// a fling past a full frame height must seam a new segment, not go dead
func TestScrollSimulationFastFlingRecovers(t *testing.T) {
const stride = 2048
const frameH = 240
rng := rand.New(rand.NewSource(7))
page := webbyPage(rng, stride, 4000)
sim := &simSession{st: newStitcher(stride)}
frame := func(top int) []byte {
return slices.Clone(page[top*stride : (top+frameH)*stride])
}
rest := func(top int) {
for range scrollSeamTicks + 2 {
sim.observe(frame(top))
}
}
rest(0)
sim.observe(frame(60))
sim.observe(frame(130))
rest(130)
firstRange := 130 + frameH
sim.observe(frame(900))
sim.observe(frame(1400))
rest(1800)
sim.observe(frame(1860))
sim.observe(frame(1930))
rest(1930)
wantRows := firstRange + (1930 - 1800) + frameH
if got := sim.st.rows(); got != wantRows {
t.Fatalf("canvas has %d rows, want %d (first range %d + new segment)", got, wantRows, firstRange)
}
seamStart := firstRange
if !slices.Equal(sim.st.canvas[seamStart*stride:], page[1800*stride:(1930+frameH)*stride]) {
t.Fatal("new segment content wrong after fling recovery")
}
}
// eased glides with up/down scrubbing must cover the range exactly once
func TestScrollSimulationSmoothGlide(t *testing.T) {
const stride = 2048
const frameH = 240
rng := rand.New(rand.NewSource(99))
page := webbyPage(rng, stride, 4000)
st := newStitcher(stride)
sidebar := make([]byte, frameH*140)
rng.Read(sidebar)
sim := &simSession{st: st}
pos := 0.0
capture := func() []byte {
f := fractionalFrame(page, stride, frameH, pos)
addFixedChrome(rng, f, stride, frameH, sidebar)
return f
}
glide := func(target float64) {
for i := 0; ; i++ {
step := (target - pos) * 0.45
if step > -1 && step < 1 {
break
}
pos += step
// mostly snapped to device pixels, with the odd fractional frame
if i%4 != 3 {
pos = float64(int(pos))
}
sim.observe(capture())
}
pos = target
sim.observe(capture())
sim.observe(capture())
}
sim.observe(capture())
for _, target := range []float64{160, 330, 480, 650, 800, 960, 1100} {
glide(target)
}
for _, target := range []float64{700, 300, 900, 1100} {
glide(target)
}
wantRows := 1100 + frameH
got := sim.st.rows()
if got < wantRows-stitchMinAppend || got > wantRows+2 {
t.Fatalf("canvas has %d rows, want ~%d (more = duplicated bands, fewer = gaps)", got, wantRows)
}
hoverLo, hoverHi := stride/3, stride/3+60
mismatched := 0
for row := 0; row < min(got, wantRows); row += 7 {
off := row * stride
a1, b1 := sim.st.canvas[off+200:off+hoverLo], page[off+200:off+hoverLo]
a2, b2 := sim.st.canvas[off+hoverHi:off+stride], page[off+hoverHi:off+stride]
if !slices.Equal(a1, b1) || !slices.Equal(a2, b2) {
mismatched++
}
}
if mismatched > (wantRows/7)/20 {
t.Fatalf("%d of %d sampled rows mismatch page content (mid-animation pixels baked in)", mismatched, wantRows/7)
}
}
-407
View File
@@ -1,407 +0,0 @@
package screenshot
// Frame stitcher after mark-shot's column-sampling design
// (https://github.com/jswysnemc/mark-shot, src/scroll/stitcher_algorithm.cpp).
// Only rows overhanging the captured range are committed; frames that match
// nothing are dropped without touching state.
const (
stitchMaxCanvasBytes = 256 << 20
stitchMaxRowsCap = 30000
// mark-shot: StitchConfig{100, 9.0f, 15, 1.0f}
stitchAcceptDiff = 9.0
stitchApproxDiff = 1.0
stitchMinCompare = 50
stitchMinCanvas = 100
stitchMinAppend = 15
stitchCoarseStep = 8
stitchPredictWindow = 160
stitchBandSamples = 17
// mark-shot: kDuplicateAvgDiff=1.1f, kDuplicateMaxDiff=4, 18x24 grid
stitchDupAvgDiff = 1.1
stitchDupMaxDiff = 4.0
stitchSigCols = 18
stitchSigRows = 24
// blank rows agree at every offset and must not decide a match
stitchActivityMin = 2.0
stitchRowMatchTol = 4.0
stitchMinActive = 12
)
// mean luminance per band (8-32%, 34-66%, 68-92%); the outer 8% is chrome
type rowCols [3]float32
type stitcher struct {
stride int
sampleOffs [3][]int
canvas []byte
cols []rowCols
anchor int
last []rowCols
lastOffset int
maxRows int
full bool
}
func newStitcher(stride int) *stitcher {
px := stride / 4
st := &stitcher{
stride: stride,
maxRows: min(stitchMaxCanvasBytes/stride, stitchMaxRowsCap),
}
bands := [3][2]float64{{0.08, 0.32}, {0.34, 0.66}, {0.68, 0.92}}
for b, band := range bands {
lo := int(float64(px) * band[0])
hi := max(int(float64(px)*band[1]), lo+1)
n := min(stitchBandSamples, hi-lo)
for s := range n {
st.sampleOffs[b] = append(st.sampleOffs[b], (lo+(hi-lo)*s/n)*4)
}
}
return st
}
func (st *stitcher) rowSamples(data []byte) []rowCols {
rows := len(data) / st.stride
cols := make([]rowCols, rows)
for y := range rows {
row := data[y*st.stride:]
for b := range 3 {
var sum float32
for _, off := range st.sampleOffs[b] {
sum += 0.114*float32(row[off]) + 0.587*float32(row[off+1]) + 0.299*float32(row[off+2])
}
cols[y][b] = sum / float32(len(st.sampleOffs[b]))
}
}
return cols
}
func (st *stitcher) frameSig(data []byte) []float32 {
rows := len(data) / st.stride
px := st.stride / 4
sig := make([]float32, 0, stitchSigCols*stitchSigRows)
for gy := range stitchSigRows {
y := (2*gy + 1) * rows / (2 * stitchSigRows)
for gx := range stitchSigCols {
x := (2*gx + 1) * px / (2 * stitchSigCols)
off := y*st.stride + x*4
sig = append(sig, 0.114*float32(data[off])+0.587*float32(data[off+1])+0.299*float32(data[off+2]))
}
}
return sig
}
func (st *stitcher) rows() int {
return len(st.cols)
}
func rowColsDiff(a, b rowCols) float32 {
return (abs32(a[0]-b[0]) + abs32(a[1]-b[1]) + abs32(a[2]-b[2])) / 3
}
func duplicateFrame(a, b []float32) bool {
if len(a) != len(b) || len(a) == 0 {
return false
}
var sum, maxDiff float32
for i := range a {
d := abs32(a[i] - b[i])
sum += d
maxDiff = max(maxDiff, d)
}
return sum/float32(len(a)) <= stitchDupAvgDiff && maxDiff <= stitchDupMaxDiff
}
// sticky header/footer zones, per mark-shot: 10% top, 8% bottom, min 16px
func matchIgnores(h int) (top, bottom int) {
if h < 80 {
return 0, 0
}
return clamp(h/10, 16, h/4), clamp(h*8/100, 16, h/4)
}
func activity(f []rowCols) []bool {
active := make([]bool, len(f))
for i := 1; i < len(f); i++ {
active[i] = rowColsDiff(f[i], f[i-1]) > stitchActivityMin
}
return active
}
func (st *stitcher) pushFrame(frame []byte, f []rowCols) (int, bool) {
if st.full || len(f) == 0 {
return 0, true
}
h := len(f)
if len(st.cols) == 0 {
n := st.appendRows(frame, f, 0)
st.anchor = 0
st.last = f
st.lastOffset = 0
return n, true
}
pos, ok := st.locateFrame(f, activity(f))
if !ok {
return 0, false
}
delta := pos - st.anchor
added := 0
if over := pos + h - len(st.cols); over >= stitchMinAppend {
added += st.appendRows(frame, f, h-over)
}
if over := -pos; over >= stitchMinAppend {
n := st.prependRows(frame, f, over)
added += n
pos += n
}
st.anchor = pos
st.last = f
st.lastOffset = delta
return added, true
}
// seamAppend starts a new segment after a jump capture couldn't follow.
func (st *stitcher) seamAppend(frame []byte, f []rowCols) int {
if st.full || len(f) == 0 {
return 0
}
pos := len(st.cols)
n := st.appendRows(frame, f, 0)
st.anchor = pos
st.last = f
st.lastOffset = 0
return n
}
func (st *stitcher) locateFrame(f []rowCols, active []bool) (int, bool) {
d, diff := st.adjacentOffset(f, active)
pred := st.anchor + d
if diff <= stitchAcceptDiff {
if _, ok := st.verifyAt(f, active, pred); ok {
return pred, true
}
}
if pos, _, ok := st.scanPositions(f, active, pred, true); ok {
return pos, true
}
pos, _, ok := st.scanPositions(f, active, pred, false)
return pos, ok
}
func (st *stitcher) verifyAt(f []rowCols, active []bool, pos int) (float32, bool) {
diff, count, activeMatches := st.canvasDiff(f, active, pos)
ok := count >= stitchMinCanvas && diff <= stitchAcceptDiff && activeMatches >= stitchMinActive
return diff, ok
}
// signed deltas searched outward from the previous one (mark-shot's
// predictOffsetIter), early-exiting once a diff beats approxDiff
func (st *stitcher) adjacentOffset(f []rowCols, active []bool) (int, float32) {
h := len(f)
if len(st.last) != h {
return 0, float32(1e9)
}
limit := max(h-stitchMinCompare-1, 0)
bestD, bestDiff := 0, float32(1e9)
countdown := -1
try := func(d int) bool {
if d < -limit || d > limit {
return false
}
diff, activeMatches := st.pairDiff(f, active, d)
if activeMatches >= stitchMinActive && diff < bestDiff {
bestDiff, bestD = diff, d
}
switch {
case bestDiff < stitchApproxDiff/4:
return true
case bestDiff < stitchApproxDiff && countdown < 0:
countdown = 10
}
if countdown > 0 {
countdown--
}
return countdown == 0
}
if try(st.lastOffset) {
return bestD, bestDiff
}
for k := 1; ; k++ {
lo, hi := st.lastOffset-k, st.lastOffset+k
if lo < -limit && hi > limit {
break
}
if try(hi) || try(lo) {
break
}
}
return bestD, bestDiff
}
func (st *stitcher) pairDiff(f []rowCols, active []bool, d int) (float32, int) {
h := len(f)
top, bottom := matchIgnores(h)
lo := max(top, -d)
hi := min(h-bottom, h-d)
count := hi - lo
if count < stitchMinCompare {
return float32(1e9), 0
}
var sum float32
activeMatches := 0
for i := lo; i < hi; i++ {
rd := rowColsDiff(f[i], st.last[i+d])
sum += rd
if active[i] && rd <= stitchRowMatchTol {
activeMatches++
}
}
return sum / float32(count), activeMatches
}
func (st *stitcher) canvasDiff(f []rowCols, active []bool, pos int) (float32, int, int) {
h := len(f)
top, bottom := matchIgnores(h)
lo := max(top, -pos)
hi := min(h-bottom, len(st.cols)-pos)
count := hi - lo
if count < 1 {
return float32(1e9), 0, 0
}
var sum float32
activeMatches := 0
for i := lo; i < hi; i++ {
rd := rowColsDiff(f[i], st.cols[pos+i])
sum += rd
if active[i] && rd <= stitchRowMatchTol {
activeMatches++
}
}
return sum / float32(count), count, activeMatches
}
// mark-shot's findEdgePosition (nearOnly: edges + prediction window, 1px) and
// findKnownPosition (coarse sweep refined around the winner)
func (st *stitcher) scanPositions(f []rowCols, active []bool, pred int, nearOnly bool) (int, float32, bool) {
h := len(f)
C := len(st.cols)
minPos := stitchMinCanvas - h
maxPos := C - stitchMinCanvas
bestPos, bestDiff := 0, float32(1e9)
bestDist := 1 << 30
consider := func(pos int) {
if pos < minPos || pos > maxPos {
return
}
diff, ok := st.verifyAt(f, active, pos)
if !ok {
return
}
dist := pos - pred
if dist < 0 {
dist = -dist
}
better := diff < bestDiff
if !nearOnly {
better = dist < bestDist || dist == bestDist && diff < bestDiff
}
if better {
bestPos, bestDiff, bestDist = pos, diff, dist
}
}
if nearOnly {
for pos := pred - stitchPredictWindow; pos <= pred+stitchPredictWindow; pos++ {
consider(pos)
}
for pos := C - h; pos <= maxPos; pos++ {
consider(pos)
}
for pos := minPos; pos <= 0; pos++ {
consider(pos)
}
if bestDiff > stitchAcceptDiff {
return 0, 0, false
}
return bestPos, bestDiff, true
}
for pos := minPos; pos <= maxPos; pos += stitchCoarseStep {
consider(pos)
}
if bestDiff > stitchAcceptDiff {
return 0, 0, false
}
refined, refinedDiff := bestPos, bestDiff
for pos := bestPos - stitchCoarseStep + 1; pos < bestPos+stitchCoarseStep; pos++ {
if pos == bestPos {
continue
}
if diff, ok := st.verifyAt(f, active, pos); ok && diff < refinedDiff {
refined, refinedDiff = pos, diff
}
}
return refined, refinedDiff, true
}
func (st *stitcher) appendRows(frame []byte, f []rowCols, from int) int {
n := len(f) - from
if room := st.maxRows - len(st.cols); n > room {
n = room
st.full = true
}
if n <= 0 {
st.full = true
return 0
}
st.canvas = append(st.canvas, frame[from*st.stride:(from+n)*st.stride]...)
st.cols = append(st.cols, f[from:from+n]...)
return n
}
func (st *stitcher) prependRows(frame []byte, f []rowCols, n int) int {
if room := st.maxRows - len(st.cols); n > room {
n = room
st.full = true
}
if n <= 0 {
st.full = true
return 0
}
canvas := make([]byte, n*st.stride+len(st.canvas))
copy(canvas, frame[:n*st.stride])
copy(canvas[n*st.stride:], st.canvas)
st.canvas = canvas
cols := make([]rowCols, 0, n+len(st.cols))
cols = append(cols, f[:n]...)
st.cols = append(cols, st.cols...)
return n
}
func abs32(f float32) float32 {
if f < 0 {
return -f
}
return f
}
-169
View File
@@ -1,169 +0,0 @@
package screenshot
import (
"bytes"
"math/rand"
"slices"
"testing"
)
const (
testStride = 512
testFrameH = 240
)
func makePage(t *testing.T, rows int) []byte {
t.Helper()
rng := rand.New(rand.NewSource(42))
page := make([]byte, rows*testStride)
rng.Read(page)
return page
}
func frameAt(page []byte, top int) []byte {
return page[top*testStride : (top+testFrameH)*testStride]
}
func pushFrame(st *stitcher, frame []byte) int {
n, _ := st.pushFrame(frame, st.rowSamples(frame))
return n
}
func TestStitchSlidingWindows(t *testing.T) {
page := makePage(t, 1000)
for _, delta := range []int{20, 60, 110} {
st := newStitcher(testStride)
lastTop := 0
for top := 0; top+testFrameH <= 900; top += delta {
lastTop = top
pushFrame(st, frameAt(page, top))
}
wantRows := lastTop + testFrameH
if st.rows() != wantRows {
t.Fatalf("delta %d: got %d rows, want %d", delta, st.rows(), wantRows)
}
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
t.Fatalf("delta %d: canvas does not match source rows", delta)
}
}
}
func TestStitchDropsNoOverlap(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
pushFrame(st, frameAt(page, 0))
if appended := pushFrame(st, frameAt(page, testFrameH+50)); appended != 0 {
t.Fatalf("unmatched jump appended %d rows", appended)
}
if !bytes.Equal(st.canvas, page[:testFrameH*testStride]) {
t.Fatal("canvas changed on unmatched frame")
}
}
func TestStitchNoGrowthCases(t *testing.T) {
page := makePage(t, 1000)
blank := make([]byte, testFrameH*testStride)
cases := []struct {
name string
first, second []byte
}{
{"identical frame", frameAt(page, 0), frameAt(page, 0)},
{"jitter below min append", frameAt(page, 0), frameAt(page, stitchMinAppend-5)},
{"blank on blank", blank, blank},
}
for _, tc := range cases {
st := newStitcher(testStride)
pushFrame(st, tc.first)
if appended := pushFrame(st, tc.second); appended != 0 {
t.Fatalf("%s: appended %d rows", tc.name, appended)
}
if st.rows() != testFrameH {
t.Fatalf("%s: got %d rows, want %d", tc.name, st.rows(), testFrameH)
}
}
}
func TestStitchRevisitNeverDuplicates(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
pushFrame(st, frameAt(page, 0))
pushFrame(st, frameAt(page, 100))
pushFrame(st, frameAt(page, 200))
for _, top := range []int{150, 60, 0, 80, 190} {
if appended := pushFrame(st, frameAt(page, top)); appended != 0 {
t.Fatalf("revisited frame at %d appended %d rows", top, appended)
}
}
pushFrame(st, frameAt(page, 300))
wantRows := 300 + testFrameH
if st.rows() != wantRows {
t.Fatalf("got %d rows, want %d", st.rows(), wantRows)
}
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
t.Fatal("canvas corrupted by revisited frames")
}
}
func TestStitchScrollUpPrepends(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
pushFrame(st, frameAt(page, 500))
if appended := pushFrame(st, frameAt(page, 420)); appended != 80 {
t.Fatalf("upward frame appended %d rows, want 80", appended)
}
pushFrame(st, frameAt(page, 560))
if !bytes.Equal(st.canvas, page[420*testStride:(560+testFrameH)*testStride]) {
t.Fatal("canvas does not match page range after prepend + append")
}
}
func TestStitchNoisyChromeStillMatches(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
addChrome := func(frame []byte, seed byte) []byte {
f := slices.Clone(frame)
for y := range testFrameH {
for x := range 32 {
f[y*testStride+x] = seed + byte(y)
}
}
for y := 100; y < 124; y++ {
for x := testStride / 2; x < testStride/2+40; x++ {
f[y*testStride+x] ^= 0x08
}
}
return f
}
pushFrame(st, addChrome(frameAt(page, 0), 1))
if appended := pushFrame(st, addChrome(frameAt(page, 90), 2)); appended != 90 {
t.Fatalf("appended %d rows, want 90", appended)
}
}
func TestStitchMaxRowsCap(t *testing.T) {
page := makePage(t, 1000)
st := newStitcher(testStride)
st.maxRows = testFrameH + 10
pushFrame(st, frameAt(page, 0))
if appended := pushFrame(st, frameAt(page, 100)); appended != 10 {
t.Fatalf("appended %d rows past cap, want 10", appended)
}
if !st.full {
t.Fatal("stitcher not marked full at cap")
}
if pushFrame(st, frameAt(page, 300)) != 0 {
t.Fatal("push after full appended rows")
}
}
-2
View File
@@ -9,7 +9,6 @@ const (
ModeAllScreens
ModeOutput
ModeLastRegion
ModeScroll
)
type Format int
@@ -63,7 +62,6 @@ type Config struct {
SaveFile bool
Notify bool
Stdout bool
IntervalMs int
}
func DefaultConfig() Config {
+6 -11
View File
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
m.promptBroker = broker
adapter, err := findAdapter(conn)
adapter, err := m.findAdapter()
if err != nil {
conn.Close()
return nil, err
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
}
m.adapterPath = adapter
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
return m, nil
}
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
return "", err
}
for path, interfaces := range objects {
@@ -89,7 +89,7 @@ func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
}
}
return "", ErrNoAdapter
return "", fmt.Errorf("no adapter found")
}
func (m *Manager) initialize() error {
@@ -487,11 +487,6 @@ func (m *Manager) StopDiscovery() error {
}
func (m *Manager) SetPowered(powered bool) error {
if powered {
if err := rfkillUnblockBluetooth(); err != nil {
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
}
}
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
}
@@ -1,5 +0,0 @@
package bluez
func rfkillUnblockBluetooth() error {
return nil
}
@@ -1,19 +0,0 @@
package bluez
import "os"
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
func rfkillUnblockBluetooth() error {
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
var event [8]byte
event[4] = 2
event[5] = 3
_, err = f.Write(event[:])
return err
}
-46
View File
@@ -1,46 +0,0 @@
package bluez
import (
"errors"
"fmt"
"github.com/godbus/dbus/v5"
)
var ErrNoAdapter = errors.New("no bluetooth adapter found")
func WaitForAdapter() error {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return err
}
defer conn.Close()
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(objectMgrIface),
dbus.WithMatchMember("InterfacesAdded"),
); err != nil {
return err
}
signals := make(chan *dbus.Signal, 64)
conn.Signal(signals)
if _, err := findAdapter(conn); err == nil {
return nil
}
for sig := range signals {
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
continue
}
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
if !ok {
continue
}
if _, ok := ifaces[adapter1Iface]; ok {
return nil
}
}
return fmt.Errorf("dbus signal stream closed")
}
+10 -23
View File
@@ -229,9 +229,6 @@ func (m *Manager) snapshotState() CUPSState {
func (m *Manager) Subscribe(id string) chan CUPSState {
ch := make(chan CUPSState, 64)
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
wasEmpty := true
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
wasEmpty = false
@@ -240,25 +237,19 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
m.subscribers.Store(id, ch)
if !wasEmpty || m.subscription == nil {
return ch
if wasEmpty && m.subscription != nil {
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
} else {
m.eventWG.Add(1)
go m.eventHandler()
}
}
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
return ch
}
m.eventWG.Add(1)
go m.eventHandler()
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
@@ -269,22 +260,18 @@ func (m *Manager) Unsubscribe(id string) {
return false
})
if !isEmpty || m.subscription == nil {
return
if isEmpty && m.subscription != nil {
m.subscription.Stop()
m.eventWG.Wait()
}
m.subscription.Stop()
m.eventWG.Wait()
}
func (m *Manager) Close() {
close(m.stopChan)
m.subLifecycleMu.Lock()
if m.subscription != nil {
m.subscription.Stop()
}
m.subLifecycleMu.Unlock()
m.eventWG.Wait()
m.notifierWg.Wait()
-64
View File
@@ -1,9 +1,6 @@
package cups
import (
"errors"
"fmt"
"sync"
"testing"
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
@@ -78,67 +75,6 @@ func TestManager_Subscribe(t *testing.T) {
assert.Equal(t, 0, count)
}
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
// unsynchronized so overlapping Start/Stop trips the race detector
type stubSubscription struct {
mu sync.Mutex
events chan SubscriptionEvent
conn *int
running bool
}
func (s *stubSubscription) Start() error {
if s.running {
return errors.New("already running")
}
s.running = true
s.mu.Lock()
s.events = make(chan SubscriptionEvent)
s.mu.Unlock()
v := 0
s.conn = &v
*s.conn++
return nil
}
func (s *stubSubscription) Stop() {
if !s.running {
return
}
s.running = false
s.conn = nil
s.mu.Lock()
close(s.events)
s.mu.Unlock()
}
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
s.mu.Lock()
defer s.mu.Unlock()
return s.events
}
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
m.subscription = &stubSubscription{}
var wg sync.WaitGroup
for i := range 8 {
wg.Go(func() {
id := fmt.Sprintf("client-%d", i)
for range 50 {
m.Subscribe(id)
m.Unsubscribe(id)
}
})
}
wg.Wait()
}
func TestManager_Close(t *testing.T) {
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
-1
View File
@@ -79,7 +79,6 @@ type Manager struct {
client CUPSClientInterface
pkHelper PkHelper
subscription SubscriptionManagerInterface
subLifecycleMu sync.Mutex
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan CUPSState]
stopChan chan struct{}
+21 -25
View File
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
return nil, fmt.Errorf("failed to find keyboards: %w", err)
}
initialCapsLock, _ := capsLockFromDevices(devices)
initialCapsLock := readInitialCapsLockState(devices[0])
watcher, err := fsnotify.NewWatcher()
if err != nil {
@@ -85,21 +85,14 @@ func NewManager() (*Manager, error) {
return m, nil
}
func capsLockFromDevices(devices []EvdevDevice) (bool, bool) {
for _, device := range devices {
if device == nil {
continue
}
ledStates, err := device.State(evLedType)
if err != nil || len(ledStates) == 0 {
continue
}
return ledStates[ledCapslockKey], true
func readInitialCapsLockState(device EvdevDevice) bool {
ledStates, err := device.State(evLedType)
if err != nil {
log.Debugf("Could not read LED state: %v", err)
return false
}
return false, false
return ledStates[ledCapslockKey]
}
func findKeyboards() ([]EvdevDevice, error) {
@@ -304,22 +297,25 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
m.devicesMutex.RUnlock()
return
}
ordered := make([]EvdevDevice, 0, len(m.devices))
ordered = append(ordered, m.devices[deviceIndex])
for i, device := range m.devices {
if i == deviceIndex {
continue
}
ordered = append(ordered, device)
}
device := m.devices[deviceIndex]
m.devicesMutex.RUnlock()
capsLockState, ok := capsLockFromDevices(ordered)
if !ok {
log.Debug("No LED-capable device available for caps lock state")
ledStates, err := device.State(evLedType)
if err != nil {
log.Warnf("Failed to read LED state: %v", err)
return
}
if len(ledStates) == 0 {
log.Debug("No LED state available (empty map)")
// This means the device either:
// - doesn't support LED reporting at all, or
// - the kernel returned an empty state
return
}
capsLockState := ledStates[ledCapslockKey]
m.updateCapsLockStateDirect(capsLockState)
}
+4 -22
View File
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
m.Close()
}
func TestCapsLockFromDevices(t *testing.T) {
func TestReadInitialCapsLockState(t *testing.T) {
t.Run("caps lock is on", func(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{
@@ -314,8 +314,7 @@ func TestCapsLockFromDevices(t *testing.T) {
}
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.True(t, ok)
result := readInitialCapsLockState(mockDevice)
assert.True(t, result)
})
@@ -326,8 +325,7 @@ func TestCapsLockFromDevices(t *testing.T) {
}
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.True(t, ok)
result := readInitialCapsLockState(mockDevice)
assert.False(t, result)
})
@@ -335,25 +333,9 @@ func TestCapsLockFromDevices(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.False(t, ok)
result := readInitialCapsLockState(mockDevice)
assert.False(t, result)
})
t.Run("falls back past device without LED state", func(t *testing.T) {
noLedDevice := mocks.NewMockEvdevDevice(t)
noLedDevice.EXPECT().State(evdev.EvType(evLedType)).Return(evdev.StateMap{}, nil).Once()
ledDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{
ledCapslockKey: true,
}
ledDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result, ok := capsLockFromDevices([]EvdevDevice{noLedDevice, nil, ledDevice})
assert.True(t, ok)
assert.True(t, result)
})
}
func TestHasInputGroupAccess(t *testing.T) {
+17 -20
View File
@@ -8,11 +8,24 @@ import (
)
func NewManager(client geolocation.Client) (*Manager, error) {
currLocation, err := client.GetLocation()
if err != nil {
log.Warnf("Failed to get initial location: %v", err)
}
m := &Manager{
client: client,
dirty: make(chan struct{}),
stopChan: make(chan struct{}),
state: &State{},
state: &State{
Latitude: currLocation.Latitude,
Longitude: currLocation.Longitude,
},
}
if err := m.startSignalPump(); err != nil {
return nil, err
}
m.notifierWg.Add(1)
@@ -21,22 +34,6 @@ func NewManager(client geolocation.Client) (*Manager, error) {
return m, nil
}
// The geolocation client may fetch IP location on first use, so nothing
// touches it until a consumer actually asks for location data.
func (m *Manager) ensureStarted() {
m.startOnce.Do(func() {
go func() {
currLocation, err := m.client.GetLocation()
if err != nil {
log.Warnf("Failed to get initial location: %v", err)
} else {
m.handleLocationChange(currLocation)
}
m.startSignalPump()
}()
})
}
func (m *Manager) Close() {
close(m.stopChan)
m.notifierWg.Wait()
@@ -51,7 +48,6 @@ func (m *Manager) Close() {
}
func (m *Manager) Subscribe(id string) chan State {
m.ensureStarted()
ch := make(chan State, 64)
m.subscribers.Store(id, ch)
return ch
@@ -63,7 +59,7 @@ func (m *Manager) Unsubscribe(id string) {
}
}
func (m *Manager) startSignalPump() {
func (m *Manager) startSignalPump() error {
m.sigWG.Add(1)
go func() {
defer m.sigWG.Done()
@@ -84,6 +80,8 @@ func (m *Manager) startSignalPump() {
}
}
}()
return nil
}
func (m *Manager) handleLocationChange(location geolocation.Location) {
@@ -104,7 +102,6 @@ func (m *Manager) notifySubscribers() {
}
func (m *Manager) GetState() State {
m.ensureStarted()
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
+1 -2
View File
@@ -16,8 +16,7 @@ type Manager struct {
state *State
stateMutex sync.RWMutex
client geolocation.Client
startOnce sync.Once
client geolocation.Client
stopChan chan struct{}
sigWG sync.WaitGroup
@@ -744,32 +744,6 @@ func (b *IWDBackend) DisconnectWiFi() error {
return nil
}
func (b *IWDBackend) abortInFlightConnection(ssid string) {
b.stateMutex.Lock()
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
b.stateMutex.Unlock()
return
}
b.state.IsConnecting = false
b.state.ConnectingSSID = ""
b.state.LastError = ""
b.stateMutex.Unlock()
b.attemptMutex.RLock()
att := b.curAttempt
b.attemptMutex.RUnlock()
if att != nil && att.ssid == ssid {
att.mu.Lock()
att.finalized = true
att.mu.Unlock()
}
if err := b.DisconnectWiFi(); err != nil {
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
}
}
func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID
@@ -838,10 +812,6 @@ func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
return fmt.Errorf("failed to set autoconnect: %w", call.Err)
}
if !autoconnect {
b.abortInFlightConnection(ssid)
}
b.updateState()
if b.onStateChange != nil {
@@ -359,24 +359,6 @@ func (b *NetworkManagerBackend) DisconnectWiFi() error {
return nil
}
func (b *NetworkManagerBackend) abortInFlightConnection(ssid string) {
b.stateMutex.Lock()
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
b.stateMutex.Unlock()
return
}
b.state.IsConnecting = false
b.state.ConnectingSSID = ""
b.state.LastError = ""
b.stateMutex.Unlock()
b.clearCachedWiFiSecretBySSID(ssid)
if err := b.DisconnectWiFi(); err != nil {
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
}
}
func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
conn, err := b.findConnection(ssid)
if err != nil {
@@ -981,10 +963,6 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
return fmt.Errorf("failed to update connection: %w", err)
}
if !autoconnect {
b.abortInFlightConnection(ssid)
}
b.updateWiFiNetworks()
if b.onStateChange != nil {
+2 -3
View File
@@ -117,12 +117,11 @@ func RouteRequest(conn net.Conn, req models.Request) {
}
if strings.HasPrefix(req.Method, "cups.") {
mgr, err := ensureCupsManager()
if err != nil {
if cupsManager == nil {
models.RespondError(conn, req.ID, "CUPS manager not initialized")
return
}
cups.HandleRequest(conn, req, mgr)
cups.HandleRequest(conn, req, cupsManager)
return
}
+36 -80
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -84,8 +85,8 @@ var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client"
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
var cupsMu sync.Mutex
var cupsSubscriberCount int
var cupsSubscribers syncmap.Map[string, bool]
var cupsSubscriberCount atomic.Int32
func getSocketDir() string {
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
@@ -242,55 +243,17 @@ func InitializeAppPickerManager() error {
return nil
}
func initializeCupsManagerLocked() (bool, error) {
if cupsManager != nil {
return false, nil
}
func InitializeCupsManager() error {
manager, err := cups.NewManager()
if err != nil {
log.Warnf("Failed to initialize cups manager: %v", err)
return false, err
return err
}
cupsManager = manager
log.Info("CUPS manager initialized")
return true, nil
}
func ensureCupsManager() (*cups.Manager, error) {
cupsMu.Lock()
created, err := initializeCupsManagerLocked()
mgr := cupsManager
cupsMu.Unlock()
if err != nil {
return nil, err
}
if created {
notifyCapabilityChange()
}
return mgr, nil
}
func cupsAvailable() bool {
cupsMu.Lock()
defer cupsMu.Unlock()
return cupsManager != nil
}
func releaseCupsSubscriber() {
cupsMu.Lock()
cupsSubscriberCount--
var mgr *cups.Manager
if cupsSubscriberCount == 0 && cupsManager != nil {
mgr = cupsManager
cupsManager = nil
}
cupsMu.Unlock()
if mgr == nil {
return
}
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
mgr.Close()
notifyCapabilityChange()
return nil
}
func InitializeBrightnessManager() error {
@@ -500,7 +463,7 @@ func getCapabilities() Capabilities {
caps = append(caps, "browser")
}
if cupsAvailable() {
if cupsManager != nil {
caps = append(caps, "cups")
}
@@ -570,7 +533,7 @@ func getServerInfo() ServerInfo {
caps = append(caps, "browser")
}
if cupsAvailable() {
if cupsManager != nil {
caps = append(caps, "cups")
}
@@ -1034,31 +997,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}
if shouldSubscribe("cups") {
cupsMu.Lock()
cupsSubscriberCount++
created, err := initializeCupsManagerLocked()
mgr := cupsManager
cupsMu.Unlock()
cupsSubscribers.Store(clientID+"-cups", true)
count := cupsSubscriberCount.Add(1)
if err != nil {
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
} else if created {
notifyCapabilityChange()
if count == 1 {
if err := InitializeCupsManager(); err != nil {
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
} else {
notifyCapabilityChange()
}
}
if mgr == nil {
releaseCupsSubscriber()
} else {
if cupsManager != nil {
wg.Add(1)
cupsChan := mgr.Subscribe(clientID + "-cups")
cupsChan := cupsManager.Subscribe(clientID + "-cups")
go func() {
defer wg.Done()
defer func() {
mgr.Unsubscribe(clientID + "-cups")
releaseCupsSubscriber()
cupsManager.Unsubscribe(clientID + "-cups")
cupsSubscribers.Delete(clientID + "-cups")
count := cupsSubscriberCount.Add(-1)
if count == 0 {
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
if cupsManager != nil {
cupsManager.Close()
cupsManager = nil
notifyCapabilityChange()
}
}
}()
initialState := mgr.GetState()
initialState := cupsManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
case <-stopChan:
@@ -1368,12 +1338,9 @@ func cleanupManagers() {
if appPickerManager != nil {
appPickerManager.Close()
}
cupsMu.Lock()
if cupsManager != nil {
cupsManager.Close()
cupsManager = nil
}
cupsMu.Unlock()
if brightnessManager != nil {
brightnessManager.Close()
}
@@ -1731,21 +1698,10 @@ func Start(printDocs bool) error {
}()
go func() {
for {
err := InitializeBluezManager()
if err == nil {
notifyCapabilityChange()
return
}
if err := InitializeBluezManager(); err != nil {
log.Warnf("Bluez manager unavailable: %v", err)
if !errors.Is(err, bluez.ErrNoAdapter) {
return
}
if err := bluez.WaitForAdapter(); err != nil {
log.Warnf("Bluetooth adapter watch failed: %v", err)
return
}
log.Info("Bluetooth adapter appeared, initializing bluez manager")
} else {
notifyCapabilityChange()
}
}()
@@ -50,42 +50,11 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
}
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.
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
if len(ignored) == 0 {
return ignored
}
repoPending := make(map[string]bool, len(pending))
for _, p := range pending {
if p.Repo == RepoSystem {
repoPending[p.Name] = true
}
}
out := make([]string, 0, len(ignored))
for _, name := range ignored {
if repoPending[name] {
continue
}
out = append(out, name)
}
return out
}
func isPacmanFamily(b Backend) bool {
if b == nil {
return false
}
switch b.ID() {
case "pacman", "paru", "yay":
return true
default:
return false
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"}
if len(opts.Ignored) > 0 {
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
}
return privilegedArgv(opts, argv...)
}
type archHelperBackend struct {
@@ -143,7 +112,7 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
}
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd, opts.TerminalArgs), RunOptions{OnLine: onLine})
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
}
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
+13 -15
View File
@@ -119,7 +119,7 @@ func findTerminal(override string) string {
return ""
}
func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
func wrapInTerminal(term, title, shellCmd string) []string {
const appID = "com.danklinux.dms"
banner := fmt.Sprintf(
`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'`,
@@ -129,26 +129,24 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer
var argv []string
execFlag := "-e"
switch term {
case "kitty", "alacritty", "wezterm":
argv = []string{term, "--class", appID, "-T", title}
case "kitty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "alacritty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "foot":
argv = []string{term, "--app-id=" + appID, "--title=" + title}
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "ghostty":
argv = []string{term, "--class=" + appID, "--title=" + title}
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "xterm":
argv = []string{term, "-class", appID, "-T", title}
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
case "konsole":
argv = []string{term, "-p", "tabtitle=" + title}
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
case "gnome-terminal":
// --wait: the factory process otherwise returns immediately
argv = []string{term, "--wait", "--title=" + title}
execFlag = "--"
return []string{term, "--title=" + title, "--", "sh", "-c", full}
default:
argv = []string{term}
return []string{term, "-e", "sh", "-c", full}
}
argv = append(argv, extraArgs...)
return append(argv, execFlag, "sh", "-c", full)
}
@@ -46,7 +46,6 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
DryRun: params.BoolOpt(req.Params, "dry", false),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""),
TerminalArgs: stringSliceOpt(req.Params, "terminalArgs"),
Ignored: stringSliceOpt(req.Params, "ignored"),
}
if err := m.Upgrade(opts); err != nil {
+13 -22
View File
@@ -336,7 +336,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
}()
if opts.CustomCommand != "" {
m.runCustomUpgrade(ctx, opts)
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
return
}
@@ -345,9 +345,6 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock()
}
if isPacmanFamily(m.selection.System) {
opts.Ignored = dropPacmanRepoIgnores(opts.Ignored, opts.Targets)
}
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
backends := upgradeBackends(m.selection, opts)
@@ -392,8 +389,8 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
m.finishSuccessfulUpgrade(true)
}
func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
term := findTerminal(opts.Terminal)
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
term := findTerminal(terminalOverride)
if term == "" {
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
return
@@ -410,30 +407,24 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
m.failCustomUpgrade(ErrCodeTimeout, err)
return
code = ErrCodeTimeout
case errors.Is(ctx.Err(), context.Canceled):
m.failCustomUpgrade(ErrCodeCancelled, err)
return
code = ErrCodeCancelled
}
// exit status reflects the trailing `read`, not the update command
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
return
}
m.finishSuccessfulUpgrade(false)
m.runRefresh(context.Background(), false)
}
func (m *Manager) failCustomUpgrade(code ErrorCode, err error) {
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
-1
View File
@@ -80,7 +80,6 @@ type UpgradeOptions struct {
AttachStdio bool
CustomCommand string
Terminal string
TerminalArgs []string
Targets []Package
Ignored []string
}
@@ -50,9 +50,9 @@ func TestUpgradeCommandBuilders(t *testing.T) {
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
},
{
name: "pacman never passes --ignore",
name: "pacman with ignored packages",
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"},
},
{
name: "dnf with ignored packages",
@@ -106,18 +106,6 @@ func TestUpgradeCommandBuilders(t *testing.T) {
}
}
func TestDropPacmanRepoIgnoresKeepsAURHolds(t *testing.T) {
pending := []Package{
{Name: "linux", Repo: RepoSystem},
{Name: "librewolf", Repo: RepoAUR},
}
got := dropPacmanRepoIgnores([]string{"linux", "librewolf", "not-pending"}, pending)
want := []string{"librewolf", "not-pending"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ignored = %#v, want %#v", got, want)
}
}
func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
@@ -233,27 +221,3 @@ func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
}
}
func TestWrapInTerminal(t *testing.T) {
tests := []struct {
term string
wantPrefix []string
}{
{"kitty", []string{"kitty", "--class", "com.danklinux.dms", "-T", "Title"}},
{"gnome-terminal", []string{"gnome-terminal", "--wait", "--title=Title"}},
{"foot", []string{"foot", "--app-id=com.danklinux.dms", "--title=Title"}},
}
for _, tt := range tests {
got := wrapInTerminal(tt.term, "Title", "echo hi", nil)
if len(got) < len(tt.wantPrefix) || !reflect.DeepEqual(got[:len(tt.wantPrefix)], tt.wantPrefix) {
t.Errorf("wrapInTerminal(%q) = %#v, want prefix %#v", tt.term, got, tt.wantPrefix)
}
tail := got[len(got)-3:]
if tail[0] != "sh" || tail[1] != "-c" {
t.Errorf("wrapInTerminal(%q) tail = %#v, want [sh -c <cmd>]", tt.term, tail)
}
if !strings.Contains(tail[2], "echo hi") {
t.Errorf("wrapInTerminal(%q) command %q does not contain shell command", tt.term, tail[2])
}
}
}
+20 -31
View File
@@ -760,9 +760,6 @@ func (m *Manager) schedulerLoop() {
now := time.Now()
m.recalcSchedule(now)
// publish independent of output readiness so night status never
// presents a stale schedule while applies are blocked (#2967)
m.updateStateFromSchedule()
var waitDur time.Duration
if enabled {
@@ -1123,15 +1120,13 @@ func (m *Manager) SetTemperature(low, high int) error {
m.configMutex.Unlock()
return nil
}
updated := m.config
updated.LowTemp = low
updated.HighTemp = high
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
m.config.LowTemp = low
m.config.HighTemp = high
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1143,16 +1138,14 @@ func (m *Manager) SetLocation(lat, lon float64) error {
m.configMutex.Unlock()
return nil
}
updated := m.config
updated.Latitude = &lat
updated.Longitude = &lon
updated.UseIPLocation = false
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
m.config.Latitude = &lat
m.config.Longitude = &lon
m.config.UseIPLocation = false
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1187,15 +1180,13 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
m.configMutex.Unlock()
return nil
}
updated := m.config
updated.ManualSunrise = &sunrise
updated.ManualSunset = &sunset
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
m.config.ManualSunrise = &sunrise
m.config.ManualSunset = &sunset
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1218,14 +1209,12 @@ func (m *Manager) SetGamma(gamma float64) error {
m.configMutex.Unlock()
return nil
}
updated := m.config
updated.Gamma = gamma
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
m.config.Gamma = gamma
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -9,7 +9,6 @@ import (
"github.com/stretchr/testify/assert"
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
)
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
@@ -413,75 +412,3 @@ func TestNewManager_InvalidConfig(t *testing.T) {
_, err := NewManager(mockDisplay, config)
assert.Error(t, err)
}
func TestSetters_RejectedValuesLeaveConfigUntouched(t *testing.T) {
newManager := func() *Manager {
return &Manager{
config: DefaultConfig(),
updateTrigger: make(chan struct{}, 1),
}
}
t.Run("SetTemperature", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetTemperature(3200, 2500)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
t.Run("SetLocation", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetLocation(120.0, 10.0)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
t.Run("SetGamma", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetGamma(-1.0)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
}
func TestSetters_ValidValuesCommitAndTrigger(t *testing.T) {
m := &Manager{
config: DefaultConfig(),
updateTrigger: make(chan struct{}, 1),
}
err := m.SetTemperature(3000, 6000)
assert.NoError(t, err)
assert.Equal(t, 3000, m.config.LowTemp)
assert.Equal(t, 6000, m.config.HighTemp)
assert.Len(t, m.updateTrigger, 1)
}
func TestApplyGamma_SkipsUnchangedTempAndGamma(t *testing.T) {
m := &Manager{config: DefaultConfig()}
m.controlsInitialized = true
out := &outputState{
id: 1,
rampSize: 256,
gammaControl: &wlr_gamma_control.ZwlrGammaControlV1{},
lastTemp: 5000,
lastGamma: m.config.Gamma,
}
m.outputs.Store(out.id, out)
m.applyGamma(5000)
assert.False(t, out.failed, "unchanged temp must not reach the compositor write path")
assert.Equal(t, 5000, out.lastTemp)
assert.Equal(t, uint32(256), out.rampSize)
}
@@ -37,7 +37,6 @@ type NiriWindowRule struct {
MatchIsUrgent *bool
MatchAtStartup *bool
Matches []NiriMatch
Excludes []NiriMatch
Opacity *float64
OpenFloating *bool
OpenMaximized *bool
@@ -213,8 +212,6 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
switch childName {
case "match":
rule.Matches = append(rule.Matches, p.parseMatchNode(child))
case "exclude":
rule.Excludes = append(rule.Excludes, p.parseMatchNode(child))
case "opacity":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
@@ -600,8 +597,7 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Matches: convertNiriMatches(nr.Matches),
Excludes: convertNiriMatches(nr.Excludes),
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
@@ -814,8 +810,7 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Matches: convertNiriMatches(nr.Matches),
Excludes: convertNiriMatches(nr.Excludes),
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
@@ -879,7 +874,7 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
}
func formatNiriMatchLine(keyword string, m windowrules.MatchCriteria) (string, bool) {
func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
var matchProps []string
if m.AppID != "" {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
@@ -911,7 +906,7 @@ func formatNiriMatchLine(keyword string, m windowrules.MatchCriteria) (string, b
if len(matchProps) == 0 {
return "", false
}
return " " + keyword + " " + strings.Join(matchProps, " "), true
return " match " + strings.Join(matchProps, " "), true
}
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
@@ -924,12 +919,7 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
matches = []windowrules.MatchCriteria{rule.MatchCriteria}
}
for _, m := range matches {
if line, ok := formatNiriMatchLine("match", m); ok {
lines = append(lines, line)
}
}
for _, m := range rule.Excludes {
if line, ok := formatNiriMatchLine("exclude", m); ok {
if line, ok := formatNiriMatchLine(m); ok {
lines = append(lines, line)
}
}
@@ -938,8 +928,8 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
if a.Opacity != nil {
lines = append(lines, fmt.Sprintf(" opacity %.2f", *a.Opacity))
}
if a.OpenFloating != nil {
lines = append(lines, fmt.Sprintf(" open-floating %t", *a.OpenFloating))
if a.OpenFloating != nil && *a.OpenFloating {
lines = append(lines, " open-floating true")
}
if a.OpenMaximized != nil && *a.OpenMaximized {
lines = append(lines, " open-maximized true")
@@ -333,56 +333,3 @@ window-rule {
t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist")
}
}
func TestNiriExcludesSurviveEditOfOtherRule(t *testing.T) {
tmpDir := t.TempDir()
provider := NewNiriWritableProvider(tmpDir)
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil {
t.Fatal(err)
}
existing := `// @id=thunderbird @name=Thunderbird
window-rule {
match app-id="^org.mozilla.Thunderbird$"
open-floating true
exclude title="^Mozilla Thunderbird$"
exclude title="^Verfassen:.*"
}
// @id=firefox @name=Firefox
window-rule {
match app-id="^firefox$"
open-floating true
}
`
if err := os.WriteFile(filepath.Join(dmsDir, "windowrules.kdl"), []byte(existing), 0644); err != nil {
t.Fatal(err)
}
edited := newTestWindowRule("firefox", "Firefox", "^firefox$")
edited.Actions.OpenFloating = boolPtr(false)
if err := provider.SetRule(edited); err != nil {
t.Fatalf("SetRule failed: %v", err)
}
rules, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules failed: %v", err)
}
if len(rules) != 2 {
t.Fatalf("expected 2 rules, got %d", len(rules))
}
if len(rules[0].Excludes) != 2 {
t.Fatalf("expected 2 excludes on untouched rule, got %d", len(rules[0].Excludes))
}
if rules[0].Excludes[0].Title != "^Mozilla Thunderbird$" {
t.Errorf("Excludes[0].Title = %q", rules[0].Excludes[0].Title)
}
if rules[0].Excludes[1].Title != "^Verfassen:.*" {
t.Errorf("Excludes[1].Title = %q", rules[0].Excludes[1].Title)
}
if rules[1].Actions.OpenFloating == nil || *rules[1].Actions.OpenFloating {
t.Error("edited rule should have open-floating false")
}
}
-1
View File
@@ -77,7 +77,6 @@ type WindowRule struct {
Enabled bool `json:"enabled"`
MatchCriteria MatchCriteria `json:"matchCriteria"`
Matches []MatchCriteria `json:"matches,omitempty"`
Excludes []MatchCriteria `json:"excludes,omitempty"`
Actions Actions `json:"actions"`
Source string `json:"source,omitempty"`
}
+1 -3
View File
@@ -200,9 +200,7 @@ in
];
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
# Declare security.pam.services.dankshell only if you want to override that runtime fallback.
# Do not add pam_u2f or pam_fprintd here for security-key unlock, enable
# programs.dank-material-shell.lockscreen.securityKey.enable, which declares the
# dedicated dankshell-u2f service DMS drives on its own.
# U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here.
# security.pam.services.dankshell = {
# # Example: add faillock
# faillock.enable = true;
-21
View File
@@ -23,15 +23,6 @@ in
description = "Systemd target to bind to.";
default = "graphical-session.target";
};
options.programs.dank-material-shell.lockscreen.securityKey = {
enable = lib.mkEnableOption "FIDO2/U2F security key unlock for the DMS lock screen via a dedicated dankshell-u2f PAM service";
package = lib.mkPackageOption pkgs "pam_u2f" { };
moduleArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "cue" ];
description = "Arguments passed to pam_u2f.so in the dankshell-u2f PAM service.";
};
};
config = lib.mkIf cfg.enable {
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
description = "DankMaterialShell";
@@ -55,18 +46,6 @@ in
inherit value;
}) common.plugins;
# DMS's bundled U2F fallback stack references pam_u2f.so by name, which NixOS's
# libpam cannot resolve; the dedicated service below uses the absolute store path
# and is picked up automatically by the lock screen when present.
security.pam.services."dankshell-u2f" = lib.mkIf cfg.lockscreen.securityKey.enable {
text = ''
auth required ${cfg.lockscreen.securityKey.package}/lib/security/pam_u2f.so ${lib.concatStringsSep " " cfg.lockscreen.securityKey.moduleArgs}
account required pam_permit.so
password required pam_deny.so
session required pam_permit.so
'';
};
services.power-profiles-daemon.enable = lib.mkDefault true;
services.accounts-daemon.enable = lib.mkDefault true;
services.geoclue2.enable = lib.mkDefault true;
-2
View File
@@ -19,7 +19,6 @@ pkgs.testers.runNixOSTest {
programs.dank-material-shell = {
enable = true;
systemd.enable = true;
lockscreen.securityKey.enable = true;
plugins = {
TestPlugin = {
src = pkgs.emptyDirectory;
@@ -40,7 +39,6 @@ pkgs.testers.runNixOSTest {
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
machine.succeed("grep -q 'lib/security/pam_u2f.so cue' /etc/pam.d/dankshell-u2f")
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
t.assertIn("summary", payload)
+1 -1
View File
@@ -1 +1 @@
The Wolverine
Marble Tabby
+1 -9
View File
@@ -11,8 +11,6 @@ Singleton {
property var appUsageRanking: {}
property bool _saving: false
property bool _hasLoaded: false
property bool _parseError: false
Component.onCompleted: {
loadSettings();
@@ -23,21 +21,15 @@ Singleton {
}
function parseSettings(content) {
_parseError = false;
try {
if (content && content.trim()) {
var settings = JSON.parse(content);
appUsageRanking = settings.appUsageRanking || {};
}
_hasLoaded = true;
} catch (e) {
_parseError = true;
}
} catch (e) {}
}
function saveSettings() {
if (_parseError || !_hasLoaded)
return;
settingsFile.setText(JSON.stringify({
"appUsageRanking": appUsageRanking
}, null, 2));
+6 -64
View File
@@ -11,7 +11,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("CacheData")
readonly property int cacheConfigVersion: 2
readonly property int cacheConfigVersion: 1
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -19,21 +19,10 @@ Singleton {
readonly property string _stateDir: Paths.strip(_stateUrl)
property bool _loading: false
property bool _hasLoaded: false
property int _loadedCacheVersion: 0
readonly property var _pinKeys: ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"]
readonly property var _dataKeys: ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings"].concat(_pinKeys)
property string wallpaperLastPath: ""
property string profileLastPath: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
property var bluetoothDevicePins: ({})
property var audioInputDevicePins: ({})
property var audioOutputDevicePins: ({})
property var fileBrowserSettings: ({
"wallpaper": {
"lastPath": "",
@@ -93,46 +82,8 @@ Singleton {
function loadCache() {
_loading = true;
try {
parseCache(cacheFile.text());
} finally {
_loading = false;
_hasLoaded = true;
}
}
function set(key, value) {
if (_dataKeys.indexOf(key) < 0) {
log.warn("Unknown cache key:", key);
return;
}
root[key] = value;
saveCache();
}
function migratePins(pins) {
if (!pins)
return;
if (!_hasLoaded)
loadCache();
if (_loadedCacheVersion >= cacheConfigVersion)
return;
let migrated = false;
for (const key of _pinKeys) {
const legacy = pins[key];
if (!legacy || Object.keys(legacy).length === 0)
continue;
if (Object.keys(root[key] || {}).length > 0)
continue;
root[key] = legacy;
migrated = true;
}
if (!migrated)
return;
log.info("Migrated device pins from settings.json");
saveCache();
parseCache(cacheFile.text());
_loading = false;
}
function parseCache(content) {
@@ -140,7 +91,6 @@ Singleton {
try {
if (content && content.trim()) {
const cache = JSON.parse(content);
_loadedCacheVersion = cache.configVersion || 0;
wallpaperLastPath = cache.wallpaperLastPath !== undefined ? cache.wallpaperLastPath : "";
profileLastPath = cache.profileLastPath !== undefined ? cache.profileLastPath : "";
@@ -176,10 +126,6 @@ Singleton {
};
}
for (const key of _pinKeys) {
root[key] = cache[key] !== undefined ? cache[key] : {};
}
if (cache.configVersion === undefined) {
migrateFromUndefinedToV1(cache);
cleanupUnusedKeys();
@@ -196,16 +142,12 @@ Singleton {
function saveCache() {
if (_loading)
return;
const data = {
cacheFile.setText(JSON.stringify({
"wallpaperLastPath": wallpaperLastPath,
"profileLastPath": profileLastPath,
"fileBrowserSettings": fileBrowserSettings,
"configVersion": cacheConfigVersion
};
for (const key of _pinKeys) {
data[key] = root[key];
}
cacheFile.setText(JSON.stringify(data, null, 2));
}, null, 2));
}
function migrateFromUndefinedToV1(cache) {
@@ -213,7 +155,7 @@ Singleton {
}
function cleanupUnusedKeys() {
const validKeys = _dataKeys.concat(["configVersion"]);
const validKeys = ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings", "configVersion"];
try {
const content = cacheFile.text();
-1
View File
@@ -63,7 +63,6 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" },
{ id: "spawn dms ipc call keybinds close", label: "Keybinds Cheatsheet: Close" },
{ id: "spawn dms ipc call lock lock", label: "Lock Screen" },
{ id: "spawn dms ipc call lock lockAndOutputsOff", label: "Lock Screen & Outputs Off" },
{ id: "spawn dms ipc call lock demo", label: "Lock Screen: Demo" },
{ id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" },
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
+2 -11
View File
@@ -15,11 +15,7 @@ Singleton {
function openModal(modal) {
PopoutManager.screenshotActive = false;
const screenName = modal.effectiveScreen?.name ?? "unknown";
var next = {};
for (var k in currentModalsByScreen)
next[k] = currentModalsByScreen[k];
next[screenName] = modal;
currentModalsByScreen = next;
currentModalsByScreen[screenName] = modal;
modalChanged();
Qt.callLater(() => {
if (!modal.allowStacking)
@@ -38,12 +34,7 @@ Singleton {
function closeModal(modal) {
const screenName = modal.effectiveScreen?.name ?? "unknown";
if (currentModalsByScreen[screenName] === modal) {
var next = {};
for (var k in currentModalsByScreen) {
if (k !== screenName)
next[k] = currentModalsByScreen[k];
}
currentModalsByScreen = next;
delete currentModalsByScreen[screenName];
modalChanged();
}
}
+2 -21
View File
@@ -65,35 +65,16 @@ Singleton {
function _openPopout(popout) {
if (popout.dashVisible !== undefined) {
let flagStayedTrue = popout.dashVisible === true;
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing) {
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing)
popout.dashVisible = false;
flagStayedTrue = false;
}
popout.dashVisible = true;
// Flag already true (e.g. retargeting to another monitor) won't re-fire
// the change handler, so drive the surface open explicitly.
if (flagStayedTrue)
_ensureSurfaceOpen(popout);
return;
}
if (popout.notificationHistoryVisible !== undefined) {
const flagStayedTrue = popout.notificationHistoryVisible === true;
popout.notificationHistoryVisible = true;
if (flagStayedTrue)
_ensureSurfaceOpen(popout);
return;
}
_ensureSurfaceOpen(popout);
}
function _ensureSurfaceOpen(popout) {
if (typeof popout.present === "function") {
popout.present();
return;
}
if (typeof popout.open === "function")
popout.open();
popout.open();
}
function _closePopout(popout) {
+6 -9
View File
@@ -15,7 +15,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 13
readonly property int settingsConfigVersion: 12
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -240,8 +240,6 @@ Singleton {
onBlurForegroundLayersChanged: saveSettings()
property real blurLayerOutlineOpacity: 0.12
onBlurLayerOutlineOpacityChanged: saveSettings()
property bool blurBorderEnabled: true
onBlurBorderEnabledChanged: saveSettings()
property string blurBorderColor: "outline"
onBlurBorderColorChanged: saveSettings()
property string blurBorderCustomColor: "#ffffff"
@@ -773,6 +771,11 @@ Singleton {
property bool fadeToDpmsEnabled: true
property int fadeToDpmsGracePeriod: 5
property string launchPrefix: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
property var bluetoothDevicePins: ({})
property var audioInputDevicePins: ({})
property var audioOutputDevicePins: ({})
property bool gtkThemingEnabled: false
property bool qtThemingEnabled: false
@@ -856,7 +859,6 @@ Singleton {
property bool notificationOverlayEnabled: false
property bool notificationPopupShadowEnabled: true
property bool notificationPopupPrivacyMode: false
property bool notificationForegroundLayers: true
property int overviewRows: 2
property int overviewColumns: 5
property real overviewScale: 0.16
@@ -896,8 +898,6 @@ Singleton {
property string lockPamPath: ""
property bool lockPamInlineFprint: false
property bool lockPamInlineU2f: false
property bool lockPamExternallyManaged: false
property string lockU2fPamPath: ""
property bool greeterPamExternallyManaged: false
property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0
@@ -1736,7 +1736,6 @@ Singleton {
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
const oldVersion = obj?.configVersion ?? 0;
const legacyPins = oldVersion < 13 ? Store.extractPins(obj) : null;
if (oldVersion < settingsConfigVersion) {
const migrated = Store.migrateToVersion(obj, settingsConfigVersion);
if (migrated) {
@@ -1744,8 +1743,6 @@ Singleton {
obj = migrated;
}
}
if (legacyPins)
Qt.callLater(() => CacheData.migratePins(legacyPins));
if (obj?.lockScreenActiveMonitor !== undefined) {
var oldVal = obj.lockScreenActiveMonitor;
-84
View File
@@ -436,10 +436,6 @@ Singleton {
function getMatugenColor(path, fallback) {
const colorMode = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "light" : "dark";
return getMatugenColorForMode(colorMode, path, fallback);
}
function getMatugenColorForMode(colorMode, path, fallback) {
let cur = matugenColors && matugenColors.colors && matugenColors.colors[colorMode];
for (const part of path.split(".")) {
if (!cur || typeof cur !== "object" || !(part in cur))
@@ -449,82 +445,6 @@ Singleton {
return cur || fallback;
}
function extractCurrentTheme(themeName) {
var name = themeName || "Extracted Theme";
var dark = {};
var light = {};
if (currentTheme === dynamic) {
dark = buildExtractedDynamicMode("dark", name + " Dark");
light = buildExtractedDynamicMode("light", name + " Light");
} else if (currentTheme === custom && customThemeRawData) {
var rawDark = customThemeRawData.dark || null;
var rawLight = customThemeRawData.light || null;
if (rawDark) {
dark = JSON.parse(JSON.stringify(rawDark));
if (!dark.name)
dark.name = name + " Dark";
} else if (rawLight) {
dark = buildExtractedDynamicMode("dark", name + " Dark");
} else {
dark = currentThemeData ? JSON.parse(JSON.stringify(currentThemeData)) : {};
dark.name = name + " Dark";
}
if (rawLight) {
light = JSON.parse(JSON.stringify(rawLight));
if (!light.name)
light.name = name + " Light";
} else if (rawDark) {
light = buildExtractedDynamicMode("light", name + " Light");
} else {
light = currentThemeData ? JSON.parse(JSON.stringify(currentThemeData)) : {};
light.name = name + " Light";
}
} else {
var darkTheme = StockThemes.getThemeByName(currentTheme, false);
var lightTheme = StockThemes.getThemeByName(currentTheme, true);
dark = darkTheme ? JSON.parse(JSON.stringify(darkTheme)) : {};
light = lightTheme ? JSON.parse(JSON.stringify(lightTheme)) : {};
dark.name = name + " Dark";
light.name = name + " Light";
}
return JSON.stringify({
dark: dark,
light: light
}, null, 2);
}
function buildExtractedDynamicMode(colorMode, name) {
return {
"name": name,
"primary": getMatugenColorForMode(colorMode, "primary", "#42a5f5"),
"primaryText": getMatugenColorForMode(colorMode, "on_primary", "#ffffff"),
"primaryContainer": getMatugenColorForMode(colorMode, "primary_container", "#1976d2"),
"secondary": getMatugenColorForMode(colorMode, "secondary", "#8ab4f8"),
"secondaryContainer": getMatugenColorForMode(colorMode, "secondary_container", getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f")),
"tertiary": getMatugenColorForMode(colorMode, "tertiary", "#efb8c8"),
"tertiaryContainer": getMatugenColorForMode(colorMode, "tertiary_container", getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f")),
"surface": getMatugenColorForMode(colorMode, "surface", "#1a1c1e"),
"surfaceText": getMatugenColorForMode(colorMode, "on_background", "#e3e8ef"),
"surfaceVariant": getMatugenColorForMode(colorMode, "surface_variant", "#44464f"),
"surfaceVariantText": getMatugenColorForMode(colorMode, "on_surface_variant", "#c4c7c5"),
"surfaceTint": getMatugenColorForMode(colorMode, "surface_tint", "#8ab4f8"),
"background": getMatugenColorForMode(colorMode, "background", "#1a1c1e"),
"backgroundText": getMatugenColorForMode(colorMode, "on_background", "#e3e8ef"),
"outline": getMatugenColorForMode(colorMode, "outline", "#8e918f"),
"surfaceContainerLowest": getMatugenColorForMode(colorMode, "surface_container_lowest", "#0e1013"),
"surfaceContainerLow": getMatugenColorForMode(colorMode, "surface_container_low", "#181a1d"),
"surfaceContainer": getMatugenColorForMode(colorMode, "surface_container", "#1e2023"),
"surfaceContainerHigh": getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f"),
"surfaceContainerHighest": getMatugenColorForMode(colorMode, "surface_container_highest", "#343740"),
"error": getMatugenColorForMode(colorMode, "error", "#F2B8B5"),
"warning": "#FF9800",
"info": "#2196F3",
"success": "#4CAF50"
};
}
readonly property var currentThemeData: {
if (currentTheme === "custom") {
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
@@ -660,15 +580,11 @@ Singleton {
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
readonly property color nestedSurface: floatingSurfaceHigh
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
readonly property color notificationNestedSurface: notificationFloatingSurfaceHigh
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
-12
View File
@@ -22,18 +22,6 @@ Singleton {
activeTrayMenus = newMenus
}
function closeHoverMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName]
if (!menu || menu.openedByHover !== true) continue
if (typeof menu.close === "function") {
menu.close()
} else if (menu.showMenu !== undefined) {
menu.showMenu = false
}
}
}
function closeAllMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName]
+1 -14
View File
@@ -63,7 +63,6 @@ Singleton {
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f")
readonly property bool lockU2fCustomSourceDetected: (settingsRoot?.lockU2fPamPath || "") !== "" && customU2fPamWatcher.loaded
readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd")
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
@@ -177,7 +176,7 @@ Singleton {
readonly property bool lockU2fReady: {
if (forcedU2fAvailable !== null)
return forcedU2fAvailable;
return lockU2fCustomSourceDetected || lockU2fCustomConfigDetected || homeU2fKeysDetected;
return lockU2fCustomConfigDetected || homeU2fKeysDetected;
}
readonly property bool lockU2fCanEnable: {
@@ -247,10 +246,6 @@ Singleton {
readonly property var _pamProbeCommand: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"]
function detectAuthCapabilities() {
// FileView cannot watch paths that do not exist yet, so reload the U2F PAM
dankshellU2fPamWatcher.reload();
u2fKeysWatcher.reload();
if (forcedFprintAvailable === null) {
fingerprintProbeFinalized = false;
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
@@ -727,22 +722,14 @@ Singleton {
FileView {
id: dankshellU2fPamWatcher
path: "/etc/pam.d/dankshell-u2f"
watchChanges: true
printErrors: false
onLoaded: root.dankshellU2fPamText = text()
onLoadFailed: root.dankshellU2fPamText = ""
}
FileView {
id: customU2fPamWatcher
path: root.settingsRoot?.lockU2fPamPath || ""
printErrors: false
}
FileView {
id: u2fKeysWatcher
path: root.u2fKeysPath
watchChanges: true
printErrors: false
onLoaded: root.u2fKeysText = text()
onLoadFailed: root.u2fKeysText = ""
+5 -4
View File
@@ -70,7 +70,6 @@ var SPEC = {
blurEnabled: { def: false },
blurForegroundLayers: { def: true },
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
blurBorderEnabled: { def: true },
blurBorderColor: { def: "outline" },
blurBorderCustomColor: { def: "#ffffff" },
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
@@ -354,6 +353,11 @@ var SPEC = {
fadeToDpmsEnabled: { def: true },
fadeToDpmsGracePeriod: { def: 5 },
launchPrefix: { def: "" },
brightnessDevicePins: { def: {} },
wifiNetworkPins: { def: {} },
bluetoothDevicePins: { def: {} },
audioInputDevicePins: { def: {} },
audioOutputDevicePins: { def: {} },
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
@@ -433,7 +437,6 @@ var SPEC = {
notificationOverlayEnabled: { def: false },
notificationPopupShadowEnabled: { def: true },
notificationPopupPrivacyMode: { def: false },
notificationForegroundLayers: { def: true },
overviewRows: { def: 2, persist: false },
overviewColumns: { def: 5, persist: false },
overviewScale: { def: 0.16, persist: false },
@@ -456,8 +459,6 @@ var SPEC = {
lockPamPath: { def: "" },
lockPamInlineFprint: { def: false },
lockPamInlineU2f: { def: false },
lockPamExternallyManaged: { def: false },
lockU2fPamPath: { def: "" },
lockScreenInactiveColor: { def: "#000000" },
lockScreenNotificationMode: { def: 0 },
lockScreenVideoEnabled: { def: false },
@@ -2,21 +2,6 @@
.import "./SettingsSpec.js" as SpecModule
var PIN_KEYS = ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"];
function extractPins(obj) {
if (!obj) return null;
var pins = null;
for (var i = 0; i < PIN_KEYS.length; i++) {
var value = obj[PIN_KEYS[i]];
if (!value || Object.keys(value).length === 0) continue;
if (!pins) pins = {};
pins[PIN_KEYS[i]] = value;
}
return pins;
}
function parse(root, jsonObj) {
var SPEC = SpecModule.SPEC;
@@ -278,17 +263,6 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 12;
}
if (currentVersion < 13) {
console.info("Migrating settings from version", currentVersion, "to version 13");
console.info("Moving device and network pins to cache.json");
for (var p = 0; p < PIN_KEYS.length; p++) {
delete settings[PIN_KEYS[p]];
}
settings.configVersion = 13;
}
return settings;
}
+66 -64
View File
@@ -1,5 +1,3 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
@@ -55,7 +53,6 @@ Item {
delegate: Loader {
id: fadeWindowLoader
required property var modelData
readonly property FadeToLockWindow loadedWindow: item as FadeToLockWindow
active: SettingsData.fadeToLockEnabled
asynchronous: false
@@ -67,29 +64,29 @@ Item {
}
onFadeCancelled: {
root.log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
}
}
Connections {
target: IdleService
enabled: fadeWindowLoader.loadedWindow !== null
enabled: fadeWindowLoader.item !== null
function onFadeToLockRequested() {
if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.loadedWindow.startFade();
if (fadeWindowLoader.item) {
fadeWindowLoader.item.startFade();
}
}
function onCancelFadeToLock() {
if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.loadedWindow.cancelFade();
if (fadeWindowLoader.item) {
fadeWindowLoader.item.cancelFade();
}
}
function onDismissFadeToLock() {
if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.loadedWindow.dismiss();
if (fadeWindowLoader.item) {
fadeWindowLoader.item.dismiss();
}
}
}
@@ -102,7 +99,6 @@ Item {
delegate: Loader {
id: fadeDpmsWindowLoader
required property var modelData
readonly property FadeToDpmsWindow loadedWindow: item as FadeToDpmsWindow
active: SettingsData.fadeToDpmsEnabled
asynchronous: false
@@ -114,38 +110,30 @@ Item {
}
onFadeCancelled: {
root.log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
}
}
Connections {
target: IdleService
enabled: fadeDpmsWindowLoader.loadedWindow !== null
enabled: fadeDpmsWindowLoader.item !== null
function onFadeToDpmsRequested() {
if (fadeDpmsWindowLoader.loadedWindow) {
fadeDpmsWindowLoader.loadedWindow.startFade();
if (fadeDpmsWindowLoader.item) {
fadeDpmsWindowLoader.item.startFade();
}
}
function onCancelFadeToDpms() {
if (fadeDpmsWindowLoader.loadedWindow) {
fadeDpmsWindowLoader.loadedWindow.cancelFade();
if (fadeDpmsWindowLoader.item) {
fadeDpmsWindowLoader.item.cancelFade();
}
}
function onRequestMonitorOn() {
if (!fadeDpmsWindowLoader.loadedWindow)
if (!fadeDpmsWindowLoader.item)
return;
fadeDpmsWindowLoader.loadedWindow.cancelFade();
}
function onMonitorsOffChanged() {
if (IdleService.monitorsOff)
return;
if (!fadeDpmsWindowLoader.loadedWindow)
return;
fadeDpmsWindowLoader.loadedWindow.dismiss();
fadeDpmsWindowLoader.item.cancelFade();
}
}
}
@@ -363,7 +351,9 @@ Item {
Variants {
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
delegate: NotificationPopupManager {}
delegate: NotificationPopupManager {
modelData: item
}
}
LazyLoader {
@@ -397,7 +387,6 @@ Item {
LazyLoader {
id: wifiPasswordModalLoader
active: false
readonly property WifiPasswordModal loadedModal: item as WifiPasswordModal
Component.onCompleted: {
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
@@ -432,7 +421,6 @@ Item {
LazyLoader {
id: polkitAuthModalLoader
active: false
readonly property PolkitAuthModal loadedModal: item as PolkitAuthModal
PolkitAuthModal {
id: polkitAuthModal
@@ -451,8 +439,8 @@ Item {
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
return;
polkitAuthModalLoader.active = true;
if (polkitAuthModalLoader.loadedModal)
polkitAuthModalLoader.loadedModal.show();
if (polkitAuthModalLoader.item)
polkitAuthModalLoader.item.show();
}
}
@@ -471,20 +459,20 @@ Item {
target: NetworkService
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
const alreadyShown = wifiPasswordModalLoader.loadedModal && wifiPasswordModalLoader.loadedModal.shouldBeVisible;
if (alreadyShown && token === root.lastCredentialsToken)
const alreadyShown = wifiPasswordModalLoader.item && wifiPasswordModalLoader.item.shouldBeVisible;
if (alreadyShown && token === lastCredentialsToken)
return;
wifiPasswordModalLoader.active = true;
if (!wifiPasswordModalLoader.loadedModal)
if (!wifiPasswordModalLoader.item)
return;
if (alreadyShown && root.lastCredentialsToken !== "" && root.lastCredentialsToken !== token)
NetworkService.cancelCredentials(root.lastCredentialsToken);
if (alreadyShown && lastCredentialsToken !== "" && lastCredentialsToken !== token)
NetworkService.cancelCredentials(lastCredentialsToken);
root.lastCredentialsToken = token;
root.lastCredentialsTime = Date.now();
wifiPasswordModalLoader.loadedModal.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
lastCredentialsToken = token;
lastCredentialsTime = Date.now();
wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
}
}
@@ -781,10 +769,10 @@ Item {
}
function onAppPickerRequested(data) {
root.log.debug("App picker requested with data:", JSON.stringify(data));
log.debug("App picker requested with data:", JSON.stringify(data));
if (!data || !data.target) {
root.log.warn("Invalid app picker request data");
log.warn("Invalid app picker request data");
return;
}
@@ -901,6 +889,7 @@ Item {
delegate: DankSlideout {
id: notepadSlideout
modelData: item
title: I18n.tr("Notepad")
slideoutWidth: 480
expandable: true
@@ -982,9 +971,6 @@ Item {
case "reboot":
SessionService.reboot();
break;
case "softreboot":
SessionService.softReboot();
break;
case "poweroff":
SessionService.poweroff();
break;
@@ -999,8 +985,8 @@ Item {
onSwitchUserRequested: {
switchUserModalLoader.active = true;
Qt.callLater(() => {
if (switchUserModalLoader.loadedModal)
switchUserModalLoader.loadedModal.showFromPowerMenu();
if (switchUserModalLoader.item)
switchUserModalLoader.item.showFromPowerMenu();
});
}
@@ -1014,7 +1000,6 @@ Item {
id: switchUserModalLoader
active: false
readonly property SwitchUserModal loadedModal: item as SwitchUserModal
SwitchUserModal {
id: switchUserModal
@@ -1070,6 +1055,7 @@ Item {
model: SettingsData.getFilteredScreens("toast")
delegate: Toast {
modelData: item
visible: ToastService.toastVisible
}
}
@@ -1084,55 +1070,73 @@ Item {
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: VolumeOSD {}
delegate: VolumeOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MediaVolumeOSD {}
delegate: MediaVolumeOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MediaPlaybackOSD {}
delegate: MediaPlaybackOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MicVolumeOSD {}
delegate: MicVolumeOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: BrightnessOSD {}
delegate: BrightnessOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: IdleInhibitorOSD {}
delegate: IdleInhibitorOSD {
modelData: item
}
}
Variants {
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
delegate: PowerProfileOSD {}
delegate: PowerProfileOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: CapsLockOSD {}
delegate: CapsLockOSD {
modelData: item
}
}
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: AudioOutputOSD {}
delegate: AudioOutputOSD {
modelData: item
}
}
}
}
@@ -1149,7 +1153,6 @@ Item {
Loader {
id: greeterLoader
active: false
readonly property GreeterModal loadedModal: item as GreeterModal
sourceComponent: GreeterModal {
onGreeterCompleted: greeterLoader.active = false
Component.onCompleted: show()
@@ -1158,8 +1161,8 @@ Item {
Connections {
target: FirstLaunchService
function onGreeterRequested() {
if (greeterLoader.active && greeterLoader.loadedModal) {
greeterLoader.loadedModal.show();
if (greeterLoader.active && greeterLoader.item) {
greeterLoader.item.show();
return;
}
greeterLoader.active = true;
@@ -1170,7 +1173,6 @@ Item {
Loader {
id: changelogLoader
active: false
readonly property ChangelogModal loadedModal: item as ChangelogModal
sourceComponent: ChangelogModal {
onChangelogDismissed: changelogLoader.active = false
Component.onCompleted: show()
@@ -1179,8 +1181,8 @@ Item {
Connections {
target: ChangelogService
function onChangelogRequested() {
if (changelogLoader.active && changelogLoader.loadedModal) {
changelogLoader.loadedModal.show();
if (changelogLoader.active && changelogLoader.item) {
changelogLoader.item.show();
return;
}
changelogLoader.active = true;
+12 -25
View File
@@ -449,12 +449,7 @@ Item {
searchQuery = query;
requestSearch();
if (autoSwitchedToFiles && !query.startsWith("/")) {
restorePreviousMode();
return;
}
if (query.startsWith("/")) {
if (searchMode !== "plugins" && query.startsWith("/")) {
var prefix = Utils.parseFileSearchPrefix(query);
var explicitType = prefix && prefix.type !== null ? prefix.type : null;
var targetType = explicitType !== null ? explicitType : (SessionData.launcherLastFileSearchType || "all");
@@ -819,24 +814,16 @@ 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]));
}
// 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]));
}
} else {
var apps = searchApps(searchQuery);
@@ -847,7 +834,7 @@ Item {
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
var newSections = Scorer.groupBySection(scoredItems, buildDynamicSectionDefs(allItems), sortAlpha, searchQuery ? 50 : 500);
var newSections = Scorer.groupBySection(scoredItems, sectionDefinitions, sortAlpha, searchQuery ? 50 : 500);
for (var sid in collapsedSections) {
for (var i = 0; i < newSections.length; i++) {
@@ -174,12 +174,9 @@ Item {
spotlightContent.controller.selectedFlatIndex = 0;
spotlightContent.controller.selectedItem = null;
spotlightContent.controller.historyIndex = -1;
if (targetQuery) {
spotlightContent.controller.setSearchQuery(targetQuery);
} else {
spotlightContent.controller.searchQuery = "";
spotlightContent.controller.performSearch();
}
spotlightContent.controller.searchQuery = targetQuery;
spotlightContent.controller.performSearch();
}
if (spotlightContent.resetScroll) {
spotlightContent.resetScroll();
@@ -274,7 +271,7 @@ Item {
target: spotlightContent?.controller ?? null
function onModeChanged(mode, userInitiated) {
if (!userInitiated || !SettingsData.rememberLastMode)
if (!userInitiated || !SettingsData.rememberLastMode || (mode !== "all" && mode !== "apps"))
return;
SessionData.setLauncherLastMode(mode);
}
@@ -214,7 +214,7 @@ Item {
Item {
id: listClip
anchors.fill: parent
anchors.topMargin: stickyHeader.visible ? 32 : 0
anchors.topMargin: BlurService.enabled && stickyHeader.visible ? 32 : 0
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
clip: true
@@ -417,7 +417,7 @@ Item {
anchors.top: parent.top
height: 32
z: 101
color: "transparent"
color: Theme.floatingSurface
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
@@ -7,7 +7,6 @@ Item {
property string source: ""
property int glyphSize: 14
property bool badgeVisible: true
readonly property var sourceAsset: ({
"flatpak": "../../assets/package-sources/flatpak.svg",
@@ -18,7 +17,7 @@ Item {
readonly property string assetPath: sourceAsset[source] || ""
visible: badgeVisible && SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
visible: SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
implicitWidth: glyphSize
implicitHeight: glyphSize
@@ -178,7 +178,7 @@ Rectangle {
anchors.margins: Theme.spacingXS
source: root.item?.type === "app" ? (root.item.source || "") : ""
glyphSize: 16
badgeVisible: !root.isSelected
visible: !root.isSelected && !!source
}
}
}
+34 -87
View File
@@ -27,7 +27,7 @@ DankModal {
property real holdProgress: 0
property bool showHoldHint: false
property bool holdFromKeyboard: false
property string committedAction: ""
property string pendingKeyAction: ""
readonly property bool needsConfirmation: SettingsData.powerActionConfirm
readonly property int holdDurationMs: SettingsData.powerActionHoldDuration * 1000
@@ -39,16 +39,10 @@ DankModal {
return action !== "lock" && action !== "restart";
}
function actionWakesOnKeyRelease(action) {
return action === "suspend" || action === "hibernate";
}
function startHold(action, actionIndex) {
if (committedAction !== "")
return;
if (!needsConfirmation || !actionNeedsConfirm(action)) {
if (holdFromKeyboard && actionWakesOnKeyRelease(action)) {
commitAction(action, actionIndex);
if (holdFromKeyboard) {
pendingKeyAction = action;
return;
}
executeAction(action);
@@ -62,6 +56,7 @@ DankModal {
}
function cancelHold() {
pendingKeyAction = "";
if (holdAction === "")
return;
const wasHolding = holdProgress > 0;
@@ -81,8 +76,8 @@ DankModal {
return;
}
holdTimer.stop();
if (holdFromKeyboard && actionWakesOnKeyRelease(holdAction)) {
commitAction(holdAction, holdActionIndex);
if (holdFromKeyboard) {
pendingKeyAction = holdAction;
return;
}
const action = holdAction;
@@ -92,25 +87,6 @@ DankModal {
executeAction(action);
}
function commitAction(action, actionIndex) {
holdTimer.stop();
holdAction = "";
holdActionIndex = actionIndex;
committedAction = action;
commitFallbackTimer.restart();
}
function executeCommittedAction() {
if (committedAction === "")
return;
commitFallbackTimer.stop();
const action = committedAction;
committedAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
}
signal switchUserRequested
function executeAction(action) {
@@ -152,12 +128,6 @@ DankModal {
onTriggered: root.showHoldHint = false
}
Timer {
id: commitFallbackTimer
interval: 5000
onTriggered: root.executeCommittedAction()
}
function openCentered() {
parentBounds = Qt.rect(0, 0, 0, 0);
parentScreen = null;
@@ -175,8 +145,6 @@ DankModal {
visibleActions = allActions.filter(action => {
if (action === "hibernate" && !SessionService.hibernateSupported)
return false;
if (action === "softreboot" && !SessionService.softRebootSupported)
return false;
return true;
});
@@ -225,12 +193,6 @@ DankModal {
"label": I18n.tr("Reboot"),
"key": "R"
};
case "softreboot":
return {
"icon": "autorenew",
"label": I18n.tr("Soft Reboot"),
"key": "B"
};
case "logout":
return {
"icon": "logout",
@@ -324,8 +286,7 @@ DankModal {
holdProgress = 0;
showHoldHint = false;
holdFromKeyboard = false;
committedAction = "";
commitFallbackTimer.stop();
pendingKeyAction = "";
updateVisibleActions();
const defaultIndex = getDefaultActionIndex();
selectedIndex = defaultIndex;
@@ -348,10 +309,6 @@ DankModal {
event.accepted = true;
return;
}
if (committedAction !== "") {
event.accepted = true;
return;
}
holdFromKeyboard = true;
if (SettingsData.powerMenuGridLayout) {
handleGridNavigation(event, true);
@@ -364,11 +321,6 @@ DankModal {
event.accepted = true;
return;
}
if (committedAction !== "") {
event.accepted = true;
executeCommittedAction();
return;
}
if (SettingsData.powerMenuGridLayout) {
handleGridNavigation(event, false);
} else {
@@ -378,8 +330,17 @@ DankModal {
function handleListNavigation(event, isPressed) {
if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
cancelHold();
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
if (pendingKeyAction !== "") {
const action = pendingKeyAction;
pendingKeyAction = "";
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
} else {
cancelHold();
}
event.accepted = true;
}
return;
@@ -437,12 +398,6 @@ DankModal {
event.accepted = true;
}
break;
case Qt.Key_B:
if (visibleActions.includes("softreboot")) {
startHold("softreboot", visibleActions.indexOf("softreboot"));
event.accepted = true;
}
break;
case Qt.Key_X:
if (visibleActions.includes("logout")) {
startHold("logout", visibleActions.indexOf("logout"));
@@ -478,8 +433,17 @@ DankModal {
function handleGridNavigation(event, isPressed) {
if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
cancelHold();
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
if (pendingKeyAction !== "") {
const action = pendingKeyAction;
pendingKeyAction = "";
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
} else {
cancelHold();
}
event.accepted = true;
}
return;
@@ -553,12 +517,6 @@ DankModal {
event.accepted = true;
}
break;
case Qt.Key_B:
if (visibleActions.includes("softreboot")) {
startHold("softreboot", visibleActions.indexOf("softreboot"));
event.accepted = true;
}
break;
case Qt.Key_X:
if (visibleActions.includes("logout")) {
startHold("logout", visibleActions.indexOf("logout"));
@@ -617,7 +575,7 @@ DankModal {
readonly property var actionData: root.getActionData(modelData)
readonly property bool isSelected: root.selectedIndex === index
readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff"
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
width: (root.modalWidth - Theme.spacingL * 2 - Theme.spacingS * (root.gridColumns - 1)) / root.gridColumns
@@ -647,7 +605,7 @@ DankModal {
color: {
if (gridButtonRect.modelData === "poweroff")
return Theme.errorSelected;
if (gridButtonRect.modelData === "reboot" || gridButtonRect.modelData === "softreboot")
if (gridButtonRect.modelData === "reboot")
return Theme.withAlpha(Theme.warning, 0.3);
return Theme.primarySelected;
}
@@ -742,7 +700,7 @@ DankModal {
readonly property var actionData: root.getActionData(modelData)
readonly property bool isSelected: root.selectedIndex === index
readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff"
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
width: parent.width
@@ -772,7 +730,7 @@ DankModal {
color: {
if (listButtonRect.modelData === "poweroff")
return Theme.errorSelected;
if (listButtonRect.modelData === "reboot" || listButtonRect.modelData === "softreboot")
if (listButtonRect.modelData === "reboot")
return Theme.withAlpha(Theme.warning, 0.3);
return Theme.primarySelected;
}
@@ -854,7 +812,6 @@ DankModal {
Row {
id: hintRow
readonly property bool selectedNeedsHold: root.actionNeedsConfirm(root.getActionAtIndex(root.selectedIndex))
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Theme.spacingS
@@ -869,13 +826,7 @@ DankModal {
}
DankIcon {
name: {
if (root.showHoldHint)
return "warning";
if (!hintRow.selectedNeedsHold)
return "bolt";
return "touch_app";
}
name: root.showHoldHint ? "warning" : "touch_app"
size: Theme.fontSizeSmall
color: root.showHoldHint ? Theme.warning : Theme.surfaceTextSecondary
anchors.verticalCenter: parent.verticalCenter
@@ -885,12 +836,8 @@ DankModal {
readonly property real totalMs: SettingsData.powerActionHoldDuration * 1000
readonly property int remainingMs: Math.ceil(totalMs * (1 - root.holdProgress))
text: {
if (root.committedAction !== "")
return I18n.tr("Release to confirm");
if (root.showHoldHint)
return I18n.tr("Hold longer to confirm");
if (!hintRow.selectedNeedsHold)
return I18n.tr("Activates immediately");
if (root.holdProgress > 0) {
if (totalMs < 1000)
return I18n.tr("Hold to confirm (%1 ms)").arg(remainingMs);
+5 -11
View File
@@ -41,9 +41,6 @@ FloatingWindow {
signal closingModal
function show() {
if (visible && !backingWindowVisible) {
visible = false;
}
visible = true;
}
@@ -52,11 +49,7 @@ FloatingWindow {
}
function toggle() {
if (visible && backingWindowVisible) {
hide();
return;
}
show();
visible = !visible;
}
function setTabIndex(tabIndex: int) {
@@ -68,12 +61,13 @@ FloatingWindow {
function showWithTab(tabIndex: int) {
setTabIndex(tabIndex);
show();
visible = true;
}
function showWithTabName(tabName: string) {
setTabIndex(sidebar.resolveTabIndex(tabName));
show();
var idx = sidebar.resolveTabIndex(tabName);
setTabIndex(idx);
visible = true;
}
function resolveTabIndex(tabName: string): int {
+1 -20
View File
@@ -52,7 +52,6 @@ FloatingWindow {
condInitialised.triState = 0;
opacityEnabled.checked = false;
opacitySlider.value = 100;
floatingCond.triState = 0;
floatingToggle.checked = false;
maximizedToggle.checked = false;
maximizedToEdgesToggle.checked = false;
@@ -165,7 +164,6 @@ FloatingWindow {
opacityEnabled.checked = hasOpacity;
opacitySlider.value = hasOpacity ? Math.round(actions.opacity * 100) : 100;
floatingCond.triState = triFromBool(actions.openFloating);
floatingToggle.checked = actions.openFloating || false;
maximizedToggle.checked = actions.openMaximized || false;
maximizedToEdgesToggle.checked = actions.openMaximizedToEdges || false;
@@ -320,9 +318,7 @@ FloatingWindow {
if (opacityEnabled.checked)
actions.opacity = opacitySlider.value / 100;
if (isNiri)
applyCond(actions, "openFloating", floatingCond.triState);
else if (floatingToggle.checked)
if (floatingToggle.checked)
actions.openFloating = true;
if (maximizedToggle.checked)
actions.openMaximized = true;
@@ -457,9 +453,6 @@ FloatingWindow {
};
if (isNiri && extraMatchModel.count > 0)
ruleData.matches = matches;
// No exclude editor yet carry existing excludes through so an edit doesn't delete them (#2996)
if (isEditMode && (editingRule.excludes?.length ?? 0) > 0)
ruleData.excludes = editingRule.excludes;
submitting = true;
@@ -950,17 +943,6 @@ FloatingWindow {
title: I18n.tr("Window Opening")
}
Flow {
width: parent.width
spacing: Theme.spacingS
visible: isNiri
MatchCond {
id: floatingCond
label: I18n.tr("Float")
}
}
Flow {
width: parent.width
spacing: Theme.spacingL
@@ -968,7 +950,6 @@ FloatingWindow {
CheckboxRow {
id: floatingToggle
label: I18n.tr("Float")
visible: !isNiri
}
CheckboxRow {
id: maximizedToggle
+236 -136
View File
@@ -1,4 +1,5 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.Common
@@ -56,96 +57,60 @@ Variants {
}
property string source: SessionData.getMonitorWallpaper(modelData.name) || ""
readonly property bool isColorSource: source.startsWith("#")
readonly property string displaySource: {
if (!source || isColorSource)
return "";
return source.startsWith("file://") ? source : encodeFileUrl(source);
}
property bool isColorSource: source.startsWith("#")
property bool contentReady: false
property bool surfaceBounce: false
// Live stack is captured into frozenLayer once stable, then unloaded; if the capture stalls the stack just stays live.
property bool liveActive: false
property bool frozenValid: false
property string _frozenSource: ""
property bool loadFailed: false
property int _freezeWaitFrames: 0
Connections {
target: SessionData
function onIsLightModeChanged() {
if (SessionData.perModeWallpaper) {
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
if (newSource !== root.source) {
root.source = newSource;
}
}
}
}
readonly property var backingWindow: Window.window
readonly property bool renderActive: !source || liveActive || _freezeWaitFrames > 0
property int _settleFrames: 3
readonly property int maxTextureSize: 8192
readonly property int textureWidth: Math.min(modelData.width, maxTextureSize)
readonly property int textureHeight: Math.min(modelData.height, maxTextureSize)
function getFillMode(modeName) {
switch (modeName) {
case "Stretch":
return Image.Stretch;
case "Fit":
case "PreserveAspectFit":
return Image.PreserveAspectFit;
case "Fill":
case "PreserveAspectCrop":
return Image.PreserveAspectCrop;
case "Tile":
return Image.Tile;
case "TileVertically":
return Image.TileVertically;
case "TileHorizontally":
return Image.TileHorizontally;
case "Pad":
return Image.Pad;
default:
return Image.PreserveAspectCrop;
}
}
Component.onCompleted: {
if (!displaySource) {
isInitialized = true;
if (!source || isColorSource) {
contentReady = true;
return;
}
liveActive = true;
}
onDisplaySourceChanged: {
invalidate();
loadFailed = false;
_freezeWaitFrames = 0;
if (!displaySource) {
liveActive = false;
frozenValid = false;
_frozenSource = "";
contentReady = true;
return;
}
liveActive = true;
}
function regenerate() {
invalidate();
if (!displaySource)
return;
if (liveActive) {
scheduleFreeze();
return;
}
liveActive = true;
}
function handleDisplayable() {
contentReady = true;
if (liveLoader.item?.currentFailed) {
if (!frozenValid)
loadFailed = true;
liveActive = false;
}
invalidate();
}
function scheduleFreeze() {
if (!liveLoader.item?.stable)
return;
frozenLayer.scheduleUpdate();
_freezeWaitFrames = 3;
_settleFrames = 3;
// No wedge watchdog: an occluded surface may never produce frames, the freeze just waits
backingWindow?.update();
}
function completeFreeze() {
const live = liveLoader.item;
if (!live || !live.stable)
return;
frozenValid = true;
_frozenSource = displaySource;
liveActive = false;
log.info("froze blur layer for", modelData.name);
invalidate();
}
onTextureWidthChanged: regenerate()
onTextureHeightChanged: regenerate()
property bool isInitialized: false
property real transitionProgress: 0
readonly property bool transitioning: transitionAnimation.running
property bool effectActive: false
property bool useNextForEffect: false
readonly property var backingWindow: Window.window
readonly property bool renderActive: !source || effectActive || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading
property int _settleFrames: 3
function invalidate() {
_settleFrames = 3;
@@ -192,35 +157,29 @@ Variants {
root._settleFrames--;
root._wedgeBounced = false;
wedgeWatchdog.stop();
if (root._freezeWaitFrames > 0 && --root._freezeWaitFrames === 0)
root.completeFreeze();
}
function onVisibleChanged() {
root.invalidate();
}
function onWidthChanged() {
root.regenerate();
root.invalidate();
}
function onHeightChanged() {
root.regenerate();
}
function onResourcesLost() {
root.frozenValid = false;
root.regenerate();
root.invalidate();
}
}
Connections {
target: Quickshell
function onScreensChanged() {
root.regenerate();
root.invalidate();
}
}
Connections {
target: SettingsData
function onWallpaperFillModeChanged() {
root.regenerate();
root.invalidate();
}
function onEffectiveWallpaperBackgroundColorChanged() {
root.invalidate();
@@ -229,26 +188,18 @@ Variants {
Connections {
target: SessionData
function onIsLightModeChanged() {
if (SessionData.perModeWallpaper) {
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
if (newSource !== root.source) {
root.source = newSource;
}
}
}
function onMonitorWallpaperFillModesChanged() {
root.regenerate();
root.invalidate();
}
function onPerMonitorWallpaperChanged() {
root.regenerate();
root.invalidate();
}
}
// Theme changes repaint DankBackdrop but nothing else wakes the render loop
Connections {
target: Theme
enabled: root.isColorSource || root.loadFailed
enabled: root.isColorSource || currentWallpaper.status === Image.Error
function onPrimaryChanged() {
root.invalidate();
}
@@ -266,23 +217,89 @@ Variants {
}
}
Connections {
target: liveLoader.item
function onBecameDisplayable() {
root.handleDisplayable();
function handleTransitionLoadError(failedSource) {
log.warn("failed to load candidate wallpaper for", modelData.name + ":", failedSource);
transitionDelayTimer.stop();
transitionAnimation.stop();
root.useNextForEffect = false;
root.effectActive = false;
root.transitionProgress = 0.0;
nextWallpaper.source = "";
}
onSourceChanged: {
invalidate();
if (!source || source.startsWith("#")) {
setWallpaperImmediate("");
return;
}
function onStableChanged() {
if (liveLoader.item.stable)
root.scheduleFreeze();
const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source);
if (!isInitialized || !currentWallpaper.source) {
setWallpaperImmediate(formattedSource);
isInitialized = true;
return;
}
function onTransitioningChanged() {
root.invalidate();
if (CompositorService.isNiri && SessionData.isSwitchingMode) {
setWallpaperImmediate(formattedSource);
return;
}
changeWallpaper(formattedSource);
}
function setWallpaperImmediate(newSource) {
transitionDelayTimer.stop();
transitionAnimation.stop();
root.transitionProgress = 0.0;
root.effectActive = false;
if (!newSource)
root.contentReady = true;
currentWallpaper.source = newSource;
nextWallpaper.source = "";
}
function startTransition() {
root.useNextForEffect = true;
root.effectActive = true;
if (srcNext.scheduleUpdate)
srcNext.scheduleUpdate();
transitionDelayTimer.start();
}
Timer {
id: transitionDelayTimer
interval: 16
repeat: false
onTriggered: transitionAnimation.start()
}
function changeWallpaper(newPath) {
if (newPath === currentWallpaper.source)
return;
if (!newPath || newPath.startsWith("#"))
return;
if (root.transitioning) {
transitionAnimation.stop();
root.transitionProgress = 0;
root.effectActive = false;
currentWallpaper.source = nextWallpaper.source;
nextWallpaper.source = "";
}
if (!currentWallpaper.source) {
setWallpaperImmediate(newPath);
return;
}
nextWallpaper.source = newPath;
if (nextWallpaper.status === Image.Ready)
root.startTransition();
}
Loader {
anchors.fill: parent
active: !root.source || root.isColorSource || root.loadFailed
active: !root.source || root.isColorSource || currentWallpaper.status === Image.Error
asynchronous: true
sourceComponent: DankBackdrop {
@@ -290,43 +307,126 @@ Variants {
}
}
ShaderEffectSource {
id: frozenLayer
readonly property int maxTextureSize: 8192
property int textureWidth: Math.min(modelData.width, maxTextureSize)
property int textureHeight: Math.min(modelData.height, maxTextureSize)
Image {
id: currentWallpaper
anchors.fill: parent
sourceItem: liveContainer
live: false
visible: false
opacity: 1
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(modelData.name))
onStatusChanged: {
if (status === Image.Error) {
log.warn("failed to load active wallpaper for", modelData.name + ":", source);
}
if (status === Image.Ready || status === Image.Error) {
root.contentReady = true;
}
}
}
Image {
id: nextWallpaper
anchors.fill: parent
visible: false
opacity: 0
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(modelData.name))
onStatusChanged: {
if (status === Image.Error) {
root.handleTransitionLoadError(source);
return;
}
if (status !== Image.Ready)
return;
if (!root.transitioning) {
root.startTransition();
}
}
}
ShaderEffectSource {
id: srcNext
sourceItem: root.effectActive ? nextWallpaper : null
hideSource: root.effectActive
live: root.effectActive
mipmap: false
recursive: false
smooth: true
visible: root.frozenValid || root.liveActive
textureSize: Qt.size(root.textureWidth, root.textureHeight)
}
Rectangle {
id: dummyRect
width: 1
height: 1
visible: false
color: "transparent"
}
ShaderEffectSource {
id: srcDummy
sourceItem: dummyRect
hideSource: true
live: false
mipmap: false
recursive: false
}
Item {
id: liveContainer
id: blurredLayer
anchors.fill: parent
visible: root.liveActive
Loader {
id: liveLoader
MultiEffect {
anchors.fill: parent
active: root.liveActive
asynchronous: false
source: currentWallpaper
visible: currentWallpaper.source !== ""
blurEnabled: true
blur: 0.8
blurMax: 75
opacity: 1 - root.transitionProgress
autoPaddingEnabled: false
}
// Cached images reach Ready synchronously during creation, before Connections retargets
onLoaded: {
if (item.displayableNow)
root.handleDisplayable();
if (item.stable)
root.scheduleFreeze();
}
MultiEffect {
anchors.fill: parent
source: root.useNextForEffect ? srcNext : srcDummy
visible: nextWallpaper.source !== "" && root.useNextForEffect
blurEnabled: true
blur: 0.8
blurMax: 75
opacity: root.transitionProgress
autoPaddingEnabled: false
}
}
sourceComponent: BlurredWallpaperLive {
wallpaperSource: root.displaySource
initialSource: root._frozenSource
screenName: modelData.name
blurTextureSize: Qt.size(root.textureWidth, root.textureHeight)
}
NumberAnimation {
id: transitionAnimation
target: root
property: "transitionProgress"
from: 0.0
to: 1.0
duration: 1000
easing.type: Easing.InOutCubic
onFinished: {
if (nextWallpaper.source && nextWallpaper.status === Image.Ready)
currentWallpaper.source = nextWallpaper.source;
root.useNextForEffect = false;
nextWallpaper.source = "";
root.transitionProgress = 0.0;
root.effectActive = false;
}
}
}
-255
View File
@@ -1,255 +0,0 @@
import QtQuick
import QtQuick.Effects
import qs.Common
import qs.Services
Item {
id: root
readonly property var log: Log.scoped("BlurredWallpaperLive")
required property string wallpaperSource
required property string initialSource
required property string screenName
required property size blurTextureSize
readonly property bool currentFailed: currentWallpaper.status === Image.Error
readonly property bool displayableNow: currentWallpaper.status === Image.Ready || currentWallpaper.status === Image.Error
readonly property bool stable: isInitialized && !effectActive && !transitionAnimation.running && !transitionDelayTimer.running && currentWallpaper.status === Image.Ready && currentWallpaper.source.toString() === wallpaperSource && !nextWallpaper.source.toString()
signal becameDisplayable
property bool isInitialized: false
property real transitionProgress: 0
readonly property bool transitioning: transitionAnimation.running
property bool effectActive: false
property bool useNextForEffect: false
function getFillMode(modeName) {
switch (modeName) {
case "Stretch":
return Image.Stretch;
case "Fit":
case "PreserveAspectFit":
return Image.PreserveAspectFit;
case "Fill":
case "PreserveAspectCrop":
return Image.PreserveAspectCrop;
case "Tile":
return Image.Tile;
case "TileVertically":
return Image.TileVertically;
case "TileHorizontally":
return Image.TileHorizontally;
case "Pad":
return Image.Pad;
default:
return Image.PreserveAspectCrop;
}
}
Component.onCompleted: {
if (initialSource && initialSource !== wallpaperSource && !(CompositorService.isNiri && SessionData.isSwitchingMode)) {
currentWallpaper.source = initialSource;
isInitialized = true;
changeWallpaper(wallpaperSource);
return;
}
currentWallpaper.source = wallpaperSource;
isInitialized = true;
}
onWallpaperSourceChanged: {
if (!isInitialized)
return;
if (!wallpaperSource) {
setWallpaperImmediate("");
return;
}
if (!currentWallpaper.source.toString()) {
setWallpaperImmediate(wallpaperSource);
return;
}
if (CompositorService.isNiri && SessionData.isSwitchingMode) {
setWallpaperImmediate(wallpaperSource);
return;
}
changeWallpaper(wallpaperSource);
}
function handleTransitionLoadError(failedSource) {
log.warn("failed to load candidate wallpaper for", screenName + ":", failedSource);
transitionDelayTimer.stop();
transitionAnimation.stop();
useNextForEffect = false;
effectActive = false;
transitionProgress = 0.0;
nextWallpaper.source = "";
}
function setWallpaperImmediate(newSource) {
transitionDelayTimer.stop();
transitionAnimation.stop();
transitionProgress = 0.0;
effectActive = false;
currentWallpaper.source = newSource;
nextWallpaper.source = "";
}
function startTransition() {
useNextForEffect = true;
effectActive = true;
if (srcNext.scheduleUpdate)
srcNext.scheduleUpdate();
transitionDelayTimer.start();
}
function changeWallpaper(newPath) {
if (!newPath)
return;
if (newPath === currentWallpaper.source.toString())
return;
if (transitioning) {
transitionAnimation.stop();
transitionProgress = 0;
effectActive = false;
currentWallpaper.source = nextWallpaper.source;
nextWallpaper.source = "";
}
if (!currentWallpaper.source.toString()) {
setWallpaperImmediate(newPath);
return;
}
nextWallpaper.source = newPath;
if (nextWallpaper.status === Image.Ready)
startTransition();
}
Timer {
id: transitionDelayTimer
interval: 16
repeat: false
onTriggered: transitionAnimation.start()
}
Image {
id: currentWallpaper
anchors.fill: parent
visible: false
opacity: 1
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: root.blurTextureSize
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(root.screenName))
onStatusChanged: {
if (status === Image.Error) {
root.log.warn("failed to load active wallpaper for", root.screenName + ":", source);
}
if (status === Image.Ready || status === Image.Error) {
root.becameDisplayable();
}
}
}
Image {
id: nextWallpaper
anchors.fill: parent
visible: false
opacity: 0
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: root.blurTextureSize
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(root.screenName))
onStatusChanged: {
if (status === Image.Error) {
root.handleTransitionLoadError(source);
return;
}
if (status !== Image.Ready)
return;
if (!root.transitioning) {
root.startTransition();
}
}
}
ShaderEffectSource {
id: srcNext
sourceItem: root.effectActive ? nextWallpaper : null
hideSource: root.effectActive
live: root.effectActive
mipmap: false
recursive: false
textureSize: root.blurTextureSize
}
Rectangle {
id: dummyRect
width: 1
height: 1
visible: false
color: "transparent"
}
ShaderEffectSource {
id: srcDummy
sourceItem: dummyRect
hideSource: true
live: false
mipmap: false
recursive: false
}
Item {
id: blurredLayer
anchors.fill: parent
MultiEffect {
anchors.fill: parent
source: currentWallpaper
visible: currentWallpaper.source !== ""
blurEnabled: true
blur: 0.8
blurMax: 75
opacity: 1 - root.transitionProgress
autoPaddingEnabled: false
}
MultiEffect {
anchors.fill: parent
source: root.useNextForEffect ? srcNext : srcDummy
visible: nextWallpaper.source !== "" && root.useNextForEffect
blurEnabled: true
blur: 0.8
blurMax: 75
opacity: root.transitionProgress
autoPaddingEnabled: false
}
}
NumberAnimation {
id: transitionAnimation
target: root
property: "transitionProgress"
from: 0.0
to: 1.0
duration: 1000
easing.type: Easing.InOutCubic
onFinished: {
if (nextWallpaper.source && nextWallpaper.status === Image.Ready)
currentWallpaper.source = nextWallpaper.source;
root.useNextForEffect = false;
nextWallpaper.source = "";
root.transitionProgress = 0.0;
root.effectActive = false;
}
}
}
@@ -469,8 +469,6 @@ PluginComponent {
}
MouseArea {
id: peerMouseArea
z: -1
anchors.fill: parent
hoverEnabled: true
@@ -77,12 +77,6 @@ DankPopout {
readonly property color _containerBg: Theme.nestedSurface
// Defer open one tick so screen-change geometry settles before the surface
// maps; a synchronous open churns the surface and loses the blur on a switch.
function present() {
Qt.callLater(open);
}
function openWithSection(section) {
StateUtils.openWithSection(root, section);
}
@@ -159,7 +159,7 @@ Rectangle {
}
function getPinnedInputs() {
const pins = CacheData.audioInputDevicePins || {};
const pins = SettingsData.audioInputDevicePins || {};
return normalizePinList(pins["preferredInput"]);
}
@@ -315,7 +315,7 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(CacheData.audioInputDevicePins || {}));
const pins = JSON.parse(JSON.stringify(SettingsData.audioInputDevicePins || {}));
let pinnedList = audioContent.normalizePinList(pins["preferredInput"]);
const pinIndex = pinnedList.indexOf(modelData.name);
@@ -332,7 +332,7 @@ Rectangle {
else
delete pins["preferredInput"];
CacheData.set("audioInputDevicePins", pins);
SettingsData.set("audioInputDevicePins", pins);
}
}
}
@@ -169,7 +169,7 @@ Rectangle {
}
function getPinnedOutputs() {
const pins = CacheData.audioOutputDevicePins || {};
const pins = SettingsData.audioOutputDevicePins || {};
return normalizePinList(pins["preferredOutput"]);
}
@@ -324,7 +324,7 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(CacheData.audioOutputDevicePins || {}));
const pins = JSON.parse(JSON.stringify(SettingsData.audioOutputDevicePins || {}));
let pinnedList = audioContent.normalizePinList(pins["preferredOutput"]);
const pinIndex = pinnedList.indexOf(modelData.name);
@@ -341,7 +341,7 @@ Rectangle {
else
delete pins["preferredOutput"];
CacheData.set("audioOutputDevicePins", pins);
SettingsData.set("audioOutputDevicePins", pins);
}
}
}
@@ -112,7 +112,7 @@ Rectangle {
}
function getPinnedDevices() {
const pins = CacheData.bluetoothDevicePins || {};
const pins = SettingsData.bluetoothDevicePins || {};
return normalizePinList(pins["preferredDevice"]);
}
@@ -400,7 +400,7 @@ Rectangle {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
const pins = JSON.parse(JSON.stringify(CacheData.bluetoothDevicePins || {}));
const pins = JSON.parse(JSON.stringify(SettingsData.bluetoothDevicePins || {}));
let pinnedList = root.normalizePinList(pins["preferredDevice"]);
const pinIndex = pinnedList.indexOf(pairedDelegate.modelData.address);
@@ -418,7 +418,7 @@ Rectangle {
delete pins["preferredDevice"];
}
CacheData.set("bluetoothDevicePins", pins);
SettingsData.set("bluetoothDevicePins", pins);
}
}
}
@@ -37,7 +37,7 @@ Rectangle {
const pinKey = getScreenPinKey();
if (pinKey.length > 0) {
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) {
const found = devices.find(d => d.name === pinnedDevice);
@@ -85,12 +85,12 @@ Rectangle {
}
const pinKey = getScreenPinKey();
if (pinKey.length > 0) {
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
const existing = pins[pinKey];
if (existing && existing !== deviceName) {
const next = JSON.parse(JSON.stringify(pins));
delete next[pinKey];
CacheData.set("brightnessDevicePins", next);
SettingsData.set("brightnessDevicePins", next);
}
}
root.currentDeviceName = deviceName;
@@ -106,7 +106,7 @@ Rectangle {
const pinKey = getScreenPinKey();
if (!pinKey || !deviceName)
return false;
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
return pins[pinKey] === deviceName;
}
@@ -114,13 +114,13 @@ Rectangle {
const pinKey = getScreenPinKey();
if (!pinKey || !deviceName)
return;
const pins = JSON.parse(JSON.stringify(CacheData.brightnessDevicePins || {}));
const pins = JSON.parse(JSON.stringify(SettingsData.brightnessDevicePins || {}));
if (pins[pinKey] === deviceName) {
delete pins[pinKey];
} else {
pins[pinKey] = deviceName;
}
CacheData.set("brightnessDevicePins", pins);
SettingsData.set("brightnessDevicePins", pins);
}
implicitHeight: {
@@ -269,7 +269,7 @@ Rectangle {
readonly property bool selected: !!(modelData && modelData.name === root.currentDeviceName)
readonly property bool devicePinnedHere: {
CacheData.brightnessDevicePins;
SettingsData.brightnessDevicePins;
return root.isDevicePinnedToScreen(modelData ? modelData.name : "");
}
@@ -49,7 +49,7 @@ Rectangle {
}
function getPinnedNetworks() {
const pins = CacheData.wifiNetworkPins || {};
const pins = SettingsData.wifiNetworkPins || {};
return normalizePinList(pins["preferredWifi"]);
}
@@ -710,7 +710,7 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(CacheData.wifiNetworkPins || {}));
const pins = JSON.parse(JSON.stringify(SettingsData.wifiNetworkPins || {}));
let pinnedList = root.normalizePinList(pins["preferredWifi"]);
const pinIndex = pinnedList.indexOf(modelData.ssid);
@@ -727,7 +727,7 @@ Rectangle {
else
delete pins["preferredWifi"];
CacheData.set("wifiNetworkPins", pins);
SettingsData.set("wifiNetworkPins", pins);
}
}
}
@@ -34,7 +34,7 @@ Row {
if (screenName && screenName.length > 0) {
const screen = Quickshell.screens.find(s => s.name === screenName);
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice);
+1 -10
View File
@@ -201,16 +201,7 @@ Item {
direction: root.effectiveShadowDirection
fallbackOffset: 4
targetRadius: root.rt
// wing-side body corners are square where the gothic fillets attach;
// rounding the shadow there leaves a shadow-filled notch under the
// translucent bar fill (#2975)
topLeftRadius: root.gothEnabled && (root.isBottom || root.isRight) ? 0 : root.rt
topRightRadius: root.gothEnabled && (root.isBottom || root.isLeft) ? 0 : root.rt
bottomLeftRadius: root.gothEnabled && (root.isTop || root.isRight) ? 0 : root.rt
bottomRightRadius: root.gothEnabled && (root.isTop || root.isLeft) ? 0 : root.rt
// barShape below is the sole painter of the bar; a fill here doubles
// the alpha of translucent bars while the wings stay single-painted
targetColor: "transparent"
targetColor: barWindow._bgColor
shadowBlurPx: root.shadowBlurPx
shadowOffsetX: root.shadowOffsetX
@@ -29,8 +29,6 @@ Item {
property var centerWidgets: []
property int totalWidgets: 0
property real totalSize: 0
property real contentStart: 0
property real contentSize: 0
function updateLayout() {
if (SettingsData.centeringMode === "geometric") {
@@ -38,25 +36,6 @@ Item {
} else {
applyIndexLayout();
}
updateContentExtent();
}
function updateContentExtent() {
if (centerWidgets.length === 0) {
contentStart = 0;
contentSize = 0;
return;
}
let start = Infinity;
let end = -Infinity;
for (const widget of centerWidgets) {
const pos = isVertical ? widget.y : widget.x;
const size = isVertical ? widget.height : widget.width;
start = Math.min(start, pos);
end = Math.max(end, pos + size);
}
contentStart = start;
contentSize = end - start;
}
function applyGeometricLayout() {
@@ -39,7 +39,7 @@ Item {
readonly property real _barInsetPaddingAuto: _barIsVertical ? Theme.spacingXS : _edgeBaseMargin
readonly property real _barInsetPadding: _barInsetPaddingRaw < 0 ? _barInsetPaddingAuto : _barInsetPaddingRaw
// Connected-frame Bar Inset Padding: absolute free-end inset (auto < 0 = frameThickness, 0 = edge-to-edge).
// FrameExclusions already moves a free bar end inward by frameThickness so use frame inset to mangage the gap
// Any amount beyond the frameThickness baseline is also added at the bar-clearance ends (all ends).
readonly property real _frameInsetResolved: SettingsData.frameBarInsetPadding < 0 ? SettingsData.frameThickness : SettingsData.frameBarInsetPadding
readonly property real _frameInsetExtra: Math.max(0, _frameInsetResolved - SettingsData.frameThickness)
@@ -47,28 +47,28 @@ Item {
if (_barIsVertical)
return _edgeBaseMargin;
if (_usesFrameBarChrome)
return hasAdjacentLeftBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : _frameInsetExtra;
return hasAdjacentLeftBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
return Math.max(0, _barInsetPadding);
}
readonly property real _rightMargin: {
if (_barIsVertical)
return _edgeBaseMargin;
if (_usesFrameBarChrome)
return hasAdjacentRightBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : _frameInsetExtra;
return hasAdjacentRightBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
return Math.max(0, _barInsetPadding);
}
readonly property real _topMargin: {
if (!_barIsVertical)
return 0;
if (_usesFrameBarChrome)
return hasAdjacentTopBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : _frameInsetExtra;
return hasAdjacentTopBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
return Math.max(0, _barInsetPadding);
}
readonly property real _bottomMargin: {
if (!_barIsVertical)
return 0;
if (_usesFrameBarChrome)
return hasAdjacentBottomBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : _frameInsetExtra;
return hasAdjacentBottomBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
return Math.max(0, _barInsetPadding);
}
@@ -609,7 +609,7 @@ Item {
_closeHoverNotepad();
activeHoverTrigger = "";
PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen);
TrayMenuManager.closeHoverMenus();
TrayMenuManager.closeAllMenus();
}
function _beginSupersededCloseForActive() {
+4 -18
View File
@@ -833,30 +833,16 @@ PanelWindow {
const pos = section.mapToItem(barWindow.contentItem, 0, 0);
const implW = section.implicitWidth || 0;
const implH = section.implicitHeight || 0;
const contentSize = isCenter ? (section.contentSize || 0) : 0;
let offsetX = isCenter && !barWindow.isVertical ? (section.width - implW) / 2 : 0;
let offsetY = !barWindow.isVertical ? (section.height - implH) / 2 : (isCenter ? (section.height - implH) / 2 : 0);
let w = implW;
let h = implH;
// index centering lays content out asymmetrically; use the real extent
if (contentSize > 0) {
if (barWindow.isVertical) {
offsetY = section.contentStart;
h = contentSize;
} else {
offsetX = section.contentStart;
w = contentSize;
}
}
const offsetX = isCenter && !barWindow.isVertical ? (section.width - implW) / 2 : 0;
const offsetY = !barWindow.isVertical ? (section.height - implH) / 2 : (isCenter ? (section.height - implH) / 2 : 0);
const edgePad = 2;
return {
"x": pos.x + offsetX - edgePad,
"y": pos.y + offsetY - edgePad,
"w": w + edgePad * 2,
"h": h + edgePad * 2
"w": implW + edgePad * 2,
"h": implH + edgePad * 2
};
}
@@ -74,7 +74,7 @@ DankPopout {
color: "transparent"
focus: true
readonly property bool upgradeRunsInTerminal: SystemUpdateService.useCustomCommand || (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
readonly property bool hasTerminalBackend: (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
property int nowUnix: Math.floor(Date.now() / 1000)
@@ -272,7 +272,7 @@ DankPopout {
includeAUR: SettingsData.updaterAllowAUR,
terminal: SessionData.terminalOverride
};
if (updaterPanel.upgradeRunsInTerminal) {
if (updaterPanel.hasTerminalBackend) {
systemUpdatePopout._reopenAfterUpgrade = true;
SystemUpdateService.runUpdates(opts);
systemUpdatePopout.close();
@@ -336,10 +336,7 @@ DankPopout {
StyledText {
id: statusText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: ignoredSection.top
anchors.fill: parent
anchors.margins: Theme.spacingM
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
@@ -364,10 +361,7 @@ DankPopout {
DankListView {
id: packagesList
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: ignoredSection.top
anchors.fill: parent
anchors.margins: Theme.spacingS
visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
clip: true
@@ -473,128 +467,18 @@ DankPopout {
iconName: "visibility_off"
iconSize: 16
iconColor: Theme.surfaceVariantText
visible: rowHoverHandler.hovered && SystemUpdateService.canIgnorePackage(packageRow.modelData)
visible: rowHoverHandler.hovered
tooltipText: I18n.tr("Ignore this package")
onClicked: SystemUpdateService.ignorePackage(packageRow.modelData.name)
}
}
}
Column {
id: ignoredSection
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.spacingS
spacing: Theme.spacingXS
readonly property var ignoredNames: SettingsData.updaterIgnoredPackages || []
readonly property bool shown: ignoredNames.length > 0 && !SystemUpdateService.isUpgrading && !SystemUpdateService.isChecking
property bool expanded: false
visible: shown
height: shown ? implicitHeight : 0
Rectangle {
id: ignoredToggle
width: parent.width
height: 32
radius: Theme.cornerRadius
color: ignoredToggleArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
DankIcon {
id: ignoredToggleIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "visibility_off"
size: 16
color: Theme.surfaceVariantText
}
StyledText {
anchors.left: ignoredToggleIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("Ignored (%1)").arg(ignoredSection.ignoredNames.length)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankIcon {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: ignoredSection.expanded ? "expand_less" : "expand_more"
size: 16
color: Theme.surfaceVariantText
}
MouseArea {
id: ignoredToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: ignoredSection.expanded = !ignoredSection.expanded
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
DankListView {
width: parent.width
height: ignoredSection.expanded ? Math.min(contentHeight, 150) : 0
visible: ignoredSection.expanded
clip: true
spacing: Theme.spacingXS
model: ignoredSection.ignoredNames
delegate: Rectangle {
id: ignoredRow
width: ListView.view.width
height: 32
radius: Theme.cornerRadius
color: Theme.surfaceLight
required property string modelData
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.right: restoreButton.left
anchors.verticalCenter: parent.verticalCenter
text: ignoredRow.modelData
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
elide: Text.ElideRight
}
DankActionButton {
id: restoreButton
anchors.right: parent.right
anchors.rightMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
buttonSize: 24
iconName: "visibility"
iconSize: 16
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Stop ignoring %1").arg(ignoredRow.modelData)
onClicked: SystemUpdateService.unignorePackage(ignoredRow.modelData)
}
}
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
visible: SystemUpdateService.isUpgrading && updaterPanel.upgradeRunsInTerminal
visible: SystemUpdateService.isUpgrading && updaterPanel.hasTerminalBackend
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
@@ -625,7 +509,7 @@ DankPopout {
DankFlickable {
anchors.fill: parent
anchors.margins: Theme.spacingM
visible: SystemUpdateService.isUpgrading && !updaterPanel.upgradeRunsInTerminal
visible: SystemUpdateService.isUpgrading && !updaterPanel.hasTerminalBackend
contentWidth: width
contentHeight: logText.implicitHeight
clip: true
@@ -63,8 +63,8 @@ BasePill {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onPressed: function (mouse) {
root.triggerRipple(this, mouse.x, mouse.y);
onPressed: mouse => root.triggerRipple(this, mouse.x, mouse.y)
onClicked: function (mouse) {
switch (mouse.button) {
case Qt.RightButton:
openContextMenu();
@@ -203,7 +203,7 @@ BasePill {
const pinKey = getScreenPinKey();
if (!pinKey)
return "";
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
return pins[pinKey] || "";
}

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