1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00

Compare commits

..

129 Commits

Author SHA1 Message Date
bbedward 990d86d481 flake: update-common 2026-07-18 10:43:43 -04:00
bbedward 526cb157fd i18n: update sync scrirpt for dank-qml-common 2026-07-18 10:43:43 -04:00
bbedward 92ba96d9f9 qs: integrate with dank-qml-common 2026-07-18 10:43:43 -04:00
bbedward 5dfd875b9e widgets: fix hidden widgets disrupting index when arranging widgets
fixes #2844
port 1.5
2026-07-17 21:09:58 -04:00
purian23 00b22df802 fix(frame): restore frame edge inset states regression in framemode
Port 1.5
2026-07-17 20:24:48 -04:00
bbedward b22b1bef9b i18n: sync 2026-07-17 18:42:17 -04:00
bbedward 5cae48a85d i18n: replace terms for 1.6 2026-07-17 18:39:22 -04:00
bbedward 4200270b55 i18n: preserve terms for 1.5 point releases
port 1.5
2026-07-17 18:38:18 -04:00
purian23 c2c180d304 feat(nix): opt-in dankshell-u2f PAM service for lock screen security keys
- Updated instructional pushed:
https://danklinux.com/docs

Related: #2874
Port 1.5
2026-07-17 18:07:06 -04:00
purian23 3938e60ce4 refactor(auth): relay on distro system auth over DMS managed sessions
- Gate greeter external-auth status fprint availability only on confirmed setups
- Reload dankshell-U2F/U2F-Key watchers live

Related: #2874
Port 1.5
2026-07-17 17:27:45 -04:00
bbedward 2986e354e8 media: further optimizations for bar widget animations
related #2863
port 1.5
2026-07-17 15:48:45 -04:00
bbedward bf12665adb lock: add lockAndOutputsOff IPC function 2026-07-17 15:24:35 -04:00
bbedward f8fe16738e screenshot: initial support for scroll capture mode
fixes #2871
port 1.5
2026-07-17 13:31:56 -04:00
purian23 3c688cfbd3 feat(notifications): introduce notification foreground layers settings override
port 1.5
2026-07-17 13:17:12 -04:00
purian23 92ba2bb57d feat(settings): add explicit border toggle and updated layouts
- Defaults On with blur enabled to clean up the border edges
- Darken Modal moves to Launcher settings

Closes #2446
port 1.5
2026-07-17 12:55:02 -04:00
bbedward 504c132f78 wallpaper: optimize blurred wallpaper layer
port 1.5
2026-07-17 11:40:57 -04:00
bbedward bdaa6bcc9d fix(network): cancel in-flight connection when setting autoconnect
fixes #2882
port 1.5
2026-07-17 11:00:37 -04:00
bbedward a50bb2cab3 sysupdate: tweak styling of ignored banner
port 1.5
2026-07-17 10:32:24 -04:00
bbedward b17a90a412 sysupdate: disallow partial updates on arch, add ignored to popout
port 1.5
2026-07-17 10:18:51 -04:00
bbedward 0ffec5011b sysupdate: pass custom command to daemon instead of fire and forget from
QML

related #2873

port 1.5
2026-07-17 09:46:32 -04:00
Artem Timofeev adbbd9b112 feat(power): lower refresh rate on battery (#2428)
* feat(power): lower refresh rate on battery (#1203)

Add a Power & Sleep setting that lowers eligible displays to 60 Hz on battery and restores their previous mode on AC power.

Skip displays without an available 60 Hz mode, displays with a single refresh-rate option, current modes at or below 60 Hz, and VRR-enabled outputs.
Apply niri changes at runtime through output commands and use wlr-output-management as fallback.

* fix(quickshell): debounce display refresh sync

* chore: regenerate settings_search_index.json for pre-commit CI fix

* fix: translator context is missing for lowerDisplayRefreshRateOnBattery

* fix: activeDisplayProfileModes is missing from SettingsSpec

* feat: added guard to syncRefreshRates function

* refactor(display): move refresh sync listeners to display service

* fix(display): publish active profile modes after config write

* feat(display): persist previous refresh modes

* fix(display): route refresh mode writes through compositor backends

* fix(display): detect preferred niri refresh modes

* some small cleanups

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-07-17 09:41:08 -04:00
bbedward 95e1d6b72e bar: fix bar order-creation regression caused by async loading
port 1.5
2026-07-17 09:17:22 -04:00
purian23 4cb09dcc1b fix(DankSlideout): update textureSize for improved rendering
- Fixes #2880
Port 1.5
2026-07-17 08:24:37 -04:00
Hec ed44d02202 systemlogo: Add Zirconium (#2881)
Signed-off-by: Hec <hec@heccraft.com>
2026-07-17 00:26:15 -04:00
bbedward 4352f49ae3 core: remove useless tests 2026-07-16 12:33:53 -04:00
Aaron Tulino 9ddea82063 bar(IdleInhibitor): Color icon when activated (#2395) 2026-07-16 12:18:19 -04:00
bbedward 3c0f2cbc48 i18n: term overhaul
- Delete ~160-ish useless terms
- Add context to more terms
- Add a mechanism to duplicate the same terms with different contexts
- sync
2026-07-16 12:15:20 -04:00
bbedward 6c45413d7a launcher: fix sticky header appearance without blur
port 1.5
2026-07-16 10:50:39 -04:00
bbedward 149e1e64b1 launcher: transparent sticky header when blur is enabled
port 1.5
2026-07-16 10:47:57 -04:00
bbedward 5589cfb783 dash/weather: fix X wheel scroll
port 1.5
2026-07-16 10:41:16 -04:00
Aldi Prasetyo ff71502083 fix(minimal-install): add hyprland packages to minimal install preference (#2872)
(I guess we'll find out if this breaks any minimal script to desktop runs)
2026-07-16 10:37:14 -04:00
bbedward 1ef5371c3b i18n: cut down new pam terms 2026-07-16 10:36:35 -04:00
bbedward 3951cd6b18 popout: guard against bad state and reset input method on close
port 1.5
2026-07-16 10:19:44 -04:00
bbedward 9686f29246 core: fix log file deadlock
port 1.5
2026-07-16 10:07:47 -04:00
bbedward 6d206c9521 launcher: fix usage history loading by adding guards
port 1.5
2026-07-16 09:30:18 -04:00
purian23 48e36ae146 feat(auth): expand shared PAM support for custom policies
- Add support for DMS and System managed factor policies
- Add validated dedicated security-key PAM / U2F Key sources
- Restore U2F OR/AND flows

Related: #2874
Port 1.5
2026-07-16 09:24:12 -04:00
purian23 7c24e85c19 fix(qml): resolve lint regressions from async shell loading
port 1.5
2026-07-16 06:10:43 -04:00
purian23 fe1a783ec2 fix(media-player): update Mpris stable metadata handling
- Restores Brave browsers
port 1.5
2026-07-15 20:57:05 -04:00
purian23 358496134c refactor: update workfows & issue notes
port 1.5
2026-07-15 16:18:36 -04:00
purian23 dc924618fb refactor: unify media control calls to MprisController sync 2026-07-15 14:35:19 -04:00
bbedward fb2dbced08 system tray: fix overflow of menus
port 1.5
2026-07-15 14:19:49 -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
279 changed files with 26355 additions and 36815 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Sync flake.lock to dank-qml-common
on:
workflow_dispatch:
push:
paths:
- "dank-qml-common"
branches:
- master
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Install Nix
uses: cachix/install-nix-action@v31
- name: Point flake input at the submodule commit
run: |
set -euo pipefail
submodule_rev=$(git ls-tree HEAD dank-qml-common --object-only)
flake_rev=$(python3 -c "import json; print(json.load(open('flake.lock'))['nodes']['dank-qml-common']['locked']['rev'])")
[ "$submodule_rev" = "$flake_rev" ] && { echo "flake.lock already matches $submodule_rev"; exit 0; }
nix flake lock --override-input dank-qml-common "github:AvengeMedia/dank-qml-common/$submodule_rev"
- name: Commit and push flake.lock update
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
if git diff --quiet flake.lock; then
echo "No changes to flake.lock"
exit 0
fi
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "nix: sync flake.lock to dank-qml-common submodule"
git pull --rebase origin ${{ github.ref_name }}
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -51,7 +51,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
+2
View File
@@ -27,6 +27,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
submodules: recursive
- name: Install flatpak - name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak run: sudo apt update && sudo apt install -y flatpak
+11 -2
View File
@@ -43,7 +43,7 @@ jobs:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -55,6 +55,15 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ steps.app_token.outputs.token }} 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) - name: Port audit (informational)
env: env:
GH_TOKEN: ${{ steps.app_token.outputs.token }} GH_TOKEN: ${{ steps.app_token.outputs.token }}
@@ -83,7 +92,7 @@ jobs:
- name: Dispatch Release workflow - name: Dispatch Release workflow
env: env:
GH_TOKEN: ${{ steps.app_token.outputs.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \ gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
-f tag="${{ steps.derive.outputs.tag }}" \ -f tag="${{ steps.derive.outputs.tag }}" \
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
+2
View File
@@ -10,6 +10,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
submodules: recursive
- name: Install flatpak - name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak run: sudo apt update && sudo apt install -y flatpak
+20
View File
@@ -42,6 +42,7 @@ jobs:
with: with:
ref: ${{ inputs.tag }} ref: ${{ inputs.tag }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -191,11 +192,13 @@ jobs:
with: with:
ref: ${{ inputs.tag }} ref: ${{ inputs.tag }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Fetch updated tag after version bump - name: Fetch updated tag after version bump
run: | run: |
git fetch origin --force tag ${TAG} git fetch origin --force tag ${TAG}
git checkout ${TAG} git checkout ${TAG}
git submodule update --init --recursive
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -252,6 +255,7 @@ jobs:
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems - **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging) - **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
- **`dms-qml.tar.gz`** - QML source code only - **`dms-qml.tar.gz`** - QML source code only
- **`dms-source.tar.gz`** - Full repository source with bundled DankCommon (for distro packaging)
### Checksums ### Checksums
- **`*.sha256`** - SHA256 checksums for verifying download integrity - **`*.sha256`** - SHA256 checksums for verifying download integrity
@@ -299,6 +303,22 @@ jobs:
# Copy completions # Copy completions
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
# Replace the DankCommon symlink with real submodule content for packaging
rm quickshell/DankCommon
cp -r dank-qml-common/DankCommon quickshell/DankCommon
# Create full source tarball (GitHub tag archives never contain submodule content)
VERSION_NUM=${TAG#v}
tar --exclude='.git' \
--exclude='.github' \
--exclude='dank-qml-common' \
--exclude='_release_assets' \
--exclude='_core_assets' \
--exclude='RELEASE_BODY.md' \
--transform "s,^\.,DankMaterialShell-${VERSION_NUM},S" \
-czf _release_assets/dms-source.tar.gz .
(cd _release_assets && sha256sum dms-source.tar.gz > dms-source.tar.gz.sha256)
# Create QML source package (exclude build artifacts and git files) # Create QML source package (exclude build artifacts and git files)
# Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging # Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging
cp LICENSE CONTRIBUTING.md quickshell/ cp LICENSE CONTRIBUTING.md quickshell/
+2
View File
@@ -36,6 +36,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Check for updates - name: Check for updates
id: check id: check
@@ -199,6 +200,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Wait before OBS upload - name: Wait before OBS upload
run: sleep 3 run: sleep 3
+2
View File
@@ -36,6 +36,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Install dependencies - name: Install dependencies
run: | run: |
@@ -94,6 +95,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
+3 -2
View File
@@ -83,6 +83,7 @@ jobs:
with: with:
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }} ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Record checked-out source commit - name: Record checked-out source commit
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV" run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
@@ -225,7 +226,7 @@ jobs:
if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then
echo "🔧 Updating stable templates for $ARCHIVE_TAG" echo "🔧 Updating stable templates for $ARCHIVE_TAG"
TARBALL="$(mktemp)" TARBALL="$(mktemp)"
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/archive/refs/tags/${ARCHIVE_TAG}.tar.gz" curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/releases/download/${ARCHIVE_TAG}/dms-source.tar.gz"
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)" RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
rm -f "$TARBALL" rm -f "$TARBALL"
@@ -246,7 +247,7 @@ jobs:
# into $wrksrc (create_wrksrc=yes handles the rest). # into $wrksrc (create_wrksrc=yes handles the rest).
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}" SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
mkdir -p "$SRC_CACHE" mkdir -p "$SRC_CACHE"
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \ tar -czhf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
--exclude=void-packages \ --exclude=void-packages \
--exclude=r2-repo \ --exclude=r2-repo \
--exclude=.git \ --exclude=.git \
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v3
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
+3
View File
@@ -0,0 +1,3 @@
[submodule "dank-qml-common"]
path = dank-qml-common
url = https://github.com/AvengeMedia/dank-qml-common.git
+8
View File
@@ -36,6 +36,14 @@ repos:
language: system language: system
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$ files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: i18n-term-variants
name: i18n term variants (no case/punctuation duplicates)
entry: python3 quickshell/translations/check_term_variants.py
language: system
files: ^quickshell/(.*\.qml|translations/(check_term_variants\.py|extract_translations\.py))$
pass_filenames: false
- repo: local - repo: local
hooks: hooks:
- id: no-console-in-qml - id: no-console-in-qml
+28
View File
@@ -6,6 +6,14 @@ To contribute fork this repository, make your changes, and open a pull request.
## Setup ## Setup
Clone with submodules — the shared widget library ([dank-qml-common](https://github.com/AvengeMedia/dank-qml-common)) is vendored at `dank-qml-common/` and symlinked into `quickshell/DankCommon`:
```bash
git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.git
# or, in an existing clone:
git submodule update --init
```
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks: Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
```bash ```bash
@@ -28,6 +36,24 @@ This will provide:
The dev shell automatically creates the `.qmlls.ini` file in the `quickshell/` directory. The dev shell automatically creates the `.qmlls.ini` file in the `quickshell/` directory.
## Shared widgets (dank-qml-common)
Everything under `quickshell/DankCommon/` (core widgets, the file browser, scroll physics, bundled fonts) is shared across the DMS suite and lives in the `dank-qml-common` submodule. It is a normal git worktree:
1. Edit files under `dank-qml-common/` (or through the `quickshell/DankCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dank-qml-common`.
2. Commit and PR those changes in the `dank-qml-common` repo: `cd dank-qml-common && git switch -c my-change`, push, open the PR there.
3. Once merged, bump the pointer here: `make update-common` (updates the submodule and the nix flake input together), then commit alongside any DMS-side changes. If you only bump the submodule, CI syncs `flake.lock` to it automatically on master.
The submodule URL in `.gitmodules` is HTTPS so CI and anonymous clones keep working. To push over SSH instead of being prompted for credentials, add a push rewrite to your git config — fetches stay HTTPS, pushes use SSH:
```bash
git config --global url."git@github.com:AvengeMedia/".pushInsteadOf "https://github.com/AvengeMedia/"
```
Shared widgets read app-provided singletons (`Theme`, `SettingsData`, ...) through a documented contract — see the dank-qml-common README. If your change needs a new contract property, add it to the library's stub singletons in the same PR, then to `quickshell/Common/` here when you bump.
Files in `quickshell/Widgets/`, `quickshell/Common/`, and `quickshell/Modals/FileBrowser/` that moved to the library remain in place as thin wrappers, so `import qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` keep working for the shell and for plugins.
## VSCode Setup ## VSCode Setup
This is a monorepo, the easiest thing to do is to open an editor in either `quickshell`, `core`, or both depending on which part of the project you are working on. This is a monorepo, the easiest thing to do is to open an editor in either `quickshell`, `core`, or both depending on which part of the project you are working on.
@@ -104,6 +130,8 @@ Text {
Preferably, try to keep new terms to a minimum and re-use existing terms where possible. See `quickshell/translations/en.json` for the list of existing terms. (This isn't always possible obviously, but instead of using `Auto-connect` you would use `Autoconnect` since it's already translated) Preferably, try to keep new terms to a minimum and re-use existing terms where possible. See `quickshell/translations/en.json` for the list of existing terms. (This isn't always possible obviously, but instead of using `Auto-connect` you would use `Autoconnect` since it's already translated)
Strings inside `quickshell/DankCommon/` are owned by the dank-qml-common repo but stay in the DMS POEditor project — extraction here deliberately skips them, and `scripts/i18nsync.py sync` uploads the union of app terms and the submodule's terms instead (common terms carry the `dank-qml-common` tag). On download the sync splits the exports: app translations go to `quickshell/translations/poexports/`, common translations go to `dank-qml-common/DankCommon/translations/poexports/` for you to commit in that repo and bump. At runtime `I18n` merges both catalogs (app terms win). Other apps (dankcalendar) keep their own POEditor projects and merge the `dank-qml-common`-tagged terms from the DMS project.
### GO (`core` directory) ### GO (`core` directory)
1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go) 1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go)
+8 -1
View File
@@ -35,6 +35,12 @@ clean:
lint-qml: lint-qml:
@./quickshell/scripts/qmllint-entrypoints.sh @./quickshell/scripts/qmllint-entrypoints.sh
# Pull the latest dank-qml-common and pin it everywhere it is consumed
# (submodule pointer + nix flake input). Commit both in one change.
update-common:
git submodule update --remote --merge dank-qml-common
nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common
# Installation targets # Installation targets
install-bin: install-bin:
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@@ -43,8 +49,9 @@ install-bin:
install-shell: install-shell:
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..." @echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
@test -e $(SHELL_DIR)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
@mkdir -p $(SHELL_INSTALL_DIR) @mkdir -p $(SHELL_INSTALL_DIR)
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/ @cp -rL $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github @rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@echo "Shell files installed" @echo "Shell files installed"
+18 -4
View File
@@ -91,21 +91,30 @@ var authListServicesCmd = &cobra.Command{
var authValidateCmd = &cobra.Command{ var authValidateCmd = &cobra.Command{
Use: "validate", Use: "validate",
Short: "Validate a PAM service file for use as the DMS lock-screen password stack", 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 stack. Exits 1 when the file is not usable.", 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.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
path, _ := cmd.Flags().GetString("path") path, _ := cmd.Flags().GetString("path")
service, _ := cmd.Flags().GetString("service") service, _ := cmd.Flags().GetString("service")
purpose, _ := cmd.Flags().GetString("purpose")
asJSON, _ := cmd.Flags().GetBool("json") asJSON, _ := cmd.Flags().GetBool("json")
if (path == "") == (service == "") { if (path == "") == (service == "") {
log.Fatalf("Error: exactly one of --path or --service is required") 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 var result sharedpam.LockscreenPamValidation
switch { switch {
case service != "": case service != "":
result = sharedpam.ValidateLockscreenPamService(service) if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamService(service)
} else {
result = sharedpam.ValidateLockscreenPamService(service)
}
case !filepath.IsAbs(path): case !filepath.IsAbs(path):
result = sharedpam.LockscreenPamValidation{ result = sharedpam.LockscreenPamValidation{
Path: path, Path: path,
@@ -114,7 +123,11 @@ var authValidateCmd = &cobra.Command{
Errors: []string{"--path must be an absolute file path"}, Errors: []string{"--path must be an absolute file path"},
} }
default: default:
result = sharedpam.ValidateLockscreenPamPath(path) if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamPath(path)
} else {
result = sharedpam.ValidateLockscreenPamPath(path)
}
} }
if asJSON { if asJSON {
@@ -159,6 +172,7 @@ func init() {
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate") 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("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") authValidateCmd.Flags().Bool("json", false, "Output as JSON")
} }
+20 -1
View File
@@ -250,6 +250,10 @@ func updateOtherDistros() error {
return fmt.Errorf("failed to fetch changes: %w", err) return fmt.Errorf("failed to fetch changes: %w", err)
} }
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
if currentTag != "" { if currentTag != "" {
latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname") latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname")
latestTagOutput, err := latestTagCmd.Output() latestTagOutput, err := latestTagCmd.Output()
@@ -291,6 +295,10 @@ func updateOtherDistros() error {
return fmt.Errorf("update cancelled") return fmt.Errorf("update cancelled")
} }
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag) fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag)
return nil return nil
} }
@@ -320,10 +328,21 @@ func updateOtherDistros() error {
return fmt.Errorf("update cancelled") return fmt.Errorf("update cancelled")
} }
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
fmt.Println("\nUpdate complete!") fmt.Println("\nUpdate complete!")
return nil return nil
} }
func updateSubmodules() error {
submoduleCmd := exec.Command("git", "submodule", "update", "--init", "--recursive")
submoduleCmd.Stdout = os.Stdout
submoduleCmd.Stderr = os.Stderr
return submoduleCmd.Run()
}
func offerReclone(dmsPath string) bool { func offerReclone(dmsPath string) bool {
fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ") fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ")
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
@@ -342,7 +361,7 @@ func offerReclone(dmsPath string) bool {
} }
fmt.Println("Cloning fresh copy...") fmt.Println("Cloning fresh copy...")
cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath) cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
cloneCmd.Stdout = os.Stdout cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr cloneCmd.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil { if err := cloneCmd.Run(); err != nil {
+39 -4
View File
@@ -51,8 +51,9 @@ Modes:
full - Capture the focused output full - Capture the focused output
all - Capture all outputs combined all - Capture all outputs combined
output - Capture a specific output by name output - Capture a specific output by name
window - Capture the focused window (Hyprland/Mango) window - Capture the focused window (Hyprland/Mango/niri)
last - Capture the last selected region last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
Output format (--format): Output format (--format):
png - PNG format (default) png - PNG format (default)
@@ -72,7 +73,9 @@ Examples:
dms screenshot --no-confirm # Region capture on mouse release dms screenshot --no-confirm # Region capture on mouse release
dms screenshot --cursor=on # Include cursor dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85 dms screenshot -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON`, dms screenshot --json # Print capture metadata as JSON
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
} }
var ssRegionCmd = &cobra.Command{ var ssRegionCmd = &cobra.Command{
@@ -110,10 +113,33 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{ var ssWindowCmd = &cobra.Command{
Use: "window", Use: "window",
Short: "Capture the focused window", Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland and Mango.`, Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
Run: runScreenshotWindow, 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{ var ssListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List available outputs", Short: "List available outputs",
@@ -143,7 +169,10 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)") screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON") screenshotCmd.PersistentFlags().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(ssRegionCmd)
screenshotCmd.AddCommand(ssScrollCmd)
screenshotCmd.AddCommand(ssFullCmd) screenshotCmd.AddCommand(ssFullCmd)
screenshotCmd.AddCommand(ssAllCmd) screenshotCmd.AddCommand(ssAllCmd)
screenshotCmd.AddCommand(ssOutputCmd) screenshotCmd.AddCommand(ssOutputCmd)
@@ -249,7 +278,7 @@ func runScreenshot(config screenshot.Config) {
// Region select needs the keyboard; drop popout grabs for its duration. // Region select needs the keyboard; drop popout grabs for its duration.
result, err := func() (*screenshot.CaptureResult, error) { result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
if interactive { if interactive {
setPopoutScreenshotMode(true) setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false) defer setPopoutScreenshotMode(false)
@@ -437,6 +466,12 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
runScreenshot(config) 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) { func runScreenshotFull(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeFullScreen) config := getScreenshotConfig(screenshot.ModeFullScreen)
runScreenshot(config) runScreenshot(config)
+15 -2
View File
@@ -520,7 +520,7 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
Progress: 0.90, Progress: 0.90,
Step: "Cloning DankMaterialShell...", Step: "Cloning DankMaterialShell...",
IsComplete: false, IsComplete: false,
CommandInfo: "git clone https://github.com/AvengeMedia/DankMaterialShell.git", CommandInfo: "git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.git",
} }
configDir := filepath.Dir(dmsPath) configDir := filepath.Dir(dmsPath)
@@ -528,7 +528,7 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return fmt.Errorf("failed to create quickshell config directory: %w", err) return fmt.Errorf("failed to create quickshell config directory: %w", err)
} }
cloneCmd := exec.CommandContext(ctx, "git", "clone", cloneCmd := exec.CommandContext(ctx, "git", "clone", "--recurse-submodules",
"https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath) "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
if err := cloneCmd.Run(); err != nil { if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone DankMaterialShell: %w", err) return fmt.Errorf("failed to clone DankMaterialShell: %w", err)
@@ -553,6 +553,8 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return nil return nil
} }
m.syncDMSSubmodules(ctx, dmsPath)
m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag)) m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag))
m.log("DankMaterialShell cloned successfully") m.log("DankMaterialShell cloned successfully")
return nil return nil
@@ -591,6 +593,8 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return nil return nil
} }
m.syncDMSSubmodules(ctx, dmsPath)
m.log("DankMaterialShell updated successfully (git variant)") m.log("DankMaterialShell updated successfully (git variant)")
return nil return nil
} }
@@ -609,10 +613,19 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return nil return nil
} }
m.syncDMSSubmodules(ctx, dmsPath)
m.log(fmt.Sprintf("Updated to tag: %s", latestTag)) m.log(fmt.Sprintf("Updated to tag: %s", latestTag))
return nil return nil
} }
func (m *ManualPackageInstaller) syncDMSSubmodules(ctx context.Context, dmsPath string) {
submoduleCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "submodule", "update", "--init", "--recursive")
if err := submoduleCmd.Run(); err != nil {
m.logError("Failed to update submodules", err)
}
}
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing xwayland-satellite from source...") m.log("Installing xwayland-satellite from source...")
+1 -1
View File
@@ -7,7 +7,7 @@ type minimalInstallGroup struct {
func shouldPreferMinimalInstall(pkg string) bool { func shouldPreferMinimalInstall(pkg string) bool {
switch pkg { switch pkg {
case "niri", "niri-git": case "niri", "niri-git", "hyprland", "hyprland-git":
return true return true
default: default:
return false return false
+7 -2
View File
@@ -124,7 +124,9 @@ func GetLogger() *Logger {
logger = &Logger{base} logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" { if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path) logMu.Lock()
_ = setLogFile(logger, path)
logMu.Unlock()
} }
}) })
return logger return logger
@@ -145,15 +147,18 @@ func SetLevel(level string) {
// profile when stderr is a TTY and route the file through ansiStripWriter so // profile when stderr is a TTY and route the file through ansiStripWriter so
// the file stays plain while stderr keeps its colors. // the file stays plain while stderr keeps its colors.
func SetLogFile(path string) error { func SetLogFile(path string) error {
l := GetLogger()
logMu.Lock() logMu.Lock()
defer logMu.Unlock() defer logMu.Unlock()
return setLogFile(l, path)
}
func setLogFile(l *Logger, path string) error {
if logFile != nil { if logFile != nil {
logFile.Close() logFile.Close()
logFile = nil logFile = nil
} }
l := GetLogger()
if path == "" { if path == "" {
l.SetOutput(logStderr) l.SetOutput(logStderr)
applyColorProfile(l, logStderr) applyColorProfile(l, logStderr)
+71 -1
View File
@@ -646,6 +646,7 @@ type lockscreenPamAnalysis struct {
inlineFingerprint bool inlineFingerprint bool
inlineU2f bool inlineU2f bool
modules []string modules []string
authModules []string
unknownDirectives []string unknownDirectives []string
err error err error
} }
@@ -730,6 +731,9 @@ func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack
} }
if !foundModule && strings.HasSuffix(field, ".so") { if !foundModule && strings.HasSuffix(field, ".so") {
acc.modules = append(acc.modules, field) acc.modules = append(acc.modules, field)
if lineType == "auth" {
acc.authModules = append(acc.authModules, field)
}
foundModule = true foundModule = true
} }
} }
@@ -774,6 +778,14 @@ func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
return validateLockscreenPam("", path, defaultValidateDeps()) 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 { func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
result := LockscreenPamValidation{ result := LockscreenPamValidation{
MissingModules: []string{}, MissingModules: []string{},
@@ -838,6 +850,64 @@ func validateLockscreenPam(serviceName string, path string, deps lockscreenPamVa
return result 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 { func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
if filepath.IsAbs(ref) { if filepath.IsAbs(ref) {
_, err := deps.stat(ref) _, err := deps.stat(ref)
@@ -895,7 +965,7 @@ func buildManagedLockscreenU2FPamContent() string {
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error { func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
if deps.isNixOS() { if deps.isNixOS() {
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.") 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.")
return nil return nil
} }
+80 -2
View File
@@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err) t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
} }
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") { if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") {
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs) t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs)
} }
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) { 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) t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
@@ -994,6 +994,84 @@ 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 { func containsSubstr(items []string, substr string) bool {
for _, item := range items { for _, item := range items {
if strings.Contains(item, substr) { if strings.Contains(item, substr) {
@@ -0,0 +1,25 @@
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)
}
}
}
@@ -1,22 +0,0 @@
package qmlchecks
import (
"os"
"strings"
"testing"
)
func TestGreeterRememberLastSessionFallsBackToDesktopID(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)
if !strings.Contains(content, "GreetdMemory.lastSessionDesktopId || desktopIdFromPath(GreetdMemory.lastSessionId)") {
t.Fatalf("remembered greeter sessions should derive a desktop id from legacy absolute session paths")
}
if !strings.Contains(content, "GreeterState.sessionDesktopIds[i] === savedDesktopId") {
t.Fatalf("remembered greeter sessions should match current sessions by desktop id")
}
}
@@ -23,3 +23,25 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control") t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
} }
} }
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")
}
}
+22 -33
View File
@@ -30,41 +30,30 @@ func DetectCompositor() Compositor {
return detectedCompositor return detectedCompositor
} }
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") candidates := []struct {
niriSocket := os.Getenv("NIRI_SOCKET") socket string
swaySocket := os.Getenv("SWAYSOCK") needsStat bool
scrollSocket := os.Getenv("SCROLLSOCK") compositor Compositor
miracleSocket := os.Getenv("MIRACLESOCK") }{
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE") {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},
}
switch { // A stale env var from a previous session must not mask the live compositor
case mangoSocket != "": for _, c := range candidates {
if _, err := os.Stat(mangoSocket); err == nil { if c.socket == "" {
detectedCompositor = CompositorMango continue
return detectedCompositor
} }
case niriSocket != "": if c.needsStat {
if _, err := os.Stat(niriSocket); err == nil { if _, err := os.Stat(c.socket); err != nil {
detectedCompositor = CompositorNiri continue
return detectedCompositor }
} }
case scrollSocket != "": detectedCompositor = c.compositor
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 return detectedCompositor
} }
@@ -90,7 +79,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
case CompositorMango: case CompositorMango:
return getMangoActiveWindow() return getMangoActiveWindow()
default: default:
return nil, fmt.Errorf("window capture requires Hyprland or Mango") return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
} }
} }
+52
View File
@@ -54,6 +54,58 @@ func BufferToImageWithFormat(buf *ShmBuffer, format uint32) *image.RGBA {
return img 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 { func EncodePNG(w io.Writer, img image.Image) error {
enc := png.Encoder{CompressionLevel: png.BestSpeed} enc := png.Encoder{CompressionLevel: png.BestSpeed}
return enc.Encode(w, img) return enc.Encode(w, img)
+164
View File
@@ -0,0 +1,164 @@
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
}
+27 -8
View File
@@ -95,6 +95,9 @@ type RegionSelector struct {
showCapturedCursor bool showCapturedCursor bool
shiftHeld bool shiftHeld bool
phase selectorPhase
scroll *scrollSession
running bool running bool
cancelled bool cancelled bool
result Region result Region
@@ -167,11 +170,15 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
r.running = true r.running = true
for r.running { for r.running {
if err := r.ctx.Dispatch(); err != nil { if err := r.dispatchOrTick(); err != nil {
return nil, false, fmt.Errorf("dispatch: %w", err) 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 { if r.cancelled || r.capturedBuffer == nil {
return nil, r.cancelled, nil return nil, r.cancelled, nil
} }
@@ -186,6 +193,10 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
scale = s scale = s
} }
} }
if r.scroll != nil {
yInverted = false
format = uint32(r.scroll.format)
}
return &CaptureResult{ return &CaptureResult{
Buffer: r.capturedBuffer, Buffer: r.capturedBuffer,
@@ -696,7 +707,9 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
} }
slot.pool = pool slot.pool = pool
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat) // 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))
if err != nil { if err != nil {
log.Error("create render slot wl_buffer failed", "err", err) log.Error("create render slot wl_buffer failed", "err", err)
pool.Destroy() pool.Destroy()
@@ -738,8 +751,9 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
x1 := float64(r.preSelect.X-os.output.x) * scaleX x1 := float64(r.preSelect.X-os.output.x) * scaleX
y1 := float64(r.preSelect.Y-os.output.y) * scaleY y1 := float64(r.preSelect.Y-os.output.y) * scaleY
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX // selection edges are inclusive; the exclusive width edge is one device px past it
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY 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
r.selection.hasSelection = true r.selection.hasSelection = true
r.selection.dragging = false r.selection.dragging = false
@@ -769,10 +783,13 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
return return
} }
slot.shm.CopyFrom(srcBuf) switch r.phase {
case phaseScroll:
// Draw overlay (dimming + selection) into this slot r.drawScrollOverlay(os, slot.shm)
r.drawOverlay(os, slot.shm) default:
slot.shm.CopyFrom(srcBuf)
r.drawOverlay(os, slot.shm)
}
if os.viewport != nil { if os.viewport != nil {
_ = os.wlSurface.SetBufferScale(1) _ = os.wlSurface.SetBufferScale(1)
@@ -808,6 +825,8 @@ func (r *RegionSelector) cleanup() {
r.cursorBuffer.Close() r.cursorBuffer.Close()
} }
r.cleanupScroll()
for _, os := range r.surfaces { for _, os := range r.surfaces {
for _, slot := range os.slots { for _, slot := range os.slots {
if slot == nil { if slot == nil {
+49 -24
View File
@@ -94,6 +94,20 @@ func (r *RegionSelector) setupPointerHandlers() {
return 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 { switch e.Button {
case 0x110: // BTN_LEFT case 0x110: // BTN_LEFT
switch e.State { switch e.State {
@@ -135,6 +149,17 @@ func (r *RegionSelector) setupKeyboardHandlers() {
return 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 { switch e.Key {
case 1: case 1:
r.cancelled = true r.cancelled = true
@@ -152,17 +177,15 @@ func (r *RegionSelector) setupKeyboardHandlers() {
}) })
} }
func (r *RegionSelector) finishSelection() { func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
if r.selection.surface == nil { if r.selection.surface == nil {
r.running = false return nil, 0, 0, 0, 0
return
} }
os := r.selection.surface os := r.selection.surface
srcBuf := r.getSourceBuffer(os) srcBuf := r.getSourceBuffer(os)
if srcBuf == nil { if srcBuf == nil {
r.running = false return nil, 0, 0, 0, 0
return
} }
x1, y1 := r.selection.anchorX, r.selection.anchorY x1, y1 := r.selection.anchorX, r.selection.anchorY
@@ -181,24 +204,10 @@ func (r *RegionSelector) finishSelection() {
scaleY = float64(srcBuf.Height) / float64(os.logicalH) scaleY = float64(srcBuf.Height) / float64(os.logicalH)
} }
bx1 := int(x1 * scaleX) bx1 := clamp(int(x1*scaleX), 0, srcBuf.Width)
by1 := int(y1 * scaleY) by1 := clamp(int(y1*scaleY), 0, srcBuf.Height)
bx2 := int(x2 * scaleX) bx2 := clamp(int(x2*scaleX), 0, srcBuf.Width)
by2 := int(y2 * scaleY) by2 := clamp(int(y2*scaleY), 0, srcBuf.Height)
// 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 w, h := bx2-bx1+1, by2-by1+1
if r.shiftHeld && w != h { if r.shiftHeld && w != h {
@@ -215,7 +224,23 @@ func (r *RegionSelector) finishSelection() {
h = 1 h = 1
} }
// Create cropped buffer and copy pixels directly 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)
cropped, err := CreateShmBuffer(w, h, w*4) cropped, err := CreateShmBuffer(w, h, w*4)
if err != nil { if err != nil {
r.running = false r.running = false
+78 -2
View File
@@ -57,7 +57,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
w, h := renderBuf.Width, renderBuf.Height w, h := renderBuf.Width, renderBuf.Height
format := os.screenFormat format := os.screenFormat
// Dim the entire buffer // dim, forcing alpha: the X-format source's padding byte is undefined
for y := 0; y < h; y++ { for y := 0; y < h; y++ {
off := y * stride off := y * stride
for x := 0; x < w; x++ { for x := 0; x < w; x++ {
@@ -68,6 +68,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data[i+0] = uint8(int(data[i+0]) * 3 / 5) data[i+0] = uint8(int(data[i+0]) * 3 / 5)
data[i+1] = uint8(int(data[i+1]) * 3 / 5) data[i+1] = uint8(int(data[i+1]) * 3 / 5)
data[i+2] = uint8(int(data[i+2]) * 3 / 5) data[i+2] = uint8(int(data[i+2]) * 3 / 5)
data[i+3] = 255
} }
} }
@@ -110,7 +111,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
data[di+0] = srcData[si+0] data[di+0] = srcData[si+0]
data[di+1] = srcData[si+1] data[di+1] = srcData[si+1]
data[di+2] = srcData[si+2] data[di+2] = srcData[si+2]
data[di+3] = srcData[si+3] data[di+3] = 255
} }
} }
@@ -126,6 +127,81 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format) 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) { func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
if r.selection.dragging { if r.selection.dragging {
return return
+5 -1
View File
@@ -92,7 +92,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
switch s.config.Mode { switch s.config.Mode {
case ModeLastRegion: case ModeLastRegion:
return s.captureLastRegion() return s.captureLastRegion()
case ModeRegion: case ModeRegion, ModeScroll:
return s.captureRegion() return s.captureRegion()
case ModeWindow: case ModeWindow:
return s.captureWindow() return s.captureWindow()
@@ -145,6 +145,10 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
} }
func (s *Screenshoter) captureWindow() (*CaptureResult, error) { func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorNiri {
return s.captureNiriWindow()
}
geom, err := GetActiveWindow() geom, err := GetActiveWindow()
if err != nil { if err != nil {
return nil, err return nil, err
+574
View File
@@ -0,0 +1,574 @@
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
@@ -0,0 +1,278 @@
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
@@ -0,0 +1,407 @@
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
@@ -0,0 +1,169 @@
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,6 +9,7 @@ const (
ModeAllScreens ModeAllScreens
ModeOutput ModeOutput
ModeLastRegion ModeLastRegion
ModeScroll
) )
type Format int type Format int
@@ -62,6 +63,7 @@ type Config struct {
SaveFile bool SaveFile bool
Notify bool Notify bool
Stdout bool Stdout bool
IntervalMs int
} }
func DefaultConfig() Config { func DefaultConfig() Config {
@@ -744,6 +744,32 @@ func (b *IWDBackend) DisconnectWiFi() error {
return nil 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 { func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
b.stateMutex.RLock() b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID currentSSID := b.state.WiFiSSID
@@ -812,6 +838,10 @@ func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
return fmt.Errorf("failed to set autoconnect: %w", call.Err) return fmt.Errorf("failed to set autoconnect: %w", call.Err)
} }
if !autoconnect {
b.abortInFlightConnection(ssid)
}
b.updateState() b.updateState()
if b.onStateChange != nil { if b.onStateChange != nil {
@@ -359,6 +359,24 @@ func (b *NetworkManagerBackend) DisconnectWiFi() error {
return nil 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 { func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
conn, err := b.findConnection(ssid) conn, err := b.findConnection(ssid)
if err != nil { if err != nil {
@@ -963,6 +981,10 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
return fmt.Errorf("failed to update connection: %w", err) return fmt.Errorf("failed to update connection: %w", err)
} }
if !autoconnect {
b.abortInFlightConnection(ssid)
}
b.updateWiFiNetworks() b.updateWiFiNetworks()
if b.onStateChange != nil { if b.onStateChange != nil {
@@ -50,11 +50,42 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
} }
func pacmanUpgradeArgv(opts UpgradeOptions) []string { func pacmanUpgradeArgv(opts UpgradeOptions) []string {
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"} return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
if len(opts.Ignored) > 0 { }
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
// 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
} }
return privilegedArgv(opts, argv...)
} }
type archHelperBackend struct { type archHelperBackend struct {
@@ -112,7 +143,7 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
} }
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ") cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
title := fmt.Sprintf("DMS — System Update (%s)", b.id) title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine}) return Run(ctx, wrapInTerminal(term, title, cmd, opts.TerminalArgs), RunOptions{OnLine: onLine})
} }
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string { func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
+14 -13
View File
@@ -119,7 +119,7 @@ func findTerminal(override string) string {
return "" return ""
} }
func wrapInTerminal(term, title, shellCmd string) []string { func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
const appID = "com.danklinux.dms" const appID = "com.danklinux.dms"
banner := fmt.Sprintf( 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'`, `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,24 +129,25 @@ func wrapInTerminal(term, title, shellCmd string) []string {
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; ` export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer full := export + banner + "; " + shellCmd + "; " + closer
var argv []string
execFlag := "-e"
switch term { switch term {
case "kitty": case "kitty", "alacritty", "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full} argv = []string{term, "--class", appID, "-T", title}
case "alacritty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "foot": case "foot":
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full} argv = []string{term, "--app-id=" + appID, "--title=" + title}
case "ghostty": case "ghostty":
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full} argv = []string{term, "--class=" + appID, "--title=" + title}
case "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "xterm": case "xterm":
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full} argv = []string{term, "-class", appID, "-T", title}
case "konsole": case "konsole":
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full} argv = []string{term, "-p", "tabtitle=" + title}
case "gnome-terminal": case "gnome-terminal":
return []string{term, "--title=" + title, "--", "sh", "-c", full} argv = []string{term, "--title=" + title}
execFlag = "--"
default: default:
return []string{term, "-e", "sh", "-c", full} argv = []string{term}
} }
argv = append(argv, extraArgs...)
return append(argv, execFlag, "sh", "-c", full)
} }
@@ -46,6 +46,7 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
DryRun: params.BoolOpt(req.Params, "dry", false), DryRun: params.BoolOpt(req.Params, "dry", false),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""), CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""), Terminal: params.StringOpt(req.Params, "terminal", ""),
TerminalArgs: stringSliceOpt(req.Params, "terminalArgs"),
Ignored: stringSliceOpt(req.Params, "ignored"), Ignored: stringSliceOpt(req.Params, "ignored"),
} }
if err := m.Upgrade(opts); err != nil { if err := m.Upgrade(opts); err != nil {
+8 -4
View File
@@ -336,7 +336,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
}() }()
if opts.CustomCommand != "" { if opts.CustomCommand != "" {
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal) m.runCustomUpgrade(ctx, opts)
return return
} }
@@ -345,6 +345,9 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
opts.Targets = append([]Package(nil), m.state.Packages...) opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock() m.mu.RUnlock()
} }
if isPacmanFamily(m.selection.System) {
opts.Ignored = dropPacmanRepoIgnores(opts.Ignored, opts.Targets)
}
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored) opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
backends := upgradeBackends(m.selection, opts) backends := upgradeBackends(m.selection, opts)
@@ -389,8 +392,8 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
m.finishSuccessfulUpgrade(true) m.finishSuccessfulUpgrade(true)
} }
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) { func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
term := findTerminal(terminalOverride) term := findTerminal(opts.Terminal)
if term == "" { if term == "" {
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)") m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
return return
@@ -407,7 +410,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
m.markDirty() m.markDirty()
onLine := func(line string) { m.appendLog(line) } onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", command) argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil { if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed code := ErrCodeBackendFailed
switch { switch {
@@ -425,6 +428,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
} }
m.finishSuccessfulUpgrade(false) m.finishSuccessfulUpgrade(false)
m.runRefresh(context.Background(), false)
} }
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) { func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
+1
View File
@@ -80,6 +80,7 @@ type UpgradeOptions struct {
AttachStdio bool AttachStdio bool
CustomCommand string CustomCommand string
Terminal string Terminal string
TerminalArgs []string
Targets []Package Targets []Package
Ignored []string Ignored []string
} }
@@ -50,9 +50,9 @@ func TestUpgradeCommandBuilders(t *testing.T) {
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"}, want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
}, },
{ {
name: "pacman with ignored packages", name: "pacman never passes --ignore",
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}), got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"}, want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
}, },
{ {
name: "dnf with ignored packages", name: "dnf with ignored packages",
@@ -106,6 +106,18 @@ 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) { func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}}) argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
if len(argv) < 2 || argv[len(argv)-2] != "-c" { if len(argv) < 2 || argv[len(argv)-2] != "-c" {
+1
Submodule dank-qml-common added at 5942bc1c60
+1 -1
View File
@@ -70,7 +70,7 @@ override_dh_auto_install:
mkdir -p debian/dms-git/usr/share/quickshell/dms debian/dms-git/usr/lib/systemd/user mkdir -p debian/dms-git/usr/share/quickshell/dms debian/dms-git/usr/lib/systemd/user
if [ -d quickshell ]; then \ if [ -d quickshell ]; then \
cp -r quickshell/* debian/dms-git/usr/share/quickshell/dms/; \ cp -rL quickshell/* debian/dms-git/usr/share/quickshell/dms/; \
install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \ install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \
install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \ install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \
install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \ install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \
+1 -1
View File
@@ -20,7 +20,7 @@ override_dh_auto_install:
fi; \ fi; \
if [ -n "$$SOURCE_DIR" ]; then \ if [ -n "$$SOURCE_DIR" ]; then \
mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter && \ mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter && \
( cd $$SOURCE_DIR && tar cf - --exclude=debian . ) | \ ( cd $$SOURCE_DIR && tar chf - --exclude=debian . ) | \
( cd debian/dms-greeter/usr/share/quickshell/dms-greeter && tar xf - ) && \ ( cd debian/dms-greeter/usr/share/quickshell/dms-greeter && tar xf - ) && \
install -Dm755 $$SOURCE_DIR/Modules/Greetd/assets/dms-greeter \ install -Dm755 $$SOURCE_DIR/Modules/Greetd/assets/dms-greeter \
debian/dms-greeter/usr/bin/dms-greeter && \ debian/dms-greeter/usr/bin/dms-greeter && \
+1 -1
View File
@@ -3,7 +3,7 @@
<service name="download_url"> <service name="download_url">
<param name="protocol">https</param> <param name="protocol">https</param>
<param name="host">github.com</param> <param name="host">github.com</param>
<param name="path">/AvengeMedia/DankMaterialShell/archive/refs/tags/v1.2.3.tar.gz</param> <param name="path">/AvengeMedia/DankMaterialShell/releases/download/v1.2.3/dms-source.tar.gz</param>
<param name="filename">dms-source.tar.gz</param> <param name="filename">dms-source.tar.gz</param>
</service> </service>
<!-- Download amd64 binary --> <!-- Download amd64 binary -->
+1 -1
View File
@@ -63,7 +63,7 @@ override_dh_auto_install:
mv "$$SOURCE_DIR" DankMaterialShell-$(UPSTREAM_VERSION); \ mv "$$SOURCE_DIR" DankMaterialShell-$(UPSTREAM_VERSION); \
fi fi
if [ -d DankMaterialShell-$(UPSTREAM_VERSION) ]; then \ if [ -d DankMaterialShell-$(UPSTREAM_VERSION) ]; then \
cp -r DankMaterialShell-$(UPSTREAM_VERSION)/quickshell/* debian/dms/usr/share/quickshell/dms/; \ cp -rL DankMaterialShell-$(UPSTREAM_VERSION)/quickshell/* debian/dms/usr/share/quickshell/dms/; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \
+3 -1
View File
@@ -200,7 +200,9 @@ in
]; ];
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS. # 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. # Declare security.pam.services.dankshell only if you want to override that runtime fallback.
# U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here. # 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.
# security.pam.services.dankshell = { # security.pam.services.dankshell = {
# # Example: add faillock # # Example: add faillock
# faillock.enable = true; # faillock.enable = true;
+21
View File
@@ -23,6 +23,15 @@ in
description = "Systemd target to bind to."; description = "Systemd target to bind to.";
default = "graphical-session.target"; 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 { config = lib.mkIf cfg.enable {
systemd.user.services.dms = lib.mkIf cfg.systemd.enable { systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
description = "DankMaterialShell"; description = "DankMaterialShell";
@@ -46,6 +55,18 @@ in
inherit value; inherit value;
}) common.plugins; }) 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.power-profiles-daemon.enable = lib.mkDefault true;
services.accounts-daemon.enable = lib.mkDefault true; services.accounts-daemon.enable = lib.mkDefault true;
services.geoclue2.enable = lib.mkDefault true; services.geoclue2.enable = lib.mkDefault true;
+2
View File
@@ -19,6 +19,7 @@ pkgs.testers.runNixOSTest {
programs.dank-material-shell = { programs.dank-material-shell = {
enable = true; enable = true;
systemd.enable = true; systemd.enable = true;
lockscreen.securityKey.enable = true;
plugins = { plugins = {
TestPlugin = { TestPlugin = {
src = pkgs.emptyDirectory; src = pkgs.emptyDirectory;
@@ -39,6 +40,7 @@ pkgs.testers.runNixOSTest {
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'") machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins") machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service") 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'")) payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
t.assertIn("summary", payload) t.assertIn("summary", payload)
+19 -19
View File
@@ -216,7 +216,7 @@ update_debian_dms_service() {
return 0 return 0
fi fi
sed -i "s|/archive/refs/tags/v[0-9][^\"]*\.tar\.gz|/archive/refs/tags/v${base_version}.tar.gz|" "$service_path" sed -i "s|/releases/download/v[0-9][^\"]*/dms-source\.tar\.gz|/releases/download/v${base_version}/dms-source.tar.gz|" "$service_path"
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-amd64\.gz|/releases/download/v${base_version}/dms-distropkg-amd64.gz|" "$service_path" sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-amd64\.gz|/releases/download/v${base_version}/dms-distropkg-amd64.gz|" "$service_path"
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-arm64\.gz|/releases/download/v${base_version}/dms-distropkg-arm64.gz|" "$service_path" sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-arm64\.gz|/releases/download/v${base_version}/dms-distropkg-arm64.gz|" "$service_path"
} }
@@ -542,8 +542,8 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
if [[ -n "$GIT_URL" ]]; then if [[ -n "$GIT_URL" ]]; then
echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})" echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})"
SOURCE_DIR="$TEMP_DIR/dms-git-source" SOURCE_DIR="$TEMP_DIR/dms-git-source"
if git clone --depth 1 --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null || if git clone --depth 1 --recurse-submodules --shallow-submodules --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null ||
git clone --depth 1 "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then git clone --depth 1 --recurse-submodules --shallow-submodules "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then
cd "$SOURCE_DIR" cd "$SOURCE_DIR"
if [[ -n "$GIT_REVISION" ]]; then if [[ -n "$GIT_REVISION" ]]; then
git checkout "$GIT_REVISION" 2>/dev/null || true git checkout "$GIT_REVISION" 2>/dev/null || true
@@ -586,7 +586,7 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)" echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
mkdir -p "$EXPECTED_DIR" mkdir -p "$EXPECTED_DIR"
cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/" cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/"
tar -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
rm -rf "$EXPECTED_DIR" rm -rf "$EXPECTED_DIR"
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)" echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
;; ;;
@@ -604,7 +604,7 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)" echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
cp -r "$SOURCE_DIR" "$EXPECTED_DIR" cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
tar -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
rm -rf "$EXPECTED_DIR" rm -rf "$EXPECTED_DIR"
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)" echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
@@ -656,8 +656,8 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
if [[ -n "$GIT_URL" ]]; then if [[ -n "$GIT_URL" ]]; then
echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})" echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})"
SOURCE_DIR="$TEMP_DIR/dms-git-source" SOURCE_DIR="$TEMP_DIR/dms-git-source"
if git clone --depth 1 --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null || if git clone --depth 1 --recurse-submodules --shallow-submodules --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null ||
git clone --depth 1 "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then git clone --depth 1 --recurse-submodules --shallow-submodules "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then
cd "$SOURCE_DIR" cd "$SOURCE_DIR"
if [[ -n "$GIT_REVISION" ]]; then if [[ -n "$GIT_REVISION" ]]; then
git checkout "$GIT_REVISION" 2>/dev/null || true git checkout "$GIT_REVISION" 2>/dev/null || true
@@ -820,11 +820,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
mkdir -p "$EXPECTED_DIR" mkdir -p "$EXPECTED_DIR"
cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/" cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/"
if [[ "$SOURCE0" == *.tar.xz ]]; then if [[ "$SOURCE0" == *.tar.xz ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
else else
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
fi fi
rm -rf "$EXPECTED_DIR" rm -rf "$EXPECTED_DIR"
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)" echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
@@ -835,11 +835,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)" echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
cp -r "$SOURCE_DIR" "$EXPECTED_DIR" cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
if [[ "$SOURCE0" == *.tar.xz ]]; then if [[ "$SOURCE0" == *.tar.xz ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
else else
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
fi fi
rm -rf "$EXPECTED_DIR" rm -rf "$EXPECTED_DIR"
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)" echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
@@ -849,11 +849,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)" echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
cp -r "$SOURCE_DIR" "$EXPECTED_DIR" cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
if [[ "$SOURCE0" == *.tar.xz ]]; then if [[ "$SOURCE0" == *.tar.xz ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
else else
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
fi fi
rm -rf "$EXPECTED_DIR" rm -rf "$EXPECTED_DIR"
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)" echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
@@ -863,11 +863,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
echo " Creating $SOURCE0 (directory: $DIR_NAME)" echo " Creating $SOURCE0 (directory: $DIR_NAME)"
cp -r "$SOURCE_DIR" "$DIR_NAME" cp -r "$SOURCE_DIR" "$DIR_NAME"
if [[ "$SOURCE0" == *.tar.xz ]]; then if [[ "$SOURCE0" == *.tar.xz ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$DIR_NAME" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJhf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$DIR_NAME" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjhf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
else else
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$DIR_NAME" tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czhf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
fi fi
rm -rf "$DIR_NAME" rm -rf "$DIR_NAME"
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)" echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
+3 -3
View File
@@ -344,7 +344,7 @@ EOF
if [ ! -f "dms-source.tar.gz" ]; then if [ ! -f "dms-source.tar.gz" ]; then
info "Downloading dms source for QML files..." info "Downloading dms source for QML files..."
if wget -O dms-source.tar.gz "https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${VERSION}.tar.gz"; then if wget -O dms-source.tar.gz "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-source.tar.gz"; then
success "source tarball downloaded" success "source tarball downloaded"
else else
error "Failed to download dms-source.tar.gz" error "Failed to download dms-source.tar.gz"
@@ -356,7 +356,7 @@ EOF
info "Downloading source for dms-greeter..." info "Downloading source for dms-greeter..."
if [ ! -f "dms-greeter-source.tar.gz" ]; then if [ ! -f "dms-greeter-source.tar.gz" ]; then
info "Downloading dms-greeter source..." info "Downloading dms-greeter source..."
if wget -O dms-greeter-source.tar.gz "https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${VERSION}.tar.gz"; then if wget -O dms-greeter-source.tar.gz "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-source.tar.gz"; then
success "source tarball downloaded" success "source tarball downloaded"
else else
error "Failed to download dms-greeter-source.tar.gz" error "Failed to download dms-greeter-source.tar.gz"
@@ -386,7 +386,7 @@ if [ "$IS_GIT_PACKAGE" = true ] && [ -n "$GIT_REPO" ]; then
info "Cloning $GIT_REPO from GitHub (getting latest commit info)..." info "Cloning $GIT_REPO from GitHub (getting latest commit info)..."
TEMP_CLONE=$(mktemp -d "$TEMP_BASE/ppa_clone_XXXXXX") TEMP_CLONE=$(mktemp -d "$TEMP_BASE/ppa_clone_XXXXXX")
if git clone "https://github.com/$GIT_REPO.git" "$TEMP_CLONE"; then if git clone --recurse-submodules --shallow-submodules "https://github.com/$GIT_REPO.git" "$TEMP_CLONE"; then
GIT_COMMIT_HASH=$(cd "$TEMP_CLONE" && git rev-parse --short HEAD) GIT_COMMIT_HASH=$(cd "$TEMP_CLONE" && git rev-parse --short HEAD)
GIT_COMMIT_COUNT=$(cd "$TEMP_CLONE" && git rev-list --count HEAD) GIT_COMMIT_COUNT=$(cd "$TEMP_CLONE" && git rev-list --count HEAD)
UPSTREAM_VERSION=$(cd "$TEMP_CLONE" && git tag -l "v*" | sed 's/^v//' | sort -V | tail -1) UPSTREAM_VERSION=$(cd "$TEMP_CLONE" && git tag -l "v*" | sed 's/^v//' | sort -V | tail -1)
+1 -1
View File
@@ -60,7 +60,7 @@ override_dh_auto_install:
# Install QML files from git clone # Install QML files from git clone
mkdir -p debian/dms-git/usr/share/quickshell/dms mkdir -p debian/dms-git/usr/share/quickshell/dms
cp -r dms-git-repo/* debian/dms-git/usr/share/quickshell/dms/ cp -rL dms-git-repo/* debian/dms-git/usr/share/quickshell/dms/
# Remove unnecessary directories # Remove unnecessary directories
rm -rf debian/dms-git/usr/share/quickshell/dms/core rm -rf debian/dms-git/usr/share/quickshell/dms/core
+1 -1
View File
@@ -26,7 +26,7 @@ override_dh_auto_build:
override_dh_auto_install: override_dh_auto_install:
# Install greeter files to shared data location # Install greeter files to shared data location
mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter
cp -r DankMaterialShell-$(BASE_VERSION)/quickshell/* debian/dms-greeter/usr/share/quickshell/dms-greeter/ cp -rL DankMaterialShell-$(BASE_VERSION)/quickshell/* debian/dms-greeter/usr/share/quickshell/dms-greeter/
# Install launcher script # Install launcher script
install -Dm755 DankMaterialShell-$(BASE_VERSION)/quickshell/Modules/Greetd/assets/dms-greeter \ install -Dm755 DankMaterialShell-$(BASE_VERSION)/quickshell/Modules/Greetd/assets/dms-greeter \
+1 -1
View File
@@ -63,7 +63,7 @@ override_dh_auto_install:
# Install QML files from source tarball # Install QML files from source tarball
mkdir -p debian/dms/usr/share/quickshell/dms mkdir -p debian/dms/usr/share/quickshell/dms
cp -r DankMaterialShell-$(BASE_VERSION)/* debian/dms/usr/share/quickshell/dms/ cp -rL DankMaterialShell-$(BASE_VERSION)/* debian/dms/usr/share/quickshell/dms/
# Remove unnecessary directories # Remove unnecessary directories
rm -rf debian/dms/usr/share/quickshell/dms/core rm -rf debian/dms/usr/share/quickshell/dms/core
+1 -1
View File
@@ -11,7 +11,7 @@ short_desc="DankMaterialShell greeter for greetd"
maintainer="AvengeMedia <AvengeMedia.US@gmail.com>" maintainer="AvengeMedia <AvengeMedia.US@gmail.com>"
license="MIT" license="MIT"
homepage="https://danklinux.com" homepage="https://danklinux.com"
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz" distfiles="https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${version}/dms-source.tar.gz"
checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c
depends="greetd quickshell acl-progs dbus elogind pam_rundir mesa-dri" depends="greetd quickshell acl-progs dbus elogind pam_rundir mesa-dri"
+1 -1
View File
@@ -19,7 +19,7 @@ maintainer="AvengeMedia <AvengeMedia.US@gmail.com>"
license="MIT" license="MIT"
homepage="https://danklinux.com" homepage="https://danklinux.com"
changelog="https://github.com/AvengeMedia/DankMaterialShell/releases" changelog="https://github.com/AvengeMedia/DankMaterialShell/releases"
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz" distfiles="https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${version}/dms-source.tar.gz"
checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c
# Optional feature deps are listed in distro/void/README.md. # Optional feature deps are listed in distro/void/README.md.
Generated
+17
View File
@@ -1,5 +1,21 @@
{ {
"nodes": { "nodes": {
"dank-qml-common": {
"flake": false,
"locked": {
"lastModified": 1784332064,
"narHash": "sha256-n0LKLirPnNSXYxElK1IpnybEwU0nnyso4kpsEFX88U0=",
"owner": "AvengeMedia",
"repo": "dank-qml-common",
"rev": "5942bc1c60b4538940922b58112c8ae125880891",
"type": "github"
},
"original": {
"owner": "AvengeMedia",
"repo": "dank-qml-common",
"type": "github"
}
},
"flake-compat": { "flake-compat": {
"flake": false, "flake": false,
"locked": { "locked": {
@@ -34,6 +50,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"dank-qml-common": "dank-qml-common",
"flake-compat": "flake-compat", "flake-compat": "flake-compat",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
+9
View File
@@ -7,12 +7,17 @@
url = "github:NixOS/flake-compat"; url = "github:NixOS/flake-compat";
flake = false; flake = false;
}; };
dank-qml-common = {
url = "github:AvengeMedia/dank-qml-common";
flake = false;
};
}; };
outputs = outputs =
{ {
self, self,
nixpkgs, nixpkgs,
dank-qml-common,
... ...
}: }:
let let
@@ -125,6 +130,10 @@
mkdir -p $out/share/quickshell/dms mkdir -p $out/share/quickshell/dms
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/ cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
rm -f $out/share/quickshell/dms/DankCommon
cp -r ${dank-qml-common}/DankCommon $out/share/quickshell/dms/DankCommon
chmod -R u+w $out/share/quickshell/dms/DankCommon
chmod u+w $out/share/quickshell/dms/VERSION chmod u+w $out/share/quickshell/dms/VERSION
echo "${version}" > $out/share/quickshell/dms/VERSION echo "${version}" > $out/share/quickshell/dms/VERSION
+5 -1
View File
@@ -141,13 +141,15 @@ DankMaterialShell/
│ │ ├── ipp/ # Internet Printing Protocol │ │ ├── ipp/ # Internet Printing Protocol
│ │ └── syncmap/ # Thread-safe map │ │ └── syncmap/ # Thread-safe map
│ └── go.mod # Go module definition │ └── go.mod # Go module definition
├── dank-qml-common/ # Shared widget library (git submodule)
├── quickshell/ # QML frontend (UI layer) - see "QML Frontend Architecture" below ├── quickshell/ # QML frontend (UI layer) - see "QML Frontend Architecture" below
│ ├── shell.qml # Main entry point │ ├── shell.qml # Main entry point
│ ├── Services/ # IPC client wrappers │ ├── Services/ # IPC client wrappers
│ ├── Modules/ # UI components │ ├── Modules/ # UI components
│ ├── Widgets/ # Reusable controls │ ├── Widgets/ # Reusable controls
│ ├── Modals/ # Full-screen overlays │ ├── Modals/ # Full-screen overlays
── Common/ # Shared resources ── Common/ # Shared resources
│ └── DankCommon/ # Symlink to ../dank-qml-common/DankCommon
├── distro/ # Distribution packaging ├── distro/ # Distribution packaging
│ ├── arch/ # AUR packages │ ├── arch/ # AUR packages
│ ├── fedora/ # RPM specs │ ├── fedora/ # RPM specs
@@ -490,6 +492,8 @@ import qs.Services // For service access
import qs.Widgets // For reusable widgets (DankIcon, etc.) import qs.Widgets // For reusable widgets (DankIcon, etc.)
``` ```
Many widgets in `Widgets/`, `Common/`, and `Modals/FileBrowser/` are thin wrappers over the shared dank-qml-common library (`quickshell/DankCommon/`, a submodule symlink). Keep importing them through `qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` — the wrappers are the stable API for the shell and for plugins. Edit the implementations in `dank-qml-common/` (see CONTRIBUTING.md, "Shared widgets").
#### Go Import Order #### Go Import Order
Follow standard Go conventions: Follow standard Go conventions:
+1 -1
View File
@@ -1 +1 @@
The Wolverine Marble Tabby
+9 -1
View File
@@ -11,6 +11,8 @@ Singleton {
property var appUsageRanking: {} property var appUsageRanking: {}
property bool _saving: false property bool _saving: false
property bool _hasLoaded: false
property bool _parseError: false
Component.onCompleted: { Component.onCompleted: {
loadSettings(); loadSettings();
@@ -21,15 +23,21 @@ Singleton {
} }
function parseSettings(content) { function parseSettings(content) {
_parseError = false;
try { try {
if (content && content.trim()) { if (content && content.trim()) {
var settings = JSON.parse(content); var settings = JSON.parse(content);
appUsageRanking = settings.appUsageRanking || {}; appUsageRanking = settings.appUsageRanking || {};
} }
} catch (e) {} _hasLoaded = true;
} catch (e) {
_parseError = true;
}
} }
function saveSettings() { function saveSettings() {
if (_parseError || !_hasLoaded)
return;
settingsFile.setText(JSON.stringify({ settingsFile.setText(JSON.stringify({
"appUsageRanking": appUsageRanking "appUsageRanking": appUsageRanking
}, null, 2)); }, null, 2));
+5 -60
View File
@@ -1,66 +1,11 @@
pragma Singleton pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell import Quickshell
import qs.DankCommon.Common as DankCommon
Singleton { Singleton {
id: root readonly property var rounding: DankCommon.Appearance.rounding
readonly property var spacing: DankCommon.Appearance.spacing
readonly property Rounding rounding: Rounding {} readonly property var fontSize: DankCommon.Appearance.fontSize
readonly property Spacing spacing: Spacing {} readonly property var anim: DankCommon.Appearance.anim
readonly property FontSize fontSize: FontSize {}
readonly property Anim anim: Anim {}
component Rounding: QtObject {
readonly property int small: 8
readonly property int normal: 12
readonly property int large: 16
readonly property int extraLarge: 24
readonly property int full: 1000
}
component Spacing: QtObject {
readonly property int small: 4
readonly property int normal: 8
readonly property int large: 12
readonly property int extraLarge: 16
readonly property int huge: 24
}
component FontSize: QtObject {
readonly property int small: 12
readonly property int normal: 14
readonly property int large: 16
readonly property int extraLarge: 20
readonly property int huge: 24
}
component AnimCurves: QtObject {
readonly property list<real> standard: [0.2, 0, 0, 1, 1, 1]
readonly property list<real> standardAccel: [0.3, 0, 1, 1, 1, 1]
readonly property list<real> standardDecel: [0, 0, 0, 1, 1, 1]
readonly property list<real> emphasized: [0.05, 0, 2 / 15, 0.06, 1
/ 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1]
readonly property list<real> emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1]
readonly property list<real> emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1]
readonly property list<real> expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1]
readonly property list<real> expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1]
readonly property list<real> expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1]
}
component AnimDurations: QtObject {
readonly property int quick: 150
readonly property int normal: 300
readonly property int slow: 500
readonly property int extraSlow: 1000
readonly property int expressiveFastSpatial: 350
readonly property int expressiveDefaultSpatial: 500
readonly property int expressiveEffects: 200
}
component Anim: QtObject {
readonly property AnimCurves curves: AnimCurves {}
readonly property AnimDurations durations: AnimDurations {}
}
} }
+2 -8
View File
@@ -1,9 +1,3 @@
import QtQuick import qs.DankCommon.Common as DankCommon
import qs.Common
// Reusable NumberAnimation wrapper DankCommon.DankAnim {}
NumberAnimation {
duration: Theme.expressiveDurations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standard
}
+2 -8
View File
@@ -1,9 +1,3 @@
import QtQuick import qs.DankCommon.Common as DankCommon
import qs.Common
// Reusable ColorAnimation wrapper DankCommon.DankColorAnim {}
ColorAnimation {
duration: Theme.expressiveDurations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standard
}
+2 -63
View File
@@ -1,64 +1,3 @@
import QtQuick import qs.DankCommon.Common as DankCommon
import Quickshell.Io
Item { DankCommon.DankSocket {}
id: root
property alias path: socket.path
property alias parser: socket.parser
property bool connected: false
property bool linkUp: false
property int reconnectBaseMs: 400
property int reconnectMaxMs: 15000
property int _reconnectAttempt: 0
signal connectionStateChanged
onConnectedChanged: {
socket.connected = connected;
}
Socket {
id: socket
onConnectionStateChanged: {
root.linkUp = connected;
root.connectionStateChanged();
if (connected) {
root._reconnectAttempt = 0;
return;
}
if (root.connected) {
root._scheduleReconnect();
}
}
}
Timer {
id: reconnectTimer
interval: 0
repeat: false
onTriggered: {
socket.connected = false;
Qt.callLater(() => socket.connected = true);
}
}
function send(data) {
const json = typeof data === "string" ? data : JSON.stringify(data);
const message = json.endsWith("\n") ? json : json + "\n";
socket.write(message);
socket.flush();
}
function _scheduleReconnect() {
const pow = Math.min(_reconnectAttempt, 10);
const base = Math.min(reconnectBaseMs * Math.pow(2, pow), reconnectMaxMs);
const jitter = Math.floor(Math.random() * Math.floor(base / 4));
reconnectTimer.interval = base + jitter;
reconnectTimer.restart();
_reconnectAttempt++;
}
}
+2 -53
View File
@@ -1,54 +1,3 @@
pragma ComponentBehavior: Bound import qs.DankCommon.Common as DankCommon
import QtQuick DankCommon.ElevationShadow {}
import qs.Common
Item {
id: root
property var level: Theme.elevationLevel2
property string direction: Theme.elevationLightDirection
property real fallbackOffset: 4
property color targetColor: "white"
property real targetRadius: Theme.cornerRadius
property real topLeftRadius: targetRadius
property real topRightRadius: targetRadius
property real bottomLeftRadius: targetRadius
property real bottomRightRadius: targetRadius
property color borderColor: "transparent"
property real borderWidth: 0
property real sourceX: 0
property real sourceY: 0
property real sourceWidth: width
property real sourceHeight: height
property bool shadowEnabled: Theme.elevationEnabled
property real shadowBlurPx: level && level.blurPx !== undefined ? level.blurPx : 0
property real shadowSpreadPx: level && level.spreadPx !== undefined ? level.spreadPx : 0
property real shadowOffsetX: Theme.elevationOffsetXFor(level, direction, fallbackOffset)
property real shadowOffsetY: Theme.elevationOffsetYFor(level, direction, fallbackOffset)
property color shadowColor: Theme.elevationShadowColor(level)
property real shadowOpacity: 1
readonly property var _ambient: Theme.elevationAmbient(level)
readonly property real _pad: shadowEnabled ? Math.ceil(Math.max(shadowBlurPx + shadowSpreadPx + Math.max(Math.abs(shadowOffsetX), Math.abs(shadowOffsetY)), _ambient.blurPx + _ambient.spreadPx) + 2) : 0
ShaderEffect {
anchors.fill: parent
anchors.margins: -root._pad
fragmentShader: Qt.resolvedUrl("../Shaders/qsb/elevation_rect.frag.qsb")
property real widthPx: width
property real heightPx: height
property real borderWidth: root.borderWidth
property vector4d rectPx: Qt.vector4d(root._pad + root.sourceX, root._pad + root.sourceY, root.sourceWidth, root.sourceHeight)
property vector4d cornerRadius: Qt.vector4d(root.topLeftRadius, root.topRightRadius, root.bottomRightRadius, root.bottomLeftRadius)
property vector4d fillColor: Qt.vector4d(root.targetColor.r, root.targetColor.g, root.targetColor.b, root.targetColor.a)
property vector4d borderColor: Qt.vector4d(root.borderColor.r, root.borderColor.g, root.borderColor.b, root.borderColor.a)
property vector4d shadowColor: Qt.vector4d(root.shadowColor.r, root.shadowColor.g, root.shadowColor.b, root.shadowEnabled ? root.shadowColor.a * root.shadowOpacity : 0)
property vector4d shadowParam: Qt.vector4d(Math.max(0, root.shadowBlurPx), root.shadowSpreadPx, root.shadowOffsetX, root.shadowOffsetY)
property vector4d ambientParam: Qt.vector4d(root._ambient.blurPx, root._ambient.spreadPx, root.shadowEnabled ? root._ambient.alpha * root.shadowOpacity : 0, 0)
}
}
+78 -12
View File
@@ -25,6 +25,7 @@ Singleton {
readonly property bool isRtl: _rtlLanguages.includes(_lang) readonly property bool isRtl: _rtlLanguages.includes(_lang)
readonly property url translationsFolder: Qt.resolvedUrl("../translations/poexports") readonly property url translationsFolder: Qt.resolvedUrl("../translations/poexports")
readonly property url commonTranslationsFolder: Qt.resolvedUrl("../DankCommon/translations/poexports")
readonly property alias folder: dir.folder readonly property alias folder: dir.folder
property var presentLocales: ({ property var presentLocales: ({
@@ -32,8 +33,11 @@ Singleton {
}) })
property var translations: ({}) property var translations: ({})
property bool translationsLoaded: false property bool translationsLoaded: false
property var commonTranslations: ({})
property bool commonTranslationsLoaded: false
property url _selectedPath: "" property url _selectedPath: ""
property url _commonSelectedPath: ""
FolderListModel { FolderListModel {
id: dir id: dir
@@ -48,6 +52,18 @@ Singleton {
} }
} }
FolderListModel {
id: commonDir
folder: root.commonTranslationsFolder
nameFilters: ["*.json"]
showDirs: false
showDotAndDotDot: false
onStatusChanged: if (status === FolderListModel.Ready) {
root._pickCommonTranslation();
}
}
FileView { FileView {
id: translationLoader id: translationLoader
path: root._selectedPath path: root._selectedPath
@@ -69,6 +85,22 @@ Singleton {
} }
} }
FileView {
id: commonTranslationLoader
path: root._commonSelectedPath
printErrors: false
onLoaded: {
try {
root.commonTranslations = JSON.parse(text());
root.commonTranslationsLoaded = true;
log.info(`I18n: Loaded DankCommon translations (${Object.keys(root.commonTranslations).length} contexts)`);
} catch (e) {
log.warn("I18n: Error parsing DankCommon translations:", e);
}
}
}
function locale() { function locale() {
if (SessionData.timeLocale) if (SessionData.timeLocale)
return Qt.locale(SessionData.timeLocale); return Qt.locale(SessionData.timeLocale);
@@ -117,24 +149,58 @@ Singleton {
log.warn("Falling back to built-in English strings"); log.warn("Falling back to built-in English strings");
} }
function tr(term, context) { function _pickCommonTranslation() {
if (!translationsLoaded || !translations) const present = {};
return term; for (let i = 0; i < commonDir.count; i++) {
const ctx = context || term; const name = commonDir.get(i, "fileName");
if (translations[ctx] && translations[ctx][term]) if (name && name.endsWith(".json"))
return translations[ctx][term]; present[name.slice(0, -5)] = true;
for (const c in translations) { }
if (translations[c] && translations[c][term]) for (let i = 0; i < _candidates.length; i++) {
return translations[c][term]; if (!present[_candidates[i]])
continue;
_commonSelectedPath = commonTranslationsFolder + "/" + _candidates[i] + ".json";
return;
}
}
function _lookup(table, term, context) {
if (!table)
return "";
if (context && table[context] && table[context][term])
return table[context][term];
if (table[term] && table[term][term])
return table[term][term];
for (const c in table) {
if (table[c] && table[c][term])
return table[c][term];
}
return "";
}
// isRealContext is consumed by translations/extract_translations.py only:
// pass a literal `true` (same line) to give (term, context) its own POEditor
// translation slot. Lookup ignores it -- a real context exists as a bucket
// in the export, a comment-only context does not.
function tr(term, context, isRealContext) {
if (translationsLoaded) {
const hit = _lookup(translations, term, context);
if (hit)
return hit;
}
if (commonTranslationsLoaded) {
const hit = _lookup(commonTranslations, term, context);
if (hit)
return hit;
} }
return term; return term;
} }
function trContext(context, term) { function trContext(context, term) {
if (!translationsLoaded || !translations) if (translationsLoaded && translations[context] && translations[context][term])
return term;
if (translations[context] && translations[context][term])
return translations[context][term]; return translations[context][term];
if (commonTranslationsLoaded && commonTranslations[context] && commonTranslations[context][term])
return commonTranslations[context][term];
return term; return term;
} }
} }
+1
View File
@@ -63,6 +63,7 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" }, { 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 keybinds close", label: "Keybinds Cheatsheet: Close" },
{ id: "spawn dms ipc call lock lock", label: "Lock Screen" }, { 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 lock demo", label: "Lock Screen: Demo" },
{ id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" }, { id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" },
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" }, { id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
+6 -65
View File
@@ -1,72 +1,13 @@
pragma Singleton pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import qs.Common import qs.DankCommon.Common as DankCommon
// Reusable ListView/GridView transitions
Singleton { Singleton {
id: root readonly property bool enabled: DankCommon.ListViewTransitions.enabled
readonly property Transition add: DankCommon.ListViewTransitions.add
// 0ms ViewTransitions break ListView delegate cleanup, so null the set when the shortest readonly property Transition remove: DankCommon.ListViewTransitions.remove
// duration truncates to 0. Keep this gate - don't inline these back into add/remove/etc. readonly property Transition displaced: DankCommon.ListViewTransitions.displaced
readonly property bool enabled: Math.floor(Theme.currentAnimationBaseDuration * 0.4) >= 1 readonly property Transition move: DankCommon.ListViewTransitions.move
readonly property Transition add: enabled ? _add : null
readonly property Transition remove: null
readonly property Transition displaced: enabled ? _displaced : null
readonly property Transition move: enabled ? _move : null
readonly property int _staggerMs: Math.round(Theme.currentAnimationBaseDuration * 0.03)
readonly property int _staggerCap: 8
readonly property Transition _add: Transition {
id: addTransition
SequentialAnimation {
PropertyAction {
property: "opacity"
value: 0
}
PauseAnimation {
duration: Math.max(0, Math.min(addTransition.ViewTransition.index - (addTransition.ViewTransition.targetIndexes[0] ?? 0), root._staggerCap)) * root._staggerMs
}
DankAnim {
property: "opacity"
from: 0
to: 1
duration: Theme.expressiveDurations.fast
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
}
}
}
readonly property Transition _displaced: Transition {
DankAnim {
property: "y"
duration: Theme.expressiveDurations.fast
easing.bezierCurve: Theme.expressiveCurves.standard
}
DankAnim {
property: "opacity"
to: 1
duration: Theme.expressiveDurations.fast
easing.bezierCurve: Theme.expressiveCurves.standard
}
}
readonly property Transition _move: Transition {
DankAnim {
property: "y"
duration: Theme.expressiveDurations.fast
easing.bezierCurve: Theme.expressiveCurves.standard
}
DankAnim {
property: "opacity"
to: 1
duration: Theme.expressiveDurations.fast
easing.bezierCurve: Theme.expressiveCurves.standard
}
}
} }
+8
View File
@@ -108,6 +108,14 @@ Singleton {
return themedIconPath(iconName) || DesktopService.resolveIconPath(iconName); return themedIconPath(iconName) || DesktopService.resolveIconPath(iconName);
} }
function trashPath(path: string, callback): void {
TrashService.trashPath(path, callback);
}
function copyPathToClipboard(path: string): void {
Quickshell.execDetached([Proc.dmsBin, "cl", "copy", path]);
}
function resolveIconUrl(iconName: string): string { function resolveIconUrl(iconName: string): string {
if (!iconName) if (!iconName)
return ""; return "";
+4 -148
View File
@@ -1,157 +1,13 @@
pragma Singleton pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell import Quickshell
import Quickshell.Io import qs.DankCommon.Common as DankCommon
import qs.Services
Singleton { Singleton {
id: root readonly property int noTimeout: DankCommon.Proc.noTimeout
readonly property var log: Log.scoped("Proc") readonly property string dmsBin: DankCommon.Proc.dmsBin
readonly property int noTimeout: -1
readonly property string dmsBin: Quickshell.env("DMS_EXECUTABLE") || "dms"
property int defaultDebounceMs: 50
property int defaultTimeoutMs: 10000
property var _procDebouncers: ({})
function runCommand(id, command, callback, debounceMs, timeoutMs) { function runCommand(id, command, callback, debounceMs, timeoutMs) {
const wait = (typeof debounceMs === "number" && debounceMs >= 0) ? debounceMs : defaultDebounceMs; DankCommon.Proc.runCommand(id, command, callback, debounceMs, timeoutMs);
const timeout = (typeof timeoutMs === "number") ? timeoutMs : defaultTimeoutMs;
let procId = id ? id : Math.random();
const isRandomId = !id;
if (!_procDebouncers[procId]) {
const t = debounceTimerComp.createObject(root);
t.triggered.connect(function () {
_launchProc(procId, isRandomId);
});
_procDebouncers[procId] = {
timer: t,
command: command,
callback: callback,
waitMs: wait,
timeoutMs: timeout,
isRandomId: isRandomId
};
} else {
_procDebouncers[procId].command = command;
_procDebouncers[procId].callback = callback;
_procDebouncers[procId].waitMs = wait;
_procDebouncers[procId].timeoutMs = timeout;
}
const entry = _procDebouncers[procId];
entry.timer.interval = entry.waitMs;
entry.timer.restart();
}
function _launchProc(id, isRandomId) {
const entry = _procDebouncers[id];
if (!entry)
return;
const proc = procComp.createObject(root, {
command: entry.command
});
const timeoutTimer = debounceTimerComp.createObject(root);
let capturedOut = "";
let capturedErr = "";
let exitSeen = false;
let exitCodeValue = -1;
let outSeen = false;
let errSeen = false;
let timedOut = false;
timeoutTimer.interval = entry.timeoutMs;
timeoutTimer.triggered.connect(function () {
if (!exitSeen) {
timedOut = true;
proc.running = false;
exitSeen = true;
exitCodeValue = 124;
maybeComplete();
}
});
proc.stdout.streamFinished.connect(function () {
try {
capturedOut = proc.stdout.text || "";
} catch (e) {
capturedOut = "";
}
outSeen = true;
maybeComplete();
});
proc.stderr.streamFinished.connect(function () {
try {
capturedErr = proc.stderr.text || "";
} catch (e) {
capturedErr = "";
}
errSeen = true;
maybeComplete();
});
proc.exited.connect(function (code) {
timeoutTimer.stop();
exitSeen = true;
exitCodeValue = code;
maybeComplete();
});
function maybeComplete() {
if (!exitSeen || !outSeen || !errSeen)
return;
timeoutTimer.stop();
if (entry && entry.callback && typeof entry.callback === "function") {
try {
const safeOutput = capturedOut !== null && capturedOut !== undefined ? capturedOut : "";
const safeExitCode = exitCodeValue !== null && exitCodeValue !== undefined ? exitCodeValue : -1;
entry.callback(safeOutput, safeExitCode);
} catch (e) {
log.warn("runCommand callback error for command:", entry.command, "Error:", e);
}
}
try {
proc.destroy();
} catch (_) {}
try {
timeoutTimer.destroy();
} catch (_) {}
if (isRandomId || entry.isRandomId) {
Qt.callLater(function () {
if (_procDebouncers[id]) {
try {
_procDebouncers[id].timer.destroy();
} catch (_) {}
delete _procDebouncers[id];
}
});
}
}
proc.running = true;
if (entry.timeoutMs !== noTimeout)
timeoutTimer.start();
}
Component {
id: debounceTimerComp
Timer {
repeat: false
}
}
Component {
id: procComp
Process {
running: false
stdout: StdioCollector {}
stderr: StdioCollector {}
}
} }
} }
+2 -2
View File
@@ -291,7 +291,7 @@ Singleton {
_parseError = true; _parseError = true;
const msg = e.message; const msg = e.message;
log.error("Failed to parse session.json - file will not be overwritten."); log.error("Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg)); Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
} }
} }
@@ -371,7 +371,7 @@ Singleton {
_parseError = true; _parseError = true;
const msg = e.message; const msg = e.message;
log.error("Failed to parse session.json - file will not be overwritten."); log.error("Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg)); Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
} }
} }
+32 -3
View File
@@ -240,6 +240,8 @@ Singleton {
onBlurForegroundLayersChanged: saveSettings() onBlurForegroundLayersChanged: saveSettings()
property real blurLayerOutlineOpacity: 0.12 property real blurLayerOutlineOpacity: 0.12
onBlurLayerOutlineOpacityChanged: saveSettings() onBlurLayerOutlineOpacityChanged: saveSettings()
property bool blurBorderEnabled: true
onBlurBorderEnabledChanged: saveSettings()
property string blurBorderColor: "outline" property string blurBorderColor: "outline"
onBlurBorderColorChanged: saveSettings() onBlurBorderColorChanged: saveSettings()
property string blurBorderCustomColor: "#ffffff" property string blurBorderCustomColor: "#ffffff"
@@ -758,6 +760,7 @@ Singleton {
property int batteryLowNotificationType: 0 property int batteryLowNotificationType: 0
property int batteryCriticalNotificationType: 1 property int batteryCriticalNotificationType: 1
property bool batteryAutoPowerSaver: false property bool batteryAutoPowerSaver: false
property bool lowerDisplayRefreshRateOnBattery: false
property bool showBatteryPercent: true property bool showBatteryPercent: true
property bool showBatteryPercentOnlyOnBattery: false property bool showBatteryPercentOnlyOnBattery: false
property bool showBatteryTime: false property bool showBatteryTime: false
@@ -859,6 +862,7 @@ Singleton {
property bool notificationOverlayEnabled: false property bool notificationOverlayEnabled: false
property bool notificationPopupShadowEnabled: true property bool notificationPopupShadowEnabled: true
property bool notificationPopupPrivacyMode: false property bool notificationPopupPrivacyMode: false
property bool notificationForegroundLayers: true
property int overviewRows: 2 property int overviewRows: 2
property int overviewColumns: 5 property int overviewColumns: 5
property real overviewScale: 0.16 property real overviewScale: 0.16
@@ -898,6 +902,8 @@ Singleton {
property string lockPamPath: "" property string lockPamPath: ""
property bool lockPamInlineFprint: false property bool lockPamInlineFprint: false
property bool lockPamInlineU2f: false property bool lockPamInlineU2f: false
property bool lockPamExternallyManaged: false
property string lockU2fPamPath: ""
property bool greeterPamExternallyManaged: false property bool greeterPamExternallyManaged: false
property string lockScreenInactiveColor: "#000000" property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0 property int lockScreenNotificationMode: 0
@@ -969,6 +975,8 @@ Singleton {
property var hyprlandOutputSettings: ({}) property var hyprlandOutputSettings: ({})
property var displayProfiles: ({}) property var displayProfiles: ({})
property var activeDisplayProfile: ({}) property var activeDisplayProfile: ({})
property var activeDisplayProfileModes: ({})
property var displayPreviousRefreshModes: ({})
property bool displayProfileAutoSelect: false property bool displayProfileAutoSelect: false
property bool displayShowDisconnected: false property bool displayShowDisconnected: false
property bool displaySnapToEdge: true property bool displaySnapToEdge: true
@@ -1791,7 +1799,7 @@ Singleton {
_parseError = true; _parseError = true;
const msg = e.message; const msg = e.message;
log.error("Failed to parse settings.json - file will not be overwritten. Error:", msg); log.error("Failed to parse settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg)); Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("settings.json"), msg));
applyStoredTheme(); applyStoredTheme();
} finally { } finally {
_loading = false; _loading = false;
@@ -1889,7 +1897,7 @@ Singleton {
_pluginParseError = true; _pluginParseError = true;
const msg = e.message; const msg = e.message;
log.error("Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg); log.error("Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse plugin_settings.json"), msg)); Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("plugin_settings.json"), msg));
pluginSettings = {}; pluginSettings = {};
} finally { } finally {
_pluginSettingsLoading = false; _pluginSettingsLoading = false;
@@ -3556,6 +3564,27 @@ Singleton {
saveSettings(); saveSettings();
} }
function setActiveDisplayProfileModes(compositor, modes) {
if (JSON.stringify(activeDisplayProfileModes[compositor] || {}) === JSON.stringify(modes || {}))
return;
const updated = JSON.parse(JSON.stringify(activeDisplayProfileModes));
updated[compositor] = modes;
activeDisplayProfileModes = updated;
saveSettings();
}
function setDisplayPreviousRefreshModes(compositor, modes) {
if (JSON.stringify(displayPreviousRefreshModes[compositor] || {}) === JSON.stringify(modes || {}))
return;
const updated = JSON.parse(JSON.stringify(displayPreviousRefreshModes));
if (Object.keys(modes || {}).length > 0)
updated[compositor] = modes;
else
delete updated[compositor];
displayPreviousRefreshModes = updated;
saveSettings();
}
ListModel { ListModel {
id: leftWidgetsModel id: leftWidgetsModel
} }
@@ -3651,7 +3680,7 @@ Singleton {
_parseError = true; _parseError = true;
const msg = e.message; const msg = e.message;
log.error("Failed to reload settings.json - file will not be overwritten. Error:", msg); log.error("Failed to reload settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg)); Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("settings.json"), msg));
} finally { } finally {
_loading = false; _loading = false;
} }
+25 -8
View File
@@ -6,6 +6,7 @@ import QtQuick
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import qs.Common import qs.Common
import qs.DankCommon.Common as DankCommon
import qs.Services import qs.Services
import qs.Modules.Greetd import qs.Modules.Greetd
import "StockThemes.js" as StockThemes import "StockThemes.js" as StockThemes
@@ -580,11 +581,15 @@ Singleton {
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true) readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
readonly property bool transparentBlurLayers: 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 readableSurface: withAlpha(surfaceContainer, popupTransparency)
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency) readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0) readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0) readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
readonly property color nestedSurface: floatingSurfaceHigh 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 blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0 readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
@@ -1226,16 +1231,28 @@ Singleton {
property string fontFamily: { property string fontFamily: {
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") { if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") {
return GreetdSettings.getEffectiveFontFamily(); return resolvedFontFamily(GreetdSettings.getEffectiveFontFamily());
} }
return typeof SettingsData !== "undefined" ? SettingsData.fontFamily : "Inter Variable"; return typeof SettingsData !== "undefined" ? resolvedFontFamily(SettingsData.fontFamily) : DankCommon.Fonts.sans;
} }
property string monoFontFamily: { property string monoFontFamily: {
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") { if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") {
return GreetdSettings.monoFontFamily; return resolvedMonoFontFamily(GreetdSettings.monoFontFamily);
} }
return typeof SettingsData !== "undefined" ? SettingsData.monoFontFamily : "Fira Code"; return typeof SettingsData !== "undefined" ? resolvedMonoFontFamily(SettingsData.monoFontFamily) : DankCommon.Fonts.mono;
}
function resolvedFontFamily(family) {
if (family === defaultFontFamily)
return DankCommon.Fonts.sans;
return family;
}
function resolvedMonoFontFamily(family) {
if (family === defaultMonoFontFamily)
return DankCommon.Fonts.mono;
return family;
} }
property int fontWeight: { property int fontWeight: {
@@ -1956,7 +1973,7 @@ Singleton {
function applyGtkColors() { function applyGtkColors() {
if (!matugenAvailable) { if (!matugenAvailable) {
if (typeof ToastService !== "undefined") { if (typeof ToastService !== "undefined") {
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply GTK colors")); ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply %1 colors").arg("GTK"));
} }
return; return;
} }
@@ -1969,7 +1986,7 @@ Singleton {
} }
} else { } else {
if (typeof ToastService !== "undefined") { if (typeof ToastService !== "undefined") {
ToastService.showError(I18n.tr("Failed to apply GTK colors")); ToastService.showError(I18n.tr("Failed to apply %1 colors").arg("GTK"));
} }
} }
}); });
@@ -1978,7 +1995,7 @@ Singleton {
function applyQtColors() { function applyQtColors() {
if (!matugenAvailable) { if (!matugenAvailable) {
if (typeof ToastService !== "undefined") { if (typeof ToastService !== "undefined") {
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply Qt colors")); ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply %1 colors").arg("Qt"));
} }
return; return;
} }
@@ -1990,7 +2007,7 @@ Singleton {
} }
} else { } else {
if (typeof ToastService !== "undefined") { if (typeof ToastService !== "undefined") {
ToastService.showError(I18n.tr("Failed to apply Qt colors")); ToastService.showError(I18n.tr("Failed to apply %1 colors").arg("Qt"));
} }
} }
}); });
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -63,6 +63,7 @@ Singleton {
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : "" readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== "" readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f") 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 greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd")
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f") readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
@@ -176,7 +177,7 @@ Singleton {
readonly property bool lockU2fReady: { readonly property bool lockU2fReady: {
if (forcedU2fAvailable !== null) if (forcedU2fAvailable !== null)
return forcedU2fAvailable; return forcedU2fAvailable;
return lockU2fCustomConfigDetected || homeU2fKeysDetected; return lockU2fCustomSourceDetected || lockU2fCustomConfigDetected || homeU2fKeysDetected;
} }
readonly property bool lockU2fCanEnable: { readonly property bool lockU2fCanEnable: {
@@ -246,6 +247,10 @@ 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"] 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() { function detectAuthCapabilities() {
// FileView cannot watch paths that do not exist yet, so reload the U2F PAM
dankshellU2fPamWatcher.reload();
u2fKeysWatcher.reload();
if (forcedFprintAvailable === null) { if (forcedFprintAvailable === null) {
fingerprintProbeFinalized = false; fingerprintProbeFinalized = false;
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => { Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
@@ -599,7 +604,7 @@ Singleton {
let details = out; let details = out;
if (err !== "") if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err; details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
ToastService.showInfo(I18n.tr("Authentication changes applied."), details, "", "auth-sync"); ToastService.showInfo(I18n.tr("Authentication changes applied"), details, "", "auth-sync");
root.detectAuthCapabilities(); root.detectAuthCapabilities();
root.finishAuthApply(); root.finishAuthApply();
return; return;
@@ -645,7 +650,7 @@ Singleton {
onExited: exitCode => { onExited: exitCode => {
if (exitCode === 0) { if (exitCode === 0) {
const message = root.authApplyTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication setup there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication setup there; it will close automatically when done."); const message = root.authApplyTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication there; it will close automatically when done.");
ToastService.showInfo(message, "", "", "auth-sync"); ToastService.showInfo(message, "", "", "auth-sync");
} else { } else {
let details = (root.authApplyTerminalFallbackStderr || "").trim(); let details = (root.authApplyTerminalFallbackStderr || "").trim();
@@ -722,14 +727,22 @@ Singleton {
FileView { FileView {
id: dankshellU2fPamWatcher id: dankshellU2fPamWatcher
path: "/etc/pam.d/dankshell-u2f" path: "/etc/pam.d/dankshell-u2f"
watchChanges: true
printErrors: false printErrors: false
onLoaded: root.dankshellU2fPamText = text() onLoaded: root.dankshellU2fPamText = text()
onLoadFailed: root.dankshellU2fPamText = "" onLoadFailed: root.dankshellU2fPamText = ""
} }
FileView {
id: customU2fPamWatcher
path: root.settingsRoot?.lockU2fPamPath || ""
printErrors: false
}
FileView { FileView {
id: u2fKeysWatcher id: u2fKeysWatcher
path: root.u2fKeysPath path: root.u2fKeysPath
watchChanges: true
printErrors: false printErrors: false
onLoaded: root.u2fKeysText = text() onLoaded: root.u2fKeysText = text()
onLoadFailed: root.u2fKeysText = "" onLoadFailed: root.u2fKeysText = ""
@@ -70,6 +70,7 @@ var SPEC = {
blurEnabled: { def: false }, blurEnabled: { def: false },
blurForegroundLayers: { def: true }, blurForegroundLayers: { def: true },
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit }, blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
blurBorderEnabled: { def: true },
blurBorderColor: { def: "outline" }, blurBorderColor: { def: "outline" },
blurBorderCustomColor: { def: "#ffffff" }, blurBorderCustomColor: { def: "#ffffff" },
blurBorderOpacity: { def: 0.35, coerce: percentToUnit }, blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
@@ -346,6 +347,7 @@ var SPEC = {
batteryLowNotificationType: { def: 0 }, batteryLowNotificationType: { def: 0 },
batteryCriticalNotificationType: { def: 1 }, batteryCriticalNotificationType: { def: 1 },
batteryAutoPowerSaver: { def: false }, batteryAutoPowerSaver: { def: false },
lowerDisplayRefreshRateOnBattery: { def: false },
lockBeforeSuspend: { def: false }, lockBeforeSuspend: { def: false },
loginctlLockIntegration: { def: true }, loginctlLockIntegration: { def: true },
fadeToLockEnabled: { def: true }, fadeToLockEnabled: { def: true },
@@ -437,6 +439,7 @@ var SPEC = {
notificationOverlayEnabled: { def: false }, notificationOverlayEnabled: { def: false },
notificationPopupShadowEnabled: { def: true }, notificationPopupShadowEnabled: { def: true },
notificationPopupPrivacyMode: { def: false }, notificationPopupPrivacyMode: { def: false },
notificationForegroundLayers: { def: true },
overviewRows: { def: 2, persist: false }, overviewRows: { def: 2, persist: false },
overviewColumns: { def: 5, persist: false }, overviewColumns: { def: 5, persist: false },
overviewScale: { def: 0.16, persist: false }, overviewScale: { def: 0.16, persist: false },
@@ -459,6 +462,8 @@ var SPEC = {
lockPamPath: { def: "" }, lockPamPath: { def: "" },
lockPamInlineFprint: { def: false }, lockPamInlineFprint: { def: false },
lockPamInlineU2f: { def: false }, lockPamInlineU2f: { def: false },
lockPamExternallyManaged: { def: false },
lockU2fPamPath: { def: "" },
lockScreenInactiveColor: { def: "#000000" }, lockScreenInactiveColor: { def: "#000000" },
lockScreenNotificationMode: { def: 0 }, lockScreenNotificationMode: { def: 0 },
lockScreenVideoEnabled: { def: false }, lockScreenVideoEnabled: { def: false },
@@ -528,6 +533,8 @@ var SPEC = {
hyprlandOutputSettings: { def: {} }, hyprlandOutputSettings: { def: {} },
displayProfiles: { def: {} }, displayProfiles: { def: {} },
activeDisplayProfile: { def: {} }, activeDisplayProfile: { def: {} },
activeDisplayProfileModes: { def: {} },
displayPreviousRefreshModes: { def: {} },
displayProfileAutoSelect: { def: false }, displayProfileAutoSelect: { def: false },
displayShowDisconnected: { def: false }, displayShowDisconnected: { def: false },
displaySnapToEdge: { def: true }, displaySnapToEdge: { def: true },
+54 -67
View File
@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import qs.Common import qs.Common
@@ -53,6 +55,7 @@ Item {
delegate: Loader { delegate: Loader {
id: fadeWindowLoader id: fadeWindowLoader
required property var modelData required property var modelData
readonly property FadeToLockWindow loadedWindow: item as FadeToLockWindow
active: SettingsData.fadeToLockEnabled active: SettingsData.fadeToLockEnabled
asynchronous: false asynchronous: false
@@ -64,29 +67,29 @@ Item {
} }
onFadeCancelled: { onFadeCancelled: {
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name); root.log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
} }
} }
Connections { Connections {
target: IdleService target: IdleService
enabled: fadeWindowLoader.item !== null enabled: fadeWindowLoader.loadedWindow !== null
function onFadeToLockRequested() { function onFadeToLockRequested() {
if (fadeWindowLoader.item) { if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.item.startFade(); fadeWindowLoader.loadedWindow.startFade();
} }
} }
function onCancelFadeToLock() { function onCancelFadeToLock() {
if (fadeWindowLoader.item) { if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.item.cancelFade(); fadeWindowLoader.loadedWindow.cancelFade();
} }
} }
function onDismissFadeToLock() { function onDismissFadeToLock() {
if (fadeWindowLoader.item) { if (fadeWindowLoader.loadedWindow) {
fadeWindowLoader.item.dismiss(); fadeWindowLoader.loadedWindow.dismiss();
} }
} }
} }
@@ -99,6 +102,7 @@ Item {
delegate: Loader { delegate: Loader {
id: fadeDpmsWindowLoader id: fadeDpmsWindowLoader
required property var modelData required property var modelData
readonly property FadeToDpmsWindow loadedWindow: item as FadeToDpmsWindow
active: SettingsData.fadeToDpmsEnabled active: SettingsData.fadeToDpmsEnabled
asynchronous: false asynchronous: false
@@ -110,30 +114,30 @@ Item {
} }
onFadeCancelled: { onFadeCancelled: {
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name); root.log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
} }
} }
Connections { Connections {
target: IdleService target: IdleService
enabled: fadeDpmsWindowLoader.item !== null enabled: fadeDpmsWindowLoader.loadedWindow !== null
function onFadeToDpmsRequested() { function onFadeToDpmsRequested() {
if (fadeDpmsWindowLoader.item) { if (fadeDpmsWindowLoader.loadedWindow) {
fadeDpmsWindowLoader.item.startFade(); fadeDpmsWindowLoader.loadedWindow.startFade();
} }
} }
function onCancelFadeToDpms() { function onCancelFadeToDpms() {
if (fadeDpmsWindowLoader.item) { if (fadeDpmsWindowLoader.loadedWindow) {
fadeDpmsWindowLoader.item.cancelFade(); fadeDpmsWindowLoader.loadedWindow.cancelFade();
} }
} }
function onRequestMonitorOn() { function onRequestMonitorOn() {
if (!fadeDpmsWindowLoader.item) if (!fadeDpmsWindowLoader.loadedWindow)
return; return;
fadeDpmsWindowLoader.item.cancelFade(); fadeDpmsWindowLoader.loadedWindow.cancelFade();
} }
} }
} }
@@ -319,7 +323,7 @@ Item {
target: TrashService target: TrashService
function onEmptyTrashConfirmRequested(itemCount) { function onEmptyTrashConfirmRequested(itemCount) {
emptyTrashConfirm.showWithOptions({ emptyTrashConfirm.showWithOptions({
title: I18n.tr("Empty Trash?"), title: I18n.tr("Empty Trash"),
message: I18n.tr("Permanently delete %1 item(s)? This cannot be undone.").arg(itemCount), message: I18n.tr("Permanently delete %1 item(s)? This cannot be undone.").arg(itemCount),
confirmText: I18n.tr("Empty"), confirmText: I18n.tr("Empty"),
cancelText: I18n.tr("Cancel"), cancelText: I18n.tr("Cancel"),
@@ -351,9 +355,7 @@ Item {
Variants { Variants {
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications") model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
delegate: NotificationPopupManager { delegate: NotificationPopupManager {}
modelData: item
}
} }
LazyLoader { LazyLoader {
@@ -387,6 +389,7 @@ Item {
LazyLoader { LazyLoader {
id: wifiPasswordModalLoader id: wifiPasswordModalLoader
active: false active: false
readonly property WifiPasswordModal loadedModal: item as WifiPasswordModal
Component.onCompleted: { Component.onCompleted: {
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader; PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
@@ -421,6 +424,7 @@ Item {
LazyLoader { LazyLoader {
id: polkitAuthModalLoader id: polkitAuthModalLoader
active: false active: false
readonly property PolkitAuthModal loadedModal: item as PolkitAuthModal
PolkitAuthModal { PolkitAuthModal {
id: polkitAuthModal id: polkitAuthModal
@@ -439,8 +443,8 @@ Item {
if (PopoutService.systemUpdatePopout?.shouldBeVisible) if (PopoutService.systemUpdatePopout?.shouldBeVisible)
return; return;
polkitAuthModalLoader.active = true; polkitAuthModalLoader.active = true;
if (polkitAuthModalLoader.item) if (polkitAuthModalLoader.loadedModal)
polkitAuthModalLoader.item.show(); polkitAuthModalLoader.loadedModal.show();
} }
} }
@@ -459,20 +463,20 @@ Item {
target: NetworkService target: NetworkService
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) { function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
const alreadyShown = wifiPasswordModalLoader.item && wifiPasswordModalLoader.item.shouldBeVisible; const alreadyShown = wifiPasswordModalLoader.loadedModal && wifiPasswordModalLoader.loadedModal.shouldBeVisible;
if (alreadyShown && token === lastCredentialsToken) if (alreadyShown && token === root.lastCredentialsToken)
return; return;
wifiPasswordModalLoader.active = true; wifiPasswordModalLoader.active = true;
if (!wifiPasswordModalLoader.item) if (!wifiPasswordModalLoader.loadedModal)
return; return;
if (alreadyShown && lastCredentialsToken !== "" && lastCredentialsToken !== token) if (alreadyShown && root.lastCredentialsToken !== "" && root.lastCredentialsToken !== token)
NetworkService.cancelCredentials(lastCredentialsToken); NetworkService.cancelCredentials(root.lastCredentialsToken);
lastCredentialsToken = token; root.lastCredentialsToken = token;
lastCredentialsTime = Date.now(); root.lastCredentialsTime = Date.now();
wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo); wifiPasswordModalLoader.loadedModal.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
} }
} }
@@ -769,10 +773,10 @@ Item {
} }
function onAppPickerRequested(data) { function onAppPickerRequested(data) {
log.debug("App picker requested with data:", JSON.stringify(data)); root.log.debug("App picker requested with data:", JSON.stringify(data));
if (!data || !data.target) { if (!data || !data.target) {
log.warn("Invalid app picker request data"); root.log.warn("Invalid app picker request data");
return; return;
} }
@@ -889,7 +893,6 @@ Item {
delegate: DankSlideout { delegate: DankSlideout {
id: notepadSlideout id: notepadSlideout
modelData: item
title: I18n.tr("Notepad") title: I18n.tr("Notepad")
slideoutWidth: 480 slideoutWidth: 480
expandable: true expandable: true
@@ -985,8 +988,8 @@ Item {
onSwitchUserRequested: { onSwitchUserRequested: {
switchUserModalLoader.active = true; switchUserModalLoader.active = true;
Qt.callLater(() => { Qt.callLater(() => {
if (switchUserModalLoader.item) if (switchUserModalLoader.loadedModal)
switchUserModalLoader.item.showFromPowerMenu(); switchUserModalLoader.loadedModal.showFromPowerMenu();
}); });
} }
@@ -1000,6 +1003,7 @@ Item {
id: switchUserModalLoader id: switchUserModalLoader
active: false active: false
readonly property SwitchUserModal loadedModal: item as SwitchUserModal
SwitchUserModal { SwitchUserModal {
id: switchUserModal id: switchUserModal
@@ -1055,7 +1059,6 @@ Item {
model: SettingsData.getFilteredScreens("toast") model: SettingsData.getFilteredScreens("toast")
delegate: Toast { delegate: Toast {
modelData: item
visible: ToastService.toastVisible visible: ToastService.toastVisible
} }
} }
@@ -1070,73 +1073,55 @@ Item {
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: VolumeOSD { delegate: VolumeOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: MediaVolumeOSD { delegate: MediaVolumeOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: MediaPlaybackOSD { delegate: MediaPlaybackOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: MicVolumeOSD { delegate: MicVolumeOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: BrightnessOSD { delegate: BrightnessOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: IdleInhibitorOSD { delegate: IdleInhibitorOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : [] model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
delegate: PowerProfileOSD { delegate: PowerProfileOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: CapsLockOSD { delegate: CapsLockOSD {}
modelData: item
}
} }
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: AudioOutputOSD { delegate: AudioOutputOSD {}
modelData: item
}
} }
} }
} }
@@ -1153,6 +1138,7 @@ Item {
Loader { Loader {
id: greeterLoader id: greeterLoader
active: false active: false
readonly property GreeterModal loadedModal: item as GreeterModal
sourceComponent: GreeterModal { sourceComponent: GreeterModal {
onGreeterCompleted: greeterLoader.active = false onGreeterCompleted: greeterLoader.active = false
Component.onCompleted: show() Component.onCompleted: show()
@@ -1161,8 +1147,8 @@ Item {
Connections { Connections {
target: FirstLaunchService target: FirstLaunchService
function onGreeterRequested() { function onGreeterRequested() {
if (greeterLoader.active && greeterLoader.item) { if (greeterLoader.active && greeterLoader.loadedModal) {
greeterLoader.item.show(); greeterLoader.loadedModal.show();
return; return;
} }
greeterLoader.active = true; greeterLoader.active = true;
@@ -1173,6 +1159,7 @@ Item {
Loader { Loader {
id: changelogLoader id: changelogLoader
active: false active: false
readonly property ChangelogModal loadedModal: item as ChangelogModal
sourceComponent: ChangelogModal { sourceComponent: ChangelogModal {
onChangelogDismissed: changelogLoader.active = false onChangelogDismissed: changelogLoader.active = false
Component.onCompleted: show() Component.onCompleted: show()
@@ -1181,8 +1168,8 @@ Item {
Connections { Connections {
target: ChangelogService target: ChangelogService
function onChangelogRequested() { function onChangelogRequested() {
if (changelogLoader.active && changelogLoader.item) { if (changelogLoader.active && changelogLoader.loadedModal) {
changelogLoader.item.show(); changelogLoader.loadedModal.show();
return; return;
} }
changelogLoader.active = true; changelogLoader.active = true;
+1
View File
@@ -0,0 +1 @@
../dank-qml-common/DankCommon
@@ -408,14 +408,14 @@ FocusScope {
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "↵ " + I18n.tr("open") text: "↵ " + I18n.tr("Open")
font.pixelSize: Theme.fontSizeSmall - 1 font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Tab " + I18n.tr("actions") text: "Tab " + I18n.tr("Actions")
font.pixelSize: Theme.fontSizeSmall - 1 font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
visible: actionPanel.hasActions visible: actionPanel.hasActions
@@ -214,7 +214,7 @@ Item {
Item { Item {
id: listClip id: listClip
anchors.fill: parent anchors.fill: parent
anchors.topMargin: BlurService.enabled && stickyHeader.visible ? 32 : 0 anchors.topMargin: stickyHeader.visible ? 32 : 0
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0 anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
clip: true clip: true
@@ -417,7 +417,7 @@ Item {
anchors.top: parent.top anchors.top: parent.top
height: 32 height: 32
z: 101 z: 101
color: Theme.floatingSurface color: "transparent"
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0 readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
@@ -156,7 +156,7 @@ Item {
function statusTitle() { function statusTitle() {
if (root.controller?.isSearching || root.controller?.isFileSearching) if (root.controller?.isSearching || root.controller?.isFileSearching)
return I18n.tr("Searching"); return I18n.tr("Searching...");
if ((root.controller?.searchMode ?? "") === "files" && !DSearchService.dsearchAvailable) if ((root.controller?.searchMode ?? "") === "files" && !DSearchService.dsearchAvailable)
return I18n.tr("File search unavailable"); return I18n.tr("File search unavailable");
if ((root.controller?.searchMode ?? "") === "files" && (root.controller?.searchQuery?.length ?? 0) < 2) if ((root.controller?.searchMode ?? "") === "files" && (root.controller?.searchQuery?.length ?? 0) < 2)
@@ -1,935 +1,3 @@
import Qt.labs.folderlistmodel import qs.DankCommon.Modals.FileBrowser as DankCommon
import QtCore
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Widgets
FocusScope { DankCommon.FileBrowserContent {}
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation)
property string docsDir: StandardPaths.writableLocation(StandardPaths.DocumentsLocation)
property string musicDir: StandardPaths.writableLocation(StandardPaths.MusicLocation)
property string videosDir: StandardPaths.writableLocation(StandardPaths.MoviesLocation)
property string picsDir: StandardPaths.writableLocation(StandardPaths.PicturesLocation)
property string downloadDir: StandardPaths.writableLocation(StandardPaths.DownloadLocation)
property string desktopDir: StandardPaths.writableLocation(StandardPaths.DesktopLocation)
property string currentPath: ""
property var fileExtensions: ["*.*"]
property alias filterExtensions: root.fileExtensions
property string browserTitle: "Select File"
property string browserIcon: "folder_open"
property string browserType: "generic"
property bool showHiddenFiles: false
property int selectedIndex: -1
property bool keyboardNavigationActive: false
property bool backButtonFocused: false
property bool saveMode: false
property string defaultFileName: ""
property int keyboardSelectionIndex: -1
property bool keyboardSelectionRequested: false
property bool showKeyboardHints: false
property bool showFileInfo: false
property string selectedFilePath: ""
property string selectedFileName: ""
property bool selectedFileIsDir: false
property bool showOverwriteConfirmation: false
property string pendingFilePath: ""
property bool showSidebar: true
property string viewMode: "grid"
property string sortBy: "name"
property bool sortAscending: true
property int iconSizeIndex: 1
property var iconSizes: [80, 120, 160, 200]
property bool pathEditMode: false
property bool pathInputHasFocus: false
property int actualGridColumns: 5
property bool _initialized: false
property bool closeOnEscape: true
property var windowControls: null
signal fileSelected(string path)
signal closeRequested
function encodeFileUrl(path) {
if (!path)
return "";
return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/');
}
function initialize() {
loadSettings();
currentPath = getLastPath();
_initialized = true;
}
function reset() {
currentPath = getLastPath();
selectedIndex = -1;
keyboardNavigationActive = false;
backButtonFocused = false;
}
function loadSettings() {
const type = browserType || "default";
const settings = CacheData.fileBrowserSettings[type];
const isImageBrowser = ["wallpaper", "profile"].includes(browserType);
if (settings) {
viewMode = settings.viewMode || (isImageBrowser ? "grid" : "list");
sortBy = settings.sortBy || "name";
sortAscending = settings.sortAscending !== undefined ? settings.sortAscending : true;
iconSizeIndex = settings.iconSizeIndex !== undefined ? settings.iconSizeIndex : 1;
showSidebar = settings.showSidebar !== undefined ? settings.showSidebar : true;
} else {
viewMode = isImageBrowser ? "grid" : "list";
}
}
function saveSettings() {
if (!_initialized)
return;
const type = browserType || "default";
let settings = CacheData.fileBrowserSettings;
if (!settings[type]) {
settings[type] = {};
}
settings[type].viewMode = viewMode;
settings[type].sortBy = sortBy;
settings[type].sortAscending = sortAscending;
settings[type].iconSizeIndex = iconSizeIndex;
settings[type].showSidebar = showSidebar;
settings[type].lastPath = currentPath;
CacheData.fileBrowserSettings = settings;
if (browserType === "wallpaper") {
CacheData.wallpaperLastPath = currentPath;
} else if (browserType === "profile") {
CacheData.profileLastPath = currentPath;
}
CacheData.saveCache();
}
onViewModeChanged: saveSettings()
onSortByChanged: saveSettings()
onSortAscendingChanged: saveSettings()
onIconSizeIndexChanged: saveSettings()
onShowSidebarChanged: saveSettings()
function isImageFile(fileName) {
if (!fileName)
return false;
const ext = fileName.toLowerCase().split('.').pop();
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
}
function getLastPath() {
const type = browserType || "default";
const settings = CacheData.fileBrowserSettings[type];
const lastPath = settings?.lastPath || "";
return (lastPath && lastPath !== "") ? lastPath : homeDir;
}
function saveLastPath(path) {
const type = browserType || "default";
let settings = CacheData.fileBrowserSettings;
if (!settings[type]) {
settings[type] = {};
}
settings[type].lastPath = path;
CacheData.fileBrowserSettings = settings;
CacheData.saveCache();
if (browserType === "wallpaper") {
CacheData.wallpaperLastPath = path;
} else if (browserType === "profile") {
CacheData.profileLastPath = path;
}
}
function setSelectedFileData(path, name, isDir) {
selectedFilePath = path;
selectedFileName = name;
selectedFileIsDir = isDir;
}
function openItemContextMenu(sender, localX, localY, path, name, isDir) {
if (!sender)
return;
const pos = sender.mapToItem(root, localX, localY);
itemContextMenu.showAt(root, pos.x, pos.y, path, name, isDir);
}
function navigateUp() {
const path = currentPath;
if (path === homeDir)
return;
const lastSlash = path.lastIndexOf('/');
if (lastSlash <= 0)
return;
const newPath = path.substring(0, lastSlash);
if (newPath.length < homeDir.length) {
currentPath = homeDir;
saveLastPath(homeDir);
} else {
currentPath = newPath;
saveLastPath(newPath);
}
}
function navigateTo(path) {
currentPath = path;
saveLastPath(path);
selectedIndex = -1;
backButtonFocused = false;
}
function keyboardFileSelection(index) {
if (index < 0)
return;
keyboardSelectionTimer.targetIndex = index;
keyboardSelectionTimer.start();
}
function executeKeyboardSelection(index) {
keyboardSelectionIndex = index;
keyboardSelectionRequested = true;
}
function activateFile(path, name, isDir) {
if (isDir) {
navigateTo(path);
return;
}
if (saveMode) {
saveRow.fileName = name;
pendingFilePath = path;
showOverwriteConfirmation = true;
} else {
fileSelected(path);
closeRequested();
}
}
function handleSaveFile(filePath) {
var normalizedPath = filePath;
if (!normalizedPath.startsWith("file://")) {
normalizedPath = encodeFileUrl(filePath);
}
var exists = false;
var fileName = filePath.split('/').pop();
for (var i = 0; i < folderModel.count; i++) {
if (folderModel.get(i, "fileName") === fileName && !folderModel.get(i, "fileIsDir")) {
exists = true;
break;
}
}
if (exists) {
pendingFilePath = normalizedPath;
showOverwriteConfirmation = true;
} else {
fileSelected(normalizedPath);
closeRequested();
}
}
onCurrentPathChanged: {
selectedFilePath = "";
selectedFileName = "";
selectedFileIsDir = false;
saveSettings();
}
onSelectedIndexChanged: {
if (selectedIndex >= 0 && folderModel && selectedIndex < folderModel.count) {
selectedFilePath = "";
selectedFileName = "";
selectedFileIsDir = false;
}
}
property var steamPaths: [StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/.steam/steam/steamapps/workshop/content/431960", StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/.local/share/Steam/steamapps/workshop/content/431960", StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/workshop/content/431960", StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/snap/steam/common/.local/share/Steam/steamapps/workshop/content/431960"]
property var quickAccessLocations: [
{
"name": I18n.tr("Home"),
"path": homeDir,
"icon": "home"
},
{
"name": I18n.tr("Documents"),
"path": docsDir,
"icon": "description"
},
{
"name": I18n.tr("Downloads"),
"path": downloadDir,
"icon": "download"
},
{
"name": I18n.tr("Pictures"),
"path": picsDir,
"icon": "image"
},
{
"name": I18n.tr("Music"),
"path": musicDir,
"icon": "music_note"
},
{
"name": I18n.tr("Videos"),
"path": videosDir,
"icon": "movie"
},
{
"name": I18n.tr("Desktop"),
"path": desktopDir,
"icon": "computer"
}
]
FolderListModel {
id: folderModel
showDirsFirst: true
showDotAndDotDot: false
showHidden: root.showHiddenFiles
caseSensitive: false
nameFilters: fileExtensions
showFiles: true
showDirs: true
folder: encodeFileUrl(currentPath || homeDir)
sortField: {
switch (sortBy) {
case "name":
return FolderListModel.Name;
case "size":
return FolderListModel.Size;
case "modified":
return FolderListModel.Time;
case "type":
return FolderListModel.Type;
default:
return FolderListModel.Name;
}
}
sortReversed: !sortAscending
}
QtObject {
id: keyboardController
property int totalItems: folderModel.count
property int gridColumns: viewMode === "list" ? 1 : Math.max(1, actualGridColumns)
function handleKey(event) {
if (event.key === Qt.Key_Escape && root.closeOnEscape) {
closeRequested();
event.accepted = true;
return;
}
if (event.key === Qt.Key_F10) {
showKeyboardHints = !showKeyboardHints;
event.accepted = true;
return;
}
if (event.key === Qt.Key_F1 || event.key === Qt.Key_I) {
showFileInfo = !showFileInfo;
event.accepted = true;
return;
}
if ((event.modifiers & Qt.AltModifier && event.key === Qt.Key_Left) || event.key === Qt.Key_Backspace) {
if (currentPath !== homeDir) {
navigateUp();
event.accepted = true;
}
return;
}
if (!keyboardNavigationActive) {
const isInitKey = event.key === Qt.Key_Tab || event.key === Qt.Key_Down || event.key === Qt.Key_Right || (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) || (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) || (event.key === Qt.Key_L && event.modifiers & Qt.ControlModifier);
if (isInitKey) {
keyboardNavigationActive = true;
if (currentPath !== homeDir) {
backButtonFocused = true;
selectedIndex = -1;
} else {
backButtonFocused = false;
selectedIndex = 0;
}
event.accepted = true;
}
return;
}
switch (event.key) {
case Qt.Key_Tab:
if (backButtonFocused) {
backButtonFocused = false;
selectedIndex = 0;
} else if (selectedIndex < totalItems - 1) {
selectedIndex++;
} else if (currentPath !== homeDir) {
backButtonFocused = true;
selectedIndex = -1;
} else {
selectedIndex = 0;
}
event.accepted = true;
break;
case Qt.Key_Backtab:
if (backButtonFocused) {
backButtonFocused = false;
selectedIndex = totalItems - 1;
} else if (selectedIndex > 0) {
selectedIndex--;
} else if (currentPath !== homeDir) {
backButtonFocused = true;
selectedIndex = -1;
} else {
selectedIndex = totalItems - 1;
}
event.accepted = true;
break;
case Qt.Key_N:
if (event.modifiers & Qt.ControlModifier) {
if (backButtonFocused) {
backButtonFocused = false;
selectedIndex = 0;
} else if (selectedIndex < totalItems - 1) {
selectedIndex++;
}
event.accepted = true;
}
break;
case Qt.Key_P:
if (event.modifiers & Qt.ControlModifier) {
if (selectedIndex > 0) {
selectedIndex--;
} else if (currentPath !== homeDir) {
backButtonFocused = true;
selectedIndex = -1;
}
event.accepted = true;
}
break;
case Qt.Key_J:
if (event.modifiers & Qt.ControlModifier) {
if (selectedIndex < totalItems - 1) {
selectedIndex++;
}
event.accepted = true;
}
break;
case Qt.Key_K:
if (event.modifiers & Qt.ControlModifier) {
if (selectedIndex > 0) {
selectedIndex--;
} else if (currentPath !== homeDir) {
backButtonFocused = true;
selectedIndex = -1;
}
event.accepted = true;
}
break;
case Qt.Key_H:
if (event.modifiers & Qt.ControlModifier) {
if (!backButtonFocused && selectedIndex > 0) {
selectedIndex--;
} else if (currentPath !== homeDir) {
backButtonFocused = true;
selectedIndex = -1;
}
event.accepted = true;
}
break;
case Qt.Key_L:
if (event.modifiers & Qt.ControlModifier) {
if (backButtonFocused) {
backButtonFocused = false;
selectedIndex = 0;
} else if (selectedIndex < totalItems - 1) {
selectedIndex++;
}
event.accepted = true;
}
break;
case Qt.Key_Left:
if (pathInputHasFocus)
return;
if (backButtonFocused)
return;
if (selectedIndex > 0) {
selectedIndex--;
} else if (currentPath !== homeDir) {
backButtonFocused = true;
selectedIndex = -1;
}
event.accepted = true;
break;
case Qt.Key_Right:
if (pathInputHasFocus)
return;
if (backButtonFocused) {
backButtonFocused = false;
selectedIndex = 0;
} else if (selectedIndex < totalItems - 1) {
selectedIndex++;
}
event.accepted = true;
break;
case Qt.Key_Up:
if (backButtonFocused) {
backButtonFocused = false;
if (gridColumns === 1) {
selectedIndex = 0;
} else {
var col = selectedIndex % gridColumns;
selectedIndex = Math.min(col, totalItems - 1);
}
} else if (selectedIndex >= gridColumns) {
selectedIndex -= gridColumns;
} else if (selectedIndex > 0 && gridColumns === 1) {
selectedIndex--;
} else if (currentPath !== homeDir) {
backButtonFocused = true;
selectedIndex = -1;
}
event.accepted = true;
break;
case Qt.Key_Down:
if (backButtonFocused) {
backButtonFocused = false;
selectedIndex = 0;
} else if (gridColumns === 1) {
if (selectedIndex < totalItems - 1) {
selectedIndex++;
}
} else {
var newIndex = selectedIndex + gridColumns;
if (newIndex < totalItems) {
selectedIndex = newIndex;
} else {
var lastRowStart = Math.floor((totalItems - 1) / gridColumns) * gridColumns;
var col = selectedIndex % gridColumns;
var targetIndex = lastRowStart + col;
if (targetIndex < totalItems && targetIndex > selectedIndex) {
selectedIndex = targetIndex;
}
}
}
event.accepted = true;
break;
case Qt.Key_Return:
case Qt.Key_Enter:
case Qt.Key_Space:
if (backButtonFocused) {
navigateUp();
} else if (selectedIndex >= 0 && selectedIndex < totalItems) {
root.keyboardFileSelection(selectedIndex);
}
event.accepted = true;
break;
}
}
}
Timer {
id: keyboardSelectionTimer
property int targetIndex: -1
interval: 1
onTriggered: {
executeKeyboardSelection(targetIndex);
}
}
focus: true
Keys.onPressed: event => {
keyboardController.handleKey(event);
}
Column {
anchors.fill: parent
spacing: 0
Item {
width: parent.width
height: 48
MouseArea {
anchors.fill: parent
onPressed: if (windowControls)
windowControls.tryStartMove()
onDoubleClicked: if (windowControls)
windowControls.tryToggleMaximize()
}
Row {
spacing: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Theme.spacingL
DankIcon {
name: browserIcon
size: Theme.iconSizeLarge
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: browserTitle
font.pixelSize: Theme.fontSizeXLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankActionButton {
circular: false
iconName: showHiddenFiles ? "visibility_off" : "visibility"
iconSize: Theme.iconSize - 4
iconColor: showHiddenFiles ? Theme.primary : Theme.surfaceText
onClicked: showHiddenFiles = !showHiddenFiles
}
DankActionButton {
circular: false
iconName: viewMode === "grid" ? "view_list" : "grid_view"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: viewMode = viewMode === "grid" ? "list" : "grid"
}
DankActionButton {
circular: false
iconName: iconSizeIndex === 0 ? "photo_size_select_small" : iconSizeIndex === 1 ? "photo_size_select_large" : iconSizeIndex === 2 ? "photo_size_select_actual" : "zoom_in"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
visible: viewMode === "grid"
onClicked: iconSizeIndex = (iconSizeIndex + 1) % iconSizes.length
}
DankActionButton {
circular: false
iconName: "info"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.showKeyboardHints = !root.showKeyboardHints
}
DankActionButton {
visible: windowControls?.supported ?? false
circular: false
iconName: windowControls?.targetWindow?.maximized ? "fullscreen_exit" : "fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: if (windowControls)
windowControls.tryToggleMaximize()
}
DankActionButton {
circular: false
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.closeRequested()
}
}
}
StyledRect {
width: parent.width
height: 1
color: Theme.outline
}
Item {
width: parent.width
height: parent.height - 49
Row {
anchors.fill: parent
anchors.bottomMargin: root.saveMode ? 40 + Theme.spacingL * 2 : 0
spacing: 0
Row {
width: showSidebar ? 201 : 0
height: parent.height
spacing: 0
visible: showSidebar
FileBrowserSidebar {
height: parent.height
quickAccessLocations: root.quickAccessLocations
currentPath: root.currentPath
onLocationSelected: path => navigateTo(path)
}
StyledRect {
width: 1
height: parent.height
color: Theme.outline
}
}
Column {
width: parent.width - (showSidebar ? 201 : 0)
height: parent.height
spacing: 0
FileBrowserNavigation {
width: parent.width
currentPath: root.currentPath
homeDir: root.homeDir
backButtonFocused: root.backButtonFocused
keyboardNavigationActive: root.keyboardNavigationActive
showSidebar: root.showSidebar
pathEditMode: root.pathEditMode
onNavigateUp: root.navigateUp()
onNavigateTo: path => root.navigateTo(path)
onPathInputFocusChanged: hasFocus => {
root.pathInputHasFocus = hasFocus;
if (hasFocus) {
root.pathEditMode = true;
}
}
}
StyledRect {
width: parent.width
height: 1
color: Theme.outline
}
Item {
id: gridContainer
width: parent.width
height: parent.height - 41
clip: true
property real gridCellWidth: iconSizes[iconSizeIndex] + 24
property real gridCellHeight: iconSizes[iconSizeIndex] + 56
property real availableGridWidth: width - Theme.spacingM * 2
property int gridColumns: Math.max(1, Math.floor(availableGridWidth / gridCellWidth))
property real gridLeftMargin: Theme.spacingM + Math.max(0, (availableGridWidth - (gridColumns * gridCellWidth)) / 2)
onGridColumnsChanged: {
root.actualGridColumns = gridColumns;
}
Component.onCompleted: {
root.actualGridColumns = gridColumns;
}
DankGridView {
id: fileGrid
anchors.fill: parent
anchors.leftMargin: gridContainer.gridLeftMargin
anchors.rightMargin: Theme.spacingM
anchors.topMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
visible: viewMode === "grid"
cellWidth: gridContainer.gridCellWidth
cellHeight: gridContainer.gridCellHeight
cacheBuffer: 260
model: folderModel
currentIndex: selectedIndex
onCurrentIndexChanged: {
if (keyboardNavigationActive && currentIndex >= 0)
positionViewAtIndex(currentIndex, GridView.Contain);
}
ScrollBar.vertical: DankScrollbar {
id: gridScrollbar
}
ScrollBar.horizontal: DankScrollbar {
policy: ScrollBar.AlwaysOff
}
delegate: FileBrowserGridDelegate {
iconSizes: root.iconSizes
iconSizeIndex: root.iconSizeIndex
selectedIndex: root.selectedIndex
keyboardNavigationActive: root.keyboardNavigationActive
onItemClicked: (index, path, name, isDir) => {
selectedIndex = index;
setSelectedFileData(path, name, isDir);
root.activateFile(path, name, isDir);
}
onItemSelected: (index, path, name, isDir) => {
setSelectedFileData(path, name, isDir);
}
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
}
Connections {
function onKeyboardSelectionRequestedChanged() {
if (root.keyboardSelectionRequested && root.keyboardSelectionIndex === index) {
root.keyboardSelectionRequested = false;
selectedIndex = index;
setSelectedFileData(filePath, fileName, fileIsDir);
root.activateFile(filePath, fileName, fileIsDir);
}
}
target: root
}
}
}
DankListView {
id: fileList
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.topMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
visible: viewMode === "list"
spacing: Theme.spacingXXS
model: folderModel
currentIndex: selectedIndex
onCurrentIndexChanged: {
if (keyboardNavigationActive && currentIndex >= 0)
positionViewAtIndex(currentIndex, ListView.Contain);
}
ScrollBar.vertical: DankScrollbar {
id: listScrollbar
}
delegate: FileBrowserListDelegate {
width: fileList.width
selectedIndex: root.selectedIndex
keyboardNavigationActive: root.keyboardNavigationActive
onItemClicked: (index, path, name, isDir) => {
selectedIndex = index;
setSelectedFileData(path, name, isDir);
root.activateFile(path, name, isDir);
}
onItemSelected: (index, path, name, isDir) => {
setSelectedFileData(path, name, isDir);
}
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
}
Connections {
function onKeyboardSelectionRequestedChanged() {
if (root.keyboardSelectionRequested && root.keyboardSelectionIndex === index) {
root.keyboardSelectionRequested = false;
selectedIndex = index;
setSelectedFileData(filePath, fileName, fileIsDir);
root.activateFile(filePath, fileName, fileIsDir);
}
}
target: root
}
}
}
}
}
}
FileBrowserSaveRow {
id: saveRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
saveMode: root.saveMode
defaultFileName: root.defaultFileName
currentPath: root.currentPath
onSaveRequested: filePath => handleSaveFile(filePath)
}
KeyboardHints {
id: keyboardHints
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
showHints: root.showKeyboardHints
}
FileInfo {
id: fileInfo
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Theme.spacingL
width: 300
showFileInfo: root.showFileInfo
selectedIndex: root.selectedIndex
sourceFolderModel: folderModel
currentPath: root.currentPath
currentFileName: root.selectedFileName
currentFileIsDir: root.selectedFileIsDir
currentFileExtension: {
if (root.selectedFileIsDir || !root.selectedFileName)
return "";
var lastDot = root.selectedFileName.lastIndexOf('.');
return lastDot > 0 ? root.selectedFileName.substring(lastDot + 1).toLowerCase() : "";
}
}
FileBrowserSortMenu {
id: sortMenu
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 120
anchors.rightMargin: Theme.spacingL
sortBy: root.sortBy
sortAscending: root.sortAscending
onSortBySelected: value => {
root.sortBy = value;
}
onSortOrderSelected: ascending => {
root.sortAscending = ascending;
}
}
}
}
FileBrowserOverwriteDialog {
anchors.fill: parent
showDialog: showOverwriteConfirmation
pendingFilePath: root.pendingFilePath
onConfirmed: filePath => {
showOverwriteConfirmation = false;
fileSelected(filePath);
pendingFilePath = "";
Qt.callLater(() => root.closeRequested());
}
onCancelled: {
showOverwriteConfirmation = false;
pendingFilePath = "";
}
}
FileBrowserItemContextMenu {
id: itemContextMenu
parentFocusItem: root
}
}
@@ -1,252 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import Quickshell.Widgets
import qs.Common
import qs.Widgets
StyledRect { DankCommon.FileBrowserGridDelegate {}
id: delegateRoot
required property bool fileIsDir
required property string filePath
required property string fileName
required property int index
property bool weMode: false
property var iconSizes: [80, 120, 160, 200]
property int iconSizeIndex: 1
property int selectedIndex: -1
property bool keyboardNavigationActive: false
signal itemClicked(int index, string path, string name, bool isDir)
signal itemSelected(int index, string path, string name, bool isDir)
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
function getFileExtension(fileName) {
const parts = fileName.split('.');
if (parts.length > 1) {
return parts[parts.length - 1].toLowerCase();
}
return "";
}
function determineFileType(fileName) {
const ext = getFileExtension(fileName);
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico", "jxl", "avif", "heif", "exr"];
if (imageExts.includes(ext)) {
return "image";
}
const videoExts = ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v"];
if (videoExts.includes(ext)) {
return "video";
}
const audioExts = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "wma"];
if (audioExts.includes(ext)) {
return "audio";
}
const codeExts = ["js", "ts", "jsx", "tsx", "py", "go", "rs", "c", "cpp", "h", "java", "kt", "swift", "rb", "php", "html", "css", "scss", "json", "xml", "yaml", "yml", "toml", "sh", "bash", "zsh", "fish", "qml", "vue", "svelte"];
if (codeExts.includes(ext)) {
return "code";
}
const docExts = ["txt", "md", "pdf", "doc", "docx", "odt", "rtf"];
if (docExts.includes(ext)) {
return "document";
}
const archiveExts = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"];
if (archiveExts.includes(ext)) {
return "archive";
}
if (!ext || fileName.indexOf('.') === -1) {
return "binary";
}
return "file";
}
function isImageFile(fileName) {
if (!fileName) {
return false;
}
return determineFileType(fileName) === "image";
}
function isVideoFile(fileName) {
if (!fileName) {
return false;
}
return determineFileType(fileName) === "video";
}
property bool isImage: isImageFile(delegateRoot.fileName)
property bool isVideo: isVideoFile(delegateRoot.fileName)
property string _xdgCacheHome: Paths.strip(Paths.xdgCache)
property string _thumbnailSize: iconSizeIndex >= 2 ? "x-large" : "large"
property int _thumbnailPx: iconSizeIndex >= 2 ? 512 : 256
property string videoThumbnailPath: {
if (!delegateRoot.fileIsDir && isVideo) {
const hash = Qt.md5("file://" + delegateRoot.filePath);
return _xdgCacheHome + "/thumbnails/" + _thumbnailSize + "/" + hash + ".png";
}
return "";
}
property string _videoThumb: ""
property bool _thumbGenAttempted: false
// Probe the thumbnail optimistically; Image.Error triggers generation
onVideoThumbnailPathChanged: {
_thumbGenAttempted = false;
_videoThumb = videoThumbnailPath;
}
function generateVideoThumbnail() {
if (_thumbGenAttempted)
return;
_thumbGenAttempted = true;
_videoThumb = "";
const thumbPath = videoThumbnailPath;
const thumbDir = _xdgCacheHome + "/thumbnails/" + _thumbnailSize;
const script = "mkdir -p \"$1\" && ffmpegthumbnailer -i \"$2\" -o \"$3\" -s " + _thumbnailPx + " -f";
Proc.runCommand(null, ["sh", "-c", script, "thumb", thumbDir, delegateRoot.filePath, thumbPath], function (output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
});
}
function getIconForFile(fileName) {
const lowerName = fileName.toLowerCase();
if (lowerName.startsWith("dockerfile")) {
return "docker";
}
const ext = fileName.split('.').pop();
return ext || "";
}
width: weMode ? 245 : iconSizes[iconSizeIndex] + 16
height: weMode ? 205 : iconSizes[iconSizeIndex] + 48
radius: Theme.cornerRadius
color: {
if (keyboardNavigationActive && delegateRoot.index === selectedIndex)
return Theme.surfacePressed;
return mouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, 0);
}
border.color: keyboardNavigationActive && delegateRoot.index === selectedIndex ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: (keyboardNavigationActive && delegateRoot.index === selectedIndex) ? 2 : 0
Component.onCompleted: {
if (keyboardNavigationActive && delegateRoot.index === selectedIndex)
itemSelected(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
}
onSelectedIndexChanged: {
if (keyboardNavigationActive && selectedIndex === delegateRoot.index)
itemSelected(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
}
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
Item {
width: weMode ? 225 : (iconSizes[iconSizeIndex] - 8)
height: weMode ? 165 : (iconSizes[iconSizeIndex] - 8)
anchors.horizontalCenter: parent.horizontalCenter
ClippingRectangle {
anchors.fill: parent
anchors.margins: 2
radius: Theme.cornerRadius
color: "transparent"
Image {
id: gridPreviewImage
anchors.fill: parent
property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga", ".jxl", ".avif", ".heif", ".exr"]
property int weExtIndex: 0
property string imagePath: {
if (weMode && delegateRoot.fileIsDir)
return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex];
if (_videoThumb)
return _videoThumb;
return "";
}
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
onStatusChanged: {
if (status !== Image.Error)
return;
if (weMode && delegateRoot.fileIsDir) {
if (weExtIndex < weExtensions.length - 1) {
weExtIndex++;
} else {
imagePath = "";
}
return;
}
if (_videoThumb)
generateVideoThumbnail();
}
fillMode: Image.PreserveAspectCrop
sourceSize.width: weMode ? 225 : iconSizes[iconSizeIndex]
sourceSize.height: weMode ? 225 : iconSizes[iconSizeIndex]
asynchronous: true
visible: status === Image.Ready && ((!delegateRoot.fileIsDir && isVideo) || (weMode && delegateRoot.fileIsDir))
}
CachingImage {
anchors.fill: parent
imagePath: !delegateRoot.fileIsDir && isImage ? delegateRoot.filePath : ""
maxCacheSize: 256
animate: false
visible: !delegateRoot.fileIsDir && isImage
}
}
DankNFIcon {
anchors.centerIn: parent
name: delegateRoot.fileIsDir ? "folder" : getIconForFile(delegateRoot.fileName)
size: iconSizes[iconSizeIndex] * 0.45
color: delegateRoot.fileIsDir ? Theme.primary : Theme.surfaceText
visible: (!delegateRoot.fileIsDir && !isImage && !(isVideo && gridPreviewImage.status === Image.Ready)) || (delegateRoot.fileIsDir && !weMode)
}
}
StyledText {
text: delegateRoot.fileName || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
width: delegateRoot.width - Theme.spacingM
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
maximumLineCount: 2
wrapMode: Text.Wrap
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
switch (mouse.button) {
case Qt.LeftButton:
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
break;
case Qt.RightButton:
itemContextMenuRequested(delegateRoot, mouse.x, mouse.y, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
break;
}
}
}
}
@@ -1,153 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Popup { DankCommon.FileBrowserItemContextMenu {}
id: root
property string filePath: ""
property string fileName: ""
property bool fileIsDir: false
property var parentFocusItem: null
signal trashed
signal menuClosed
readonly property var menuItems: [
{
text: I18n.tr("Move to Trash"),
icon: "delete",
action: trashItem,
enabled: filePath.length > 0,
dangerous: true
},
{
text: I18n.tr("Copy path"),
icon: "content_copy",
action: copyPath,
enabled: filePath.length > 0
}
]
function showAt(parentItem, localX, localY, path, name, isDir) {
if (!parentItem)
return;
parent = parentItem;
filePath = path || "";
fileName = name || "";
fileIsDir = !!isDir;
x = Math.max(0, Math.min(parentItem.width - width, localX));
y = Math.max(0, Math.min(parentItem.height - height, localY));
open();
}
function trashItem() {
if (!filePath)
return;
TrashService.trashPath(filePath, ok => {
if (ok)
root.trashed();
});
close();
}
function copyPath() {
if (!filePath)
return;
Quickshell.execDetached(["dms", "cl", "copy", filePath]);
close();
}
width: 220
height: menuColumn.implicitHeight + Theme.spacingS * 2
padding: 0
modal: false
closePolicy: Popup.CloseOnEscape
onClosed: {
closePolicy = Popup.CloseOnEscape;
menuClosed();
if (parentFocusItem)
Qt.callLater(() => parentFocusItem.forceActiveFocus());
}
onOpened: outsideClickTimer.start()
Timer {
id: outsideClickTimer
interval: 100
onTriggered: root.closePolicy = Popup.CloseOnEscape | Popup.CloseOnPressOutside
}
background: Rectangle {
color: "transparent"
}
contentItem: Rectangle {
color: Theme.floatingSurface
radius: Theme.cornerRadius
border.color: BlurService.borderColor
border.width: BlurService.borderWidth
Column {
id: menuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: 1
Repeater {
model: root.menuItems
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadius
opacity: modelData.enabled ? 1 : 0.5
color: {
if (!modelData.enabled || !area.containsMouse)
return "transparent";
if (modelData.dangerous)
return Theme.errorHover;
return BlurService.hoverColor(Theme.widgetBaseHoverColor);
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: modelData.icon
size: 16
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: modelData.text
font.pixelSize: Theme.fontSizeSmall
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
elide: Text.ElideRight
}
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
enabled: modelData.enabled
cursorShape: Qt.PointingHandCursor
onClicked: modelData.action()
}
}
}
}
}
}
@@ -1,256 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import Quickshell.Widgets
import qs.Common
import qs.Widgets
StyledRect { DankCommon.FileBrowserListDelegate {}
id: listDelegateRoot
required property bool fileIsDir
required property string filePath
required property string fileName
required property int index
required property var fileModified
required property int fileSize
property int selectedIndex: -1
property bool keyboardNavigationActive: false
signal itemClicked(int index, string path, string name, bool isDir)
signal itemSelected(int index, string path, string name, bool isDir)
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
function getFileExtension(fileName) {
const parts = fileName.split('.');
if (parts.length > 1) {
return parts[parts.length - 1].toLowerCase();
}
return "";
}
function determineFileType(fileName) {
const ext = getFileExtension(fileName);
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico", "jxl", "avif", "heif", "exr"];
if (imageExts.includes(ext)) {
return "image";
}
const videoExts = ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v"];
if (videoExts.includes(ext)) {
return "video";
}
const audioExts = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "wma"];
if (audioExts.includes(ext)) {
return "audio";
}
const codeExts = ["js", "ts", "jsx", "tsx", "py", "go", "rs", "c", "cpp", "h", "java", "kt", "swift", "rb", "php", "html", "css", "scss", "json", "xml", "yaml", "yml", "toml", "sh", "bash", "zsh", "fish", "qml", "vue", "svelte"];
if (codeExts.includes(ext)) {
return "code";
}
const docExts = ["txt", "md", "pdf", "doc", "docx", "odt", "rtf"];
if (docExts.includes(ext)) {
return "document";
}
const archiveExts = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"];
if (archiveExts.includes(ext)) {
return "archive";
}
if (!ext || fileName.indexOf('.') === -1) {
return "binary";
}
return "file";
}
function isImageFile(fileName) {
if (!fileName) {
return false;
}
return determineFileType(fileName) === "image";
}
function isVideoFile(fileName) {
if (!fileName) {
return false;
}
return determineFileType(fileName) === "video";
}
property bool isImage: isImageFile(listDelegateRoot.fileName)
property bool isVideo: isVideoFile(listDelegateRoot.fileName)
property string _xdgCacheHome: Paths.strip(Paths.xdgCache)
property string videoThumbnailPath: {
if (!listDelegateRoot.fileIsDir && isVideo) {
const hash = Qt.md5("file://" + listDelegateRoot.filePath);
return _xdgCacheHome + "/thumbnails/normal/" + hash + ".png";
}
return "";
}
property string _videoThumb: ""
property bool _thumbGenAttempted: false
// Probe the thumbnail optimistically; Image.Error triggers generation
onVideoThumbnailPathChanged: {
_thumbGenAttempted = false;
_videoThumb = videoThumbnailPath;
}
function generateVideoThumbnail() {
if (_thumbGenAttempted)
return;
_thumbGenAttempted = true;
_videoThumb = "";
const thumbPath = videoThumbnailPath;
const thumbDir = _xdgCacheHome + "/thumbnails/normal";
const script = "mkdir -p \"$1\" && ffmpegthumbnailer -i \"$2\" -o \"$3\" -s 128 -f";
Proc.runCommand(null, ["sh", "-c", script, "thumb", thumbDir, listDelegateRoot.filePath, thumbPath], function (output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
});
}
function getIconForFile(fileName) {
const lowerName = fileName.toLowerCase();
if (lowerName.startsWith("dockerfile")) {
return "docker";
}
const ext = fileName.split('.').pop();
return ext || "";
}
function formatFileSize(size) {
if (size < 1024)
return size + " B";
if (size < 1024 * 1024)
return (size / 1024).toFixed(1) + " KB";
if (size < 1024 * 1024 * 1024)
return (size / (1024 * 1024)).toFixed(1) + " MB";
return (size / (1024 * 1024 * 1024)).toFixed(1) + " GB";
}
height: 44
radius: Theme.cornerRadius
color: {
if (keyboardNavigationActive && listDelegateRoot.index === selectedIndex)
return Theme.surfacePressed;
return listMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, 0);
}
border.color: keyboardNavigationActive && listDelegateRoot.index === selectedIndex ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: (keyboardNavigationActive && listDelegateRoot.index === selectedIndex) ? 2 : 0
Component.onCompleted: {
if (keyboardNavigationActive && listDelegateRoot.index === selectedIndex)
itemSelected(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
}
onSelectedIndexChanged: {
if (keyboardNavigationActive && selectedIndex === listDelegateRoot.index)
itemSelected(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
}
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingS
Item {
width: 28
height: 28
anchors.verticalCenter: parent.verticalCenter
ClippingRectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: "transparent"
Image {
id: listPreviewImage
anchors.fill: parent
property string imagePath: _videoThumb
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
fillMode: Image.PreserveAspectCrop
sourceSize.width: 32
sourceSize.height: 32
asynchronous: true
visible: status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo
onStatusChanged: {
if (status === Image.Error && _videoThumb)
generateVideoThumbnail();
}
}
CachingImage {
anchors.fill: parent
imagePath: !listDelegateRoot.fileIsDir && isImage ? listDelegateRoot.filePath : ""
maxCacheSize: 256
animate: false
visible: !listDelegateRoot.fileIsDir && isImage
}
}
DankNFIcon {
anchors.centerIn: parent
name: listDelegateRoot.fileIsDir ? "folder" : getIconForFile(listDelegateRoot.fileName)
size: Theme.iconSize - 2
color: listDelegateRoot.fileIsDir ? Theme.primary : Theme.surfaceText
visible: listDelegateRoot.fileIsDir || (!isImage && !(isVideo && listPreviewImage.status === Image.Ready))
}
}
StyledText {
text: listDelegateRoot.fileName || ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
width: parent.width - 280
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
maximumLineCount: 1
clip: true
}
StyledText {
text: listDelegateRoot.fileIsDir ? "" : formatFileSize(listDelegateRoot.fileSize)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: 70
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: Qt.formatDateTime(listDelegateRoot.fileModified, "MMM d, yyyy h:mm AP")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: 140
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: listMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
switch (mouse.button) {
case Qt.LeftButton:
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
break;
case Qt.RightButton:
itemContextMenuRequested(listDelegateRoot, mouse.x, mouse.y, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
break;
}
}
}
}
@@ -1,100 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import Quickshell
import qs.Common
import qs.Widgets
FloatingWindow { DankCommon.FileBrowserModal {}
id: fileBrowserModal
property bool disablePopupTransparency: true
property string browserTitle: "Select File"
property string browserIcon: "folder_open"
property string browserType: "generic"
property var fileExtensions: ["*.*"]
property alias filterExtensions: fileBrowserModal.fileExtensions
property bool showHiddenFiles: false
property bool saveMode: false
property string defaultFileName: ""
property var parentModal: null
parentWindow: parentModal
property bool shouldHaveFocus: visible
property bool allowFocusOverride: false
property bool shouldBeVisible: visible
property bool allowStacking: true
signal fileSelected(string path)
signal dialogClosed
function open() {
visible = true;
}
function close() {
visible = false;
}
objectName: "fileBrowserModal"
title: "Files - " + browserTitle
minimumSize: Qt.size(500, 400)
implicitWidth: 800
implicitHeight: 600
color: Theme.surfaceContainer
visible: false
onClosed: close()
onVisibleChanged: {
if (visible) {
if (parentModal && "shouldHaveFocus" in parentModal) {
parentModal.shouldHaveFocus = false;
parentModal.allowFocusOverride = true;
}
Qt.callLater(() => {
if (content) {
content.reset();
content.forceActiveFocus();
}
});
} else {
if (parentModal && "allowFocusOverride" in parentModal) {
parentModal.allowFocusOverride = false;
parentModal.shouldHaveFocus = Qt.binding(() => parentModal.shouldBeVisible);
}
dialogClosed();
}
}
Loader {
id: contentLoader
anchors.fill: parent
active: fileBrowserModal.visible
sourceComponent: FileBrowserContent {
id: content
anchors.fill: parent
focus: true
closeOnEscape: false
windowControls: fileBrowserModal.windowControlsRef
browserTitle: fileBrowserModal.browserTitle
browserIcon: fileBrowserModal.browserIcon
browserType: fileBrowserModal.browserType
fileExtensions: fileBrowserModal.fileExtensions
showHiddenFiles: fileBrowserModal.showHiddenFiles
saveMode: fileBrowserModal.saveMode
defaultFileName: fileBrowserModal.defaultFileName
Component.onCompleted: initialize()
onFileSelected: path => fileBrowserModal.fileSelected(path)
onCloseRequested: fileBrowserModal.close()
}
}
property alias content: contentLoader.item
property alias windowControlsRef: windowControls
FloatingWindowControls {
id: windowControls
targetWindow: fileBrowserModal
}
}
@@ -1,130 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import qs.Common
import qs.Widgets
Row { DankCommon.FileBrowserNavigation {}
id: navigation
property string currentPath: ""
property string homeDir: ""
property bool backButtonFocused: false
property bool keyboardNavigationActive: false
property bool showSidebar: true
property bool pathEditMode: false
property bool pathInputHasFocus: false
signal navigateUp
signal navigateTo(string path)
signal pathInputFocusChanged(bool hasFocus)
height: 40
leftPadding: Theme.spacingM
rightPadding: Theme.spacingM
spacing: Theme.spacingS
StyledRect {
width: 32
height: 32
radius: Theme.cornerRadius
color: (backButtonMouseArea.containsMouse || (backButtonFocused && keyboardNavigationActive)) && currentPath !== homeDir ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
opacity: currentPath !== homeDir ? 1 : 0
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: "arrow_back"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
MouseArea {
id: backButtonMouseArea
anchors.fill: parent
hoverEnabled: currentPath !== homeDir
cursorShape: currentPath !== homeDir ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: currentPath !== homeDir
onClicked: navigation.navigateUp()
}
}
Item {
width: Math.max(0, (parent?.width ?? 0) - 40 - Theme.spacingS - (showSidebar ? 0 : 80))
height: 32
anchors.verticalCenter: parent.verticalCenter
StyledRect {
anchors.fill: parent
radius: Theme.cornerRadius
color: pathEditMode ? Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainer, 0)
border.color: pathEditMode ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: pathEditMode ? 1 : 0
visible: !pathEditMode
StyledText {
id: pathDisplay
text: currentPath.replace("file://", "")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
elide: Text.ElideMiddle
verticalAlignment: Text.AlignVCenter
maximumLineCount: 1
wrapMode: Text.NoWrap
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.IBeamCursor
onClicked: {
pathEditMode = true;
pathInput.text = currentPath.replace("file://", "");
Qt.callLater(() => pathInput.forceActiveFocus());
}
}
}
DankTextField {
id: pathInput
anchors.fill: parent
visible: pathEditMode
topPadding: Theme.spacingXS
bottomPadding: Theme.spacingXS
onAccepted: {
const newPath = text.trim();
if (newPath !== "") {
navigation.navigateTo(newPath);
}
pathEditMode = false;
}
Keys.onEscapePressed: {
pathEditMode = false;
}
Keys.onDownPressed: {
pathEditMode = false;
}
onActiveFocusChanged: {
navigation.pathInputFocusChanged(activeFocus);
if (!activeFocus && pathEditMode) {
pathEditMode = false;
}
}
}
}
Row {
spacing: Theme.spacingXS
visible: !showSidebar
anchors.verticalCenter: parent.verticalCenter
DankActionButton {
circular: false
iconName: "sort"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
}
}
}
@@ -1,127 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import qs.Common
import qs.Widgets
Item { DankCommon.FileBrowserOverwriteDialog {}
id: overwriteDialog
property bool showDialog: false
property string pendingFilePath: ""
signal confirmed(string filePath)
signal cancelled
visible: showDialog
focus: showDialog
Keys.onEscapePressed: {
cancelled();
}
Keys.onReturnPressed: {
confirmed(pendingFilePath);
}
Rectangle {
anchors.fill: parent
color: Theme.shadowStrong
opacity: 0.8
MouseArea {
anchors.fill: parent
onClicked: {
cancelled();
}
}
}
StyledRect {
anchors.centerIn: parent
width: 400
height: 160
color: Theme.surfaceContainer
radius: Theme.cornerRadius
border.color: Theme.outlineMedium
border.width: 1
Column {
anchors.centerIn: parent
width: parent.width - Theme.spacingL * 2
spacing: Theme.spacingM
StyledText {
text: I18n.tr("File Already Exists")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("A file with this name already exists. Do you want to overwrite it?")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingM
StyledRect {
width: 80
height: 36
radius: Theme.cornerRadius
color: cancelArea.containsMouse ? Qt.lighter(Theme.surfaceVariant, 1.2) : Theme.surfaceVariant
border.color: Theme.outline
border.width: 1
StyledText {
anchors.centerIn: parent
text: I18n.tr("Cancel")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
cancelled();
}
}
}
StyledRect {
width: 90
height: 36
radius: Theme.cornerRadius
color: overwriteArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
StyledText {
anchors.centerIn: parent
text: I18n.tr("Overwrite")
font.pixelSize: Theme.fontSizeMedium
color: Theme.background
font.weight: Font.Medium
}
MouseArea {
id: overwriteArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
confirmed(pendingFilePath);
}
}
}
}
}
}
}
@@ -1,75 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import qs.Common
import qs.Widgets
Row { DankCommon.FileBrowserSaveRow {}
id: saveRow
property bool saveMode: false
property string defaultFileName: ""
property string currentPath: ""
property alias fileName: fileNameInput.text
signal saveRequested(string filePath)
height: saveMode ? 40 : 0
visible: saveMode
spacing: Theme.spacingM
DankTextField {
id: fileNameInput
width: parent.width - saveButton.width - Theme.spacingM
height: 40
text: defaultFileName
placeholderText: I18n.tr("Enter filename...")
ignoreLeftRightKeys: false
focus: saveMode
topPadding: Theme.spacingS
bottomPadding: Theme.spacingS
Component.onCompleted: {
if (saveMode)
Qt.callLater(() => {
forceActiveFocus();
});
}
onAccepted: {
if (text.trim() !== "") {
var basePath = currentPath.replace(/^file:\/\//, '');
var fullPath = basePath + "/" + text.trim();
fullPath = fullPath.replace(/\/+/g, '/');
saveRequested(fullPath);
}
}
}
StyledRect {
id: saveButton
width: 80
height: 40
color: fileNameInput.text.trim() !== "" ? Theme.primary : Theme.surfaceVariant
radius: Theme.cornerRadius
StyledText {
anchors.centerIn: parent
text: I18n.tr("Save")
color: fileNameInput.text.trim() !== "" ? Theme.primaryText : Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeMedium
}
StateLayer {
stateColor: Theme.primary
cornerRadius: Theme.cornerRadius
enabled: fileNameInput.text.trim() !== ""
onClicked: {
if (fileNameInput.text.trim() !== "") {
var basePath = currentPath.replace(/^file:\/\//, '');
var fullPath = basePath + "/" + fileNameInput.text.trim();
fullPath = fullPath.replace(/\/+/g, '/');
saveRequested(fullPath);
}
}
}
}
}
@@ -1,70 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import qs.Common
import qs.Widgets
StyledRect { DankCommon.FileBrowserSidebar {}
id: sidebar
property var quickAccessLocations: []
property string currentPath: ""
signal locationSelected(string path)
width: 200
color: Theme.nestedSurface
clip: true
Column {
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Quick Access")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
font.weight: Font.Medium
leftPadding: Theme.spacingS
bottomPadding: Theme.spacingXS
}
Repeater {
model: quickAccessLocations
StyledRect {
width: parent?.width ?? 0
height: 38
radius: Theme.cornerRadius
color: quickAccessMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : (currentPath === modelData?.path ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0))
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
spacing: Theme.spacingS
DankIcon {
name: modelData?.icon ?? ""
size: Theme.iconSize - 2
color: currentPath === modelData?.path ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData?.name ?? ""
font.pixelSize: Theme.fontSizeMedium
color: currentPath === modelData?.path ? Theme.primary : Theme.surfaceText
font.weight: currentPath === modelData?.path ? Font.Medium : Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: quickAccessMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: locationSelected(modelData?.path ?? "")
}
}
}
}
}
@@ -1,189 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import qs.Common
import qs.Widgets
StyledRect { DankCommon.FileBrowserSortMenu {}
id: sortMenu
property string sortBy: "name"
property bool sortAscending: true
property color surfaceColor: Theme.surfaceContainer
signal sortBySelected(string value)
signal sortOrderSelected(bool ascending)
width: 200
height: sortColumn.height + Theme.spacingM * 2
color: surfaceColor
radius: Theme.cornerRadius
border.color: Theme.outlineMedium
border.width: 1
visible: false
z: 100
Column {
id: sortColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingM
spacing: Theme.spacingXS
StyledText {
text: "Sort By"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
font.weight: Font.Medium
}
Repeater {
model: [
{
"name": "Name",
"value": "name"
},
{
"name": "Size",
"value": "size"
},
{
"name": "Modified",
"value": "modified"
},
{
"name": "Type",
"value": "type"
}
]
StyledRect {
width: sortColumn?.width ?? 0
height: 32
radius: Theme.cornerRadius
color: sortMouseArea.containsMouse ? Theme.surfaceVariant : (sortBy === modelData?.value ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0))
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: sortBy === modelData?.value ? "check" : ""
size: Theme.iconSizeSmall
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
visible: sortBy === modelData?.value
}
StyledText {
text: modelData?.name ?? ""
font.pixelSize: Theme.fontSizeMedium
color: sortBy === modelData?.value ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: sortMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
sortMenu.sortBySelected(modelData?.value ?? "name");
sortMenu.visible = false;
}
}
}
}
StyledRect {
width: sortColumn.width
height: 1
color: Theme.outline
}
StyledText {
text: "Order"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
font.weight: Font.Medium
topPadding: Theme.spacingXS
}
StyledRect {
width: sortColumn?.width ?? 0
height: 32
radius: Theme.cornerRadius
color: ascMouseArea.containsMouse ? Theme.surfaceVariant : (sortAscending ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0))
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: "arrow_upward"
size: Theme.iconSizeSmall
color: sortAscending ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Ascending"
font.pixelSize: Theme.fontSizeMedium
color: sortAscending ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: ascMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
sortMenu.sortOrderSelected(true);
sortMenu.visible = false;
}
}
}
StyledRect {
width: sortColumn?.width ?? 0
height: 32
radius: Theme.cornerRadius
color: descMouseArea.containsMouse ? Theme.surfaceVariant : (!sortAscending ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0))
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: "arrow_downward"
size: Theme.iconSizeSmall
color: !sortAscending ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Descending"
font.pixelSize: Theme.fontSizeMedium
color: !sortAscending ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: descMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
sortMenu.sortOrderSelected(false);
sortMenu.visible = false;
}
}
}
}
}
+2 -235
View File
@@ -1,236 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import Quickshell.Io
import qs.Common
import qs.Widgets
Rectangle { DankCommon.FileInfo {}
id: root
property bool showFileInfo: false
property int selectedIndex: -1
property var sourceFolderModel: null
property string currentPath: ""
height: 200
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, 0.95)
border.color: Theme.secondary
border.width: 2
opacity: showFileInfo ? 1 : 0
z: 100
onShowFileInfoChanged: {
if (showFileInfo && currentFileName && currentPath) {
const fullPath = currentPath + "/" + currentFileName;
fileStatProcess.selectedFilePath = fullPath;
fileStatProcess.running = true;
}
}
Process {
id: fileStatProcess
command: ["stat", "-c", "%y|%A|%s|%n", selectedFilePath]
property string selectedFilePath: ""
property var fileStats: null
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
const parts = text.trim().split('|');
if (parts.length >= 4) {
fileStatProcess.fileStats = {
"modifiedTime": parts[0],
"permissions": parts[1],
"size": parseInt(parts[2]) || 0,
"fullPath": parts[3]
};
}
}
}
}
onExited: function (exitCode) {}
}
property string currentFileName: ""
property bool currentFileIsDir: false
property string currentFileExtension: ""
onCurrentFileNameChanged: {
if (showFileInfo && currentFileName && currentPath) {
const fullPath = currentPath + "/" + currentFileName;
if (fullPath !== fileStatProcess.selectedFilePath) {
fileStatProcess.selectedFilePath = fullPath;
fileStatProcess.running = true;
}
}
}
function updateFileInfo(filePath, fileName, isDirectory) {
if (filePath && filePath !== fileStatProcess.selectedFilePath) {
fileStatProcess.selectedFilePath = filePath;
currentFileName = fileName || "";
currentFileIsDir = isDirectory || false;
let ext = "";
if (!isDirectory && fileName) {
const lastDot = fileName.lastIndexOf('.');
if (lastDot > 0) {
ext = fileName.substring(lastDot + 1).toLowerCase();
}
}
currentFileExtension = ext;
if (showFileInfo) {
fileStatProcess.running = true;
}
}
}
readonly property var currentFileDisplayData: {
if (selectedIndex < 0 || !sourceFolderModel) {
return {
"exists": false,
"name": "No selection",
"type": "",
"size": "",
"modified": "",
"permissions": "",
"extension": "",
"position": "N/A"
};
}
const hasValidFile = currentFileName !== "";
return {
"exists": hasValidFile,
"name": hasValidFile ? currentFileName : "Loading...",
"type": currentFileIsDir ? "Directory" : "File",
"size": fileStatProcess.fileStats ? formatFileSize(fileStatProcess.fileStats.size) : "Calculating...",
"modified": fileStatProcess.fileStats ? formatDateTime(fileStatProcess.fileStats.modifiedTime) : "Loading...",
"permissions": fileStatProcess.fileStats ? fileStatProcess.fileStats.permissions : "Loading...",
"extension": currentFileExtension,
"position": sourceFolderModel ? ((selectedIndex + 1) + " of " + sourceFolderModel.count) : "N/A"
};
}
Column {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingM
spacing: Theme.spacingXS
Row {
width: parent.width
spacing: Theme.spacingS
DankIcon {
name: "info"
size: Theme.iconSize
color: Theme.secondary
}
StyledText {
text: I18n.tr("File Information")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: currentFileDisplayData.name
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
width: parent.width
elide: Text.ElideMiddle
wrapMode: Text.NoWrap
font.weight: Font.Medium
}
StyledText {
text: currentFileDisplayData.type + (currentFileDisplayData.extension ? " (." + currentFileDisplayData.extension + ")" : "")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: parent.width
}
StyledText {
text: currentFileDisplayData.size
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: parent.width
visible: currentFileDisplayData.exists && !currentFileIsDir
}
StyledText {
text: currentFileDisplayData.modified
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: parent.width
elide: Text.ElideRight
visible: currentFileDisplayData.exists
}
StyledText {
text: currentFileDisplayData.permissions
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
visible: currentFileDisplayData.exists
}
StyledText {
text: currentFileDisplayData.position
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: parent.width
}
}
}
StyledText {
text: I18n.tr("F1/I: Toggle • F10: Help")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingM
horizontalAlignment: Text.AlignHCenter
}
function formatFileSize(bytes) {
if (bytes === 0 || !bytes) {
return "0 B";
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDateTime(dateTimeString) {
if (!dateTimeString) {
return "Unknown";
}
const parts = dateTimeString.split(' ');
if (parts.length >= 2) {
return parts[0] + " " + parts[1].split('.')[0];
}
return dateTimeString;
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
@@ -1,50 +1,3 @@
import QtQuick import qs.DankCommon.Modals.FileBrowser as DankCommon
import qs.Common
import qs.Widgets
Rectangle { DankCommon.KeyboardHints {}
id: root
property bool showHints: false
height: 80
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, 0.95)
border.color: Theme.primary
border.width: 2
opacity: showHints ? 1 : 0
z: 100
Column {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingS
spacing: Theme.spacingXXS
StyledText {
text: I18n.tr("Tab/Shift+Tab: Nav • ←→↑↓: Grid Nav • Enter/Space: Select")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
StyledText {
text: I18n.tr("Alt+←/Backspace: Back • F1/I: File Info • F10: Help • Esc: Close")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
+1 -1
View File
@@ -294,7 +294,7 @@ FocusScope {
} }
StyledText { StyledText {
text: I18n.tr("Authentication failed, please try again") text: I18n.tr("Authentication failed - try again")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.error color: Theme.error
width: parent.width width: parent.width

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