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

Compare commits

...

115 Commits

Author SHA1 Message Date
purian23 d91481c464 fix(nix): restore pre-wrap Qt paths for launcher-spawned apps 2026-07-04 23:10:49 -04:00
bbedward dd7f4f3ac5 audio/bluez: update device aliases at runtime when renamed
fixes #1628
2026-07-04 23:00:22 -04:00
bbedward 3df801e3b2 keyboard layout: add support for sway
fixes #1681
2026-07-04 22:55:50 -04:00
purian23 74d220767d fix(greeter): update user cursor theme support
- Fixes #2054
2026-07-04 22:52:16 -04:00
bbedward a3fba8fcb6 matugen: make GTK4 live-refresh opt-in, off by default. Bump the settle
timeout
fixes #2552
2026-07-04 22:38:49 -04:00
purian23 33384e4d5d fix(niri): update custom keyboard shortcut parsing properties
Fixes #2187
2026-07-04 22:25:53 -04:00
purian23 78f2ea4642 fix(Pywalfox): update distro detection handling
- Fixes #2220
2026-07-04 22:17:55 -04:00
purian23 7dffe85e94 fix(iwd): update rfkill to allow backend management
- Fixes #2221
2026-07-04 21:57:27 -04:00
purian23 c5c3105469 fix(Bluetooth): update codec selection and error handling
Fixes #2240
2026-07-04 21:48:34 -04:00
bbedward 8f100960cf theme: fix gtk3 checkboxes in light mode
fixes #1555
2026-07-04 21:28:08 -04:00
bbedward 8a5e7c78fb dankbar: keep blur enabled at opacity-0 2026-07-04 20:57:07 -04:00
bbedward 8a85ab06a1 portal: prevent feedback loop with the gtk4 hack to make it react to
color scheme changes
2026-07-04 20:43:32 -04:00
bbedward 30ceda97a0 brightness: avoid targeting inactive panels
fixes #1127
2026-07-04 20:43:32 -04:00
purian23 fefc0afa6f fix(SystemTrayBar): improve menu position boundaries
- Fixes #2252
2026-07-04 20:24:59 -04:00
bbedward b01042d530 brightness/ddc: avoid re-probing already identified devices
related #2049
2026-07-04 20:12:27 -04:00
bbedward 7355cc54b2 display config: persist applied layout to auto profile and skip no-op re-apply on startup
related #2526
2026-07-04 20:03:11 -04:00
purian23 187b7ba8ff feat(Settings): add option to show icon for focused windows
- Closes #2122
2026-07-04 18:42:40 -04:00
purian23 c0e7c1b2b4 feat(Battery): add horizontal battery pill style option in widget settings 2026-07-04 18:41:52 -04:00
bbedward ea0c235d6e notepad: make dentry NoDisplay 2026-07-04 17:00:43 -04:00
bbedward 57a04cf409 i18n: add mechanism for blocking new terms 2026-07-04 16:51:28 -04:00
bbedward fd99558ce5 clipboard: implement virtual-keyboard-unstable-v1 to replace wtype for
pasting entries
2026-07-04 16:24:14 -04:00
purian23 81a4d3b4e0 feat(Notepad): add notepad desktop entry & IPC commands file handling 2026-07-04 16:10:56 -04:00
Scott McKendry bfda5f2d66 fix(lockscreen): clock off-center with single hour digit (#2745) 2026-07-04 15:18:34 -04:00
bbedward 65c4167ab2 display config: fix mixed ID/model identifiers
fixes #2736
2026-07-04 13:24:53 -04:00
bbedward e7bd8e4002 greeter: fix weather location sync
fixes #2303
2026-07-04 13:06:49 -04:00
bbedward 25a1b125b7 keyboard layout: add consistent helper to map layout codes to display
fixes #1849
2026-07-04 11:57:17 -04:00
bbedward d766c0773c theme: fix setting color mode when no wallpaper is set in auto mode
fixes #2239
2026-07-04 11:40:27 -04:00
Huỳnh Thiện Lộc 5d12962fd6 fix(clipboard): fix action buttons hover cursor shape (#2744) 2026-07-04 11:31:39 -04:00
bbedward 9911698237 lock: synchronize password inputs between displays and fix video
screensave/password focus
fixes #1933
2026-07-04 11:29:41 -04:00
Huỳnh Thiện Lộc 7e095415a7 refactor(battery): align status layout and add individual notification types (#2743)
* refactor(battery): restore status list layout and add individual notification types

* settings/battery: fix RTL display of top tablular-like view

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-07-04 11:20:44 -04:00
bbedward 68d5d7c5ed wallpaper: persist cycling state to disk
fixes #1010
2026-07-04 11:17:13 -04:00
Scott McKendry 7b1c542585 feat: lockscreen screen filter (#2740)
this makes use of the existing getFilteredScreens function to allow
users to specify which displays the lockscreen should appear on. prior
to this change, it was "all" or just one.
2026-07-04 11:04:59 -04:00
Kangheng Liu 52ed719489 fix: add missing vertical anchor for cava/music icon (#2742) 2026-07-04 10:58:55 -04:00
bbedward 6e508d416d core/wayland: fix fatal error handling when the socket becomes
unavailable
2026-07-04 10:52:52 -04:00
bbedward 518cc93276 weather: fix location search with han characters
fixes #2274
2026-07-04 10:48:08 -04:00
bbedward 8cb4e21716 animations: fix regression eliding button group text caused by recently
RTL fixes
fixes #2741
2026-07-04 10:44:02 -04:00
bbedward 19b44b1782 settings: make display config identify only show when interacting 2026-07-04 10:30:24 -04:00
purian23 0509694d78 fix(Notifications): update flexible image caching in DankCircularImage
- Fixes #2480
2026-07-04 01:26:56 -04:00
purian23 da44e07cf7 fix(International keyboards): update key mapping w/symbolic & shifted fallback support
- Fixes #2497
2026-07-04 01:13:49 -04:00
purian23 b34941e3b8 fix(keybinds): respect niri mod-key when capturing binds
Hardens #2224
2026-07-04 00:50:58 -04:00
purian23 c554d973ef fix(Hover): refactor & update hover tracking w/context menus
Fixes #2737
2026-07-04 00:08:58 -04:00
bbedward 2861cc89c6 idle: fix screensaver inhibit interfering with manually triggered
inhibitor
2026-07-03 23:27:19 -04:00
bbedward 1c5965f1e4 matugen: fix vscode text legibility on auto-complete menu
fixes #2198
2026-07-03 23:18:32 -04:00
bbedward 66d552b3d5 niri: re-work layer-rule to exclude bar for xray instead of using 2
rules
2026-07-03 23:13:25 -04:00
bbedward ebad8e2dda mango: add specific sorting for widgets using data from mango IPC
fixes #649
2026-07-03 23:11:34 -04:00
purian23 8dadf73d52 refactor: (modals/popouts): sync functions & improve blur calculations across modals 2026-07-03 22:20:10 -04:00
bbedward 9f77b3127c control center: fix brightness icon not showing up if no pinned devices
fixes #2488
2026-07-03 21:59:01 -04:00
bbedward 46f8457777 niri: make xdg pictures dir for screenshot, in case it doesnt exist
fixes #1577
2026-07-03 21:56:10 -04:00
Huỳnh Thiện Lộc 8465ed4311 feat(plugins): add update all CLI flag and settings updates dialog (#2682)
* feat(plugins): add update all CLI flag and settings update dialog

* feat(plugins): add comparison diff URL support and update button styling

* feat(plugins): skip system plugins in bulk CLI update

* fix(plugins): remove check shorthand to resolve conflict with config flag

* feat(plugins): inline update dialog, show version tags, restructure buttons
2026-07-03 21:51:39 -04:00
bbedward 6e3e178721 running apps: fix alignment of fallback icons in non-compact mode
fixes #1963
fixes #2024
2026-07-03 17:04:45 -04:00
bbedward 27230227ef tmux: fix new session hint
fixes #2556
2026-07-03 16:56:32 -04:00
purian23 7178563c2c fix(sway): correctly handle Sway named workspaces
- Fixes #2531
2026-07-03 16:33:44 -04:00
bbedward dfe309a543 dock: fix launcher button color override
fixes #2530
2026-07-03 16:29:06 -04:00
bbedward 04962ffed1 desktop widgets: fix rounding of system monitor graphs
fixes #2356
2026-07-03 15:50:06 -04:00
bbedward 5008406be8 display config: fix monitor names overflowing, add identity overlay to
ease configuration
fixes #1398
2026-07-03 15:45:58 -04:00
bbedward 930a36bcfc animations: add DankColorAnimation helper to properly animate between
colors with different alpha values
fixes #2720
2026-07-03 15:36:45 -04:00
bbedward 3253adb6dd workspace: fix display of named workspace + icon set on vertical bars
fixes #2067
2026-07-03 15:21:13 -04:00
purian23 b8fd52e00f fix(Notifications): update hasNotificationImage logic to support file paths
- Fixes #2533
2026-07-03 13:38:21 -04:00
bbedward 8e7126472e workspaces: fix hardcoded font weight to use configured value
fixes #2076
2026-07-03 12:10:45 -04:00
bbedward 61108b7668 i18n: numerous RTL layout fixes across DankDash and Settings 2026-07-03 11:57:09 -04:00
bbedward 09ca37c9fc workspace: fix occupied color in niri
fixes #2538
2026-07-03 11:21:50 -04:00
purian23 a2513e939a fix(NiriService): enhance column movement functions to focus on specified monitor
- Fixes #2498
2026-07-03 00:14:14 -04:00
purian23 a73380b310 fix(PowerMenu): defer keyboard hold actions to key release
- Fixes #2489
2026-07-03 00:02:35 -04:00
purian23 69d7c1fb13 fix(WorkspaceSwitcher): color handling w/animation transition
- Fixes #2720
2026-07-02 23:50:47 -04:00
bbedward d1eb655e46 theme: fix accent colors in light mode
fixes #2711
2026-07-02 23:38:14 -04:00
purian23 a3ecef7627 fix(PluginBrowser): simplify model binding for filtered plugins
- Fixes #2721
2026-07-02 23:01:23 -04:00
bbedward 186621ab81 lock: set LockedHint when locking with loginctl lock integration
- was previously read-only
fixes #2732
2026-07-02 22:57:09 -04:00
bbedward 31efe9b385 hyprland: fix named workspaces hiding from widget
fixes #2047
2026-07-02 22:49:09 -04:00
purian23 6cdb891551 feat(matugen): add color preview pallette & outline border option theme settings
- Closes #1738
2026-07-02 22:45:55 -04:00
bbedward 3bb3da228c dock: fix context menu overflow
fixes #2287
2026-07-02 22:12:02 -04:00
purian23 0c99e4b8d1 refactor(compositor): reduce unnecessary UI updates
- Defer updates when window state hasn't actually changed
- Cache frame-blocked state per screen instead of recalculating constantly
- keep popouts cache so reopening them is instant & clean up otherwise
2026-07-02 21:42:20 -04:00
bbedward 8b79d1dad3 notifications: fix blur on popout
fixes #2288
2026-07-02 21:32:10 -04:00
bbedward 85fa3326c1 blur: fix blur for goth corners in dankbar
fixes #2666
2026-07-02 20:27:42 -04:00
purian23 2fade1a617 fix(notepad): use surfaceVariantText color for line numbers 2026-07-02 17:14:32 -04:00
purian23 597ba597e5 feat(settings): implement greeter & lockscreen customization options
- New option to customize your font & wallpaper on the LockScreen
- New floating sync button if missed to explicitly sync on greeter changes
2026-07-02 17:13:18 -04:00
purian23 957020c9ea fix(nix): support declarative greeter auto-login (#2734) 2026-07-02 17:33:10 -03:00
bbedward ae520620ac idle: fix idle monitors firing when bar isnt visible + keep awake is
active
fixes #2698
2026-07-02 15:51:22 -04:00
bbedward 4c43bed2f8 lock: fix custom lock commands with lock before suspend and hibernate
fixes #2326
2026-07-02 15:41:05 -04:00
bbedward 2663bcd96c lock: add blinking cursor for text input
fixes #2316
2026-07-02 15:20:36 -04:00
purian23 e423e17807 fix(greeter): update NixOS declarative configuration
- Report NixOS greeter state from `var/lib/dms-greeter`
- Allows `dms greeter status` use
- Prevent imperative greeter changes on module-managed NixOS systems
- Preserve immutable-distro sync policy
- Closes #2728
2026-07-02 15:18:53 -04:00
bbedward 70fa0ec903 dankbar: fix widget registration during visibility changes
fixes #1956
2026-07-02 15:07:43 -04:00
bbedward db3615805c process list: use onClosed signal to call hide()
fixes #2560
2026-07-02 14:55:09 -04:00
bbedward 8162e60475 clock/calendar: toggle clock enabled/disabled on resume from suspend
fixes #1298
fixes #2705
2026-07-02 14:53:23 -04:00
purian23 5594ec27ff fix(xray): fix dbar autohide w/xray blur
Fixes #2733
2026-07-02 14:08:52 -04:00
bbedward ca050ace9c battery: clamp percentage to 100
fixes #1834
2026-07-02 13:35:26 -04:00
bbedward 093acdbff7 theme: sweep everything that isnt using a spacing token from singleton,
add Theme.XXS
2026-07-02 12:00:34 -04:00
Legend-017 a98417d66b fix(spotlight): select last query text on open; last-query persistence in connected frame mode (#2726)
* fix(spotlight): Fixes 'remember last query' being ignored in connected frame mode."

* fix(spotlight): select remembered query text instead of placing cursor at end
2026-07-02 11:39:35 -04:00
Youseffo13 83b9cdcb27 fix(ui): resolve ui inconsistencies and missing i18n strings (#2719)
* Update WorkspacesTab.qml

* Update SettingsButtonGroupRow.qml

* Update WallpaperTab.qml

* Update WidgetsTabSection.qml

* add missing tooltips

* Update WallpaperTab.qml

* Update WorkspacesTab.qml
2026-07-02 11:27:15 -04:00
purian23 4ae8ef927c refactor(launcher): invert framemode emerge from top position 2026-07-02 11:18:33 -04:00
bbedward 2128e6f14e compositor/xray: re-add manual bar xray toggle 2026-07-02 10:59:34 -04:00
bbedward 9ff085c46a niri/xray: make bar xray implicit, add conflict detection for
configuration
2026-07-02 10:05:23 -04:00
purian23 57d08f6b3b refactor(Xray): Update Xray & standalone to frame transitions
- Fix dbar autohide with Xray options that could have blocked bar area content
- Fixes #2729
2026-07-01 21:39:47 -04:00
purian23 7964b66827 feat(Xray): Automatic Xray blur effects for Niri & Hyprland layouts
- Utilizes include layout within niri configs & hyprland's layout layer rules for lua configs
2026-07-01 16:15:02 -04:00
bbedward 38a03a4485 hyprland: general focus fixes related to #2561 2026-06-30 23:27:25 -04:00
purian23 a1727d18fd refactor(transparency): stop overwriting dms settings buttons/sliders by default
- Fixes #2692
2026-06-30 17:01:49 -04:00
purian23 aabcef0c9c refactor(theme): decouple foreground layers dep on blur on/off 2026-06-30 16:57:49 -04:00
purian23 19406e99b9 refactor(include): Add missing layout include & normalize config banners
- The Display config process ID now includes the output name in NiriService
2026-06-30 14:48:09 -04:00
bbedward 5432d264f1 ci: add guards to workflows 2026-06-30 13:15:01 -04:00
bbedward d704a0ba3d wallpaper: migrate auto-cycling scheduling to backend 2026-06-30 13:11:29 -04:00
bbedward 161118122e launcher: fix hyprland focus on context menu
fixes #2714
2026-06-30 10:04:44 -04:00
bbedward 9d550d7c11 desktop widgets: ensure creation order based on configured order,
improve drag and drop behavior in settings
fixes #2715
2026-06-30 09:51:00 -04:00
bbedward e40faaa442 calendar: use surfaceVariantText for adjacent month day numbers 2026-06-29 16:14:41 -04:00
bbedward 894efa58a3 theme: large consolidation of colors into Theme.qml, add
surfaceContainerLow/Lowest
2026-06-29 15:50:42 -04:00
bbedward 50570023ff ui: cleanup some anti patterns 2026-06-29 12:39:56 -04:00
bbedward f293def3a8 welcome page: fix to not shell our to external dms IPC 2026-06-29 12:12:17 -04:00
Huỳnh Thiện Lộc de3e8cffa0 feat(mpris): allow excluding specific media players by identity (#2712)
* feat(mpris): allow excluding specific media players by identity

* chore(translation): update settings search index and clean trailing whitespace
2026-06-29 12:01:19 -04:00
bbedward a874f76d72 popout: fix texture sizing during animation 2026-06-29 08:21:21 -04:00
bbedward 424d4ed701 run pre-commit fixes 2026-06-29 08:01:38 -04:00
purian23 8f2a50c00e workflow(void): update the cron schedules 2026-06-28 21:23:07 -04:00
purian23 46cddb965f distro(Void): Require dbus by default 2026-06-28 21:17:11 -04:00
purian23 1ca64abad9 void: remove dbl dms-git transform 2026-06-28 20:44:17 -04:00
purian23 32d68a034f void: automate XBPS key registration 2026-06-28 20:36:15 -04:00
purian23 1a176920d4 distros(Void): refactor to read danklinux repos for local builds 2026-06-28 20:10:49 -04:00
purian23 afa6fd9e2e distros(Void): Prep DMS to host Void packages 2026-06-28 19:54:24 -04:00
purian23 6bee1b2c86 feat(HoverMode): implement hover popout & launcher functionality in all modes
- New Hover toggle found in DankBar Settings
- New Hover to Reveal Launcher in FrameTab Settings
2026-06-27 22:47:38 -04:00
473 changed files with 26803 additions and 9363 deletions
+1
View File
@@ -24,6 +24,7 @@ jobs:
check-updates: check-updates:
name: Check for updates name: Check for updates
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs: outputs:
has_updates: ${{ steps.check.outputs.has_updates }} has_updates: ${{ steps.check.outputs.has_updates }}
+1
View File
@@ -24,6 +24,7 @@ jobs:
check-updates: check-updates:
name: Check package/series updates name: Check package/series updates
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs: outputs:
has_updates: ${{ steps.check.outputs.has_updates }} has_updates: ${{ steps.check.outputs.has_updates }}
+268
View File
@@ -0,0 +1,268 @@
name: Void Linux XBPS Repository
on:
schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
release:
types: [published]
workflow_dispatch:
inputs:
force_rebuild:
description: 'Force rebuilding packages even if they already exist in the repository'
type: boolean
required: false
default: false
build_git:
description: 'Build dms-git package'
type: boolean
required: false
default: true
build_dms:
description: 'Build stable dms package'
type: boolean
required: false
default: true
build_greeter:
description: 'Build stable dms-greeter package'
type: boolean
required: false
default: true
permissions:
contents: write
jobs:
build-and-deploy:
name: Build & Deploy XBPS packages
runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Checkout gh-pages branch
run: |
git clone --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages-repo || {
echo "⚠️ gh-pages branch not found or empty, initializing a new one..."
mkdir gh-pages-repo
cd gh-pages-repo
git init
git checkout -b gh-pages
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
cd ..
}
- name: Install XBPS Static Utilities
run: |
mkdir -p ${{ github.workspace }}/xbps
curl -s -L https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz | tar -xJ -C ${{ github.workspace }}/xbps
echo "${{ github.workspace }}/xbps/usr/bin" >> $GITHUB_PATH
- name: Clone void-packages
run: |
git clone --depth=1 https://github.com/void-linux/void-packages.git
- name: Inject templates
run: |
cp -R distro/void/srcpkgs/dms void-packages/srcpkgs/
cp -R distro/void/srcpkgs/dms-greeter void-packages/srcpkgs/
cp -R distro/void/srcpkgs/dms-git void-packages/srcpkgs/
- name: Enable unprivileged user namespaces (Ubuntu 24.04)
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- name: Bootstrap xbps-src
run: |
cd void-packages
./xbps-src binary-bootstrap
- name: Configure signing keys and trust
run: |
# Write private key; extract and register the public key
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
chmod 600 /tmp/xbps_privkey.pem
# Extract public key in PEM format
openssl rsa -in /tmp/xbps_privkey.pem -pubout -out /tmp/dms-key.pub
rm -f /tmp/xbps_privkey.pem
# Compute MD5 fingerprint in colon-separated hex format
FINGERPRINT=$(openssl rsa -pubin -in /tmp/dms-key.pub -outform DER 2>/dev/null | openssl dgst -md5 -c | tr '[:upper:]' '[:lower:]' | awk '{print $NF}')
# Format key in XML property list (plist) format as expected by xbps
mkdir -p /tmp/keys
cat <<EOF > "/tmp/keys/${FINGERPRINT}.plist"
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>public-key</key>
<data>$(base64 -w0 /tmp/dms-key.pub)</data>
<key>public-key-size</key>
<integer>4096</integer>
<key>signature-by</key>
<string>AvengeMedia</string>
</dict>
</plist>
EOF
# Copy keys to all host and chroot trust stores
sudo mkdir -p /var/db/xbps/keys
sudo cp "/tmp/keys/${FINGERPRINT}.plist" "/var/db/xbps/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/masterdir/var/db/xbps/keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/masterdir/var/db/xbps/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/etc/xbps.d/keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/etc/xbps.d/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/common/repo-keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/common/repo-keys/${FINGERPRINT}.plist"
rm -rf /tmp/keys /tmp/dms-key.pub
- name: Configure repositories
run: |
# Append the repository to repos-remote templates so xbps-src translates it automatically
echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
# Add any existing compiled packages to the build cache directory to avoid rebuilds
if [ -d "gh-pages-repo/current" ]; then
mkdir -p void-packages/hostdir/binpkgs
cp -L gh-pages-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
fi
- name: Build packages
run: |
mkdir -p gh-pages-repo/current
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
BUILD_GIT="${{ github.event.inputs.build_git || 'true' }}"
FORCE_REBUILD="${{ github.event.inputs.force_rebuild || 'false' }}"
if [ "${{ github.event_name }}" = "schedule" ]; then
BUILD_DMS="false"
BUILD_GREETER="false"
BUILD_GIT="true"
fi
if [ "${{ github.event_name }}" = "release" ]; then
BUILD_DMS="true"
BUILD_GREETER="true"
BUILD_GIT="false"
fi
echo "=== Starting Builds ==="
echo "DMS stable build enabled: $BUILD_DMS"
echo "Greeter stable build enabled: $BUILD_GREETER"
echo "Git build enabled: $BUILD_GIT"
echo "Force rebuild: $FORCE_REBUILD"
cd void-packages
# 1. Build dms-git (development package)
if [ "$BUILD_GIT" = "true" ]; then
# Calculate dynamic git version (tag.commits.hash)
GIT_VER=$(git -C .. describe --tags --always | sed 's/^v//; s/-/./g')
echo "🔨 Preparing dms-git version $GIT_VER"
# Stage source tarball in the xbps-src source cache.
# --transform adds a top-level directory so xbps-src can extract
# into $wrksrc (create_wrksrc=yes handles the rest).
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
mkdir -p "$SRC_CACHE"
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
--exclude=void-packages \
--exclude=gh-pages-repo \
--exclude=.git \
--exclude=danklinux \
-C .. .
CHECKSUM=$(sha256sum "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" | cut -d' ' -f1)
# Dynamically patch template version, checksum, and distfiles
sed -i "s/^version=.*/version=${GIT_VER}/" srcpkgs/dms-git/template
sed -i "s/^checksum=.*/checksum=${CHECKSUM}/" srcpkgs/dms-git/template
sed -i "s|^distfiles=.*|distfiles=\"dms-git-${GIT_VER}.tar.gz\"|" srcpkgs/dms-git/template
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
if [ -f "../gh-pages-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
else
echo "🔨 Compiling dms-git..."
./xbps-src pkg dms-git
rm -f "../gh-pages-repo/current/${EXPECTED_GIT_FILE}"
cp -L hostdir/binpkgs/dms-git-*.xbps ../gh-pages-repo/current/
fi
fi
# 2. Build stable dms package
if [ "$BUILD_DMS" = "true" ]; then
STABLE_VER=$(grep -E '^version=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
if [ -f "../gh-pages-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
else
echo "🔨 Compiling dms ($STABLE_VER)..."
./xbps-src pkg dms
rm -f "../gh-pages-repo/current/${EXPECTED_DMS_FILE}"
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../gh-pages-repo/current/
fi
fi
# 3. Build stable dms-greeter package
if [ "$BUILD_GREETER" = "true" ]; then
GREETER_VER=$(grep -E '^version=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
if [ -f "../gh-pages-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build."
else
echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
./xbps-src pkg dms-greeter
rm -f "../gh-pages-repo/current/${EXPECTED_GREETER_FILE}"
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../gh-pages-repo/current/
fi
fi
- name: Index and sign repository
run: |
cd gh-pages-repo/current
# Clean up any stale or dangling signature files to prevent O_CREAT ENOENT errors
rm -f *.sig2 *.sig
# Guard: nothing to index if no .xbps files exist
if ! ls *.xbps 1>/dev/null 2>&1; then
echo "⚠️ No .xbps files found to index, skipping."
exit 0
fi
# Regenerate repo index
xbps-rindex -a $(pwd)/*.xbps
# Sign repository
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
chmod 600 /tmp/xbps_privkey.pem
xbps-rindex --sign --signedby "AvengeMedia <AvengeMedia.US@gmail.com>" --privkey /tmp/xbps_privkey.pem $(pwd)
xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps
rm -f /tmp/xbps_privkey.pem
- name: Deploy to gh-pages branch
run: |
cd gh-pages-repo
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add current/
git diff --quiet && git diff --staged --quiet || (git commit -m "Update XBPS packages [skip ci]" && git push origin gh-pages)
+3
View File
@@ -110,6 +110,9 @@ bin/
# Core dumps # Core dumps
core.* core.*
# prek-installed local git hooks (generated from .pre-commit-config.yaml)
.githooks/
# direnv # direnv
.envrc .envrc
.direnv/ .direnv/
+8
View File
@@ -28,6 +28,14 @@ repos:
language: system language: system
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$ files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: i18n-term-freeze
name: i18n term freeze (no new I18n.tr/qsTr terms)
entry: python3 quickshell/translations/check_term_freeze.py
language: system
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
pass_filenames: false
- repo: local - repo: local
hooks: hooks:
- id: no-console-in-qml - id: no-console-in-qml
+2
View File
@@ -77,6 +77,7 @@ install-desktop:
@echo "Installing desktop entries..." @echo "Installing desktop entries..."
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop @install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop @install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed" @echo "Desktop entries installed"
@@ -120,6 +121,7 @@ uninstall-desktop:
@echo "Removing desktop entries..." @echo "Removing desktop entries..."
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop @rm -f $(APPLICATIONS_DIR)/dms-open.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop @rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries removed" @echo "Desktop entries removed"
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=DMS Notepad
GenericName=Text Editor
Comment=Quick notes and text files in the dank desktop shell
Exec=dms ipc call notepad openFile %f
Icon=danklogo
Terminal=false
NoDisplay=true
Categories=Utility;TextEditor;
MimeType=text/plain;text/markdown;
StartupNotify=false
+1 -1
View File
@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/golangci/golangci-lint - repo: https://github.com/golangci/golangci-lint
rev: v2.10.1 rev: v2.12.2
hooks: hooks:
- id: golangci-lint-fmt - id: golangci-lint-fmt
require_serial: true require_serial: true
+18 -1
View File
@@ -63,6 +63,15 @@ var clipPasteCmd = &cobra.Command{
Run: runClipPaste, Run: runClipPaste,
} }
var clipSendPasteCmd = &cobra.Command{
Use: "send-paste",
Short: "Send a paste keystroke to the focused window",
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
Run: runClipSendPaste,
}
var clipSendPasteShift bool
var clipWatchCmd = &cobra.Command{ var clipWatchCmd = &cobra.Command{
Use: "watch [command]", Use: "watch [command]",
Short: "Watch clipboard for changes", Short: "Watch clipboard for changes",
@@ -216,8 +225,10 @@ func init() {
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration") clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd) clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd) clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
} }
func runClipCopy(cmd *cobra.Command, args []string) { func runClipCopy(cmd *cobra.Command, args []string) {
@@ -314,6 +325,12 @@ func runClipPaste(cmd *cobra.Command, args []string) {
os.Stdout.Write(data) os.Stdout.Write(data)
} }
func runClipSendPaste(cmd *cobra.Command, args []string) {
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
log.Fatalf("send-paste: %v", err)
}
}
func runClipWatch(cmd *cobra.Command, args []string) { func runClipWatch(cmd *cobra.Command, args []string) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
+197 -5
View File
@@ -106,6 +106,8 @@ func init() {
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
printIPCHelp() printIPCHelp()
}) })
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
} }
var debugSrvCmd = &cobra.Command{ var debugSrvCmd = &cobra.Command{
@@ -184,10 +186,22 @@ var pluginsUninstallCmd = &cobra.Command{
} }
var pluginsUpdateCmd = &cobra.Command{ var pluginsUpdateCmd = &cobra.Command{
Use: "update <plugin-id>", Use: "update [plugin-id]",
Short: "Update a plugin by ID", Short: "Update a plugin by ID, or all plugins",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.", Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
Args: cobra.ExactArgs(1), Args: func(cmd *cobra.Command, args []string) error {
updateAll, _ := cmd.Flags().GetBool("all")
if updateAll {
if len(args) > 0 {
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
}
return nil
}
if len(args) != 1 {
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
}
return nil
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 { if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
@@ -195,6 +209,26 @@ var pluginsUpdateCmd = &cobra.Command{
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
checkOnly, _ := cmd.Flags().GetBool("check")
updateAll, _ := cmd.Flags().GetBool("all")
if checkOnly {
if updateAll {
if err := checkAllPluginsCLI(); err != nil {
log.Fatalf("Error checking updates: %v", err)
}
return
}
if err := checkPluginCLI(args[0]); err != nil {
log.Fatalf("Error checking update: %v", err)
}
return
}
if updateAll {
if err := updateAllPluginsCLI(); err != nil {
log.Fatalf("Error updating plugins: %v", err)
}
return
}
if err := updatePluginCLI(args[0]); err != nil { if err := updatePluginCLI(args[0]); err != nil {
log.Fatalf("Error updating plugin: %v", err) log.Fatalf("Error updating plugin: %v", err)
} }
@@ -370,7 +404,11 @@ func listInstalledPlugins() error {
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames)) fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
for _, id := range installedNames { for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok { if plugin, ok := pluginMap[id]; ok {
fmt.Printf(" %s\n", plugin.Name) hasUpdateStr := ""
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
hasUpdateStr = " (update available)"
}
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
fmt.Printf(" ID: %s\n", plugin.ID) fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category) fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author) fmt.Printf(" Author: %s\n", plugin.Author)
@@ -550,6 +588,160 @@ func updatePluginCLI(idOrName string) error {
return nil return nil
} }
func updateAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var errs []error
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
if plugin != nil {
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Update(*plugin); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
}
} else {
fmt.Printf("Updating plugin: %s\n", pluginID)
if err := manager.UpdateByIDOrName(pluginID); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", pluginID)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
}
}
}
if len(errs) > 0 {
for _, err := range errs {
fmt.Fprintf(os.Stderr, "%v\n", err)
}
return fmt.Errorf("failed to update some plugins")
}
return nil
}
func checkPluginCLI(idOrName string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(idOrName, pluginList)
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
} else {
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
}
return nil
}
dummyPlugin := plugins.Plugin{ID: idOrName}
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s\n", idOrName)
} else {
fmt.Printf("Plugin is up to date: %s\n", idOrName)
}
return nil
}
func checkAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var count int
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
var hasUpdates bool
var name string
if plugin != nil {
name = plugin.Name
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
} else {
name = pluginID
dummyPlugin := plugins.Plugin{ID: pluginID}
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
count++
}
}
if count > 0 {
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
} else {
fmt.Println("All plugins are up to date.")
}
return nil
}
func getCommonCommands() []*cobra.Command { func getCommonCommands() []*cobra.Command {
return []*cobra.Command{ return []*cobra.Command{
versionCmd, versionCmd,
+2
View File
@@ -37,9 +37,11 @@ var resolveIncludeCmd = &cobra.Command{
"cursor.lua", "cursor.lua",
"windowrules.lua", "windowrules.lua",
"cursor.kdl", "cursor.kdl",
"layout.kdl",
"outputs.kdl", "outputs.kdl",
"binds.kdl", "binds.kdl",
"cursor.conf", "cursor.conf",
"layout.conf",
"outputs.conf", "outputs.conf",
"binds.conf", "binds.conf",
}, cobra.ShellCompDirectiveNoFileComp }, cobra.ShellCompDirectiveNoFileComp
+124 -10
View File
@@ -32,13 +32,14 @@ var greeterCmd = &cobra.Command{
var ( var (
greeterConfigSyncFn = greeter.SyncDMSConfigs greeterConfigSyncFn = greeter.SyncDMSConfigs
sharedAuthSyncFn = sharedpam.SyncAuthConfig sharedAuthSyncFn = sharedpam.SyncAuthConfig
greeterIsNixOSFn = greeter.IsNixOS
) )
var greeterInstallCmd = &cobra.Command{ var greeterInstallCmd = &cobra.Command{
Use: "install", Use: "install",
Short: "Install and configure DMS greeter", Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface", Long: "Install greetd and configure it to use DMS as the greeter interface",
PreRunE: preRunPrivileged, PreRunE: preRunGreeterMutation,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -64,6 +65,9 @@ var greeterSyncCmd = &cobra.Command{
Short: "Sync DMS theme and settings with greeter", Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.", Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
PreRunE: func(cmd *cobra.Command, args []string) error { PreRunE: func(cmd *cobra.Command, args []string) error {
if err := rejectNixOSGreeterMutation(cmd); err != nil {
return err
}
profile, _ := cmd.Flags().GetBool("profile") profile, _ := cmd.Flags().GetBool("profile")
if profile { if profile {
return nil return nil
@@ -140,7 +144,7 @@ var greeterEnableCmd = &cobra.Command{
Use: "enable", Use: "enable",
Short: "Enable DMS greeter in greetd config", Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter", Long: "Configure greetd to use DMS as the greeter",
PreRunE: preRunPrivileged, PreRunE: preRunGreeterMutation,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -176,7 +180,7 @@ var greeterUninstallCmd = &cobra.Command{
Use: "uninstall", Use: "uninstall",
Short: "Remove DMS greeter configuration and restore previous display manager", Short: "Remove DMS greeter configuration and restore previous display manager",
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state", Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
PreRunE: preRunPrivileged, PreRunE: preRunGreeterMutation,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -206,6 +210,21 @@ func init() {
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
} }
func rejectNixOSGreeterMutation(cmd *cobra.Command) error {
if !greeterIsNixOSFn() {
return nil
}
return fmt.Errorf("dms %s is disabled on NixOS because the greeter is managed declaratively\nConfigure the DMS greeter in your NixOS module, then apply the change with your normal nixos-rebuild workflow", normalizeCommandSpec(cmd.CommandPath()))
}
func preRunGreeterMutation(cmd *cobra.Command, args []string) error {
if err := rejectNixOSGreeterMutation(cmd); err != nil {
return err
}
return preRunPrivileged(cmd, args)
}
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error { func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil { if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
return err return err
@@ -1415,24 +1434,36 @@ func readDefaultSessionCommand(configPath string) string {
return "" return ""
} }
func extractGreeterCacheDirFromCommand(command string) string { func explicitGreeterCacheDirFromCommand(command string) (string, bool) {
if command == "" {
return greeter.GreeterCacheDir
}
tokens := strings.Fields(command) tokens := strings.Fields(command)
for i := 0; i < len(tokens); i++ { for i := 0; i < len(tokens); i++ {
token := strings.Trim(tokens[i], "\"") token := strings.Trim(tokens[i], "\"")
if token == "--cache-dir" && i+1 < len(tokens) { if token == "--cache-dir" && i+1 < len(tokens) {
return strings.Trim(tokens[i+1], "\"") value := strings.Trim(tokens[i+1], "\"")
if value != "" {
return value, true
}
} }
if strings.HasPrefix(token, "--cache-dir=") { if strings.HasPrefix(token, "--cache-dir=") {
value := strings.TrimPrefix(token, "--cache-dir=") value := strings.TrimPrefix(token, "--cache-dir=")
value = strings.Trim(value, "\"") value = strings.Trim(value, "\"")
if value != "" { if value != "" {
return value return value, true
} }
} }
} }
return "", false
}
const nixOSGreeterStateDir = "/var/lib/dms-greeter"
func greeterStatusStateDir(command string, isNixOS bool) string {
if cacheDir, ok := explicitGreeterCacheDirFromCommand(command); ok {
return cacheDir
}
if isNixOS {
return nixOSGreeterStateDir
}
return greeter.GreeterCacheDir return greeter.GreeterCacheDir
} }
@@ -1569,6 +1600,10 @@ func checkGreeterStatus() error {
fmt.Println("=== DMS Greeter Status ===") fmt.Println("=== DMS Greeter Status ===")
fmt.Println() fmt.Println()
if greeterIsNixOSFn() {
return checkNixOSGreeterStatus()
}
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err) return fmt.Errorf("failed to get user home directory: %w", err)
@@ -1632,7 +1667,7 @@ func checkGreeterStatus() error {
fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions") fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions")
} }
cacheDir := extractGreeterCacheDirFromCommand(configuredCommand) cacheDir := greeterStatusStateDir(configuredCommand, false)
fmt.Println("\nGreeter Cache Directory:") fmt.Println("\nGreeter Cache Directory:")
fmt.Printf(" Effective cache dir: %s\n", cacheDir) fmt.Printf(" Effective cache dir: %s\n", cacheDir)
if cacheDir != greeter.GreeterCacheDir { if cacheDir != greeter.GreeterCacheDir {
@@ -1895,6 +1930,85 @@ func checkGreeterStatus() error {
return nil return nil
} }
func checkNixOSGreeterStatus() error {
const configPath = "/etc/greetd/config.toml"
configuredCommand := readDefaultSessionCommand(configPath)
allGood := true
fmt.Println("Greeter Configuration:")
switch {
case strings.Contains(configuredCommand, "dms-greeter"):
fmt.Println(" ✓ DMS greeter command found")
if wrapper := extractGreeterWrapperFromCommand(configuredCommand); wrapper != "" {
fmt.Printf(" Wrapper: %s\n", wrapper)
}
case configuredCommand != "":
fmt.Println(" ⚠ greetd default session does not reference dms-greeter")
allGood = false
default:
fmt.Printf(" No readable DMS command found in %s\n", configPath)
}
fmt.Println(" NixOS manages greeter configuration declaratively; apply changes through your NixOS module.")
stateDir := greeterStatusStateDir(configuredCommand, true)
fmt.Println("\nGreeter State Directory:")
fmt.Printf(" Effective state dir: %s\n", stateDir)
if stateDir == nixOSGreeterStateDir {
fmt.Println(" ✓ Using the NixOS module state path")
}
if stat, err := os.Stat(stateDir); err == nil && stat.IsDir() {
fmt.Printf(" ✓ %s exists\n", stateDir)
} else if os.IsNotExist(err) {
fmt.Printf(" ✗ %s not found\n", stateDir)
fmt.Println(" Rebuild your NixOS configuration after enabling the DMS greeter module.")
allGood = false
} else if err != nil {
fmt.Printf(" ✗ Could not inspect %s: %v\n", stateDir, err)
allGood = false
} else {
fmt.Printf(" ✗ %s is not a directory\n", stateDir)
allGood = false
}
fmt.Println("\nDeclarative Configuration Files:")
configFiles := []struct {
name string
path string
}{
{name: "Settings", path: filepath.Join(stateDir, "settings.json")},
{name: "Session state", path: filepath.Join(stateDir, "session.json")},
{name: "Color theme", path: filepath.Join(stateDir, "colors.json")},
}
for _, configFile := range configFiles {
if stat, err := os.Stat(configFile.path); err == nil && !stat.IsDir() {
fmt.Printf(" ✓ %s: %s\n", configFile.name, configFile.path)
} else if os.IsNotExist(err) {
fmt.Printf(" %s not present (optional; configure configHome/configFiles in the NixOS module)\n", configFile.name)
} else if err != nil {
fmt.Printf(" ⚠ %s could not be inspected: %v\n", configFile.name, err)
} else {
fmt.Printf(" ⚠ %s path is not a regular file: %s\n", configFile.name, configFile.path)
}
}
fmt.Println("\nGroup Membership:")
fmt.Println(" User group membership is managed by NixOS and is not required for declarative theme copies.")
fmt.Println("\nGreeter PAM Authentication:")
fmt.Println(" PAM is managed by NixOS modules.")
fmt.Println(" Configure fingerprint/U2F through security.pam.services.greetd.")
fmt.Println()
if allGood {
fmt.Println("✓ NixOS greeter state looks healthy and is managed declaratively.")
} else {
fmt.Println("⚠ Some issues detected. Update the DMS greeter module and rebuild NixOS; do not run 'dms greeter sync'.")
}
return nil
}
func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) { func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) {
if sampleLimit <= 0 { if sampleLimit <= 0 {
sampleLimit = 3 sampleLimit = 3
+56
View File
@@ -3,9 +3,11 @@ package main
import ( import (
"errors" "errors"
"reflect" "reflect"
"strings"
"testing" "testing"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra"
) )
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) { func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
@@ -85,3 +87,57 @@ func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
t.Fatal("expected auth sync not to run after config sync failure") t.Fatal("expected auth sync not to run after config sync failure")
} }
} }
func TestGreeterStatusStateDirUsesNixOSDefault(t *testing.T) {
if got := greeterStatusStateDir("", true); got != nixOSGreeterStateDir {
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, nixOSGreeterStateDir)
}
}
func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
command := "dms-greeter --cache-dir /srv/dms-greeter --command niri"
if got := greeterStatusStateDir(command, true); got != "/srv/dms-greeter" {
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, "/srv/dms-greeter")
}
}
func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) {
origGreeterIsNixOSFn := greeterIsNixOSFn
greeterIsNixOSFn = func() bool { return true }
t.Cleanup(func() {
greeterIsNixOSFn = origGreeterIsNixOSFn
})
for _, commandName := range []string{"install", "enable", "sync", "uninstall"} {
t.Run(commandName, func(t *testing.T) {
root := &cobra.Command{Use: "dms"}
greeterCommand := &cobra.Command{Use: "greeter"}
mutationCommand := &cobra.Command{Use: commandName}
root.AddCommand(greeterCommand)
greeterCommand.AddCommand(mutationCommand)
err := rejectNixOSGreeterMutation(mutationCommand)
if err == nil {
t.Fatalf("expected NixOS greeter %s to be rejected", commandName)
}
if !strings.Contains(err.Error(), "dms greeter "+commandName+" is disabled on NixOS") {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(err.Error(), "/var/cache/dms-greeter") {
t.Fatalf("NixOS remediation should not recommend the non-NixOS cache path: %v", err)
}
})
}
}
func TestRejectNixOSGreeterMutationAllowsOtherDistros(t *testing.T) {
origGreeterIsNixOSFn := greeterIsNixOSFn
greeterIsNixOSFn = func() bool { return false }
t.Cleanup(func() {
greeterIsNixOSFn = origGreeterIsNixOSFn
})
if err := rejectNixOSGreeterMutation(&cobra.Command{Use: "sync"}); err != nil {
t.Fatalf("expected non-NixOS greeter command to be allowed, got %v", err)
}
}
+23
View File
@@ -38,10 +38,17 @@ var matugenCheckCmd = &cobra.Command{
Run: runMatugenCheck, Run: runMatugenCheck,
} }
var matugenPreviewCmd = &cobra.Command{
Use: "preview",
Short: "Preview Matugen scheme colors without applying them",
Run: runMatugenPreview,
}
func init() { func init() {
matugenCmd.AddCommand(matugenGenerateCmd) matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd) matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd) matugenCmd.AddCommand(matugenCheckCmd)
matugenCmd.AddCommand(matugenPreviewCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} { for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files") cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -62,6 +69,8 @@ func init() {
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion") matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting") matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
} }
func buildMatugenOptions(cmd *cobra.Command) matugen.Options { func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -200,3 +209,17 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
} }
fmt.Println(string(data)) fmt.Println(string(data))
} }
func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color")
contrast, _ := cmd.Flags().GetFloat64("contrast")
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err)
}
data, err := json.Marshal(previews)
if err != nil {
log.Fatalf("Failed to marshal Matugen previews: %v", err)
}
fmt.Println(string(data))
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"encoding/json"
"testing"
)
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
var policyFile cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
t.Fatalf("failed to parse embedded CLI policy: %v", err)
}
if policyFile.BlockedCommands == nil {
t.Fatal("embedded CLI policy has no blocked_commands")
}
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
if !commandBlockedByPolicy("greeter enable", blocked) {
t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems")
}
if commandBlockedByPolicy("greeter sync", blocked) {
t.Fatal("expected greeter sync to remain available on immutable/image-based systems")
}
}
+1 -1
View File
@@ -22,7 +22,6 @@ require (
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0 golang.org/x/image v0.39.0
tailscale.com v1.96.5 tailscale.com v1.96.5
) )
@@ -64,6 +63,7 @@ require (
github.com/yeqown/reedsolomon v1.0.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
+147 -319
View File
@@ -9,7 +9,6 @@ import (
"syscall" "syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
const envServe = "_DMS_CLIPBOARD_SERVE" const envServe = "_DMS_CLIPBOARD_SERVE"
@@ -17,6 +16,37 @@ const envMime = "_DMS_CLIPBOARD_MIME"
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE" const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
const envCacheFile = "_DMS_CLIPBOARD_CACHE" const envCacheFile = "_DMS_CLIPBOARD_CACHE"
type Offer struct {
MimeType string
Data []byte
}
// textMimeAliases are offered alongside plain-text content so legacy X11
// clients bridged through XWayland find a target they can convert.
var textMimeAliases = []string{
"text/plain",
"text/plain;charset=utf-8",
"UTF8_STRING",
"STRING",
"TEXT",
}
// ExpandOffers turns raw clipboard data into the full offer list to serve,
// adding the standard alias set for text content.
func ExpandOffers(data []byte, mimeType string) []Offer {
offers := []Offer{{MimeType: mimeType, Data: data}}
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
return offers
}
for _, alias := range textMimeAliases {
if alias == mimeType {
continue
}
offers = append(offers, Offer{MimeType: alias, Data: data})
}
return offers
}
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard // MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
// child. Reads source data into memory, deletes any cache file, then serves. // child. Reads source data into memory, deletes any cache file, then serves.
func MaybeServeAndExit() { func MaybeServeAndExit() {
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
os.Exit(1) os.Exit(1)
} }
if err := serveClipboard(data, mimeType, pasteOnce); err != nil { if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err) fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -55,22 +85,33 @@ func Copy(data []byte, mimeType string) error {
return copyForkCached(data, mimeType, false) return copyForkCached(data, mimeType, false)
} }
func CopyText(text string) error {
return Copy([]byte(text), "text/plain;charset=utf-8")
}
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error { func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground { if foreground {
return serveClipboard(data, mimeType, pasteOnce) return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
} }
return copyForkCached(data, mimeType, pasteOnce) return copyForkCached(data, mimeType, pasteOnce)
} }
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error { func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if foreground { if !foreground {
buf, err := io.ReadAll(data) return copyFork(data, mimeType, pasteOnce)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
} }
return copyFork(data, mimeType, pasteOnce) buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if foreground {
return serveOffers(offers, pasteOnce)
}
return copyMultiFork(offers, pasteOnce)
} }
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd { func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
@@ -132,39 +173,70 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error { func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
cmd := newForkCmd(mimeType, pasteOnce) cmd := newForkCmd(mimeType, pasteOnce)
switch src := data.(type) { if src, ok := data.(*os.File); ok {
case *os.File:
cmd.Stdin = src cmd.Stdin = src
return waitReady(cmd) return waitReady(cmd)
default:
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
} }
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
}
}
stdin.Close()
return nil
} }
func signalReady() { func signalReady() {
@@ -194,57 +266,25 @@ func createClipboardCacheFile() (*os.File, error) {
return os.CreateTemp("", "dms-clipboard-*") return os.CreateTemp("", "dms-clipboard-*")
} }
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error { // serveOffers owns the Wayland selection until cancelled (or first paste when
display, err := wlclient.Connect("") // pasteOnce is set), answering every offered mime type with its data.
func serveOffers(offers []Offer, pasteOnce bool) error {
if len(offers) == 0 {
return fmt.Errorf("no offers to serve")
}
s, err := connectSession()
if err != nil { if err != nil {
return fmt.Errorf("wayland connect: %w", err) return err
} }
defer display.Destroy() defer s.Close()
ctx := display.Context() dataControlMgr, err := s.requireDataControl()
registry, err := display.GetRegistry()
if err != nil { if err != nil {
return fmt.Errorf("get registry: %w", err) return err
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
} }
device, err := dataControlMgr.GetDataDevice(seat) device, err := dataControlMgr.GetDataDevice(s.seat)
if err != nil { if err != nil {
return fmt.Errorf("get data device: %w", err) return fmt.Errorf("get data device: %w", err)
} }
@@ -255,25 +295,12 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return fmt.Errorf("create data source: %w", err) return fmt.Errorf("create data source: %w", err)
} }
if err := source.Offer(mimeType); err != nil { offerData := make(map[string][]byte, len(offers))
return fmt.Errorf("offer mime type: %w", err) for _, offer := range offers {
} if err := source.Offer(offer.MimeType); err != nil {
if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" { return fmt.Errorf("offer %s: %w", offer.MimeType, err)
if err := source.Offer("text/plain"); err != nil {
return fmt.Errorf("offer text/plain: %w", err)
}
if err := source.Offer("text/plain;charset=utf-8"); err != nil {
return fmt.Errorf("offer text/plain;charset=utf-8: %w", err)
}
if err := source.Offer("UTF8_STRING"); err != nil {
return fmt.Errorf("offer UTF8_STRING: %w", err)
}
if err := source.Offer("STRING"); err != nil {
return fmt.Errorf("offer STRING: %w", err)
}
if err := source.Offer("TEXT"); err != nil {
return fmt.Errorf("offer TEXT: %w", err)
} }
offerData[offer.MimeType] = offer.Data
} }
cancelled := make(chan struct{}) cancelled := make(chan struct{})
@@ -283,7 +310,11 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
_ = syscall.SetNonblock(e.Fd, false) _ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe") file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close() defer file.Close()
_, _ = file.Write(data)
if data, ok := offerData[e.MimeType]; ok {
_, _ = file.Write(data)
}
select { select {
case pasted <- struct{}{}: case pasted <- struct{}{}:
default: default:
@@ -298,7 +329,7 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return fmt.Errorf("set selection: %w", err) return fmt.Errorf("set selection: %w", err)
} }
display.Roundtrip() s.display.Roundtrip()
signalReady() signalReady()
for { for {
@@ -310,70 +341,26 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
return nil return nil
} }
default: default:
if err := ctx.Dispatch(); err != nil { if err := s.ctx.Dispatch(); err != nil {
return nil return nil
} }
} }
} }
} }
func CopyText(text string) error {
return Copy([]byte(text), "text/plain;charset=utf-8")
}
func Paste() ([]byte, string, error) { func Paste() ([]byte, string, error) {
display, err := wlclient.Connect("") s, err := connectSession()
if err != nil { if err != nil {
return nil, "", fmt.Errorf("wayland connect: %w", err) return nil, "", err
} }
defer display.Destroy() defer s.Close()
ctx := display.Context() dataControlMgr, err := s.requireDataControl()
registry, err := display.GetRegistry()
if err != nil { if err != nil {
return nil, "", fmt.Errorf("get registry: %w", err) return nil, "", err
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return nil, "", fmt.Errorf("registry bind: %w", bindErr)
} }
if dataControlMgr == nil { device, err := dataControlMgr.GetDataDevice(s.seat)
return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return nil, "", fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("get data device: %w", err) return nil, "", fmt.Errorf("get data device: %w", err)
} }
@@ -399,15 +386,14 @@ func Paste() ([]byte, string, error) {
gotSelection = true gotSelection = true
}) })
display.Roundtrip() s.display.Roundtrip()
display.Roundtrip() s.display.Roundtrip()
if !gotSelection || selectionOffer == nil { if !gotSelection || selectionOffer == nil {
return nil, "", fmt.Errorf("no clipboard data") return nil, "", fmt.Errorf("no clipboard data")
} }
mimeTypes := offerMimeTypes[selectionOffer] selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
selectedMime := selectPreferredMimeType(mimeTypes)
if selectedMime == "" { if selectedMime == "" {
return nil, "", fmt.Errorf("no supported mime type") return nil, "", fmt.Errorf("no supported mime type")
} }
@@ -424,7 +410,7 @@ func Paste() ([]byte, string, error) {
} }
w.Close() w.Close()
display.Roundtrip() s.display.Roundtrip()
data, err := io.ReadAll(r) data, err := io.ReadAll(r)
if err != nil { if err != nil {
@@ -470,161 +456,3 @@ func selectPreferredMimeType(mimes []string) string {
func IsImageMimeType(mime string) bool { func IsImageMimeType(mime string) bool {
return len(mime) > 6 && mime[:6] == "image/" return len(mime) > 6 && mime[:6] == "image/"
} }
type Offer struct {
MimeType string
Data []byte
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if !foreground {
return copyMultiFork(offers, pasteOnce)
}
return copyMultiServe(offers, pasteOnce)
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
}
}
stdin.Close()
return nil
}
func copyMultiServe(offers []Offer, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
}
defer display.Destroy()
ctx := display.Context()
registry, err := display.GetRegistry()
if err != nil {
return fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
defer device.Destroy()
source, err := dataControlMgr.CreateDataSource()
if err != nil {
return fmt.Errorf("create data source: %w", err)
}
offerMap := make(map[string][]byte)
for _, offer := range offers {
if err := source.Offer(offer.MimeType); err != nil {
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
}
offerMap[offer.MimeType] = offer.Data
}
cancelled := make(chan struct{})
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerMap[e.MimeType]; ok {
_, _ = file.Write(data)
}
select {
case pasted <- struct{}{}:
default:
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
close(cancelled)
})
if err := device.SetSelection(source); err != nil {
return fmt.Errorf("set selection: %w", err)
}
display.Roundtrip()
for {
select {
case <-cancelled:
return nil
case <-pasted:
if pasteOnce {
return nil
}
default:
if err := ctx.Dispatch(); err != nil {
return nil
}
}
}
}
+206
View File
@@ -0,0 +1,206 @@
package clipboard
import (
"fmt"
"regexp"
"strconv"
"strings"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
const (
xkbKeymapFormatV1 = 1
keyStateReleased = 0
keyStatePressed = 1
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
shiftModMask = 1 << 0
ctrlModMask = 1 << 2
// evdev fallbacks for a standard pc105 map
fallbackCtrlKey = 29 // KEY_LEFTCTRL
fallbackShiftKey = 42 // KEY_LEFTSHIFT
fallbackVKey = 47 // KEY_V
)
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
// using the seat's own keymap, so keycodes stay valid for XWayland clients
// (a synthetic wtype-style keymap breaks X11 apps like Steam). withShift
// selects ctrl+shift+v for terminal targets.
func SendPasteKeystroke(withShift bool) error {
s, err := connectSession()
if err != nil {
return err
}
defer s.Close()
if s.virtualKeyboardMgr == nil {
return fmt.Errorf("compositor does not support zwp_virtual_keyboard_manager_v1")
}
if s.seat == nil {
return fmt.Errorf("no seat available")
}
keyboard, err := s.seat.GetKeyboard()
if err != nil {
return fmt.Errorf("get keyboard: %w", err)
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
return fmt.Errorf("no xkb keymap from seat")
}
defer unix.Close(keymap.Fd)
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
return fmt.Errorf("read keymap: %w", err)
}
keys := resolveKeycodes(keymapText)
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
if err != nil {
return fmt.Errorf("create virtual keyboard: %w", err)
}
defer vk.Destroy()
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
return fmt.Errorf("set keymap: %w", err)
}
mods := uint32(ctrlModMask)
held := []uint32{keys.ctrl}
if withShift {
mods |= shiftModMask
held = append(held, keys.shift)
}
t := uint32(0)
press := func(key, state uint32) error {
t++
return vk.Key(t, key, state)
}
for _, key := range held {
if err := press(key, keyStatePressed); err != nil {
return fmt.Errorf("key press: %w", err)
}
}
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
return fmt.Errorf("set modifiers: %w", err)
}
if err := press(keys.v, keyStatePressed); err != nil {
return fmt.Errorf("key press: %w", err)
}
if err := press(keys.v, keyStateReleased); err != nil {
return fmt.Errorf("key release: %w", err)
}
for i := len(held) - 1; i >= 0; i-- {
if err := press(held[i], keyStateReleased); err != nil {
return fmt.Errorf("key release: %w", err)
}
}
if err := vk.Modifiers(0, 0, 0, 0); err != nil {
return fmt.Errorf("clear modifiers: %w", err)
}
s.display.Roundtrip()
return nil
}
func readKeymap(fd int, size uint32) (string, error) {
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
if err != nil {
return "", err
}
text := strings.TrimRight(string(data), "\x00")
return text, unix.Munmap(data)
}
type pasteKeycodes struct {
ctrl uint32
shift uint32
v uint32
}
var (
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
)
// xkbcommon may serialize keysyms as hex escapes instead of names
// (e.g. "0x76" for v, "0xffe3" for Control_L).
var keysymNames = map[uint32]string{
0x76: "v",
0xffe3: "Control_L",
0xffe1: "Shift_L",
}
func canonicalKeysym(sym string) string {
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
return sym
}
value, err := strconv.ParseUint(sym[2:], 16, 32)
if err != nil {
return sym
}
if name, ok := keysymNames[uint32(value)]; ok {
return name
}
return sym
}
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
// the seat keymap's first group, falling back to pc105 positions.
func resolveKeycodes(keymap string) pasteKeycodes {
codes := map[string]uint32{}
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
if code, err := strconv.Atoi(m[2]); err == nil {
codes[m[1]] = uint32(code)
}
}
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
want := map[string]*uint32{
"Control_L": &keys.ctrl,
"Shift_L": &keys.shift,
"v": &keys.v,
}
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
if group == nil {
continue
}
xkbCode, ok := codes[m[1]]
if !ok || xkbCode < 8 {
continue
}
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
target, wanted := want[level1]
if !wanted {
continue
}
*target = xkbCode - 8
delete(want, level1)
if len(want) == 0 {
break
}
}
return keys
}
@@ -0,0 +1,65 @@
package clipboard
import (
"os"
"testing"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
func TestLiveSeatKeymapResolution(t *testing.T) {
if os.Getenv("DMS_LIVE_TEST") == "" {
t.Skip("set DMS_LIVE_TEST=1 to run against the live compositor")
}
s, err := connectSession()
if err != nil {
t.Fatalf("connect: %v", err)
}
defer s.Close()
if s.virtualKeyboardMgr == nil {
t.Fatal("compositor does not advertise zwp_virtual_keyboard_manager_v1")
}
if s.seat == nil {
t.Fatal("no seat")
}
keyboard, err := s.seat.GetKeyboard()
if err != nil {
t.Fatalf("get keyboard: %v", err)
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if keymap == nil {
t.Fatal("no keymap event")
}
defer unix.Close(keymap.Fd)
text, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
t.Fatalf("read keymap: %v", err)
}
if dump := os.Getenv("DMS_LIVE_DUMP"); dump != "" {
if err := os.WriteFile(dump, []byte(text), 0o644); err != nil {
t.Fatalf("dump keymap: %v", err)
}
}
keys := resolveKeycodes(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
}
}
+105
View File
@@ -0,0 +1,105 @@
package clipboard
import "testing"
const qwertyKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
minimum = 8;
maximum = 708;
<ESC> = 9;
<AB04> = 55;
<LCTL> = 37;
<LFSH> = 50;
alias <AL01> = <AC01>;
indicator 1 = "Caps Lock";
};
xkb_types "(unnamed)" {
type "ALPHABETIC" {
modifiers = Shift+Lock;
map[Shift] = Level2;
level_name[Level1] = "Base";
};
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Escape ] };
key <AB04> { type= "ALPHABETIC", [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const hexKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB04> = 56;
<LCTL> = 38;
<LFSH> = 51;
};
xkb_symbols "(unnamed)" {
key <LCTL> { [ 0xffe3 ] };
key <LFSH> {
type= "PC_ALT_LEVEL2",
symbols[1]= [ 0xffe1, 0xfe08 ]
};
key <AB04> { [ 0x76, 0x56 ] };
};
};`
const dvorakKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB09> = 60;
<LCTL> = 37;
<LFSH> = 50;
};
xkb_symbols "(unnamed)" {
key <AB09> { [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
func TestResolveKeycodes(t *testing.T) {
tests := []struct {
name string
keymap string
want pasteKeycodes
}{
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveKeycodes(tt.keymap)
if got != tt.want {
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestExpandOffers(t *testing.T) {
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
if len(text) != 5 {
t.Fatalf("expected 5 text offers, got %d", len(text))
}
seen := map[string]bool{}
for _, o := range text {
if string(o.Data) != "hi" {
t.Errorf("offer %s has wrong data", o.MimeType)
}
if seen[o.MimeType] {
t.Errorf("duplicate offer %s", o.MimeType)
}
seen[o.MimeType] = true
}
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
t.Errorf("missing X11 alias offers: %v", seen)
}
img := ExpandOffers([]byte{1}, "image/png")
if len(img) != 1 || img[0].MimeType != "image/png" {
t.Errorf("non-text mime should not expand, got %+v", img)
}
}
+93
View File
@@ -0,0 +1,93 @@
package clipboard
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type session struct {
display *wlclient.Display
ctx *wlclient.Context
registry *wlclient.Registry
seat *wlclient.Seat
dataControlMgr *ext_data_control.ExtDataControlManagerV1
virtualKeyboardMgr *virtual_keyboard.ZwpVirtualKeyboardManagerV1
}
// connectSession opens a short-lived Wayland connection and binds the seat
// plus whichever clipboard-related globals the compositor advertises.
func connectSession() (*session, error) {
display, err := wlclient.Connect("")
if err != nil {
return nil, fmt.Errorf("wayland connect: %w", err)
}
s := &session{display: display, ctx: display.Context()}
registry, err := display.GetRegistry()
if err != nil {
display.Destroy()
return nil, fmt.Errorf("get registry: %w", err)
}
s.registry = registry
var bindErr error
bind := func(name uint32, iface string, version uint32, proxy wlclient.Proxy) {
if err := registry.Bind(name, iface, version, proxy); err != nil {
bindErr = fmt.Errorf("bind %s: %w", iface, err)
}
}
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case ext_data_control.ExtDataControlManagerV1InterfaceName:
mgr := ext_data_control.NewExtDataControlManagerV1(s.ctx)
bind(e.Name, e.Interface, e.Version, mgr)
s.dataControlMgr = mgr
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
mgr := virtual_keyboard.NewZwpVirtualKeyboardManagerV1(s.ctx)
bind(e.Name, e.Interface, e.Version, mgr)
s.virtualKeyboardMgr = mgr
case "wl_seat":
if s.seat != nil {
return
}
seat := wlclient.NewSeat(s.ctx)
bind(e.Name, e.Interface, e.Version, seat)
s.seat = seat
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
s.Close()
return nil, bindErr
}
return s, nil
}
func (s *session) requireDataControl() (*ext_data_control.ExtDataControlManagerV1, error) {
switch {
case s.dataControlMgr == nil:
return nil, fmt.Errorf("compositor does not support ext_data_control_manager_v1")
case s.seat == nil:
return nil, fmt.Errorf("no seat available")
default:
return s.dataControlMgr, nil
}
}
func (s *session) Close() {
if s.dataControlMgr != nil {
s.dataControlMgr.Destroy()
}
if s.registry != nil {
s.registry.Destroy()
}
s.display.Destroy()
}
@@ -13,6 +13,7 @@ hl.config({
input = { input = {
kb_layout = "us", kb_layout = "us",
numlock_by_default = true, numlock_by_default = true,
follow_mouse = 0,
touchpad = { touchpad = {
tap_to_click = true, tap_to_click = true,
natural_scroll = true, natural_scroll = true,
+36 -9
View File
@@ -19,6 +19,20 @@ type NiriProvider struct {
parsed bool parsed bool
} }
type niriActionPart struct {
value string
quoted bool
}
var niriActionPropertyOrder = []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"}
var niriActionProperties = map[string]struct{}{
"focus": {},
"show-pointer": {},
"write-to-disk": {},
"skip-confirmation": {},
"delay-ms": {},
}
func NewNiriProvider(configDir string) *NiriProvider { func NewNiriProvider(configDir string) *NiriProvider {
if configDir == "" { if configDir == "" {
configDir = defaultNiriConfigDir() configDir = defaultNiriConfigDir()
@@ -55,6 +69,7 @@ func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
sheet := &keybinds.CheatSheet{ sheet := &keybinds.CheatSheet{
Title: "Niri Keybinds", Title: "Niri Keybinds",
Provider: n.Name(), Provider: n.Name(),
ModKey: result.ModKey,
Binds: categorizedBinds, Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded, DMSBindsIncluded: result.DMSBindsIncluded,
} }
@@ -352,7 +367,7 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
} }
if actionNode.Properties != nil { if actionNode.Properties != nil {
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} { for _, propName := range niriActionPropertyOrder {
if val, ok := actionNode.Properties.Get(propName); ok { if val, ok := actionNode.Properties.Get(propName); ok {
parts = append(parts, propName+"="+val.String()) parts = append(parts, propName+"="+val.String())
} }
@@ -440,10 +455,10 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
return node return node
} }
node.SetName(parts[0]) node.SetName(parts[0].value)
for _, arg := range parts[1:] { for _, arg := range parts[1:] {
if strings.Contains(arg, "=") { if n.isNiriActionPropertyToken(arg) {
kv := strings.SplitN(arg, "=", 2) kv := strings.SplitN(arg.value, "=", 2)
switch kv[1] { switch kv[1] {
case "true": case "true":
node.AddProperty(kv[0], true, "") node.AddProperty(kv[0], true, "")
@@ -454,13 +469,25 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
} }
continue continue
} }
node.AddArgument(arg, "") node.AddArgument(arg.value, "")
} }
return node return node
} }
func (n *NiriProvider) parseActionParts(action string) []string { func (n *NiriProvider) isNiriActionPropertyToken(part niriActionPart) bool {
var parts []string if part.quoted || !strings.Contains(part.value, "=") {
return false
}
key, _, ok := strings.Cut(part.value, "=")
if !ok {
return false
}
_, ok = niriActionProperties[key]
return ok
}
func (n *NiriProvider) parseActionParts(action string) []niriActionPart {
var parts []niriActionPart
var current strings.Builder var current strings.Builder
var inQuote, escaped, wasQuoted bool var inQuote, escaped, wasQuoted bool
@@ -476,7 +503,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
inQuote = !inQuote inQuote = !inQuote
case r == ' ' && !inQuote: case r == ' ' && !inQuote:
if current.Len() > 0 || wasQuoted { if current.Len() > 0 || wasQuoted {
parts = append(parts, current.String()) parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
current.Reset() current.Reset()
wasQuoted = false wasQuoted = false
} }
@@ -485,7 +512,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
} }
} }
if current.Len() > 0 || wasQuoted { if current.Len() > 0 || wasQuoted {
parts = append(parts, current.String()) parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
} }
return parts return parts
} }
@@ -33,6 +33,7 @@ type NiriSection struct {
type NiriParser struct { type NiriParser struct {
configDir string configDir string
modKey string
processedFiles map[string]bool processedFiles map[string]bool
bindMap map[string]*NiriKeyBinding bindMap map[string]*NiriKeyBinding
bindOrder []string bindOrder []string
@@ -237,6 +238,7 @@ func isBraceAdjacentSpace(b byte) bool {
func NewNiriParser(configDir string) *NiriParser { func NewNiriParser(configDir string) *NiriParser {
return &NiriParser{ return &NiriParser{
configDir: configDir, configDir: configDir,
modKey: "Super",
processedFiles: make(map[string]bool), processedFiles: make(map[string]bool),
bindMap: make(map[string]*NiriKeyBinding), bindMap: make(map[string]*NiriKeyBinding),
bindOrder: []string{}, bindOrder: []string{},
@@ -377,6 +379,8 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
switch name { switch name {
case "include": case "include":
p.handleInclude(node, section, baseDir) p.handleInclude(node, section, baseDir)
case "input":
p.handleInput(node)
case "binds": case "binds":
p.extractBinds(node, section, "") p.extractBinds(node, section, "")
case "recent-windows": case "recent-windows":
@@ -385,6 +389,19 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
} }
} }
func (p *NiriParser) handleInput(node *document.Node) {
for _, child := range node.Children {
if child.Name.String() != "mod-key" || len(child.Arguments) == 0 {
continue
}
modKey := strings.Trim(strings.TrimSpace(child.Arguments[0].String()), "\"")
if modKey != "" {
p.modKey = modKey
}
}
}
func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) { func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) {
if len(node.Arguments) == 0 { if len(node.Arguments) == 0 {
return return
@@ -534,6 +551,7 @@ func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) {
type NiriParseResult struct { type NiriParseResult struct {
Section *NiriSection Section *NiriSection
ModKey string
DMSBindsIncluded bool DMSBindsIncluded bool
DMSStatus *DMSBindsStatusInfo DMSStatus *DMSBindsStatusInfo
ConflictingConfigs map[string]*NiriKeyBinding ConflictingConfigs map[string]*NiriKeyBinding
@@ -586,6 +604,7 @@ func ParseNiriKeys(configDir string) (*NiriParseResult, error) {
} }
return &NiriParseResult{ return &NiriParseResult{
Section: section, Section: section,
ModKey: parser.modKey,
DMSBindsIncluded: parser.HasDMSBindsIncluded(), DMSBindsIncluded: parser.HasDMSBindsIncluded(),
DMSStatus: parser.buildDMSStatus(), DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs, ConflictingConfigs: parser.conflictingConfigs,
@@ -7,6 +7,28 @@ import (
"testing" "testing"
) )
func TestNiriParseModKey(t *testing.T) {
config := `input {
mod-key "Alt"
}
binds {
Mod+T { spawn "kitty"; }
}
`
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if result.ModKey != "Alt" {
t.Errorf("ModKey = %q, want %q", result.ModKey, "Alt")
}
}
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) { func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
config := `recent-windows { config := `recent-windows {
binds { binds {
+60 -1
View File
@@ -17,7 +17,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
content := `binds { content := `input {
mod-key "Alt"
}
binds {
Mod+Q { close-window; } Mod+Q { close-window; }
Mod+F { fullscreen-window; } Mod+F { fullscreen-window; }
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
@@ -45,6 +48,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri") t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri")
} }
if cheatSheet.ModKey != "Alt" {
t.Errorf("ModKey = %q, want %q", cheatSheet.ModKey, "Alt")
}
windowBinds := cheatSheet.Binds["Window"] windowBinds := cheatSheet.Binds["Window"]
if len(windowBinds) < 2 { if len(windowBinds) < 2 {
t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds)) t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds))
@@ -227,6 +234,58 @@ func TestNiriGenerateBindsContent(t *testing.T) {
expected: `binds { expected: `binds {
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; } Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
} }
`,
},
{
name: "spawn with equals arg",
binds: map[string]*overrideBind{
"Mod+B": {
Key: "Mod+B",
Action: `spawn /opt/browser --profile-directory=Default`,
},
},
expected: `binds {
Mod+B { spawn "/opt/browser" "--profile-directory=Default"; }
}
`,
},
{
name: "spawn shell command with quoted equals args",
binds: map[string]*overrideBind{
"Mod+C": {
Key: "Mod+C",
Action: `spawn sh -c "chrome --profile-directory=Default --app=x"`,
},
},
expected: `binds {
Mod+C { spawn "sh" "-c" "chrome --profile-directory=Default --app=x"; }
}
`,
},
{
name: "spawn env assignment stays arg",
binds: map[string]*overrideBind{
"Mod+E": {
Key: "Mod+E",
Action: `spawn env FOO=bar mycmd`,
},
},
expected: `binds {
Mod+E { spawn "env" "FOO=bar" "mycmd"; }
}
`,
},
{
name: "niri action property remains property",
binds: map[string]*overrideBind{
"Print": {
Key: "Print",
Action: `screenshot show-pointer=false`,
},
},
expected: `binds {
Print { screenshot show-pointer=false; }
}
`, `,
}, },
{ {
+1
View File
@@ -32,6 +32,7 @@ type DMSBindsStatus struct {
type CheatSheet struct { type CheatSheet struct {
Title string `json:"title"` Title string `json:"title"`
Provider string `json:"provider"` Provider string `json:"provider"`
ModKey string `json:"modKey,omitempty"`
Binds map[string][]Keybind `json:"binds"` Binds map[string][]Keybind `json:"binds"`
DMSBindsIncluded bool `json:"dmsBindsIncluded"` DMSBindsIncluded bool `json:"dmsBindsIncluded"`
DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"` DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"`
+70 -1
View File
@@ -118,6 +118,50 @@ type ColorsOutput struct {
} `json:"colors"` } `json:"colors"`
} }
type SchemePreview struct {
Dark string `json:"dark"`
Light string `json:"light"`
}
var previewSchemeTypes = []string{
"scheme-tonal-spot",
"scheme-vibrant",
"scheme-content",
"scheme-expressive",
"scheme-fidelity",
"scheme-fruit-salad",
"scheme-monochrome",
"scheme-neutral",
"scheme-rainbow",
}
func PreviewSchemes(sourceColor string, contrast float64) (map[string]SchemePreview, error) {
if sourceColor == "" {
return nil, fmt.Errorf("source color is required")
}
previews := make(map[string]SchemePreview, len(previewSchemeTypes))
for _, schemeType := range previewSchemeTypes {
output, err := runMatugenDryRun(&Options{
Kind: "hex",
Value: sourceColor,
MatugenType: schemeType,
Contrast: contrast,
})
if err != nil {
return nil, fmt.Errorf("preview %s: %w", schemeType, err)
}
dark := extractMatugenColor(output, "primary", "dark")
light := extractMatugenColor(output, "primary", "light")
if dark == "" || light == "" {
return nil, fmt.Errorf("preview %s: primary colors missing from matugen output", schemeType)
}
previews[schemeType] = SchemePreview{Dark: dark, Light: light}
}
return previews, nil
}
func (o *Options) ColorsOutput() string { func (o *Options) ColorsOutput() string {
return filepath.Join(o.StateDir, "dms-colors.json") return filepath.Join(o.StateDir, "dms-colors.json")
} }
@@ -655,6 +699,7 @@ func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
func detectMatugenVersionLocked() (matugenFlags, error) { func detectMatugenVersionLocked() (matugenFlags, error) {
cmd := exec.Command("matugen", "--version") cmd := exec.Command("matugen", "--version")
cmd.Env = utils.EnvWithUserBinPath(nil)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err) return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
@@ -711,6 +756,7 @@ func runMatugen(baseArgs []string) error {
args := buildMatugenArgs(baseArgs, flags) args := buildMatugenArgs(baseArgs, flags)
cmd := exec.Command("matugen", args...) cmd := exec.Command("matugen", args...)
cmd.Env = utils.EnvWithUserBinPath(nil)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
runErr := cmd.Run() runErr := cmd.Run()
@@ -728,6 +774,7 @@ func runMatugen(baseArgs []string) error {
log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4) log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
args = buildMatugenArgs(baseArgs, newFlags) args = buildMatugenArgs(baseArgs, newFlags)
retryCmd := exec.Command("matugen", args...) retryCmd := exec.Command("matugen", args...)
retryCmd.Env = utils.EnvWithUserBinPath(nil)
retryCmd.Stdout = os.Stdout retryCmd.Stdout = os.Stdout
retryCmd.Stderr = os.Stderr retryCmd.Stderr = os.Stderr
return retryCmd.Run() return retryCmd.Run()
@@ -770,6 +817,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
} }
cmd := exec.Command("matugen", baseArgs...) cmd := exec.Command("matugen", baseArgs...)
cmd.Env = utils.EnvWithUserBinPath(nil)
var stderr strings.Builder var stderr strings.Builder
cmd.Stderr = &stderr cmd.Stderr = &stderr
output, err := cmd.Output() output, err := cmd.Output()
@@ -868,7 +916,26 @@ func refreshGTK(mode ColorMode) {
} }
} }
var colorSchemeEchoHook func(scheme string)
func SetColorSchemeEchoHook(hook func(scheme string)) {
colorSchemeEchoHook = hook
}
func expectColorSchemeEcho(scheme string) {
if colorSchemeEchoHook != nil {
colorSchemeEchoHook(scheme)
}
}
// The color-scheme round trip is the only mechanism that makes running GTK4
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
// following the portal color-scheme (Chromium) can drop the restore signal
// mid-repaint and latch the wrong mode, so this is opt-in.
func refreshGTK4() { func refreshGTK4() {
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
return
}
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme") output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
if err != nil { if err != nil {
return return
@@ -882,11 +949,13 @@ func refreshGTK4() {
toggle = "prefer-dark" toggle = "prefer-dark"
} }
expectColorSchemeEcho(toggle)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil { if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err) log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
return return
} }
time.Sleep(50 * time.Millisecond) time.Sleep(400 * time.Millisecond)
expectColorSchemeEcho(current)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil { if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err) log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
} }
+23 -8
View File
@@ -554,35 +554,50 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
return "", nil return "", nil
} }
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) { func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
pluginPath, err := m.findInstalledPath(pluginID) pluginPath, err := m.findInstalledPath(pluginID)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to find plugin: %w", err) return false, "", fmt.Errorf("failed to find plugin: %w", err)
} }
if pluginPath == "" { if pluginPath == "" {
return false, fmt.Errorf("plugin not installed: %s", pluginID) return false, "", fmt.Errorf("plugin not installed: %s", pluginID)
} }
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return false, nil return false, "", nil
} }
metaPath := pluginPath + ".meta" metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath) metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to check metadata: %w", err) return false, "", fmt.Errorf("failed to check metadata: %w", err)
} }
var hasUp bool
var localHash, remoteHash string
if metaExists { if metaExists {
// Plugin is from a monorepo, check the repo directory // Plugin is from a monorepo, check the repo directory
reposDir := filepath.Join(m.pluginsDir, ".repos") reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo) repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName) repoPath := filepath.Join(reposDir, repoName)
return m.gitClient.HasUpdates(repoPath) hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
} else {
// Plugin is a standalone repo
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
} }
// Plugin is a standalone repo if err != nil {
return m.gitClient.HasUpdates(pluginPath) return false, "", err
}
diffURL = plugin.Repo
if diffURL != "" {
diffURL = strings.TrimSuffix(diffURL, ".git")
if hasUp && len(localHash) >= 7 && len(remoteHash) >= 7 {
diffURL = fmt.Sprintf("%s/compare/%s...%s", diffURL, localHash[:7], remoteHash[:7])
}
}
return hasUp, diffURL, nil
} }
+10 -9
View File
@@ -33,7 +33,7 @@ type Plugin struct {
type GitClient interface { type GitClient interface {
PlainClone(path string, url string) error PlainClone(path string, url string) error
Pull(path string) error Pull(path string) error
HasUpdates(path string) (bool, error) HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
} }
type realGitClient struct{} type realGitClient struct{}
@@ -65,10 +65,10 @@ func (g *realGitClient) Pull(path string) error {
return nil return nil
} }
func (g *realGitClient) HasUpdates(path string) (bool, error) { func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
repo, err := git.PlainOpen(path) repo, err := git.PlainOpen(path)
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
// Fetch remote changes // Fetch remote changes
@@ -76,24 +76,24 @@ func (g *realGitClient) HasUpdates(path string) (bool, error) {
if err != nil && err.Error() != "already up-to-date" { if err != nil && err.Error() != "already up-to-date" {
// If fetch fails, we can't determine if there are updates // If fetch fails, we can't determine if there are updates
// Return false and the error // Return false and the error
return false, err return false, "", "", err
} }
// Get the HEAD reference // Get the HEAD reference
head, err := repo.Head() head, err := repo.Head()
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
// Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master) // Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master)
remote, err := repo.Remote("origin") remote, err := repo.Remote("origin")
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
refs, err := remote.List(&git.ListOptions{}) refs, err := remote.List(&git.ListOptions{})
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
// Find the default branch remote ref // Find the default branch remote ref
@@ -108,13 +108,14 @@ func (g *realGitClient) HasUpdates(path string) (bool, error) {
} }
} }
localHash := head.Hash().String()
// If we couldn't find a remote HEAD, assume no updates // If we couldn't find a remote HEAD, assume no updates
if remoteHead == "" { if remoteHead == "" {
return false, nil return false, localHash, "", nil
} }
// Compare local HEAD with remote HEAD // Compare local HEAD with remote HEAD
return head.Hash().String() != remoteHead, nil return localHash != remoteHead, localHash, remoteHead, nil
} }
type Registry struct { type Registry struct {
+3 -3
View File
@@ -13,7 +13,7 @@ import (
type mockGitClient struct { type mockGitClient struct {
cloneFunc func(path string, url string) error cloneFunc func(path string, url string) error
pullFunc func(path string) error pullFunc func(path string) error
hasUpdatesFunc func(path string) (bool, error) hasUpdatesFunc func(path string) (bool, string, string, error)
} }
func (m *mockGitClient) PlainClone(path string, url string) error { func (m *mockGitClient) PlainClone(path string, url string) error {
@@ -30,11 +30,11 @@ func (m *mockGitClient) Pull(path string) error {
return nil return nil
} }
func (m *mockGitClient) HasUpdates(path string) (bool, error) { func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
if m.hasUpdatesFunc != nil { if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path) return m.hasUpdatesFunc(path)
} }
return false, nil return false, "", "", nil
} }
func TestNewRegistry(t *testing.T) { func TestNewRegistry(t *testing.T) {
@@ -0,0 +1,289 @@
// Generated by go-wayland-scanner
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
// XML file : internal/proto/xml/virtual-keyboard-unstable-v1.xml
//
// virtual_keyboard_unstable_v1 Protocol Copyright:
//
// Copyright © 2008-2011 Kristian Høgsberg
// Copyright © 2010-2013 Intel Corporation
// Copyright © 2012-2013 Collabora, Ltd.
// Copyright © 2018 Purism SPC
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice (including the next
// paragraph) shall be included in all copies or substantial portions of the
// Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
package virtual_keyboard
import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
// ZwpVirtualKeyboardV1InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZwpVirtualKeyboardV1InterfaceName = "zwp_virtual_keyboard_v1"
// ZwpVirtualKeyboardV1 : virtual keyboard
//
// The virtual keyboard provides an application with requests which emulate
// the behaviour of a physical keyboard.
//
// This interface can be used by clients on its own to provide raw input
// events, or it can accompany the input method protocol.
type ZwpVirtualKeyboardV1 struct {
client.BaseProxy
}
// NewZwpVirtualKeyboardV1 : virtual keyboard
//
// The virtual keyboard provides an application with requests which emulate
// the behaviour of a physical keyboard.
//
// This interface can be used by clients on its own to provide raw input
// events, or it can accompany the input method protocol.
func NewZwpVirtualKeyboardV1(ctx *client.Context) *ZwpVirtualKeyboardV1 {
zwpVirtualKeyboardV1 := &ZwpVirtualKeyboardV1{}
ctx.Register(zwpVirtualKeyboardV1)
return zwpVirtualKeyboardV1
}
// Keymap : keyboard mapping
//
// Provide a file descriptor to the compositor which can be
// memory-mapped to provide a keyboard mapping description.
//
// Format carries a value from the keymap_format enumeration.
//
// format: keymap format
// fd: keymap file descriptor
// size: keymap size, in bytes
func (i *ZwpVirtualKeyboardV1) Keymap(format uint32, fd int, size uint32) error {
const opcode = 0
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(format))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(size))
l += 4
oob := unix.UnixRights(int(fd))
err := i.Context().WriteMsg(_reqBuf[:], oob)
return err
}
// Key : key event
//
// A key was pressed or released.
// The time argument is a timestamp with millisecond granularity, with an
// undefined base. All requests regarding a single object must share the
// same clock.
//
// Keymap must be set before issuing this request.
//
// State carries a value from the key_state enumeration.
//
// time: timestamp with millisecond granularity
// key: key that produced the event
// state: physical state of the key
func (i *ZwpVirtualKeyboardV1) Key(time, key, state uint32) error {
const opcode = 1
const _reqBufLen = 8 + 4 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(time))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(key))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(state))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// Modifiers : modifier and group state
//
// Notifies the compositor that the modifier and/or group state has
// changed, and it should update state.
//
// The client should use wl_keyboard.modifiers event to synchronize its
// internal state with seat state.
//
// Keymap must be set before issuing this request.
//
// modsDepressed: depressed modifiers
// modsLatched: latched modifiers
// modsLocked: locked modifiers
// group: keyboard layout
func (i *ZwpVirtualKeyboardV1) Modifiers(modsDepressed, modsLatched, modsLocked, group uint32) error {
const opcode = 2
const _reqBufLen = 8 + 4 + 4 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(modsDepressed))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(modsLatched))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(modsLocked))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(group))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// Destroy : destroy the virtual keyboard keyboard object
func (i *ZwpVirtualKeyboardV1) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 3
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
type ZwpVirtualKeyboardV1Error uint32
// ZwpVirtualKeyboardV1Error :
const (
// ZwpVirtualKeyboardV1ErrorNoKeymap : No keymap was set
ZwpVirtualKeyboardV1ErrorNoKeymap ZwpVirtualKeyboardV1Error = 0
)
func (e ZwpVirtualKeyboardV1Error) Name() string {
switch e {
case ZwpVirtualKeyboardV1ErrorNoKeymap:
return "no_keymap"
default:
return ""
}
}
func (e ZwpVirtualKeyboardV1Error) Value() string {
switch e {
case ZwpVirtualKeyboardV1ErrorNoKeymap:
return "0"
default:
return ""
}
}
func (e ZwpVirtualKeyboardV1Error) String() string {
return e.Name() + "=" + e.Value()
}
// ZwpVirtualKeyboardManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZwpVirtualKeyboardManagerV1InterfaceName = "zwp_virtual_keyboard_manager_v1"
// ZwpVirtualKeyboardManagerV1 : virtual keyboard manager
//
// A virtual keyboard manager allows an application to provide keyboard
// input events as if they came from a physical keyboard.
type ZwpVirtualKeyboardManagerV1 struct {
client.BaseProxy
}
// NewZwpVirtualKeyboardManagerV1 : virtual keyboard manager
//
// A virtual keyboard manager allows an application to provide keyboard
// input events as if they came from a physical keyboard.
func NewZwpVirtualKeyboardManagerV1(ctx *client.Context) *ZwpVirtualKeyboardManagerV1 {
zwpVirtualKeyboardManagerV1 := &ZwpVirtualKeyboardManagerV1{}
ctx.Register(zwpVirtualKeyboardManagerV1)
return zwpVirtualKeyboardManagerV1
}
// CreateVirtualKeyboard : Create a new virtual keyboard
//
// Creates a new virtual keyboard associated to a seat.
//
// If the compositor enables a keyboard to perform arbitrary actions, it
// should present an error when an untrusted client requests a new
// keyboard.
func (i *ZwpVirtualKeyboardManagerV1) CreateVirtualKeyboard(seat *client.Seat) (*ZwpVirtualKeyboardV1, error) {
id := NewZwpVirtualKeyboardV1(i.Context())
const opcode = 0
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], seat.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], id.ID())
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return id, err
}
func (i *ZwpVirtualKeyboardManagerV1) Destroy() error {
i.Context().Unregister(i)
return nil
}
type ZwpVirtualKeyboardManagerV1Error uint32
// ZwpVirtualKeyboardManagerV1Error :
const (
// ZwpVirtualKeyboardManagerV1ErrorUnauthorized : client not authorized to use the interface
ZwpVirtualKeyboardManagerV1ErrorUnauthorized ZwpVirtualKeyboardManagerV1Error = 0
)
func (e ZwpVirtualKeyboardManagerV1Error) Name() string {
switch e {
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
return "unauthorized"
default:
return ""
}
}
func (e ZwpVirtualKeyboardManagerV1Error) Value() string {
switch e {
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
return "0"
default:
return ""
}
}
func (e ZwpVirtualKeyboardManagerV1Error) String() string {
return e.Name() + "=" + e.Value()
}
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="virtual_keyboard_unstable_v1">
<copyright>
Copyright © 2008-2011 Kristian Høgsberg
Copyright © 2010-2013 Intel Corporation
Copyright © 2012-2013 Collabora, Ltd.
Copyright © 2018 Purism SPC
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="zwp_virtual_keyboard_v1" version="1">
<description summary="virtual keyboard">
The virtual keyboard provides an application with requests which emulate
the behaviour of a physical keyboard.
This interface can be used by clients on its own to provide raw input
events, or it can accompany the input method protocol.
</description>
<request name="keymap">
<description summary="keyboard mapping">
Provide a file descriptor to the compositor which can be
memory-mapped to provide a keyboard mapping description.
Format carries a value from the keymap_format enumeration.
</description>
<arg name="format" type="uint" summary="keymap format"/>
<arg name="fd" type="fd" summary="keymap file descriptor"/>
<arg name="size" type="uint" summary="keymap size, in bytes"/>
</request>
<enum name="error">
<entry name="no_keymap" value="0" summary="No keymap was set"/>
</enum>
<request name="key">
<description summary="key event">
A key was pressed or released.
The time argument is a timestamp with millisecond granularity, with an
undefined base. All requests regarding a single object must share the
same clock.
Keymap must be set before issuing this request.
State carries a value from the key_state enumeration.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="key" type="uint" summary="key that produced the event"/>
<arg name="state" type="uint" summary="physical state of the key"/>
</request>
<request name="modifiers">
<description summary="modifier and group state">
Notifies the compositor that the modifier and/or group state has
changed, and it should update state.
The client should use wl_keyboard.modifiers event to synchronize its
internal state with seat state.
Keymap must be set before issuing this request.
</description>
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
<arg name="group" type="uint" summary="keyboard layout"/>
</request>
<request name="destroy" type="destructor" since="1">
<description summary="destroy the virtual keyboard keyboard object"/>
</request>
</interface>
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
<description summary="virtual keyboard manager">
A virtual keyboard manager allows an application to provide keyboard
input events as if they came from a physical keyboard.
</description>
<enum name="error">
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
</enum>
<request name="create_virtual_keyboard">
<description summary="Create a new virtual keyboard">
Creates a new virtual keyboard associated to a seat.
If the compositor enables a keyboard to perform arbitrary actions, it
should present an error when an untrusted client requests a new
keyboard.
</description>
<arg name="seat" type="object" interface="wl_seat"/>
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
</request>
</interface>
</protocol>
+8 -1
View File
@@ -68,6 +68,12 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
activeBuses[i] = true activeBuses[i] = true
id := fmt.Sprintf("ddc:i2c-%d", i) id := fmt.Sprintf("ddc:i2c-%d", i)
// Don't re-probe identified monitors: DDC traffic during a wake
// sequence can disturb some monitors' own brightness handling.
if _, ok := b.devices.Load(id); ok {
continue
}
dev, err := b.probeDDCDevice(i) dev, err := b.probeDDCDevice(i)
if err != nil || dev == nil { if err != nil || dev == nil {
continue continue
@@ -107,7 +113,8 @@ func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
dummy := make([]byte, 32) dummy := make([]byte, 32)
syscall.Read(fd, dummy) //nolint:errcheck syscall.Read(fd, dummy) //nolint:errcheck
writebuf := []byte{0x00} writebuf := []byte{DDC_SOURCE_ADDR, 0x80}
writebuf = append(writebuf, ddcciChecksum(writebuf))
n, err := syscall.Write(fd, writebuf) n, err := syscall.Write(fd, writebuf)
if err == nil && n == len(writebuf) { if err == nil && n == len(writebuf) {
name := b.getDDCName(bus) name := b.getDDCName(bus)
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net" "net"
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
) )
@@ -28,6 +29,10 @@ func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
handleCopyEntry(conn, req, m) handleCopyEntry(conn, req, m)
case "clipboard.paste": case "clipboard.paste":
handlePaste(conn, req, m) handlePaste(conn, req, m)
case "clipboard.sendPaste":
handleSendPaste(conn, req)
case "clipboard.pasteSupported":
models.Respond(conn, req.ID, map[string]bool{"supported": m.pasteSupported})
case "clipboard.subscribe": case "clipboard.subscribe":
handleSubscribe(conn, req, m) handleSubscribe(conn, req, m)
case "clipboard.search": case "clipboard.search":
@@ -176,6 +181,17 @@ func handlePaste(conn net.Conn, req models.Request, m *Manager) {
models.Respond(conn, req.ID, map[string]string{"text": text}) models.Respond(conn, req.ID, map[string]string{"text": text})
} }
func handleSendPaste(conn net.Conn, req models.Request) {
shift, _ := models.Get[bool](req, "shift")
if err := clipboardstore.SendPasteKeystroke(shift); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "paste sent"})
}
func handleSubscribe(conn net.Conn, req models.Request, m *Manager) { func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
clientID := fmt.Sprintf("clipboard-%d", req.ID) clientID := fmt.Sprintf("clipboard-%d", req.ID)
+31 -76
View File
@@ -31,6 +31,7 @@ import (
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
@@ -164,6 +165,8 @@ func (m *Manager) setupRegistry() error {
m.seat = seat m.seat = seat
m.seatName = e.Name m.seatName = e.Name
log.Info("Bound wl_seat") log.Info("Bound wl_seat")
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
m.pasteSupported = true
} }
}) })
@@ -1054,6 +1057,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
dataCopy := make([]byte, len(data)) dataCopy := make([]byte, len(data))
copy(dataCopy, data) copy(dataCopy, data)
m.takeSelection(clipboardstore.ExpandOffers(dataCopy, mimeType))
return nil
}
// takeSelection makes the daemon the selection owner, serving the given
// offers until another client claims the clipboard.
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
m.post(func() { m.post(func() {
if m.dataControlMgr == nil || m.dataDevice == nil { if m.dataControlMgr == nil || m.dataDevice == nil {
log.Error("Data control manager or device not initialized") log.Error("Data control manager or device not initialized")
@@ -1068,9 +1078,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
return return
} }
if err := source.Offer(mimeType); err != nil { offerData := make(map[string][]byte, len(offers))
log.Errorf("Failed to offer mime type: %v", err) for _, offer := range offers {
return if err := source.Offer(offer.MimeType); err != nil {
log.Errorf("Failed to offer %s: %v", offer.MimeType, err)
return
}
offerData[offer.MimeType] = offer.Data
} }
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
@@ -1080,7 +1094,11 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
file := os.NewFile(uintptr(fd), "clipboard-pipe") file := os.NewFile(uintptr(fd), "clipboard-pipe")
defer file.Close() defer file.Close()
if _, err := file.Write(dataCopy); err != nil { data, ok := offerData[e.MimeType]
if !ok {
return
}
if _, err := file.Write(data); err != nil {
log.Errorf("Failed to write clipboard data: %v", err) log.Errorf("Failed to write clipboard data: %v", err)
} }
}) })
@@ -1093,9 +1111,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
m.releaseCurrentSource() m.releaseCurrentSource()
m.currentSource = source m.currentSource = source
m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType}
m.sourceMutex.Unlock()
m.ownerLock.Lock() m.ownerLock.Lock()
m.isOwner = true m.isOwner = true
@@ -1106,8 +1121,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
log.Errorf("Failed to set selection: %v", err) log.Errorf("Failed to set selection: %v", err)
} }
}) })
return nil
} }
func (m *Manager) CopyText(text string) error { func (m *Manager) CopyText(text string) error {
@@ -1779,74 +1792,16 @@ func (m *Manager) CopyFile(filePath string) error {
m.updateState() m.updateState()
m.notifySubscribers() m.notifySubscribers()
_, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData)) offers := []clipboardstore.Offer{
{MimeType: "x-special/gnome-copied-files", Data: []byte("copy\n" + fileURI)},
m.post(func() { {MimeType: "text/uri-list", Data: []byte(fileURI + "\r\n")},
if m.dataControlMgr == nil || m.dataDevice == nil { {MimeType: "text/plain", Data: []byte(filePath)},
log.Error("Data control manager or device not initialized") }
return if _, imgMime, err := image.DecodeConfig(bytes.NewReader(fileData)); err == nil {
} offers = append(offers, clipboardstore.Offer{MimeType: "image/" + imgMime, Data: fileData})
}
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
source, err := dataMgr.CreateDataSource()
if err != nil {
log.Errorf("Failed to create data source: %v", err)
return
}
type offer struct {
mime string
data []byte
}
offers := []offer{
{"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
{"text/uri-list", []byte(fileURI + "\r\n")},
{"text/plain", []byte(filePath)},
}
if imgErr == nil {
imgMimeType := "image/" + imgMime
offers = append(offers, offer{imgMimeType, fileData})
}
offerData := make(map[string][]byte)
for _, o := range offers {
if err := source.Offer(o.mime); err != nil {
log.Errorf("Failed to offer %s: %v", o.mime, err)
return
}
offerData[o.mime] = o.data
}
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
fd := e.Fd
defer syscall.Close(fd)
file := os.NewFile(uintptr(fd), "clipboard-pipe")
defer file.Close()
if data, ok := offerData[e.MimeType]; ok {
file.Write(data)
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.releaseCurrentSource()
m.currentSource = source
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err)
}
})
m.takeSelection(offers)
return nil return nil
} }
@@ -531,47 +531,6 @@ func TestManager_ConcurrentOfferAccess(t *testing.T) {
wg.Wait() wg.Wait()
} }
func TestManager_ConcurrentPersistAccess(t *testing.T) {
m := &Manager{
persistData: make(map[string][]byte),
persistMimeTypes: []string{},
}
var wg sync.WaitGroup
const goroutines = 20
const iterations = 50
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.persistMutex.RLock()
_ = m.persistData
_ = m.persistMimeTypes
m.persistMutex.RUnlock()
}
}()
}
for i := 0; i < goroutines/2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
m.persistMutex.Lock()
m.persistMimeTypes = []string{"text/plain", "text/html"}
m.persistData = map[string][]byte{
"text/plain": []byte("test"),
}
m.persistMutex.Unlock()
}
}(i)
}
wg.Wait()
}
func TestManager_ConcurrentOwnerAccess(t *testing.T) { func TestManager_ConcurrentOwnerAccess(t *testing.T) {
m := &Manager{} m := &Manager{}
+3 -9
View File
@@ -132,15 +132,9 @@ type Manager struct {
offerMutex sync.RWMutex offerMutex sync.RWMutex
offerRegistry map[uint32]any offerRegistry map[uint32]any
sourceMimeTypes []string isOwner bool
sourceMutex sync.RWMutex ownerLock sync.Mutex
pasteSupported bool
persistData map[string][]byte
persistMimeTypes []string
persistMutex sync.RWMutex
isOwner bool
ownerLock sync.Mutex
initialized bool initialized bool
+39 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"sync" "sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
@@ -100,6 +101,41 @@ func (m *Manager) initializeSettings() error {
return nil return nil
} }
// ExpectColorSchemeEcho registers a self-write so the watcher swallows its SettingChanged echo instead of forwarding it as an external change.
func (m *Manager) ExpectColorSchemeEcho(scheme string) {
var value uint32
switch scheme {
case "prefer-dark":
value = 1
case "prefer-light":
value = 2
}
m.selfEchoMu.Lock()
m.selfEchoes = append(m.selfEchoes, colorSchemeEcho{value: value, expires: time.Now().Add(10 * time.Second)})
m.selfEchoMu.Unlock()
}
func (m *Manager) consumeSelfEcho(value uint32) bool {
m.selfEchoMu.Lock()
defer m.selfEchoMu.Unlock()
now := time.Now()
kept := m.selfEchoes[:0]
consumed := false
for _, echo := range m.selfEchoes {
if now.After(echo.expires) {
continue
}
if !consumed && echo.value == value {
consumed = true
continue
}
kept = append(kept, echo)
}
m.selfEchoes = kept
return consumed
}
func (m *Manager) watchSettingsChanges() { func (m *Manager) watchSettingsChanges() {
conn, err := dbus.ConnectSessionBus() conn, err := dbus.ConnectSessionBus()
if err != nil { if err != nil {
@@ -142,13 +178,15 @@ func (m *Manager) watchSettingsChanges() {
continue continue
} }
selfInflicted := m.consumeSelfEcho(colorScheme)
m.stateMutex.Lock() m.stateMutex.Lock()
changed := m.state.Settings.ColorScheme != colorScheme || !m.state.Settings.Available changed := m.state.Settings.ColorScheme != colorScheme || !m.state.Settings.Available
m.state.Settings.ColorScheme = colorScheme m.state.Settings.ColorScheme = colorScheme
m.state.Settings.Available = true m.state.Settings.Available = true
m.stateMutex.Unlock() m.stateMutex.Unlock()
if changed { if changed && !selfInflicted {
m.NotifySubscribers() m.NotifySubscribers()
} }
} }
@@ -141,3 +141,45 @@ func TestManager_SettingsState_Modification(t *testing.T) {
original := manager.GetState() original := manager.GetState()
assert.Equal(t, uint32(0), original.Settings.ColorScheme) assert.Equal(t, uint32(0), original.Settings.ColorScheme)
} }
func TestManager_SelfEcho_ConsumesRegisteredWrites(t *testing.T) {
manager := &Manager{state: &FreedeskState{}}
manager.ExpectColorSchemeEcho("prefer-dark")
manager.ExpectColorSchemeEcho("default")
assert.True(t, manager.consumeSelfEcho(1))
assert.True(t, manager.consumeSelfEcho(0))
assert.False(t, manager.consumeSelfEcho(1))
assert.False(t, manager.consumeSelfEcho(0))
}
func TestManager_SelfEcho_ExternalChangePassesThrough(t *testing.T) {
manager := &Manager{state: &FreedeskState{}}
manager.ExpectColorSchemeEcho("prefer-dark")
assert.False(t, manager.consumeSelfEcho(2))
assert.True(t, manager.consumeSelfEcho(1))
}
func TestManager_SelfEcho_ConsumesOnePerRegistration(t *testing.T) {
manager := &Manager{state: &FreedeskState{}}
manager.ExpectColorSchemeEcho("prefer-dark")
manager.ExpectColorSchemeEcho("prefer-dark")
assert.True(t, manager.consumeSelfEcho(1))
assert.True(t, manager.consumeSelfEcho(1))
assert.False(t, manager.consumeSelfEcho(1))
}
func TestManager_SelfEcho_SchemeMapping(t *testing.T) {
manager := &Manager{state: &FreedeskState{}}
manager.ExpectColorSchemeEcho("prefer-light")
assert.True(t, manager.consumeSelfEcho(2))
manager.ExpectColorSchemeEcho("default")
assert.True(t, manager.consumeSelfEcho(0))
}
@@ -2,11 +2,17 @@ package freedesktop
import ( import (
"sync" "sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
type colorSchemeEcho struct {
value uint32
expires time.Time
}
type AccountsState struct { type AccountsState struct {
Available bool `json:"available"` Available bool `json:"available"`
UserPath string `json:"userPath"` UserPath string `json:"userPath"`
@@ -63,4 +69,6 @@ type Manager struct {
screensaverCookieCounter uint32 screensaverCookieCounter uint32
screensaverFreedesktopClaimed bool screensaverFreedesktopClaimed bool
screensaverGnomeClaimed bool screensaverGnomeClaimed bool
selfEchoMu sync.Mutex
selfEchoes []colorSchemeEcho
} }
+13
View File
@@ -46,6 +46,19 @@ func (m *Manager) Activate() error {
return nil return nil
} }
func (m *Manager) SetLockedHint(locked bool) error {
err := m.sessionObj.Call(dbusSessionInterface+".SetLockedHint", 0, locked).Err
if err != nil {
if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
err = m.sessionObj.Call(dbusSessionInterface+".SetLockedHint", 0, locked).Err
}
if err != nil {
return fmt.Errorf("failed to set locked hint: %w", err)
}
}
return nil
}
func (m *Manager) SetIdleHint(idle bool) error { func (m *Manager) SetIdleHint(idle bool) error {
err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
if err != nil { if err != nil {
+16
View File
@@ -21,6 +21,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleActivate(conn, req, manager) handleActivate(conn, req, manager)
case "loginctl.setIdleHint": case "loginctl.setIdleHint":
handleSetIdleHint(conn, req, manager) handleSetIdleHint(conn, req, manager)
case "loginctl.setLockedHint":
handleSetLockedHint(conn, req, manager)
case "loginctl.setLockBeforeSuspend": case "loginctl.setLockBeforeSuspend":
handleSetLockBeforeSuspend(conn, req, manager) handleSetLockBeforeSuspend(conn, req, manager)
case "loginctl.setSleepInhibitorEnabled": case "loginctl.setSleepInhibitorEnabled":
@@ -78,6 +80,20 @@ func handleSetIdleHint(conn net.Conn, req models.Request, manager *Manager) {
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "idle hint set"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "idle hint set"})
} }
func handleSetLockedHint(conn net.Conn, req models.Request, manager *Manager) {
locked, err := params.Bool(req.Params, "locked")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := manager.SetLockedHint(locked); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "locked hint set"})
}
func handleSetLockBeforeSuspend(conn net.Conn, req models.Request, manager *Manager) { func handleSetLockBeforeSuspend(conn net.Conn, req models.Request, manager *Manager) {
enabled, err := params.Bool(req.Params, "enabled") enabled, err := params.Bool(req.Params, "enabled")
if err != nil { if err != nil {
+25 -1
View File
@@ -114,6 +114,14 @@ func (b *IWDBackend) discoverDevices() error {
return fmt.Errorf("failed to get managed objects: %w", err) return fmt.Errorf("failed to get managed objects: %w", err)
} }
return b.applyManagedObjects(objects)
}
func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant) error {
b.stationPath = ""
b.devicePath = ""
b.adapterPath = ""
for path, interfaces := range objects { for path, interfaces := range objects {
if _, hasStation := interfaces[iwdStationInterface]; hasStation { if _, hasStation := interfaces[iwdStationInterface]; hasStation {
b.stationPath = path b.stationPath = path
@@ -129,6 +137,13 @@ func (b *IWDBackend) discoverDevices() error {
b.stateMutex.Unlock() b.stateMutex.Unlock()
} }
} }
if poweredVar, ok := devProps["Powered"]; ok {
if powered, ok := poweredVar.Value().(bool); ok {
b.stateMutex.Lock()
b.state.WiFiEnabled = powered
b.stateMutex.Unlock()
}
}
} }
} }
if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter { if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter {
@@ -136,9 +151,18 @@ func (b *IWDBackend) discoverDevices() error {
} }
} }
if b.stationPath == "" || b.devicePath == "" { if b.devicePath == "" {
return fmt.Errorf("no WiFi device found") return fmt.Errorf("no WiFi device found")
} }
if b.stationPath == "" {
b.stateMutex.Lock()
b.state.WiFiEnabled = false
b.state.WiFiConnected = false
b.state.NetworkStatus = StatusDisconnected
b.state.WiFiNetworks = nil
b.stateMutex.Unlock()
log.Infof("iwd device %s has no station interface; treating WiFi as disabled", b.devicePath)
}
return nil return nil
} }
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -35,12 +36,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
} }
if b.stationPath != "" { if b.stationPath != "" {
err := b.conn.AddMatchSignal( if err := b.addStationSignalMatch(b.stationPath); err != nil {
dbus.WithMatchObjectPath(b.stationPath),
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
if err != nil {
return fmt.Errorf("failed to add station signal match: %w", err) return fmt.Errorf("failed to add station signal match: %w", err)
} }
} }
@@ -81,6 +77,48 @@ func (b *IWDBackend) refreshWiFiNetworkState() bool {
return b.updateSavedWiFiNetworks() == nil return b.updateSavedWiFiNetworks() == nil
} }
func (b *IWDBackend) addStationSignalMatch(path dbus.ObjectPath) error {
return b.conn.AddMatchSignal(
dbus.WithMatchObjectPath(path),
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
func (b *IWDBackend) handleStationAdded(path dbus.ObjectPath) bool {
if path == "" || path == b.stationPath {
return false
}
b.stationPath = path
if err := b.addStationSignalMatch(path); err != nil {
log.Warnf("Failed to add iwd station signal match for %s: %v", path, err)
}
if err := b.updateState(); err != nil {
log.Warnf("Failed to update iwd state after station appeared: %v", err)
}
return b.refreshWiFiNetworkState()
}
func (b *IWDBackend) handleStationRemoved(path dbus.ObjectPath) bool {
if path == "" || path != b.stationPath {
return false
}
b.stationPath = ""
b.stateMutex.Lock()
b.state.WiFiEnabled = false
b.state.WiFiConnected = false
b.state.WiFiSSID = ""
b.state.WiFiSignal = 0
b.state.NetworkStatus = StatusDisconnected
b.state.WiFiNetworks = nil
b.stateMutex.Unlock()
return true
}
func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
defer b.sigWG.Done() defer b.sigWG.Done()
@@ -98,7 +136,13 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
if sig.Name == dbusObjectManager+".InterfacesAdded" { if sig.Name == dbusObjectManager+".InterfacesAdded" {
if len(sig.Body) >= 2 { if len(sig.Body) >= 2 {
path, _ := sig.Body[0].(dbus.ObjectPath)
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok { if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
if _, ok := interfaces[iwdStationInterface]; ok {
if b.handleStationAdded(path) && b.onStateChange != nil {
b.onStateChange()
}
}
if _, ok := interfaces[iwdKnownNetworkInterface]; ok { if _, ok := interfaces[iwdKnownNetworkInterface]; ok {
if b.refreshWiFiNetworkState() && b.onStateChange != nil { if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange() b.onStateChange()
@@ -111,8 +155,15 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
if sig.Name == dbusObjectManager+".InterfacesRemoved" { if sig.Name == dbusObjectManager+".InterfacesRemoved" {
if len(sig.Body) >= 2 { if len(sig.Body) >= 2 {
path, _ := sig.Body[0].(dbus.ObjectPath)
if interfaces, ok := sig.Body[1].([]string); ok { if interfaces, ok := sig.Body[1].([]string); ok {
for _, iface := range interfaces { for _, iface := range interfaces {
if iface == iwdStationInterface {
if b.handleStationRemoved(path) && b.onStateChange != nil {
b.onStateChange()
}
break
}
if iface == iwdKnownNetworkInterface { if iface == iwdKnownNetworkInterface {
if b.refreshWiFiNetworkState() && b.onStateChange != nil { if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange() b.onStateChange()
@@ -169,6 +169,41 @@ func TestIWDBackend_MapIwdDBusError(t *testing.T) {
} }
} }
func TestIWDBackend_ApplyManagedObjects_DeviceWithoutStation(t *testing.T) {
backend, _ := NewIWDBackend()
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/0/1": {
iwdDeviceInterface: {
"Name": dbus.MakeVariant("wlan0"),
"Powered": dbus.MakeVariant(false),
},
},
})
assert.NoError(t, err)
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/1"), backend.devicePath)
assert.Empty(t, backend.stationPath)
state, err := backend.GetCurrentState()
assert.NoError(t, err)
assert.Equal(t, "wlan0", state.WiFiDevice)
assert.False(t, state.WiFiEnabled)
assert.False(t, state.WiFiConnected)
}
func TestIWDBackend_ApplyManagedObjects_NoDevice(t *testing.T) {
backend, _ := NewIWDBackend()
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/0": {
iwdAdapterInterface: {},
},
})
assert.ErrorContains(t, err, "no WiFi device found")
}
func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) { func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) {
objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{ objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/known_network/1": { "/net/connman/iwd/known_network/1": {
@@ -42,12 +42,15 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
for _, id := range installedNames { for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok { if plugin, ok := pluginMap[id]; ok {
hasUpdate := false hasUpdate := false
if hasUpdates, err := manager.HasUpdates(id, plugin); err == nil { diffURL := plugin.Repo
if hasUpdates, dURL, err := manager.HasUpdates(id, plugin); err == nil {
hasUpdate = hasUpdates hasUpdate = hasUpdates
diffURL = dURL
} }
info := pluginInfoFromPlugin(plugin) info := pluginInfoFromPlugin(plugin)
info.HasUpdate = hasUpdate info.HasUpdate = hasUpdate
info.DiffURL = diffURL
result = append(result, info) result = append(result, info)
} else { } else {
result = append(result, PluginInfo{ result = append(result, PluginInfo{
+1
View File
@@ -18,6 +18,7 @@ type PluginInfo struct {
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
HasUpdate bool `json:"hasUpdate,omitempty"` HasUpdate bool `json:"hasUpdate,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"` RequiresDMS string `json:"requires_dms,omitempty"`
DiffURL string `json:"diffUrl,omitempty"`
Upvotes int `json:"upvotes,omitempty"` Upvotes int `json:"upvotes,omitempty"`
Status []string `json:"status,omitempty"` Status []string `json:"status,omitempty"`
IssueURL string `json:"issueUrl,omitempty"` IssueURL string `json:"issueUrl,omitempty"`
+10
View File
@@ -23,6 +23,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
) )
@@ -56,6 +57,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "wallpaper.") {
if wallpaperManager == nil {
models.RespondError(conn, req.ID, "wallpaper manager not initialized")
return
}
wallpaper.HandleRequest(conn, req, wallpaperManager)
return
}
if strings.HasPrefix(req.Method, "loginctl.") { if strings.HasPrefix(req.Method, "loginctl.") {
if loginctlManager == nil { if loginctlManager == nil {
models.RespondError(conn, req.ID, "loginctl manager not initialized") models.RespondError(conn, req.ID, "loginctl manager not initialized")
+68 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
@@ -33,13 +34,14 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
) )
const APIVersion = 26 const APIVersion = 27
var CLIVersion = "dev" var CLIVersion = "dev"
@@ -73,6 +75,7 @@ var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager var themeModeManager *thememode.Manager
var wallpaperManager *wallpaper.Manager
var trayRecoveryManager *trayrecovery.Manager var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager var locationManager *location.Manager
var sysUpdateManager *sysupdate.Manager var sysUpdateManager *sysupdate.Manager
@@ -188,6 +191,7 @@ func InitializeFreedeskManager() error {
} }
freedesktopManager = manager freedesktopManager = manager
matugen.SetColorSchemeEchoHook(manager.ExpectColorSchemeEcho)
log.Info("Freedesktop manager initialized") log.Info("Freedesktop manager initialized")
return nil return nil
@@ -347,6 +351,13 @@ func InitializeThemeModeManager() error {
return nil return nil
} }
func InitializeWallpaperManager() error {
wallpaperManager = wallpaper.NewManager()
log.Info("Wallpaper rotation scheduler initialized")
return nil
}
func InitializeTrayRecoveryManager() error { func InitializeTrayRecoveryManager() error {
manager, err := trayrecovery.NewManager() manager, err := trayrecovery.NewManager()
if err != nil { if err != nil {
@@ -463,6 +474,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "theme.auto") caps = append(caps, "theme.auto")
} }
if wallpaperManager != nil {
caps = append(caps, "wallpaper")
}
if dbusManager != nil { if dbusManager != nil {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
@@ -529,6 +544,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "theme.auto") caps = append(caps, "theme.auto")
} }
if wallpaperManager != nil {
caps = append(caps, "wallpaper")
}
if locationManager != nil { if locationManager != nil {
caps = append(caps, "location") caps = append(caps, "location")
} }
@@ -841,6 +860,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}() }()
} }
if shouldSubscribe("wallpaper") && wallpaperManager != nil {
wg.Add(1)
wallpaperChan := wallpaperManager.Subscribe(clientID + "-wallpaper")
go func() {
defer wg.Done()
defer wallpaperManager.Unsubscribe(clientID + "-wallpaper")
initialState := wallpaperManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "wallpaper", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-wallpaperChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "wallpaper", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("bluetooth") && bluezManager != nil { if shouldSubscribe("bluetooth") && bluezManager != nil {
wg.Add(1) wg.Add(1)
bluezChan := bluezManager.Subscribe(clientID + "-bluetooth") bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
@@ -1286,6 +1337,9 @@ func cleanupManagers() {
if themeModeManager != nil { if themeModeManager != nil {
themeModeManager.Close() themeModeManager.Close()
} }
if wallpaperManager != nil {
wallpaperManager.Close()
}
if trayRecoveryManager != nil { if trayRecoveryManager != nil {
trayRecoveryManager.Close() trayRecoveryManager.Close()
} }
@@ -1578,6 +1632,19 @@ func Start(printDocs bool) error {
}() }()
} }
if err := InitializeWallpaperManager(); err != nil {
log.Warnf("Wallpaper scheduler unavailable: %v", err)
} else {
notifyCapabilityChange()
go func() {
<-loginctlReady
if loginctlManager == nil {
return
}
wallpaperManager.WatchLoginctl(loginctlManager)
}()
}
go func() { go func() {
<-loginctlReady <-loginctlReady
if loginctlManager == nil { if loginctlManager == nil {
@@ -0,0 +1,84 @@
package wallpaper
import (
"encoding/json"
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
if manager == nil {
models.RespondError(conn, req.ID, "wallpaper manager not initialized")
return
}
switch req.Method {
case "wallpaper.getState":
handleGetState(conn, req, manager)
case "wallpaper.setConfig":
handleSetConfig(conn, req, manager)
case "wallpaper.trigger":
handleTrigger(conn, req, manager)
case "wallpaper.subscribe":
handleSubscribe(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
models.Respond(conn, req.ID, manager.GetState())
}
func handleSetConfig(conn net.Conn, req models.Request, manager *Manager) {
raw, ok := params.Any(req.Params, "config")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'config' parameter")
return
}
data, err := json.Marshal(raw)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
manager.SetConfig(config)
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wallpaper schedule set"})
}
func handleTrigger(conn net.Conn, req models.Request, manager *Manager) {
manager.ResetSchedule(params.StringOpt(req.Params, "target", ""))
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wallpaper schedule reset"})
}
func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
clientID := fmt.Sprintf("client-%p", conn)
stateChan := manager.Subscribe(clientID)
defer manager.Unsubscribe(clientID)
initialState := manager.GetState()
if err := json.NewEncoder(conn).Encode(models.Response[State]{
ID: req.ID,
Result: &initialState,
}); err != nil {
return
}
for state := range stateChan {
if err := json.NewEncoder(conn).Encode(models.Response[State]{
Result: &state,
}); err != nil {
return
}
}
}
+374
View File
@@ -0,0 +1,374 @@
package wallpaper
import (
"reflect"
"strconv"
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
type activeSchedule struct {
cfg ScheduleConfig
nextFire time.Time
}
// Grace period before a missed-while-off schedule fires, so a freshly
// connected frontend has its cycle-seq baseline before the bump arrives.
var catchUpDelay = 3 * time.Second
type Manager struct {
config Config
configMutex sync.RWMutex
state *State
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan State]
stopChan chan struct{}
updateTrigger chan struct{}
resetReq chan string
wg sync.WaitGroup
// Owned by schedulerLoop after start; keyed like schedules ("" = global).
lastFires map[string]time.Time
persistFires func(map[string]time.Time)
}
func NewManager() *Manager {
return newManager(loadLastFires(), saveLastFires)
}
func newManager(lastFires map[string]time.Time, persistFires func(map[string]time.Time)) *Manager {
if lastFires == nil {
lastFires = map[string]time.Time{}
}
m := &Manager{
config: Config{
Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"},
Monitors: map[string]ScheduleConfig{},
},
stopChan: make(chan struct{}),
updateTrigger: make(chan struct{}, 1),
resetReq: make(chan string, 8),
lastFires: lastFires,
persistFires: persistFires,
}
m.state = &State{Config: m.getConfig()}
m.wg.Add(1)
go m.schedulerLoop()
return m
}
func (m *Manager) GetState() State {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
return State{Config: m.getConfig()}
}
return *m.state
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 64)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) SetConfig(config Config) {
if config.Monitors == nil {
config.Monitors = map[string]ScheduleConfig{}
}
m.configMutex.Lock()
if reflect.DeepEqual(m.config, config) {
m.configMutex.Unlock()
return
}
m.config = config
m.configMutex.Unlock()
m.TriggerUpdate()
}
func (m *Manager) ResetSchedule(target string) {
select {
case m.resetReq <- target:
default:
}
}
func (m *Manager) TriggerUpdate() {
select {
case m.updateTrigger <- struct{}{}:
default:
}
}
func (m *Manager) Close() {
select {
case <-m.stopChan:
return
default:
close(m.stopChan)
}
m.wg.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
ch := lm.Subscribe("wallpaper")
m.wg.Go(func() {
defer lm.Unsubscribe("wallpaper")
for {
select {
case <-m.stopChan:
return
case state, ok := <-ch:
if !ok {
return
}
if state.PreparingForSleep {
continue
}
m.TriggerUpdate()
}
}
})
}
func (m *Manager) schedulerLoop() {
defer m.wg.Done()
schedules := map[string]*activeSchedule{}
resets := map[string]bool{}
var seq uint64
var timer *time.Timer
for {
now := time.Now()
config := m.getConfig()
active := activeSchedules(config)
for key := range schedules {
if _, ok := active[key]; !ok {
delete(schedules, key)
}
}
firesDirty := false
for key, cfg := range active {
s, ok := schedules[key]
switch {
case !ok:
s = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)}
schedules[key] = s
if cfg.Mode == "time" {
last := m.lastFires[key]
prev, valid := prevDailyTime(now, cfg.Time)
switch {
case last.IsZero():
// First enable (or no history): seed instead of firing.
m.lastFires[key] = now
firesDirty = true
case valid && last.Before(prev):
// Scheduled time passed while dms wasn't running.
s.nextFire = now.Add(catchUpDelay)
}
}
case s.cfg != cfg || resets[key]:
s.cfg = cfg
s.nextFire = computeNext(now, cfg)
}
delete(resets, key)
}
var dueKeys []string
for key, s := range schedules {
if !s.nextFire.After(now) {
dueKeys = append(dueKeys, key)
s.nextFire = computeNext(now, s.cfg)
if s.cfg.Mode == "time" {
m.lastFires[key] = now
firesDirty = true
}
}
}
if firesDirty {
for key := range m.lastFires {
if _, ok := schedules[key]; !ok {
delete(m.lastFires, key)
}
}
m.persistFires(m.lastFires)
}
next, hasNext := soonest(schedules)
if len(dueKeys) == 0 {
m.setState(config, next, seq, "")
}
for _, key := range dueKeys {
seq++
m.setState(config, next, seq, key)
}
waitDur := 24 * time.Hour
if hasNext {
waitDur = max(time.Until(next), time.Second)
}
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(waitDur)
select {
case <-m.stopChan:
timer.Stop()
return
case <-m.updateTrigger:
timer.Stop()
case key := <-m.resetReq:
timer.Stop()
resets[key] = true
case <-timer.C:
}
}
}
func (m *Manager) setState(config Config, next time.Time, seq uint64, target string) {
newState := State{Config: config, NextRotation: next, CycleSeq: seq, Target: target}
m.stateMutex.Lock()
if m.state != nil && statesEqual(m.state, &newState) {
m.stateMutex.Unlock()
return
}
m.state = &newState
m.stateMutex.Unlock()
m.notifySubscribers()
}
func (m *Manager) notifySubscribers() {
state := m.GetState()
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- state:
default:
}
return true
})
}
func (m *Manager) getConfig() Config {
m.configMutex.RLock()
defer m.configMutex.RUnlock()
return m.config
}
func activeSchedules(config Config) map[string]ScheduleConfig {
out := map[string]ScheduleConfig{}
if config.PerMonitor {
for name, cfg := range config.Monitors {
if cfg.Enabled {
out[name] = cfg
}
}
return out
}
if config.Global.Enabled {
out[""] = config.Global
}
return out
}
func computeNext(now time.Time, cfg ScheduleConfig) time.Time {
switch cfg.Mode {
case "time":
return nextDailyTime(now, cfg.Time)
default:
sec := max(cfg.IntervalSec, 1)
return now.Add(time.Duration(sec) * time.Second)
}
}
func nextDailyTime(now time.Time, hhmm string) time.Time {
hour, minute, ok := parseHHMM(hhmm)
if !ok {
return now.Add(24 * time.Hour)
}
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next
}
func prevDailyTime(now time.Time, hhmm string) (time.Time, bool) {
hour, minute, ok := parseHHMM(hhmm)
if !ok {
return time.Time{}, false
}
prev := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if prev.After(now) {
prev = prev.Add(-24 * time.Hour)
}
return prev, true
}
func parseHHMM(hhmm string) (int, int, bool) {
parts := strings.Split(hhmm, ":")
if len(parts) != 2 {
return 0, 0, false
}
hour, err := strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return 0, 0, false
}
minute, err := strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return 0, 0, false
}
return hour, minute, true
}
func soonest(schedules map[string]*activeSchedule) (time.Time, bool) {
var best time.Time
found := false
for _, s := range schedules {
if !found || s.nextFire.Before(best) {
best = s.nextFire
found = true
}
}
return best, found
}
func statesEqual(a, b *State) bool {
switch {
case a == nil || b == nil:
return a == b
case a.CycleSeq != b.CycleSeq:
return false
case a.Target != b.Target:
return false
case !a.NextRotation.Equal(b.NextRotation):
return false
}
return reflect.DeepEqual(a.Config, b.Config)
}
@@ -0,0 +1,179 @@
package wallpaper
import (
"testing"
"time"
)
func TestParseHHMM(t *testing.T) {
cases := []struct {
in string
hour int
minute int
ok bool
}{
{"06:00", 6, 0, true},
{"23:59", 23, 59, true},
{"00:00", 0, 0, true},
{"24:00", 0, 0, false},
{"6:5", 6, 5, true},
{"bad", 0, 0, false},
{"12", 0, 0, false},
{"12:60", 0, 0, false},
}
for _, c := range cases {
hour, minute, ok := parseHHMM(c.in)
if ok != c.ok || (ok && (hour != c.hour || minute != c.minute)) {
t.Errorf("parseHHMM(%q) = (%d, %d, %v), want (%d, %d, %v)", c.in, hour, minute, ok, c.hour, c.minute, c.ok)
}
}
}
func TestComputeNextInterval(t *testing.T) {
now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
got := computeNext(now, ScheduleConfig{Mode: "interval", IntervalSec: 300})
if want := now.Add(300 * time.Second); !got.Equal(want) {
t.Errorf("interval next = %v, want %v", got, want)
}
clamped := computeNext(now, ScheduleConfig{Mode: "interval", IntervalSec: 0})
if want := now.Add(time.Second); !clamped.Equal(want) {
t.Errorf("interval clamp = %v, want %v", clamped, want)
}
}
func TestComputeNextTime(t *testing.T) {
now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
later := computeNext(now, ScheduleConfig{Mode: "time", Time: "18:00"})
if want := time.Date(2026, 6, 30, 18, 0, 0, 0, time.UTC); !later.Equal(want) {
t.Errorf("time next (today) = %v, want %v", later, want)
}
tomorrow := computeNext(now, ScheduleConfig{Mode: "time", Time: "06:00"})
if want := time.Date(2026, 7, 1, 6, 0, 0, 0, time.UTC); !tomorrow.Equal(want) {
t.Errorf("time next (tomorrow) = %v, want %v", tomorrow, want)
}
}
func TestActiveSchedules(t *testing.T) {
global := activeSchedules(Config{
Global: ScheduleConfig{Enabled: true, Mode: "interval", IntervalSec: 60},
})
if len(global) != 1 {
t.Fatalf("global active = %d, want 1", len(global))
}
if _, ok := global[""]; !ok {
t.Errorf("global active missing global key")
}
perMonitor := activeSchedules(Config{
PerMonitor: true,
Global: ScheduleConfig{Enabled: true},
Monitors: map[string]ScheduleConfig{
"DP-1": {Enabled: true, Mode: "interval", IntervalSec: 60},
"DP-2": {Enabled: false},
},
})
if len(perMonitor) != 1 {
t.Fatalf("per-monitor active = %d, want 1", len(perMonitor))
}
if _, ok := perMonitor["DP-1"]; !ok {
t.Errorf("per-monitor active missing DP-1")
}
}
func TestPrevDailyTime(t *testing.T) {
now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
earlier, ok := prevDailyTime(now, "06:00")
if !ok || !earlier.Equal(time.Date(2026, 6, 30, 6, 0, 0, 0, time.UTC)) {
t.Errorf("prev (today) = %v, %v", earlier, ok)
}
later, ok := prevDailyTime(now, "18:00")
if !ok || !later.Equal(time.Date(2026, 6, 29, 18, 0, 0, 0, time.UTC)) {
t.Errorf("prev (yesterday) = %v, %v", later, ok)
}
if _, ok := prevDailyTime(now, "bad"); ok {
t.Error("prev (invalid) should not be ok")
}
}
func TestTimeScheduleCatchUpAfterRestart(t *testing.T) {
origDelay := catchUpDelay
catchUpDelay = 100 * time.Millisecond
defer func() { catchUpDelay = origDelay }()
now := time.Now()
hhmm := now.Add(-2 * time.Hour).Format("15:04")
seed := map[string]time.Time{"": now.Add(-26 * time.Hour)}
m := newManager(seed, func(map[string]time.Time) {})
defer m.Close()
sub := m.Subscribe("test")
defer m.Unsubscribe("test")
m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "time", Time: hhmm}})
deadline := time.After(3 * time.Second)
for {
select {
case state := <-sub:
if state.CycleSeq > 0 && state.Target == "" {
return
}
case <-deadline:
t.Fatal("missed time schedule did not catch up within 3s")
}
}
}
func TestTimeScheduleNoFireOnFirstEnable(t *testing.T) {
now := time.Now()
hhmm := now.Add(-2 * time.Hour).Format("15:04")
m := newManager(map[string]time.Time{}, func(map[string]time.Time) {})
defer m.Close()
sub := m.Subscribe("test")
defer m.Unsubscribe("test")
m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "time", Time: hhmm}})
deadline := time.After(1500 * time.Millisecond)
for {
select {
case state := <-sub:
if state.CycleSeq > 0 {
t.Fatal("first enable must not fire immediately")
}
case <-deadline:
return
}
}
}
func TestSchedulerEmitsCycle(t *testing.T) {
m := newManager(map[string]time.Time{}, func(map[string]time.Time) {})
defer m.Close()
sub := m.Subscribe("test")
defer m.Unsubscribe("test")
m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "interval", IntervalSec: 1}})
deadline := time.After(3 * time.Second)
for {
select {
case state := <-sub:
if state.CycleSeq > 0 && state.Target == "" {
return
}
case <-deadline:
t.Fatal("scheduler did not emit a cycle event within 3s")
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package wallpaper
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type persistentState struct {
LastFires map[string]time.Time `json:"lastFires"`
}
func stateFilePath() string {
return filepath.Join(utils.XDGCacheHome(), "dms", "wallpaper-schedule.json")
}
func loadLastFires() map[string]time.Time {
data, err := os.ReadFile(stateFilePath())
if err != nil {
return map[string]time.Time{}
}
var state persistentState
if err := json.Unmarshal(data, &state); err != nil || state.LastFires == nil {
return map[string]time.Time{}
}
return state.LastFires
}
func saveLastFires(fires map[string]time.Time) {
path := stateFilePath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
data, err := json.Marshal(persistentState{LastFires: fires})
if err != nil {
return
}
if err := os.WriteFile(path, data, 0o644); err != nil {
log.Warnf("wallpaper: failed to persist schedule state: %v", err)
}
}
+23
View File
@@ -0,0 +1,23 @@
package wallpaper
import "time"
type ScheduleConfig struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
IntervalSec int `json:"intervalSec"`
Time string `json:"time"`
}
type Config struct {
PerMonitor bool `json:"perMonitor"`
Global ScheduleConfig `json:"global"`
Monitors map[string]ScheduleConfig `json:"monitors"`
}
type State struct {
Config Config `json:"config"`
NextRotation time.Time `json:"nextRotation"`
CycleSeq uint64 `json:"cycleSeq"`
Target string `json:"target"`
}
+22 -6
View File
@@ -74,6 +74,9 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
if config.Enabled { if config.Enabled {
m.post(func() { m.post(func() {
if m.controlsInitialized {
return
}
log.Info("Gamma control enabled at startup") log.Info("Gamma control enabled at startup")
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
m.availOutputsMu.RLock() m.availOutputsMu.RLock()
@@ -184,13 +187,26 @@ func (m *Manager) setupRegistry() error {
enabled := m.config.Enabled enabled := m.config.Enabled
m.configMutex.RUnlock() m.configMutex.RUnlock()
if enabled && m.controlsInitialized { if !enabled {
m.post(func() { return
if err := m.addOutputControl(output); err != nil {
log.Warnf("Failed to add output control: %v", err)
}
})
} }
m.post(func() {
if err := m.addOutputControl(output); err != nil {
log.Warnf("gamma: failed to add output control: %v", err)
return
}
if m.controlsInitialized {
return
}
// All outputs had been torn down (monitor sleep/disconnect),
// clearing controlsInitialized. Mark it ready again so the
// gamma_size event that follows control creation drives the
// reapply, instead of waiting for a manual toggle. No explicit
// apply here: applyGamma's dedup would suppress a no-change
// write anyway, and the new control isn't ready until gamma_size.
log.Info("gamma: output returned, re-establishing controls")
m.controlsInitialized = true
})
} }
}) })
+10 -4
View File
@@ -108,10 +108,7 @@ func (sc *SharedContext) eventDispatcher() {
if r := recover(); r != nil { if r := recover(); r != nil {
err := fmt.Errorf("FATAL: Wayland event dispatcher panic: %v", r) err := fmt.Errorf("FATAL: Wayland event dispatcher panic: %v", r)
log.Error(err) log.Error(err)
select { sc.signalFatal(err)
case sc.fatalError <- err:
default:
}
} }
}() }()
@@ -141,6 +138,7 @@ func (sc *SharedContext) eventDispatcher() {
continue continue
case err != nil: case err != nil:
log.Errorf("Poll error: %v", err) log.Errorf("Poll error: %v", err)
sc.signalFatal(fmt.Errorf("wayland poll error: %w", err))
return return
} }
@@ -161,6 +159,7 @@ func (sc *SharedContext) eventDispatcher() {
if consecutiveErrors >= maxConsecutiveErrors { if consecutiveErrors >= maxConsecutiveErrors {
log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors) log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors)
sc.signalFatal(fmt.Errorf("wayland connection unrecoverable after %d dispatch failures: %w", maxConsecutiveErrors, err))
return return
} }
@@ -172,6 +171,13 @@ func (sc *SharedContext) eventDispatcher() {
} }
} }
func (sc *SharedContext) signalFatal(err error) {
select {
case sc.fatalError <- err:
default:
}
}
func (sc *SharedContext) drainCmdQueue() { func (sc *SharedContext) drainCmdQueue() {
for { for {
select { select {
+72 -1
View File
@@ -1,7 +1,10 @@
package utils package utils
import ( import (
"os"
"os/exec" "os/exec"
"path/filepath"
"strings"
) )
type AppChecker interface { type AppChecker interface {
@@ -31,7 +34,20 @@ func (DefaultAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
func CommandExists(cmd string) bool { func CommandExists(cmd string) bool {
_, err := exec.LookPath(cmd) _, err := exec.LookPath(cmd)
return err == nil if err == nil {
return true
}
if strings.ContainsRune(cmd, os.PathSeparator) {
return false
}
for _, dir := range userBinDirs() {
path := filepath.Join(dir, cmd)
info, statErr := os.Stat(path)
if statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
return true
}
}
return false
} }
func AnyCommandExists(cmds ...string) bool { func AnyCommandExists(cmds ...string) bool {
@@ -42,3 +58,58 @@ func AnyCommandExists(cmds ...string) bool {
} }
return false return false
} }
func EnvWithUserBinPath(env []string) []string {
if env == nil {
env = os.Environ()
}
out := append([]string(nil), env...)
pathIndex := -1
pathValue := ""
for i, entry := range out {
if strings.HasPrefix(entry, "PATH=") {
pathIndex = i
pathValue = strings.TrimPrefix(entry, "PATH=")
break
}
}
parts := filepath.SplitList(pathValue)
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
if part != "" {
seen[part] = struct{}{}
}
}
prepend := make([]string, 0, 2)
for _, dir := range userBinDirs() {
if dir == "" {
continue
}
if _, ok := seen[dir]; ok {
continue
}
prepend = append(prepend, dir)
seen[dir] = struct{}{}
}
parts = append(prepend, parts...)
newPath := "PATH=" + strings.Join(parts, string(os.PathListSeparator))
if pathIndex >= 0 {
out[pathIndex] = newPath
} else {
out = append(out, newPath)
}
return out
}
func userBinDirs() []string {
dirs := []string{}
if home, err := os.UserHomeDir(); err == nil && home != "" {
dirs = append(dirs, filepath.Join(home, ".local", "bin"))
}
dirs = append(dirs, "/usr/local/bin")
return dirs
}
+55
View File
@@ -0,0 +1,55 @@
package utils
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommandExistsFallsBackToLocalBin(t *testing.T) {
home := t.TempDir()
binDir := filepath.Join(home, ".local", "bin")
require.NoError(t, os.MkdirAll(binDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(binDir, "pywalfox"), []byte("#!/bin/sh\n"), 0o755))
t.Setenv("HOME", home)
t.Setenv("PATH", t.TempDir())
assert.True(t, CommandExists("pywalfox"))
}
func TestCommandExistsIgnoresNonExecutableLocalBinFile(t *testing.T) {
home := t.TempDir()
binDir := filepath.Join(home, ".local", "bin")
require.NoError(t, os.MkdirAll(binDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(binDir, "pywalfox"), []byte("not executable"), 0o644))
t.Setenv("HOME", home)
t.Setenv("PATH", t.TempDir())
assert.False(t, CommandExists("pywalfox"))
}
func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
env := EnvWithUserBinPath([]string{"PATH=/usr/bin", "OTHER=value"})
var pathValue string
for _, entry := range env {
if strings.HasPrefix(entry, "PATH=") {
pathValue = strings.TrimPrefix(entry, "PATH=")
break
}
}
parts := filepath.SplitList(pathValue)
require.NotEmpty(t, parts)
assert.Equal(t, filepath.Join(home, ".local", "bin"), parts[0])
assert.Contains(t, parts, "/usr/local/bin")
assert.Contains(t, env, "OTHER=value")
}
+2 -2
View File
@@ -1,8 +1,8 @@
package utils package utils
import "golang.org/x/exp/constraints" import "cmp"
func Clamp[T constraints.Ordered](val, min, max T) T { func Clamp[T cmp.Ordered](val, min, max T) T {
if val < min { if val < min {
return min return min
} }
+1
View File
@@ -74,6 +74,7 @@ override_dh_auto_install:
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; \
install -Dm644 assets/com.danklinux.dms.notepad.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.notepad.desktop; \
install -Dm644 assets/danklogo.svg debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \ install -Dm644 assets/danklogo.svg debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \
else \ else \
echo "ERROR: quickshell directory not found!" && \ echo "ERROR: quickshell directory not found!" && \
+1
View File
@@ -67,6 +67,7 @@ override_dh_auto_install:
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; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.notepad.desktop debian/dms/usr/share/applications/com.danklinux.dms.notepad.desktop; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/danklogo.svg debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/danklogo.svg debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \
else \ else \
echo "ERROR: DankMaterialShell-$(UPSTREAM_VERSION) directory not found!" && \ echo "ERROR: DankMaterialShell-$(UPSTREAM_VERSION) directory not found!" && \
+2
View File
@@ -122,6 +122,7 @@ install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.servic
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
# Install shell files to shared data location # Install shell files to shared data location
@@ -146,6 +147,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg %{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
%files -n dms-cli %files -n dms-cli
+2
View File
@@ -90,6 +90,7 @@ install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms.service %{buildroot}%{_us
install -Dm644 %{_builddir}/dms-qml/assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 %{_builddir}/dms-qml/assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
install -Dm644 %{_builddir}/dms-qml/assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg install -Dm644 %{_builddir}/dms-qml/assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
install -dm755 %{buildroot}%{_datadir}/quickshell/dms install -dm755 %{buildroot}%{_datadir}/quickshell/dms
@@ -113,6 +114,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg %{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
%files -n dms-cli %files -n dms-cli
+1 -2
View File
@@ -18,8 +18,7 @@ in
] ]
++ lib.optional cfg.enableDynamicTheming pkgs.matugen ++ lib.optional cfg.enableDynamicTheming pkgs.matugen
++ lib.optional cfg.enableAudioWavelength pkgs.cava ++ lib.optional cfg.enableAudioWavelength pkgs.cava
++ lib.optional cfg.enableCalendarEvents pkgs.khal ++ lib.optional cfg.enableCalendarEvents pkgs.khal;
++ lib.optional cfg.enableClipboardPaste pkgs.wtype;
plugins = lib.mapAttrs (name: plugin: { plugins = lib.mapAttrs (name: plugin: {
source = plugin.src; source = plugin.src;
+49 -1
View File
@@ -10,6 +10,8 @@ let
inherit (lib) types; inherit (lib) types;
cfg = config.programs.dank-material-shell.greeter; cfg = config.programs.dank-material-shell.greeter;
cfgDms = config.programs.dank-material-shell; cfgDms = config.programs.dank-material-shell;
cfgAutoLogin = config.services.displayManager.autoLogin;
sessionData = config.services.displayManager.sessionData;
inherit (config.services.greetd.settings.default_session) user; inherit (config.services.greetd.settings.default_session) user;
@@ -26,6 +28,7 @@ let
cfg.quickshell.package cfg.quickshell.package
compositorPackage compositorPackage
pkgs.glib # provides gdbus, used by the fprintd hardware probe in GreeterContent.qml pkgs.glib # provides gdbus, used by the fprintd hardware probe in GreeterContent.qml
pkgs.jq # reads the user's cursor theme from settings.json in dms-greeter
] ]
} }
${ ${
@@ -48,6 +51,38 @@ let
} ${lib.optionalString cfg.logs.save "> ${cfg.logs.path} 2>&1"} } ${lib.optionalString cfg.logs.save "> ${cfg.logs.path} 2>&1"}
''; '';
autoLoginCommand =
pkgs.runCommand "dms-greeter-autologin-command"
{
nativeBuildInputs = [
pkgs.gnugrep
pkgs.coreutils
];
}
''
set -euo pipefail
session="${sessionData.autologinSession}"
desktops="${sessionData.desktops}"
for sessionFile in \
"$desktops/share/wayland-sessions/$session.desktop" \
"$desktops/share/xsessions/$session.desktop"
do
if [ -f "$sessionFile" ]; then
command="$(grep -m1 '^Exec=' "$sessionFile" | cut -d= -f2- || true)"
if [ -n "$command" ]; then
printf '%s\n' "$command" > "$out"
exit 0
fi
fi
done
echo "dms-greeter autologin: could not resolve Exec for session '$session'" >&2
exit 1
'';
jq = lib.getExe pkgs.jq; jq = lib.getExe pkgs.jq;
in in
{ {
@@ -155,6 +190,13 @@ in
dmsgreeter: user set for greetd default_session ${user} does not exist. Please create it before referencing it. dmsgreeter: user set for greetd default_session ${user} does not exist. Please create it before referencing it.
''; '';
} }
{
assertion = cfgAutoLogin.enable -> sessionData.autologinSession != null;
message = ''
dms-greeter auto-login requires services.displayManager.defaultSession to be set,
or at least one session in services.displayManager.sessionPackages.
'';
}
]; ];
# 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.
@@ -165,7 +207,13 @@ in
# }; # };
services.greetd = { services.greetd = {
enable = lib.mkDefault true; enable = lib.mkDefault true;
settings.default_session.command = lib.mkDefault (lib.getExe greeterScript); settings = {
default_session.command = lib.mkDefault (lib.getExe greeterScript);
initial_session = lib.mkIf (cfgAutoLogin.enable && (cfgAutoLogin.user != null)) {
inherit (cfgAutoLogin) user;
command = ''${lib.getExe pkgs.bash} -lc "${pkgs.systemd}/bin/systemd-cat $(<${autoLoginCommand})"'';
};
};
}; };
fonts.packages = with pkgs; [ fonts.packages = with pkgs; [
fira-code fira-code
+1 -1
View File
@@ -76,7 +76,7 @@ in
enableClipboardPaste = lib.mkOption { enableClipboardPaste = lib.mkOption {
type = types.bool; type = types.bool;
default = true; default = true;
description = "Adds needed dependencies for directly pasting items from the clipboard history."; description = "Deprecated: paste is built into dms; no extra dependencies needed. Kept as a no-op for compatibility.";
}; };
quickshell = { quickshell = {
+11
View File
@@ -16,8 +16,14 @@ pkgs.testers.runNixOSTest {
isSystemUser = true; isSystemUser = true;
group = "greeter"; group = "greeter";
}; };
users.users.alice.isNormalUser = true;
services.greetd.settings.default_session.user = "greeter"; services.greetd.settings.default_session.user = "greeter";
services.displayManager.autoLogin = {
enable = true;
user = "alice";
};
services.displayManager.defaultSession = "niri";
programs.niri.enable = true; programs.niri.enable = true;
@@ -46,6 +52,11 @@ pkgs.testers.runNixOSTest {
greetd_config_path = config_match.group(1) greetd_config_path = config_match.group(1)
greetd_config = machine.succeed(f"cat {greetd_config_path}") greetd_config = machine.succeed(f"cat {greetd_config_path}")
t.assertIn("dms-greeter", greetd_config) t.assertIn("dms-greeter", greetd_config)
t.assertIn("[initial_session]", greetd_config)
initial_session = greetd_config.split("[initial_session]", 1)[1]
t.assertIn('user = "alice"', initial_session)
t.assertIn("systemd-cat", initial_session)
script_match = re.search(r'command\s*=\s*"([^"]+/bin/dms-greeter)"', greetd_config) script_match = re.search(r'command\s*=\s*"([^"]+/bin/dms-greeter)"', greetd_config)
if script_match is None: if script_match is None:
+3 -1
View File
@@ -25,7 +25,7 @@ Recommends: danksearch
Recommends: matugen Recommends: matugen
Recommends: quickshell-git Recommends: quickshell-git
Recommends: NetworkManager Recommends: NetworkManager
Recommends: qt6-qtmultimedia Recommends: qt6-multimedia-imports
Suggests: cups-pk-helper Suggests: cups-pk-helper
Suggests: qt6ct Suggests: qt6ct
@@ -117,6 +117,7 @@ install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.servic
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
install -dm755 %{buildroot}%{_datadir}/quickshell/dms install -dm755 %{buildroot}%{_datadir}/quickshell/dms
@@ -153,6 +154,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
%dir %{_datadir}/icons/hicolor %dir %{_datadir}/icons/hicolor
%dir %{_datadir}/icons/hicolor/scalable %dir %{_datadir}/icons/hicolor/scalable
%dir %{_datadir}/icons/hicolor/scalable/apps %dir %{_datadir}/icons/hicolor/scalable/apps
+3 -1
View File
@@ -26,7 +26,7 @@ Recommends: cava
Recommends: danksearch Recommends: danksearch
Recommends: matugen Recommends: matugen
Recommends: NetworkManager Recommends: NetworkManager
Recommends: qt6-qtmultimedia Recommends: qt6-multimedia-imports
Suggests: cups-pk-helper Suggests: cups-pk-helper
Suggests: qt6ct Suggests: qt6ct
@@ -66,6 +66,7 @@ install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.servic
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
install -dm755 %{buildroot}%{_datadir}/quickshell/dms install -dm755 %{buildroot}%{_datadir}/quickshell/dms
@@ -100,6 +101,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
%dir %{_datadir}/icons/hicolor %dir %{_datadir}/icons/hicolor
%dir %{_datadir}/icons/hicolor/scalable %dir %{_datadir}/icons/hicolor/scalable
%dir %{_datadir}/icons/hicolor/scalable/apps %dir %{_datadir}/icons/hicolor/scalable/apps
+2
View File
@@ -75,6 +75,8 @@ override_dh_auto_install:
debian/dms-git/usr/share/applications/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop
install -Dm644 dms-git-repo/assets/com.danklinux.dms.desktop \ install -Dm644 dms-git-repo/assets/com.danklinux.dms.desktop \
debian/dms-git/usr/share/applications/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop
install -Dm644 dms-git-repo/assets/com.danklinux.dms.notepad.desktop \
debian/dms-git/usr/share/applications/com.danklinux.dms.notepad.desktop
install -Dm644 dms-git-repo/assets/danklogo.svg \ install -Dm644 dms-git-repo/assets/danklogo.svg \
debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg
+2
View File
@@ -56,6 +56,8 @@ override_dh_auto_install:
debian/dms/usr/share/applications/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/com.danklinux.dms.desktop \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/com.danklinux.dms.desktop \
debian/dms/usr/share/applications/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/com.danklinux.dms.notepad.desktop \
debian/dms/usr/share/applications/com.danklinux.dms.notepad.desktop
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/danklogo.svg \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/danklogo.svg \
debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg
+33 -9
View File
@@ -13,16 +13,41 @@ All build from source.
## Distribution ## Distribution
These packages target the official This is a DMS maintained repo for VoidLinux until these packages are officially merged upstream in the Void Linux repositories, you can install them from our self-hosted custom XBPS repositories served via GitHub Pages.
[`void-linux/void-packages`](https://github.com/void-linux/void-packages)
repository, so they install with a plain `xbps-install dms` and no extra setup. ### Using the Self-Hosted Repositories
Most dependencies (`quickshell`, `matugen`, `cava`, `niri`, `greetd`, …) are
already in Void; `dgop` and `danksearch` are packaged alongside in the We serve both stable release and development packages directly from our repository branches.
[danklinux repo](https://github.com/AvengeMedia/danklinux/tree/master/distro/void).
#### 1. Add Repository Configurations
Create configuration files in `/etc/xbps.d/` pointing to our repositories (needed for both stable and git/nightly variants):
```sh
echo "repository=https://avengemedia.github.io/DankMaterialShell/current" | sudo tee /etc/xbps.d/dms.conf
echo "repository=https://avengemedia.github.io/DankLinux/current" | sudo tee /etc/xbps.d/danklinux.conf
```
#### 2. Install DMS
Synchronize repositories and install the package:
* For the **stable** variant:
```sh
sudo xbps-install -S dms
```
* For the **git/nightly** variant (this will conflict with and replace the stable package):
```sh
sudo xbps-install -S dms-git
```
*Note: On the first sync, `xbps-install` will output our signing key fingerprint and ask you to type `y` to trust and import it. Verify that the key matches our official signing fingerprint.*
The templates here are the source of truth: copy each into a void-packages The templates here are the source of truth: copy each into a void-packages
checkout at `srcpkgs/<pkg>/template` to build or submit it. Only tagged releases checkout at `srcpkgs/<pkg>/template` to build or submit it.
are packaged (no `-git`/nightly variant).
## Dependencies ## Dependencies
@@ -36,7 +61,6 @@ install whichever features you want:
| `cava` | audio visualiser widget | | `cava` | audio visualiser widget |
| `qt6-multimedia` | system sound feedback | | `qt6-multimedia` | system sound feedback |
| `qt6ct` | Qt app theming | | `qt6ct` | Qt app theming |
| `wtype` | virtual keyboard input |
| `power-profiles-daemon` | power profile control | | `power-profiles-daemon` | power profile control |
| `cups-pk-helper` | printer management | | `cups-pk-helper` | printer management |
| `NetworkManager` | network control | | `NetworkManager` | network control |
+60
View File
@@ -0,0 +1,60 @@
# Template file for 'dms-git'
#
# DankMaterialShell git development release
#
# NOTE: This template is built exclusively by CI. The workflow stages a local
# source tarball and dynamically patches version, checksum, and distfiles
# before calling xbps-src. Do not attempt to build this template manually
# without first providing the source archive.
#
# The binary is built with the `distro_binary` build tag (drops the in-app
# self-update command).
pkgname=dms-git
version=1.4.6.git
revision=1
create_wrksrc=yes
build_style=go
build_wrksrc="core"
go_import_path="github.com/AvengeMedia/DankMaterialShell/core"
go_package="${go_import_path}/cmd/dms"
go_build_tags="distro_binary"
go_ldflags="-X main.Version=${version}"
short_desc="DankMaterialShell — Material 3 desktop shell for Wayland (git variant)"
maintainer="AvengeMedia <AvengeMedia.US@gmail.com>"
license="MIT"
homepage="https://danklinux.com"
# CI populates distfiles/checksum dynamically; these are placeholders.
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz"
checksum=badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb
# Conflicts and provides
conflicts="dms"
provides="dms-${version}_${revision}"
# Optional feature deps are listed in distro/void/README.md.
depends="quickshell accountsservice dgop matugen dbus"
post_install() {
# QML shell tree (build_style=go already installed the dms binary)
vmkdir usr/share/quickshell/dms
vcopy "${wrksrc}/quickshell/*" usr/share/quickshell/dms
echo "${version}" > "${DESTDIR}/usr/share/quickshell/dms/VERSION"
# Desktop entry + icon
vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/com.danklinux.dms.notepad.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps
# Shell completions (generated by the built binary; skip when cross-building)
vmkdir usr/share/bash-completion/completions
vmkdir usr/share/zsh/site-functions
vmkdir usr/share/fish/vendor_completions.d
if [ -z "$CROSS_BUILD" ]; then
"${DESTDIR}/usr/bin/dms" completion bash > "${DESTDIR}/usr/share/bash-completion/completions/dms"
"${DESTDIR}/usr/bin/dms" completion zsh > "${DESTDIR}/usr/share/zsh/site-functions/_dms"
"${DESTDIR}/usr/bin/dms" completion fish > "${DESTDIR}/usr/share/fish/vendor_completions.d/dms.fish"
fi
vlicense "${wrksrc}/LICENSE"
}
+3 -2
View File
@@ -21,8 +21,8 @@ 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/archive/refs/tags/v${version}.tar.gz"
checksum=f54601e522c883fa9cce02bec070e4321e47389a1cf453e7ad0bb7379ad91b61 checksum=f54601e522c883fa9cce02bec070e4321e47389a1cf453e7ad0bb7379ad91b61
# Optional feature deps (XBPS has no "recommends") are listed in distro/void/README.md. # Optional feature deps are listed in distro/void/README.md.
depends="quickshell accountsservice dgop matugen" depends="quickshell accountsservice dgop matugen dbus"
post_install() { post_install() {
# QML shell tree (build_style=go already installed the dms binary) # QML shell tree (build_style=go already installed the dms binary)
@@ -33,6 +33,7 @@ post_install() {
# Desktop entry + icon # Desktop entry + icon
vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/com.danklinux.dms.notepad.desktop" 644 usr/share/applications
vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps
# Shell completions (generated by the built binary; skip when cross-building) # Shell completions (generated by the built binary; skip when cross-building)
+4
View File
@@ -684,6 +684,10 @@ Notepad/scratchpad modal control for quick note-taking.
- `open` - Show notepad modal - `open` - Show notepad modal
- `close` - Hide notepad modal - `close` - Hide notepad modal
- `toggle` - Toggle notepad modal visibility - `toggle` - Toggle notepad modal visibility
- `openFile <path>` - Show notepad and load a file into a tab, reusing an existing tab if that file is already open
- Parameters: `path` - Absolute path to a file. Empty path behaves like `open`.
- Returns: Success/failure message
- This is what `com.danklinux.dms.notepad.desktop`'s `Exec=dms ipc call notepad openFile %f` uses so Notepad shows up in "Open With" pickers for text files.
- `expand` - Expand the active notepad width and open it if hidden - `expand` - Expand the active notepad width and open it if hidden
- `collapse` - Collapse the active notepad width without changing visibility - `collapse` - Collapse the active notepad width without changing visibility
- `toggleExpand` - Toggle the active notepad width between collapsed and expanded - `toggleExpand` - Toggle the active notepad width between collapsed and expanded
+5
View File
@@ -133,11 +133,16 @@
$out/share/applications/dms-open.desktop $out/share/applications/dms-open.desktop
install -D ${rootSrc}/assets/com.danklinux.dms.desktop \ install -D ${rootSrc}/assets/com.danklinux.dms.desktop \
$out/share/applications/com.danklinux.dms.desktop $out/share/applications/com.danklinux.dms.desktop
install -D ${rootSrc}/assets/com.danklinux.dms.notepad.desktop \
$out/share/applications/com.danklinux.dms.notepad.desktop
install -D ${rootSrc}/core/assets/danklogo.svg \ install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg $out/share/hicolor/scalable/apps/danklogo.svg
# Snapshot pre-wrap Qt paths for launched apps to restore.
wrapProgram $out/bin/dms \ wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \ --add-flags "-c $out/share/quickshell/dms" \
--run 'export DMS_ORIG_NIXPKGS_QT6_QML_IMPORT_PATH="''${NIXPKGS_QT6_QML_IMPORT_PATH:-}"' \
--run 'export DMS_ORIG_QT_PLUGIN_PATH="''${QT_PLUGIN_PATH:-}"' \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \ --prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}" --prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
+1 -1
View File
@@ -55,7 +55,7 @@ Singleton {
readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1 readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1
readonly property bool isDepthEffect: _effect === 2 readonly property bool isDepthEffect: _effect === 2
readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.connectedFrameModeActive readonly property bool isConnectedEffect: (typeof FrameTransitionState !== "undefined") && FrameTransitionState.effectiveConnectedFrameModeActive
readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96 readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96
readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16 readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16
+21 -1
View File
@@ -7,6 +7,24 @@ function dirname(path) {
return idx > 0 ? path.substring(0, idx) : "."; return idx > 0 ? path.substring(0, idx) : ".";
} }
function sectionHeaderFor(includeLine) {
const line = String(includeLine ?? "").trim();
if (line.startsWith("require"))
return "-- DMS Include Configs";
if (line.startsWith("source"))
return "# DMS Include Configs";
return "// DMS Include Configs";
}
function managedIncludePatternFor(includeLine) {
const line = String(includeLine ?? "").trim();
if (line.startsWith("require"))
return "require.*dms[.]";
if (line.startsWith("source"))
return "source.*dms/";
return "include.*dms/";
}
function buildRepairScript(options) { function buildRepairScript(options) {
const configFile = options.configFile; const configFile = options.configFile;
const backupFile = options.backupFile; const backupFile = options.backupFile;
@@ -31,7 +49,9 @@ function buildRepairScript(options) {
for (const include of includes) { for (const include of includes) {
if (!include.grepPattern || !include.includeLine) if (!include.grepPattern || !include.includeLine)
continue; continue;
commands.push(`if ! grep -v '^[[:space:]]*\\(//\\|#\\|--\\)' ${shQuote(configFile)} 2>/dev/null | grep -q ${shQuote(include.grepPattern)}; then echo '' >> ${shQuote(configFile)} && printf '%s\\n' ${shQuote(include.includeLine)} >> ${shQuote(configFile)}; fi`); const sectionHeader = options.sectionHeader || sectionHeaderFor(include.includeLine);
const managedIncludePattern = managedIncludePatternFor(include.includeLine);
commands.push(`if ! grep -v '^[[:space:]]*\\(//\\|#\\|--\\)' ${shQuote(configFile)} 2>/dev/null | grep -q ${shQuote(include.grepPattern)}; then if grep -Fqx ${shQuote(sectionHeader)} ${shQuote(configFile)} 2>/dev/null || grep -v '^[[:space:]]*\\(//\\|#\\|--\\)' ${shQuote(configFile)} 2>/dev/null | grep -q ${shQuote(managedIncludePattern)}; then printf '%s\\n' ${shQuote(include.includeLine)} >> ${shQuote(configFile)}; elif [ -s ${shQuote(configFile)} ]; then printf '\\n%s\\n%s\\n' ${shQuote(sectionHeader)} ${shQuote(include.includeLine)} >> ${shQuote(configFile)}; else printf '%s\\n%s\\n' ${shQuote(sectionHeader)} ${shQuote(include.includeLine)} >> ${shQuote(configFile)}; fi; fi`);
} }
return commands.join("; "); return commands.join("; ");
@@ -0,0 +1,56 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
Singleton {
id: root
property int revision: 0
property int appliedRevision: 0
readonly property bool ready: appliedRevision >= revision
// Latched: surfaces render the last compositor-acknowledged state until the atomic flip on ack
property bool effectiveFrameEnabled: false
property string effectiveFrameMode: "connected"
readonly property bool effectiveConnectedFrameModeActive: effectiveFrameEnabled && effectiveFrameMode === "connected"
signal transitionRequested(int revision)
function begin() {
revision++;
transitionRequested(revision);
return revision;
}
function acknowledge(requestRevision) {
if (requestRevision > appliedRevision)
appliedRevision = requestRevision;
}
function syncEffective() {
effectiveFrameEnabled = SettingsData.frameEnabled;
effectiveFrameMode = SettingsData.frameMode;
}
onReadyChanged: {
if (ready)
syncEffective();
}
// Tracks settings-load changes; live toggles begin() first (ready false) so the latch holds
Connections {
target: SettingsData
function onFrameEnabledChanged() {
if (root.ready)
root.syncEffective();
}
function onFrameModeChanged() {
if (root.ready)
root.syncEffective();
}
}
Component.onCompleted: syncEffective()
}
+81 -29
View File
@@ -68,27 +68,6 @@ const KEY_MAP = {
16777429: "XF86Explorer", 16777429: "XF86Explorer",
16777442: "XF86Launch0", 16777442: "XF86Launch0",
16777443: "XF86Launch1", 16777443: "XF86Launch1",
33: "1",
64: "2",
35: "3",
36: "4",
37: "5",
94: "6",
38: "7",
42: "8",
40: "9",
41: "0",
60: "Comma",
62: "Period",
63: "Slash",
58: "Semicolon",
34: "Apostrophe",
123: "BracketLeft",
125: "BracketRight",
124: "Backslash",
95: "Minus",
43: "Equal",
126: "grave",
196: "Adiaeresis", 196: "Adiaeresis",
214: "Odiaeresis", 214: "Odiaeresis",
220: "Udiaeresis", 220: "Udiaeresis",
@@ -126,6 +105,56 @@ const KEY_MAP = {
161: "exclamdown" 161: "exclamdown"
}; };
// Preserve unshifted symbols from the active layout
const SYMBOL_KEYSYM = {
33: "exclam",
34: "quotedbl",
35: "numbersign",
36: "dollar",
37: "percent",
38: "ampersand",
40: "parenleft",
41: "parenright",
42: "asterisk",
43: "plus",
58: "colon",
60: "less",
62: "greater",
63: "question",
64: "at",
94: "asciicircum",
95: "underscore",
123: "braceleft",
124: "bar",
125: "braceright",
126: "asciitilde"
};
// Preserve the existing shifted-US physical-key mapping
const SHIFTED_US_FALLBACK = {
33: "1",
34: "Apostrophe",
35: "3",
36: "4",
37: "5",
38: "7",
40: "9",
41: "0",
42: "8",
43: "Equal",
58: "Semicolon",
60: "Comma",
62: "Period",
63: "Slash",
64: "2",
94: "6",
95: "Minus",
123: "BracketLeft",
124: "Backslash",
125: "BracketRight",
126: "grave"
};
// Numpad (keypad) keys. Qt reuses the same Qt::Key_* values for the numpad and // Numpad (keypad) keys. Qt reuses the same Qt::Key_* values for the numpad and
// the main rows/nav cluster; only Qt.KeypadModifier distinguishes them. niri and // the main rows/nav cluster; only Qt.KeypadModifier distinguishes them. niri and
// the other compositors bind against the xkb KP_* keysym names, so we must emit // the other compositors bind against the xkb KP_* keysym names, so we must emit
@@ -153,13 +182,17 @@ const KP_MAP = {
46: "KP_Decimal" 46: "KP_Decimal"
}; };
function xkbKeyFromQtKey(qk, isKeypad) { function xkbKeyFromQtKey(qk, isKeypad, hasShift) {
if (isKeypad) { if (isKeypad) {
if (qk >= 48 && qk <= 57) if (qk >= 48 && qk <= 57)
return "KP_" + (qk - 48); return "KP_" + (qk - 48);
if (KP_MAP[qk]) if (KP_MAP[qk])
return KP_MAP[qk]; return KP_MAP[qk];
} }
if (!hasShift && SYMBOL_KEYSYM[qk])
return SYMBOL_KEYSYM[qk];
if (hasShift && SHIFTED_US_FALLBACK[qk])
return SHIFTED_US_FALLBACK[qk];
if (qk >= 65 && qk <= 90) if (qk >= 65 && qk <= 90)
return String.fromCharCode(qk); return String.fromCharCode(qk);
if (qk >= 97 && qk <= 122) if (qk >= 97 && qk <= 122)
@@ -192,23 +225,42 @@ function formatToken(mods, key) {
return (mods.length ? mods.join("+") + "+" : "") + key; return (mods.length ? mods.join("+") + "+" : "") + key;
} }
function normalizeKeyCombo(keyCombo) { function canonicalModifier(modifier) {
if (!keyCombo) var normalized = (modifier || "").toLowerCase();
return ""; if (normalized === "control")
return keyCombo.toLowerCase().replace(/\bmod\b/g, "super").replace(/\bsuper\b/g, "super"); return "ctrl";
if (normalized === "win")
return "super";
return normalized;
} }
function getConflictingBinds(keyCombo, currentAction, allBinds) { function withSymbolicMod(mods, modKey) {
var configuredMod = canonicalModifier(modKey);
if (!configuredMod)
return mods;
return mods.map(function (modifier) {
return canonicalModifier(modifier) === configuredMod ? "Mod" : modifier;
});
}
function normalizeKeyCombo(keyCombo, modKey) {
if (!keyCombo)
return "";
var configuredMod = canonicalModifier(modKey) || "super";
return keyCombo.toLowerCase().replace(/\bmod\b/g, configuredMod).replace(/\bcontrol\b/g, "ctrl").replace(/\bwin\b/g, "super");
}
function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) {
if (!keyCombo) if (!keyCombo)
return []; return [];
var conflicts = []; var conflicts = [];
var normalizedKey = normalizeKeyCombo(keyCombo); var normalizedKey = normalizeKeyCombo(keyCombo, modKey);
for (var i = 0; i < allBinds.length; i++) { for (var i = 0; i < allBinds.length; i++) {
var bind = allBinds[i]; var bind = allBinds[i];
if (bind.action === currentAction) if (bind.action === currentAction)
continue; continue;
for (var k = 0; k < bind.keys.length; k++) { for (var k = 0; k < bind.keys.length; k++) {
if (normalizeKeyCombo(bind.keys[k].key) === normalizedKey) { if (normalizeKeyCombo(bind.keys[k].key, modKey) === normalizedKey) {
conflicts.push({ conflicts.push({
action: bind.action, action: bind.action,
desc: bind.desc || bind.action desc: bind.desc || bind.action
+10
View File
@@ -25,6 +25,16 @@ Singleton {
return CompositorService.useHyprlandFocusGrab && keyboardFocus(active, customFocus) === WlrKeyboardFocus.OnDemand; return CompositorService.useHyprlandFocusGrab && keyboardFocus(active, customFocus) === WlrKeyboardFocus.OnDemand;
} }
function captureActiveToplevel() {
return ToplevelManager.activeToplevel;
}
function restoreToplevel(toplevel) {
if (toplevel)
Qt.callLater(() => toplevel.activate());
return null;
}
property list<var> barWindows: [] property list<var> barWindows: []
function registerBarWindow(window) { function registerBarWindow(window) {
+107
View File
@@ -0,0 +1,107 @@
.pragma library
// English language name -> ISO 639-1 code, for abbreviating xkb layout names.
const LANG_CODES = {
"afrikaans": "af",
"albanian": "sq",
"amharic": "am",
"arabic": "ar",
"armenian": "hy",
"azerbaijani": "az",
"basque": "eu",
"belarusian": "be",
"bengali": "bn",
"bosnian": "bs",
"bulgarian": "bg",
"burmese": "my",
"catalan": "ca",
"chinese": "zh",
"croatian": "hr",
"czech": "cs",
"danish": "da",
"dutch": "nl",
"english": "en",
"esperanto": "eo",
"estonian": "et",
"filipino": "fil",
"finnish": "fi",
"french": "fr",
"galician": "gl",
"georgian": "ka",
"german": "de",
"greek": "el",
"gujarati": "gu",
"hausa": "ha",
"hebrew": "he",
"hindi": "hi",
"hungarian": "hu",
"icelandic": "is",
"igbo": "ig",
"indonesian": "id",
"irish": "ga",
"italian": "it",
"japanese": "ja",
"javanese": "jv",
"kannada": "kn",
"kazakh": "kk",
"khmer": "km",
"korean": "ko",
"kurdish": "ku",
"kyrgyz": "ky",
"lao": "lo",
"latvian": "lv",
"lithuanian": "lt",
"luxembourgish": "lb",
"macedonian": "mk",
"malay": "ms",
"malayalam": "ml",
"maltese": "mt",
"maori": "mi",
"marathi": "mr",
"mongolian": "mn",
"nepali": "ne",
"norwegian": "no",
"pashto": "ps",
"persian": "fa",
"iranian": "fa",
"farsi": "fa",
"polish": "pl",
"portuguese": "pt",
"punjabi": "pa",
"romanian": "ro",
"russian": "ru",
"serbian": "sr",
"sindhi": "sd",
"sinhala": "si",
"slovak": "sk",
"slovenian": "sl",
"somali": "so",
"spanish": "es",
"swahili": "sw",
"swedish": "sv",
"tajik": "tg",
"tamil": "ta",
"tatar": "tt",
"telugu": "te",
"thai": "th",
"tibetan": "bo",
"turkish": "tr",
"turkmen": "tk",
"ukrainian": "uk",
"urdu": "ur",
"uyghur": "ug",
"uzbek": "uz",
"vietnamese": "vi",
"welsh": "cy",
"yiddish": "yi",
"yoruba": "yo",
"zulu": "zu"
};
function layoutCode(layoutName) {
if (!layoutName)
return "";
const lang = layoutName.split(" ")[0].toLowerCase();
const code = LANG_CODES[lang] || lang.substring(0, 2);
return code.toUpperCase();
}
+157 -22
View File
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import QtQuick import QtQuick
import qs.Common
Singleton { Singleton {
id: root id: root
@@ -16,8 +17,70 @@ Singleton {
signal popoutOpening signal popoutOpening
signal popoutChanged signal popoutChanged
property real hoverCursorGlobalX: 0
property real hoverCursorGlobalY: 0
function updateHoverCursor(gx, gy) {
hoverCursorGlobalX = gx;
hoverCursorGlobalY = gy;
}
function cursorOverBar(gx, gy, padding, excludedWindow) {
const pad = padding !== undefined ? padding : 16;
const bars = KeyboardFocus.barWindows || [];
for (let i = 0; i < bars.length; i++) {
const w = bars[i];
if (!w?.visible || w === excludedWindow)
continue;
if (typeof w.containsGlobalPoint === "function") {
if (w.containsGlobalPoint(gx, gy, pad))
return true;
continue;
}
const item = w.contentItem;
if (!item || typeof item.mapToItem !== "function")
continue;
const topLeft = item.mapToItem(null, 0, 0);
if (!topLeft)
continue;
if (gx >= topLeft.x - pad && gx < topLeft.x + item.width + pad && gy >= topLeft.y - pad && gy < topLeft.y + item.height + pad)
return true;
}
return false;
}
function _isPopoutPresented(popout) {
if (!popout)
return false;
try {
if (popout.dashVisible !== undefined)
return !!popout.dashVisible;
if (popout.notificationHistoryVisible !== undefined)
return !!popout.notificationHistoryVisible;
return !!(popout.shouldBeVisible || popout.isClosing);
} catch (e) {
return false;
}
}
function _openPopout(popout) {
if (popout.dashVisible !== undefined) {
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing)
popout.dashVisible = false;
popout.dashVisible = true;
return;
}
if (popout.notificationHistoryVisible !== undefined) {
popout.notificationHistoryVisible = true;
return;
}
popout.open();
}
function _closePopout(popout) { function _closePopout(popout) {
try { try {
if (popout.hoverDismissEnabled !== undefined)
popout.hoverDismissEnabled = false;
switch (true) { switch (true) {
case popout.dashVisible !== undefined: case popout.dashVisible !== undefined:
popout.dashVisible = false; popout.dashVisible = false;
@@ -35,6 +98,20 @@ Singleton {
} }
} }
function _dismissPopoutFromHover(popout) {
try {
if (!popout || popout.hoverDismissEnabled !== true)
return;
if (typeof popout.closeFromHoverDismiss === "function") {
popout.closeFromHoverDismiss();
return;
}
_closePopout(popout);
} catch (e) {
return;
}
}
function _isStale(popout) { function _isStale(popout) {
try { try {
if (!popout || !("shouldBeVisible" in popout)) if (!popout || !("shouldBeVisible" in popout))
@@ -89,7 +166,39 @@ Singleton {
continue; continue;
_closePopout(popout); _closePopout(popout);
} }
currentPopoutsByScreen = {}; // Keep map entries until each popout's close animation finishes (hidePopout).
}
function closePopoutForScreen(screen) {
if (!screen)
return;
const screenName = screen.name;
const popout = currentPopoutsByScreen[screenName];
if (!popout || _isStale(popout)) {
currentPopoutsByScreen[screenName] = null;
currentPopoutTriggers[screenName] = null;
return;
}
_closePopout(popout);
}
function dismissHoverPopoutForScreen(screen) {
if (!screen)
return;
const screenName = screen.name;
const popout = currentPopoutsByScreen[screenName];
if (!popout || _isStale(popout)) {
currentPopoutsByScreen[screenName] = null;
currentPopoutTriggers[screenName] = null;
return;
}
_dismissPopoutFromHover(popout);
}
function cancelHoverDismiss(screen) {
const popout = getActivePopout(screen);
if (popout?.cancelHoverDismiss)
popout.cancelHoverDismiss();
} }
function getActivePopout(screen) { function getActivePopout(screen) {
@@ -98,23 +207,38 @@ Singleton {
return currentPopoutsByScreen[screen.name] || null; return currentPopoutsByScreen[screen.name] || null;
} }
// Checks if the active popout is pinned for auto-dismissal
function isActivePopoutPinned(screen) {
const p = getActivePopout(screen);
if (!p || !_isPopoutPresented(p))
return false;
const dismissSuspended = p.effectiveHoverDismissSuspended ?? p.hoverDismissSuspended;
return p.hoverDismissEnabled === false || dismissSuspended === true;
}
function isCurrentPopout(popout, screenName) { function isCurrentPopout(popout, screenName) {
const name = screenName || popout?.screen?.name || ""; const name = screenName || popout?.screen?.name || "";
return !!name && currentPopoutsByScreen[name] === popout; return !!name && currentPopoutsByScreen[name] === popout;
} }
function requestPopout(popout, tabIndex, triggerSource) { function _requestPopout(popout, tabIndex, triggerSource, hoverRequest) {
if (!popout || !popout.screen) if (!popout || !popout.screen)
return; return;
// Clicking a transient popout pins it instead of toggling it closed.
const wasTransient = popout.hoverDismissEnabled === true;
if (!hoverRequest && popout.hoverDismissEnabled !== undefined)
popout.hoverDismissEnabled = false;
screenshotActive = false; screenshotActive = false;
const screenName = popout.screen.name; const screenName = popout.screen.name;
const currentPopout = currentPopoutsByScreen[screenName]; const currentPopout = currentPopoutsByScreen[screenName];
const triggerId = triggerSource !== undefined ? triggerSource : tabIndex; const triggerId = triggerSource !== undefined ? triggerSource : tabIndex;
const alreadyPresented = currentPopout === popout && (hoverRequest ? _isPopoutPresented(popout) : popout.shouldBeVisible);
const willOpen = !(currentPopout === popout && popout.shouldBeVisible && triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId); const willOpen = !(alreadyPresented && triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId);
if (willOpen) { if (willOpen)
popoutOpening(); popoutOpening();
}
let movedFromOtherScreen = false; let movedFromOtherScreen = false;
for (const otherScreenName in currentPopoutsByScreen) { for (const otherScreenName in currentPopoutsByScreen) {
@@ -145,18 +269,26 @@ Singleton {
currentPopoutsByScreen[screenName] = null; currentPopoutsByScreen[screenName] = null;
currentPopoutTriggers[screenName] = null; currentPopoutTriggers[screenName] = null;
} else { } else {
if (hoverRequest && typeof currentPopout.beginSupersededClose === "function")
currentPopout.beginSupersededClose();
_closePopout(currentPopout); _closePopout(currentPopout);
} }
} }
if (currentPopout === popout && popout.shouldBeVisible && !movedFromOtherScreen) { if (alreadyPresented && !movedFromOtherScreen) {
if (triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId) { const sameDefinedTrigger = triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId;
_closePopout(popout); if (hoverRequest && sameDefinedTrigger)
return; return;
}
if (triggerId === undefined) { if (!hoverRequest && (triggerId === undefined || sameDefinedTrigger)) {
_closePopout(popout); if (!wasTransient) {
_closePopout(popout);
return;
}
if (popout.updateSurfacePosition)
popout.updateSurfacePosition();
if (triggerId !== undefined)
currentPopoutTriggers[screenName] = triggerId;
return; return;
} }
@@ -166,6 +298,8 @@ Singleton {
if (popout.updateSurfacePosition) if (popout.updateSurfacePosition)
popout.updateSurfacePosition(); popout.updateSurfacePosition();
currentPopoutTriggers[screenName] = triggerId; currentPopoutTriggers[screenName] = triggerId;
if (hoverRequest && popout.hoverDismissEnabled !== undefined)
popout.hoverDismissEnabled = true;
return; return;
} }
@@ -181,16 +315,17 @@ Singleton {
ModalManager.closeAllModalsExcept(null); ModalManager.closeAllModalsExcept(null);
} }
if (movedFromOtherScreen) { if (hoverRequest && popout.hoverDismissEnabled !== undefined)
popout.open(); popout.hoverDismissEnabled = true;
} else {
if (popout.dashVisible !== undefined) { _openPopout(popout);
popout.dashVisible = true; }
} else if (popout.notificationHistoryVisible !== undefined) {
popout.notificationHistoryVisible = true; function requestPopout(popout, tabIndex, triggerSource) {
} else { _requestPopout(popout, tabIndex, triggerSource, false);
popout.open(); }
}
} function requestHoverPopout(popout, tabIndex, triggerSource) {
_requestPopout(popout, tabIndex, triggerSource, true);
} }
} }
+201 -11
View File
@@ -15,7 +15,7 @@ Singleton {
id: root id: root
readonly property var log: Log.scoped("SettingsData") readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 11 readonly property int settingsConfigVersion: 12
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -177,10 +177,12 @@ Singleton {
property int niriLayoutRadiusOverride: -1 property int niriLayoutRadiusOverride: -1
property int niriLayoutBorderSize: -1 property int niriLayoutBorderSize: -1
property int hyprlandLayoutGapsOverride: -1 property int hyprlandLayoutGapsOverride: -1
property int hyprlandLayoutGapsOutOverride: -1
property int hyprlandLayoutRadiusOverride: -1 property int hyprlandLayoutRadiusOverride: -1
property int hyprlandLayoutBorderSize: -1 property int hyprlandLayoutBorderSize: -1
property bool hyprlandResizeOnBorder: false property bool hyprlandResizeOnBorder: false
property int mangoLayoutGapsOverride: -1 property int mangoLayoutGapsOverride: -1
property int mangoLayoutGapsOutOverride: -1
property int mangoLayoutRadiusOverride: -1 property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1 property int mangoLayoutBorderSize: -1
property bool mangoTrackpadNaturalScrolling: true property bool mangoTrackpadNaturalScrolling: true
@@ -262,7 +264,11 @@ Singleton {
} }
property bool frameEnabled: false property bool frameEnabled: false
onFrameEnabledChanged: saveSettings() onFrameEnabledChanged: {
saveSettings();
if (!_loading)
updateFrameCompositorLayout();
}
property real frameThickness: 16 property real frameThickness: 16
onFrameThicknessChanged: saveSettings() onFrameThicknessChanged: saveSettings()
property int barInsetPaddingShared: -1 property int barInsetPaddingShared: -1
@@ -291,9 +297,15 @@ Singleton {
onFrameLauncherEmergeSideChanged: saveSettings() onFrameLauncherEmergeSideChanged: saveSettings()
property bool frameLauncherArcExtender: false property bool frameLauncherArcExtender: false
onFrameLauncherArcExtenderChanged: saveSettings() onFrameLauncherArcExtenderChanged: saveSettings()
property bool frameLauncherEdgeHover: false
onFrameLauncherEdgeHoverChanged: saveSettings()
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top" readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
property string frameMode: "connected" property string frameMode: "connected"
onFrameModeChanged: saveSettings() onFrameModeChanged: {
saveSettings();
if (!_loading && frameEnabled)
updateFrameCompositorLayout();
}
property var connectedFrameBarStyleBackups: ({}) property var connectedFrameBarStyleBackups: ({})
onConnectedFrameBarStyleBackupsChanged: saveSettings() onConnectedFrameBarStyleBackupsChanged: saveSettings()
readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected" readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected"
@@ -446,9 +458,11 @@ Singleton {
property string audioScrollMode: "volume" property string audioScrollMode: "volume"
property int audioWheelScrollAmount: 5 property int audioWheelScrollAmount: 5
property bool audioDeviceScrollVolumeEnabled: false property bool audioDeviceScrollVolumeEnabled: false
property var mediaExcludePlayers: []
property bool clockCompactMode: false property bool clockCompactMode: false
property int focusedWindowSize: 1 property int focusedWindowSize: 1
property bool focusedWindowCompactMode: false property bool focusedWindowCompactMode: false
property bool focusedWindowShowIcon: false
property bool runningAppsCompactMode: true property bool runningAppsCompactMode: true
property int barMaxVisibleApps: 0 property int barMaxVisibleApps: 0
property int barMaxVisibleRunningApps: 0 property int barMaxVisibleRunningApps: 0
@@ -483,6 +497,8 @@ Singleton {
property string greeterLockDateFormat: "" property string greeterLockDateFormat: ""
property string greeterFontFamily: "" property string greeterFontFamily: ""
property string greeterWallpaperFillMode: "" property string greeterWallpaperFillMode: ""
property bool greeterSyncPending: false
property var greeterSyncBaseline: ({})
property int mediaSize: 1 property int mediaSize: 1
property string appLauncherViewMode: "list" property string appLauncherViewMode: "list"
@@ -603,9 +619,9 @@ Singleton {
if (!on && id !== "settings" && current.filter(t => t.enabled && t.id !== "settings").length <= 1) if (!on && id !== "settings" && current.filter(t => t.enabled && t.id !== "settings").length <= 1)
return; return;
dashTabs = current.map(t => t.id === id ? { dashTabs = current.map(t => t.id === id ? {
"id": t.id, "id": t.id,
"enabled": on "enabled": on
} : t); } : t);
} }
function resetDashTabs() { function resetDashTabs() {
@@ -737,12 +753,16 @@ Singleton {
property bool batteryNotifyCritical: true property bool batteryNotifyCritical: true
property int batteryLowThreshold: 20 property int batteryLowThreshold: 20
property bool batteryNotifyLow: false property bool batteryNotifyLow: false
property int batteryNotificationType: 0 property int batteryChargeLimitNotificationType: 0
property int batteryLowNotificationType: 0
property int batteryCriticalNotificationType: 1
property bool batteryAutoPowerSaver: false property bool batteryAutoPowerSaver: 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
property bool showBatteryTimeOnlyOnBattery: false property bool showBatteryTimeOnlyOnBattery: false
property bool batteryPillStyle: false
property bool batteryPillPercentSign: false
property bool lockBeforeSuspend: false property bool lockBeforeSuspend: false
property bool loginctlLockIntegration: true property bool loginctlLockIntegration: true
property bool fadeToLockEnabled: true property bool fadeToLockEnabled: true
@@ -874,12 +894,14 @@ Singleton {
readonly property bool greeterU2fReady: Processes.greeterU2fReady readonly property bool greeterU2fReady: Processes.greeterU2fReady
readonly property string greeterU2fReason: Processes.greeterU2fReason readonly property string greeterU2fReason: Processes.greeterU2fReason
readonly property string greeterU2fSource: Processes.greeterU2fSource readonly property string greeterU2fSource: Processes.greeterU2fSource
property string lockScreenActiveMonitor: "all"
property string lockScreenInactiveColor: "#000000" property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0 property int lockScreenNotificationMode: 0
property bool lockScreenVideoEnabled: false property bool lockScreenVideoEnabled: false
property string lockScreenVideoPath: "" property string lockScreenVideoPath: ""
property bool lockScreenVideoCycling: false property bool lockScreenVideoCycling: false
property string lockScreenWallpaperPath: ""
property string lockScreenWallpaperFillMode: ""
property string lockScreenFontFamily: ""
property bool hideBrightnessSlider: false property bool hideBrightnessSlider: false
property int notificationTimeoutLow: 5000 property int notificationTimeoutLow: 5000
@@ -999,10 +1021,23 @@ Singleton {
"shadowOpacity": 60, "shadowOpacity": 60,
"shadowColorMode": "default", "shadowColorMode": "default",
"shadowCustomColor": "#000000", "shadowCustomColor": "#000000",
"clickThrough": false "clickThrough": false,
"hoverPopouts": false,
"hoverPopoutDelay": 150
} }
] ]
// Standalone bar xray is unsafe when windows can render beneath its surface
function _standaloneBarXrayAvailable(configs) {
const list = configs || [];
const activeBars = list.filter(c => c && c.enabled && (c.visible ?? true));
const gapsOverride = (typeof CompositorService !== "undefined" && CompositorService.isHyprland) ? hyprlandLayoutGapsOverride : niriLayoutGapsOverride;
const layoutGaps = gapsOverride >= 0 ? gapsOverride : Math.max(4, (list[0]?.spacing ?? 4));
return activeBars.every(c => !c.autoHide && !(c.useOverlayLayer ?? false) && (c.spacing ?? 4) + (c.bottomGap ?? 0) + layoutGaps >= 0);
}
readonly property bool standaloneBarXrayAvailable: _standaloneBarXrayAvailable(barConfigs)
property bool desktopClockEnabled: false property bool desktopClockEnabled: false
property string desktopClockStyle: "analog" property string desktopClockStyle: "analog"
property real desktopClockTransparency: 0.8 property real desktopClockTransparency: 0.8
@@ -1318,6 +1353,35 @@ Singleton {
return true; return true;
} }
function moveDesktopWidgetInstanceToGroup(instanceId, groupId, newIndexInGroup) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const groups = desktopWidgetGroups || [];
const idx = instances.findIndex(inst => inst.id === instanceId);
if (idx === -1)
return false;
const [item] = instances.splice(idx, 1);
item.group = groupId || null;
const groupMatches = inst => {
if (!groupId)
return !inst.group || !groups.some(g => g.id === inst.group);
return inst.group === groupId;
};
const groupInstances = instances.filter(groupMatches);
const clamped = Math.max(0, Math.min(newIndexInGroup, groupInstances.length));
let targetGlobalIdx;
if (clamped >= groupInstances.length) {
const last = groupInstances[groupInstances.length - 1];
targetGlobalIdx = last ? instances.findIndex(inst => inst.id === last.id) + 1 : instances.length;
} else {
const targetInstance = groupInstances[clamped];
targetGlobalIdx = instances.findIndex(inst => inst.id === targetInstance.id);
}
instances.splice(targetGlobalIdx, 0, item);
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function createDesktopWidgetGroup(name) { function createDesktopWidgetGroup(name) {
const id = "dwg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9); const id = "dwg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9);
const group = { const group = {
@@ -1418,6 +1482,17 @@ Singleton {
MangoService.generateLayoutConfig(); MangoService.generateLayoutConfig();
} }
function updateFrameCompositorLayout() {
// Generate before begin() so compositor readiness is already pending at transitionRequested
if (typeof CompositorService !== "undefined") {
if (CompositorService.isNiri && typeof NiriService !== "undefined")
NiriService.generateNiriLayoutConfig(true);
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined")
HyprlandService.generateLayoutConfig(true);
}
FrameTransitionState.begin();
}
function resolveIconTheme() { function resolveIconTheme() {
if (iconThemePerMode && typeof SessionData !== "undefined" && SessionData.isLightMode) if (iconThemePerMode && typeof SessionData !== "undefined" && SessionData.isLightMode)
return iconThemeLight; return iconThemeLight;
@@ -1613,6 +1688,32 @@ Singleton {
}); });
} }
function markGreeterSyncPending(who, key, oldValue) {
if (isGreeterMode)
return;
if (!(key in greeterSyncBaseline)) {
var baseline = greeterSyncBaseline;
baseline[key] = oldValue;
greeterSyncBaseline = baseline;
}
greeterSyncPending = true;
}
function clearGreeterSyncPending() {
greeterSyncBaseline = {};
greeterSyncPending = false;
saveSettings();
}
function revertGreeterSyncPending() {
for (var key in greeterSyncBaseline) {
root[key] = greeterSyncBaseline[key];
}
greeterSyncBaseline = {};
greeterSyncPending = false;
saveSettings();
}
readonly property var _hooks: ({ readonly property var _hooks: ({
"applyStoredTheme": applyStoredTheme, "applyStoredTheme": applyStoredTheme,
"regenSystemThemes": regenSystemThemes, "regenSystemThemes": regenSystemThemes,
@@ -1621,7 +1722,8 @@ Singleton {
"updateBarConfigs": updateBarConfigs, "updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor, "updateCompositorCursor": updateCompositorCursor,
"scheduleAuthApply": scheduleAuthApply, "scheduleAuthApply": scheduleAuthApply,
"scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync "scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync,
"markGreeterSyncPending": markGreeterSyncPending
}) })
function set(key, value) { function set(key, value) {
@@ -1647,6 +1749,18 @@ Singleton {
} }
} }
if (obj?.lockScreenActiveMonitor !== undefined) {
var oldVal = obj.lockScreenActiveMonitor;
if (oldVal && oldVal !== "all") {
if (!obj.screenPreferences)
obj.screenPreferences = {};
if (obj.screenPreferences.lockScreen === undefined) {
obj.screenPreferences.lockScreen = [oldVal];
}
}
delete obj.lockScreenActiveMonitor;
}
Store.parse(root, obj); Store.parse(root, obj);
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected") if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
@@ -2365,13 +2479,17 @@ Singleton {
if (index === -1) if (index === -1)
return; return;
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position; const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
const barXrayTargetWasAvailable = _standaloneBarXrayAvailable(configs);
if (updates.autoHide === false || updates.visible === false) if (updates.autoHide === false || updates.visible === false)
setBarIpcReveal(barId, false); setBarIpcReveal(barId, false);
Object.assign(configs[index], updates); Object.assign(configs[index], updates);
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs; const sanitizedConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
barConfigs = sanitizedConfigs;
updateBarConfigs(); updateBarConfigs();
if (!frameEnabled && _standaloneBarXrayAvailable(sanitizedConfigs) !== barXrayTargetWasAvailable)
updateCompositorLayout();
if (positionChanged) { if (positionChanged) {
NotificationService.dismissAllPopups(); NotificationService.dismissAllPopups();
} }
@@ -2436,6 +2554,46 @@ Singleton {
return barConfigs.filter(cfg => cfg.enabled); return barConfigs.filter(cfg => cfg.enabled);
} }
function _sideToPosition(side) {
switch (side) {
case "top":
return SettingsData.Position.Top;
case "bottom":
return SettingsData.Position.Bottom;
case "left":
return SettingsData.Position.Left;
case "right":
return SettingsData.Position.Right;
}
return -1;
}
// Check if a bar occupies the specified screen edge
function barOccupiesSide(screen, side) {
if (!screen)
return false;
const sidePos = _sideToPosition(side);
if (sidePos < 0)
return false;
const bars = getEnabledBarConfigs();
for (var i = 0; i < bars.length; i++) {
const bc = bars[i];
if (bc.position !== sidePos)
continue;
const prefs = bc.screenPreferences || ["all"];
if (prefs.includes("all") || isScreenInPreferences(screen, prefs))
return true;
}
return false;
}
// Check if the dock occupies the specified screen edge.
function dockOccupiesSide(side) {
if (!showDock)
return false;
return dockPosition === _sideToPosition(side);
}
function getScreensSortedByPosition() { function getScreensSortedByPosition() {
const screens = []; const screens = [];
for (var i = 0; i < Quickshell.screens.length; i++) { for (var i = 0; i < Quickshell.screens.length; i++) {
@@ -3018,6 +3176,32 @@ Singleton {
saveSettings(); saveSettings();
} }
function addMediaExcludePlayer(identity) {
if (identity === undefined || identity === null)
return;
var normalizedIdentity = identity.toString().trim().toLowerCase();
if (!normalizedIdentity)
return;
var list = mediaExcludePlayers ? mediaExcludePlayers.slice() : [];
var normalizedList = list.map(function (id) {
return id ? id.toString().trim().toLowerCase() : "";
});
if (normalizedList.indexOf(normalizedIdentity) >= 0)
return;
list.push(normalizedIdentity);
mediaExcludePlayers = list;
saveSettings();
}
function removeMediaExcludePlayer(index) {
var list = mediaExcludePlayers ? mediaExcludePlayers.slice() : [];
if (index < 0 || index >= list.length)
return;
list.splice(index, 1);
mediaExcludePlayers = list;
saveSettings();
}
property bool _pendingExpandNotificationRules: false property bool _pendingExpandNotificationRules: false
property int _pendingNotificationRuleIndex: -1 property int _pendingNotificationRuleIndex: -1
@@ -3438,6 +3622,9 @@ Singleton {
onLoaded: { onLoaded: {
if (isGreeterMode) if (isGreeterMode)
return; return;
const wasLoaded = _hasLoaded;
const prevFrameEnabled = frameEnabled;
const prevFrameMode = frameMode;
_loading = true; _loading = true;
_hasUnsavedChanges = false; _hasUnsavedChanges = false;
try { try {
@@ -3472,6 +3659,9 @@ Singleton {
} finally { } finally {
_loading = false; _loading = false;
} }
// External edits reload under _loading, which skips the per-property transition triggers
if (wasLoaded && !_parseError && (frameEnabled !== prevFrameEnabled || (frameEnabled && frameMode !== prevFrameMode)))
updateFrameCompositorLayout();
} }
onLoadFailed: error => { onLoadFailed: error => {
if (!isGreeterMode) { if (!isGreeterMode) {
+40
View File
@@ -17,6 +17,8 @@ const StockThemes = {
background: "#101418", background: "#101418",
backgroundText: "#e0e2e8", backgroundText: "#e0e2e8",
outline: "#8c9199", outline: "#8c9199",
surfaceContainerLowest: "#0c1014",
surfaceContainerLow: "#191c20",
surfaceContainer: "#1d2024", surfaceContainer: "#1d2024",
surfaceContainerHigh: "#272a2f", surfaceContainerHigh: "#272a2f",
surfaceContainerHighest: "#32353a", surfaceContainerHighest: "#32353a",
@@ -35,6 +37,8 @@ const StockThemes = {
background: "#141218", background: "#141218",
backgroundText: "#e6e0e9", backgroundText: "#e6e0e9",
outline: "#948f99", outline: "#948f99",
surfaceContainerLowest: "#100e14",
surfaceContainerLow: "#1d1b20",
surfaceContainer: "#211f24", surfaceContainer: "#211f24",
surfaceContainerHigh: "#2b292f", surfaceContainerHigh: "#2b292f",
surfaceContainerHighest: "#36343a", surfaceContainerHighest: "#36343a",
@@ -53,6 +57,8 @@ const StockThemes = {
background: "#10140f", background: "#10140f",
backgroundText: "#e0e4db", backgroundText: "#e0e4db",
outline: "#8c9388", outline: "#8c9388",
surfaceContainerLowest: "#0c100b",
surfaceContainerLow: "#191d17",
surfaceContainer: "#1d211b", surfaceContainer: "#1d211b",
surfaceContainerHigh: "#272b25", surfaceContainerHigh: "#272b25",
surfaceContainerHighest: "#323630", surfaceContainerHighest: "#323630",
@@ -71,6 +77,8 @@ const StockThemes = {
background: "#1a120e", background: "#1a120e",
backgroundText: "#f0dfd8", backgroundText: "#f0dfd8",
outline: "#a08d85", outline: "#a08d85",
surfaceContainerLowest: "#160e0a",
surfaceContainerLow: "#231a16",
surfaceContainer: "#271e1a", surfaceContainer: "#271e1a",
surfaceContainerHigh: "#322824", surfaceContainerHigh: "#322824",
surfaceContainerHighest: "#3d332e", surfaceContainerHighest: "#3d332e",
@@ -89,6 +97,8 @@ const StockThemes = {
background: "#1a1110", background: "#1a1110",
backgroundText: "#f1dedc", backgroundText: "#f1dedc",
outline: "#a08c89", outline: "#a08c89",
surfaceContainerLowest: "#160d0c",
surfaceContainerLow: "#231918",
surfaceContainer: "#271d1c", surfaceContainer: "#271d1c",
surfaceContainerHigh: "#322826", surfaceContainerHigh: "#322826",
surfaceContainerHighest: "#3d3231", surfaceContainerHighest: "#3d3231",
@@ -107,6 +117,8 @@ const StockThemes = {
background: "#0e1416", background: "#0e1416",
backgroundText: "#dee3e5", backgroundText: "#dee3e5",
outline: "#899295", outline: "#899295",
surfaceContainerLowest: "#0a1012",
surfaceContainerLow: "#171d1e",
surfaceContainer: "#1b2122", surfaceContainer: "#1b2122",
surfaceContainerHigh: "#252b2c", surfaceContainerHigh: "#252b2c",
surfaceContainerHighest: "#303637", surfaceContainerHighest: "#303637",
@@ -125,6 +137,8 @@ const StockThemes = {
background: "#191112", background: "#191112",
backgroundText: "#f0dee0", backgroundText: "#f0dee0",
outline: "#9f8c8e", outline: "#9f8c8e",
surfaceContainerLowest: "#150d0e",
surfaceContainerLow: "#22191a",
surfaceContainer: "#261d1e", surfaceContainer: "#261d1e",
surfaceContainerHigh: "#312829", surfaceContainerHigh: "#312829",
surfaceContainerHighest: "#3c3233", surfaceContainerHighest: "#3c3233",
@@ -143,6 +157,8 @@ const StockThemes = {
background: "#17130b", background: "#17130b",
backgroundText: "#ebe1d4", backgroundText: "#ebe1d4",
outline: "#998f80", outline: "#998f80",
surfaceContainerLowest: "#130f07",
surfaceContainerLow: "#1f1b13",
surfaceContainer: "#231f17", surfaceContainer: "#231f17",
surfaceContainerHigh: "#2e2921", surfaceContainerHigh: "#2e2921",
surfaceContainerHighest: "#39342b", surfaceContainerHighest: "#39342b",
@@ -161,6 +177,8 @@ const StockThemes = {
background: "#1a1110", background: "#1a1110",
backgroundText: "#f1dedc", backgroundText: "#f1dedc",
outline: "#a08c8a", outline: "#a08c8a",
surfaceContainerLowest: "#160d0c",
surfaceContainerLow: "#231918",
surfaceContainer: "#271d1c", surfaceContainer: "#271d1c",
surfaceContainerHigh: "#322826", surfaceContainerHigh: "#322826",
surfaceContainerHighest: "#3d3231", surfaceContainerHighest: "#3d3231",
@@ -179,6 +197,8 @@ const StockThemes = {
background: "#131315", background: "#131315",
backgroundText: "#e4e2e3", backgroundText: "#e4e2e3",
outline: "#929092", outline: "#929092",
surfaceContainerLowest: "#252525",
surfaceContainerLow: "#303030",
surfaceContainer: "#353535", surfaceContainer: "#353535",
surfaceContainerHigh: "#424242", surfaceContainerHigh: "#424242",
surfaceContainerHighest: "#505050", surfaceContainerHighest: "#505050",
@@ -203,6 +223,8 @@ const StockThemes = {
background: "#f7f9ff", background: "#f7f9ff",
backgroundText: "#181c20", backgroundText: "#181c20",
outline: "#72777f", outline: "#72777f",
surfaceContainerLowest: "#f9fbff",
surfaceContainerLow: "#eef0f6",
surfaceContainer: "#eceef4", surfaceContainer: "#eceef4",
surfaceContainerHigh: "#e6e8ee", surfaceContainerHigh: "#e6e8ee",
surfaceContainerHighest: "#e0e2e8", surfaceContainerHighest: "#e0e2e8",
@@ -221,6 +243,8 @@ const StockThemes = {
background: "#fef7ff", background: "#fef7ff",
backgroundText: "#1d1b20", backgroundText: "#1d1b20",
outline: "#7a757f", outline: "#7a757f",
surfaceContainerLowest: "#fff9ff",
surfaceContainerLow: "#f4eef6",
surfaceContainer: "#f2ecf4", surfaceContainer: "#f2ecf4",
surfaceContainerHigh: "#ece6ee", surfaceContainerHigh: "#ece6ee",
surfaceContainerHighest: "#e6e0e9", surfaceContainerHighest: "#e6e0e9",
@@ -239,6 +263,8 @@ const StockThemes = {
background: "#f7fbf1", background: "#f7fbf1",
backgroundText: "#191d17", backgroundText: "#191d17",
outline: "#72796f", outline: "#72796f",
surfaceContainerLowest: "#f9fdf3",
surfaceContainerLow: "#eef1e8",
surfaceContainer: "#ecefe6", surfaceContainer: "#ecefe6",
surfaceContainerHigh: "#e6e9e0", surfaceContainerHigh: "#e6e9e0",
surfaceContainerHighest: "#e0e4db", surfaceContainerHighest: "#e0e4db",
@@ -257,6 +283,8 @@ const StockThemes = {
background: "#fff8f6", background: "#fff8f6",
backgroundText: "#221a16", backgroundText: "#221a16",
outline: "#85736c", outline: "#85736c",
surfaceContainerLowest: "#fffaf8",
surfaceContainerLow: "#feece5",
surfaceContainer: "#fceae3", surfaceContainer: "#fceae3",
surfaceContainerHigh: "#f6e5de", surfaceContainerHigh: "#f6e5de",
surfaceContainerHighest: "#f0dfd8", surfaceContainerHighest: "#f0dfd8",
@@ -275,6 +303,8 @@ const StockThemes = {
background: "#fff8f7", background: "#fff8f7",
backgroundText: "#231918", backgroundText: "#231918",
outline: "#857370", outline: "#857370",
surfaceContainerLowest: "#fffaf9",
surfaceContainerLow: "#feece9",
surfaceContainer: "#fceae7", surfaceContainer: "#fceae7",
surfaceContainerHigh: "#f7e4e1", surfaceContainerHigh: "#f7e4e1",
surfaceContainerHighest: "#f1dedc", surfaceContainerHighest: "#f1dedc",
@@ -293,6 +323,8 @@ const StockThemes = {
background: "#f5fafc", background: "#f5fafc",
backgroundText: "#171d1e", backgroundText: "#171d1e",
outline: "#6f797b", outline: "#6f797b",
surfaceContainerLowest: "#f7fcfe",
surfaceContainerLow: "#ebf1f2",
surfaceContainer: "#e9eff0", surfaceContainer: "#e9eff0",
surfaceContainerHigh: "#e3e9eb", surfaceContainerHigh: "#e3e9eb",
surfaceContainerHighest: "#dee3e5", surfaceContainerHighest: "#dee3e5",
@@ -311,6 +343,8 @@ const StockThemes = {
background: "#fff8f7", background: "#fff8f7",
backgroundText: "#22191a", backgroundText: "#22191a",
outline: "#847375", outline: "#847375",
surfaceContainerLowest: "#fffaf9",
surfaceContainerLow: "#fdeced",
surfaceContainer: "#fbeaeb", surfaceContainer: "#fbeaeb",
surfaceContainerHigh: "#f5e4e5", surfaceContainerHigh: "#f5e4e5",
surfaceContainerHighest: "#f0dee0", surfaceContainerHighest: "#f0dee0",
@@ -329,6 +363,8 @@ const StockThemes = {
background: "#fff8f2", background: "#fff8f2",
backgroundText: "#1f1b13", backgroundText: "#1f1b13",
outline: "#7f7667", outline: "#7f7667",
surfaceContainerLowest: "#fffaf4",
surfaceContainerLow: "#f8eee1",
surfaceContainer: "#f6ecdf", surfaceContainer: "#f6ecdf",
surfaceContainerHigh: "#f1e7d9", surfaceContainerHigh: "#f1e7d9",
surfaceContainerHighest: "#ebe1d4", surfaceContainerHighest: "#ebe1d4",
@@ -347,6 +383,8 @@ const StockThemes = {
background: "#fff8f7", background: "#fff8f7",
backgroundText: "#231918", backgroundText: "#231918",
outline: "#857371", outline: "#857371",
surfaceContainerLowest: "#fffaf9",
surfaceContainerLow: "#feece9",
surfaceContainer: "#fceae7", surfaceContainer: "#fceae7",
surfaceContainerHigh: "#f6e4e2", surfaceContainerHigh: "#f6e4e2",
surfaceContainerHighest: "#f1dedc", surfaceContainerHighest: "#f1dedc",
@@ -365,6 +403,8 @@ const StockThemes = {
background: "#ffffff", background: "#ffffff",
backgroundText: "#1a1a1a", backgroundText: "#1a1a1a",
outline: "#757577", outline: "#757577",
surfaceContainerLowest: "#fafafb",
surfaceContainerLow: "#ededef",
surfaceContainer: "#e8e8ea", surfaceContainer: "#e8e8ea",
surfaceContainerHigh: "#dcdcde", surfaceContainerHigh: "#dcdcde",
surfaceContainerHighest: "#d0d0d2", surfaceContainerHighest: "#d0d0d2",
+78 -53
View File
@@ -142,6 +142,8 @@ Singleton {
Component.onCompleted: { Component.onCompleted: {
Quickshell.execDetached(["mkdir", "-p", stateDir]); Quickshell.execDetached(["mkdir", "-p", stateDir]);
if (typeof SessionData === "undefined" || !SessionData.isGreeterMode)
Quickshell.execDetached([shellDir + "/scripts/gtk.sh", configDir, "", shellDir, "assets-only"]);
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => { Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
matugenAvailable = (code === 0) && !envDisableMatugen; matugenAvailable = (code === 0) && !envDisableMatugen;
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
@@ -463,6 +465,8 @@ Singleton {
"background": getMatugenColor("background", "#1a1c1e"), "background": getMatugenColor("background", "#1a1c1e"),
"backgroundText": getMatugenColor("on_background", "#e3e8ef"), "backgroundText": getMatugenColor("on_background", "#e3e8ef"),
"outline": getMatugenColor("outline", "#8e918f"), "outline": getMatugenColor("outline", "#8e918f"),
"surfaceContainerLowest": getMatugenColor("surface_container_lowest", "#0e1013"),
"surfaceContainerLow": getMatugenColor("surface_container_low", "#181a1d"),
"surfaceContainer": getMatugenColor("surface_container", "#1e2023"), "surfaceContainer": getMatugenColor("surface_container", "#1e2023"),
"surfaceContainerHigh": getMatugenColor("surface_container_high", "#292b2f"), "surfaceContainerHigh": getMatugenColor("surface_container_high", "#292b2f"),
"surfaceContainerHighest": getMatugenColor("surface_container_highest", "#343740"), "surfaceContainerHighest": getMatugenColor("surface_container_highest", "#343740"),
@@ -535,7 +539,9 @@ Singleton {
property color background: currentThemeData.background property color background: currentThemeData.background
property color backgroundText: currentThemeData.backgroundText property color backgroundText: currentThemeData.backgroundText
property color outline: currentThemeData.outline property color outline: currentThemeData.outline
property color outlineVariant: currentThemeData.outlineVariant || Qt.rgba(outline.r, outline.g, outline.b, 0.6) property color outlineVariant: currentThemeData.outlineVariant || withAlpha(outline, 0.6)
property color surfaceContainerLowest: currentThemeData.surfaceContainerLowest || blend(surfaceContainer, surface, 1.2)
property color surfaceContainerLow: currentThemeData.surfaceContainerLow || blend(surface, surfaceContainer, 0.667)
property color surfaceContainer: currentThemeData.surfaceContainer property color surfaceContainer: currentThemeData.surfaceContainer
property color surfaceContainerHigh: currentThemeData.surfaceContainerHigh property color surfaceContainerHigh: currentThemeData.surfaceContainerHigh
property color surfaceContainerHighest: currentThemeData.surfaceContainerHighest || surfaceContainerHigh property color surfaceContainerHighest: currentThemeData.surfaceContainerHighest || surfaceContainerHigh
@@ -546,9 +552,9 @@ Singleton {
property color onSurface: surfaceText property color onSurface: surfaceText
property color onSurfaceVariant: surfaceVariantText property color onSurfaceVariant: surfaceVariantText
property color onPrimary: primaryText property color onPrimary: primaryText
property color onSurface_12: Qt.rgba(onSurface.r, onSurface.g, onSurface.b, 0.12) property color onSurface_12: withAlpha(onSurface, 0.12)
property color onSurface_38: Qt.rgba(onSurface.r, onSurface.g, onSurface.b, 0.38) property color onSurface_38: withAlpha(onSurface, 0.38)
property color onSurfaceVariant_30: Qt.rgba(onSurfaceVariant.r, onSurfaceVariant.g, onSurfaceVariant.b, 0.30) property color onSurfaceVariant_30: withAlpha(onSurfaceVariant, 0.30)
property color error: currentThemeData.error || "#F2B8B5" property color error: currentThemeData.error || "#F2B8B5"
property color warning: currentThemeData.warning || "#FF9800" property color warning: currentThemeData.warning || "#FF9800"
@@ -557,32 +563,33 @@ Singleton {
property color tempDanger: "#ff5555" property color tempDanger: "#ff5555"
property color success: currentThemeData.success || "#4CAF50" property color success: currentThemeData.success || "#4CAF50"
property color primaryHover: Qt.rgba(primary.r, primary.g, primary.b, 0.12) property color primaryHover: withAlpha(primary, 0.12)
property color primaryHoverLight: Qt.rgba(primary.r, primary.g, primary.b, transparentBlurLayers ? 0.12 : 0.08) property color primaryHoverLight: withAlpha(primary, transparentBlurLayers ? 0.12 : 0.08)
property color primaryPressed: Qt.rgba(primary.r, primary.g, primary.b, transparentBlurLayers ? 0.24 : 0.16) property color primaryPressed: withAlpha(primary, transparentBlurLayers ? 0.24 : 0.16)
property color primarySelected: Qt.rgba(primary.r, primary.g, primary.b, 0.3) property color primarySelected: withAlpha(primary, 0.3)
property color primaryBackground: Qt.rgba(primary.r, primary.g, primary.b, 0.04) property color primaryBackground: withAlpha(primary, 0.04)
property color secondaryHover: Qt.rgba(secondary.r, secondary.g, secondary.b, 0.08) property color secondaryHover: withAlpha(secondary, 0.08)
property color surfaceHover: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.08) property color surfaceHover: withAlpha(surfaceVariant, 0.08)
property color surfacePressed: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.12) property color surfacePressed: withAlpha(surfaceVariant, 0.12)
property color surfaceSelected: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.15) property color surfaceSelected: withAlpha(surfaceVariant, 0.15)
property color surfaceLight: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, transparentBlurLayers ? 0.3 : 0.1) property color surfaceLight: withAlpha(surfaceVariant, transparentBlurLayers ? 0.3 : 0.1)
property color surfaceVariantAlpha: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.2) property color surfaceVariantAlpha: withAlpha(surfaceVariant, 0.2)
readonly property bool blurForegroundLayers: BlurService.enabled && (typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)) readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool transparentBlurLayers: BlurService.enabled && !blurForegroundLayers readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
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: transparentBlurLayers ? "transparent" : readableSurface readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color floatingSurfaceHigh: transparentBlurLayers ? "transparent" : readableSurfaceHigh readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
readonly property color nestedSurface: floatingSurfaceHigh readonly property color nestedSurface: floatingSurfaceHigh
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: BlurService.enabled ? blurLayerOutlineOpacity : 0.08 readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
readonly property int layerOutlineWidth: BlurService.enabled && layerOutlineOpacity > 0 ? 1 : 0 readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
property color surfaceTextHover: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.08) property color surfaceTextHover: withAlpha(surfaceText, 0.08)
property color surfaceTextAlpha: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.3) property color surfaceTextAlpha: withAlpha(surfaceText, 0.3)
function roleColor(mode) { function roleColor(mode) {
switch (mode) { switch (mode) {
@@ -607,6 +614,10 @@ Singleton {
return surfaceVariant; return surfaceVariant;
case "s": case "s":
return surface; return surface;
case "scll":
return surfaceContainerLowest;
case "scl":
return surfaceContainerLow;
case "sc": case "sc":
return surfaceContainer; return surfaceContainer;
case "sch": case "sch":
@@ -619,19 +630,23 @@ Singleton {
case "err": case "err":
return error; return error;
default: default:
return "transparent"; return withAlpha(surface, 0);
} }
} }
property color surfaceTextLight: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.06) property color surfaceTextLight: withAlpha(surfaceText, 0.06)
property color surfaceTextMedium: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.7) property color surfaceTextSecondary: withAlpha(surfaceText, 0.6)
property color surfaceTextMedium: withAlpha(surfaceText, 0.7)
property color outlineButton: Qt.rgba(outline.r, outline.g, outline.b, 0.5) property color outlineButton: withAlpha(outline, 0.5)
property color outlineLight: Qt.rgba(outline.r, outline.g, outline.b, BlurService.enabled ? Math.min(1, layerOutlineOpacity * 0.625) : 0.05) property color outlineLight: withAlpha(outline, Math.min(1, layerOutlineOpacity * 0.625))
property color outlineMedium: Qt.rgba(outline.r, outline.g, outline.b, layerOutlineOpacity) property color outlineMedium: withAlpha(outline, layerOutlineOpacity)
property color outlineStrong: Qt.rgba(outline.r, outline.g, outline.b, BlurService.enabled ? Math.min(1, layerOutlineOpacity * 1.5) : 0.12) property color outlineStrong: withAlpha(outline, Math.min(1, layerOutlineOpacity * 1.5))
property color outlineHeavy: withAlpha(outline, 0.2)
property color errorHover: Qt.rgba(error.r, error.g, error.b, 0.12) property color errorHover: withAlpha(error, 0.12)
property color errorPressed: Qt.rgba(error.r, error.g, error.b, 0.16) property color errorPressed: withAlpha(error, 0.16)
property color errorSelected: withAlpha(error, 0.3)
property color warningHover: withAlpha(warning, 0.12)
readonly property color ccTileActiveBg: { readonly property color ccTileActiveBg: {
switch (SettingsData.controlCenterTileColorMode) { switch (SettingsData.controlCenterTileColorMode) {
@@ -646,7 +661,7 @@ Singleton {
} }
} }
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (blurForegroundLayers ? withAlpha(surfaceContainerHigh, Math.min(popupTransparency, 0.24)) : withAlpha(surfaceContainer, popupTransparency)) readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (foregroundLayers ? withAlpha(surfaceContainerHigh, BlurService.enabled ? Math.min(popupTransparency, 0.24) : popupTransparency) : withAlpha(surfaceContainer, 0))
readonly property color ccPillInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.08) : nestedSurface readonly property color ccPillInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.08) : nestedSurface
readonly property color ccPillInactiveHoverBg: transparentBlurLayers ? withAlpha(primary, 0.10) : primaryPressed readonly property color ccPillInactiveHoverBg: transparentBlurLayers ? withAlpha(primary, 0.10) : primaryPressed
readonly property color ccSliderTrackColor: transparentBlurLayers ? surfaceText : surfaceContainerHigh readonly property color ccSliderTrackColor: transparentBlurLayers ? surfaceText : surfaceContainerHigh
@@ -681,13 +696,13 @@ Singleton {
readonly property color ccTileRing: { readonly property color ccTileRing: {
switch (SettingsData.controlCenterTileColorMode) { switch (SettingsData.controlCenterTileColorMode) {
case "primaryContainer": case "primaryContainer":
return Qt.rgba(primary.r, primary.g, primary.b, 0.22); return withAlpha(primary, 0.22);
case "secondary": case "secondary":
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.22); return withAlpha(surfaceText, 0.22);
case "surfaceVariant": case "surfaceVariant":
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.22); return withAlpha(surfaceText, 0.22);
default: default:
return Qt.rgba(primaryText.r, primaryText.g, primaryText.b, 0.22); return withAlpha(primaryText, 0.22);
} }
} }
@@ -720,11 +735,11 @@ Singleton {
readonly property color buttonHover: { readonly property color buttonHover: {
switch (SettingsData.buttonColorMode) { switch (SettingsData.buttonColorMode) {
case "primaryContainer": case "primaryContainer":
return Qt.rgba(primary.r, primary.g, primary.b, 0.12); return withAlpha(primary, 0.12);
case "secondary": case "secondary":
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.12); return withAlpha(surfaceText, 0.12);
case "surfaceVariant": case "surfaceVariant":
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.12); return withAlpha(surfaceText, 0.12);
default: default:
return primaryHover; return primaryHover;
} }
@@ -733,11 +748,11 @@ Singleton {
readonly property color buttonPressed: { readonly property color buttonPressed: {
switch (SettingsData.buttonColorMode) { switch (SettingsData.buttonColorMode) {
case "primaryContainer": case "primaryContainer":
return Qt.rgba(primary.r, primary.g, primary.b, 0.16); return withAlpha(primary, 0.16);
case "secondary": case "secondary":
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.16); return withAlpha(surfaceText, 0.16);
case "surfaceVariant": case "surfaceVariant":
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.16); return withAlpha(surfaceText, 0.16);
default: default:
return primaryPressed; return primaryPressed;
} }
@@ -1063,12 +1078,12 @@ Singleton {
readonly property real connectedCornerRadius: { readonly property real connectedCornerRadius: {
if (typeof SettingsData === "undefined") if (typeof SettingsData === "undefined")
return 12; return 12;
return SettingsData.connectedFrameModeActive ? SettingsData.frameRounding : cornerRadius; return FrameTransitionState.effectiveConnectedFrameModeActive ? SettingsData.frameRounding : cornerRadius;
} }
readonly property color connectedSurfaceColor: { readonly property color connectedSurfaceColor: {
if (typeof SettingsData === "undefined") if (typeof SettingsData === "undefined")
return withAlpha(surfaceContainer, popupTransparency); return withAlpha(surfaceContainer, popupTransparency);
return isConnectedEffect ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : withAlpha(surfaceContainer, popupTransparency); return isConnectedEffect ? withAlpha(SettingsData.effectiveFrameColor, SettingsData.frameOpacity) : withAlpha(surfaceContainer, popupTransparency);
} }
readonly property real connectedSurfaceRadius: isConnectedEffect ? connectedCornerRadius : cornerRadius readonly property real connectedSurfaceRadius: isConnectedEffect ? connectedCornerRadius : cornerRadius
readonly property bool connectedSurfaceBlurEnabled: (typeof SettingsData === "undefined") ? true : (!isConnectedEffect || SettingsData.frameBlurEnabled) readonly property bool connectedSurfaceBlurEnabled: (typeof SettingsData === "undefined") ? true : (!isConnectedEffect || SettingsData.frameBlurEnabled)
@@ -1237,6 +1252,7 @@ Singleton {
return typeof SettingsData !== "undefined" ? SettingsData.fontScale : 1.0; return typeof SettingsData !== "undefined" ? SettingsData.fontScale : 1.0;
} }
property real spacingXXS: 2
property real spacingXS: 4 property real spacingXS: 4
property real spacingS: 8 property real spacingS: 8
property real spacingM: 12 property real spacingM: 12
@@ -1468,7 +1484,7 @@ Singleton {
property string currentThemeName: currentTheme property string currentThemeName: currentTheme
function panelBackground() { function panelBackground() {
return Qt.rgba(surfaceContainer.r, surfaceContainer.g, surfaceContainer.b, panelTransparency); return withAlpha(surfaceContainer, panelTransparency);
} }
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency
@@ -1787,8 +1803,11 @@ Singleton {
const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default"; const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default";
if (currentTheme === dynamic) { if (currentTheme === dynamic) {
if (!rawWallpaperPath) if (!rawWallpaperPath) {
log.warn("Auto theme has no wallpaper - skipping matugen, syncing portal mode only");
PortalService.setLightMode(isLight);
return; return;
}
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot"; const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
const kind = rawWallpaperPath.startsWith("#") ? "hex" : "image"; const kind = rawWallpaperPath.startsWith("#") ? "hex" : "image";
setDesiredTheme(kind, rawWallpaperPath, isLight, iconTheme, selectedMatugenType, null); setDesiredTheme(kind, rawWallpaperPath, isLight, iconTheme, selectedMatugenType, null);
@@ -1813,19 +1832,21 @@ Singleton {
const storedLight = isGreeterMode ? (GreetdSettings.registryThemeVariants[themeId]?.light || lightDefaults) : (typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, lightDefaults, "light") : lightDefaults); const storedLight = isGreeterMode ? (GreetdSettings.registryThemeVariants[themeId]?.light || lightDefaults) : (typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, lightDefaults, "light") : lightDefaults);
const darkFlavorId = storedDark.flavor || darkDefaults.flavor || ""; const darkFlavorId = storedDark.flavor || darkDefaults.flavor || "";
const lightFlavorId = storedLight.flavor || lightDefaults.flavor || ""; const lightFlavorId = storedLight.flavor || lightDefaults.flavor || "";
const accentId = storedDark.accent || darkDefaults.accent || ""; const darkAccentId = storedDark.accent || darkDefaults.accent || "";
const lightAccentId = storedLight.accent || lightDefaults.accent || "";
const darkFlavor = findVariant(customThemeRawData.variants.flavors, darkFlavorId); const darkFlavor = findVariant(customThemeRawData.variants.flavors, darkFlavorId);
const lightFlavor = findVariant(customThemeRawData.variants.flavors, lightFlavorId); const lightFlavor = findVariant(customThemeRawData.variants.flavors, lightFlavorId);
const accent = findAccent(customThemeRawData.variants.accents, accentId); const darkAccent = findAccent(customThemeRawData.variants.accents, darkAccentId);
const lightAccent = findAccent(customThemeRawData.variants.accents, lightAccentId);
if (darkFlavor) { if (darkFlavor) {
darkTheme = mergeColors(darkTheme, darkFlavor.dark || {}); darkTheme = mergeColors(darkTheme, darkFlavor.dark || {});
if (accent) if (darkAccent)
darkTheme = mergeColors(darkTheme, accent[darkFlavor.id] || {}); darkTheme = mergeColors(darkTheme, darkAccent[darkFlavor.id] || {});
} }
if (lightFlavor) { if (lightFlavor) {
lightTheme = mergeColors(lightTheme, lightFlavor.light || {}); lightTheme = mergeColors(lightTheme, lightFlavor.light || {});
if (accent) if (lightAccent)
lightTheme = mergeColors(lightTheme, accent[lightFlavor.id] || {}); lightTheme = mergeColors(lightTheme, lightAccent[lightFlavor.id] || {});
} }
} else if (customThemeRawData.variants.options) { } else if (customThemeRawData.variants.options) {
const isGreeterMode = typeof SessionData !== "undefined" && SessionData.isGreeterMode; const isGreeterMode = typeof SessionData !== "undefined" && SessionData.isGreeterMode;
@@ -1978,6 +1999,8 @@ Singleton {
} }
function withAlpha(c, a) { function withAlpha(c, a) {
if (!c || c.r === undefined)
return Qt.rgba(0, 0, 0, 0);
return Qt.rgba(c.r, c.g, c.b, a); return Qt.rgba(c.r, c.g, c.b, a);
} }
@@ -1988,6 +2011,8 @@ Singleton {
} }
function blendAlpha(c, a) { function blendAlpha(c, a) {
if (!c || c.r === undefined)
return Qt.rgba(0, 0, 0, 0);
return Qt.rgba(c.r, c.g, c.b, c.a * a); return Qt.rgba(c.r, c.g, c.b, c.a * a);
} }
+6
View File
@@ -32,6 +32,8 @@ Singleton {
showBatteryPercentOnlyOnBattery: false, showBatteryPercentOnlyOnBattery: false,
showBatteryTime: false, showBatteryTime: false,
showBatteryTimeOnlyOnBattery: false, showBatteryTimeOnlyOnBattery: false,
batteryPillStyle: false,
batteryPillPercentSign: false,
showPrinterIcon: false, showPrinterIcon: false,
showScreenSharingIcon: true showScreenSharingIcon: true
}; };
@@ -95,6 +97,10 @@ Singleton {
item.showBatteryTime = order[i].showBatteryTime; item.showBatteryTime = order[i].showBatteryTime;
if (isObj && order[i].showBatteryTimeOnlyOnBattery !== undefined) if (isObj && order[i].showBatteryTimeOnlyOnBattery !== undefined)
item.showBatteryTimeOnlyOnBattery = order[i].showBatteryTimeOnlyOnBattery; item.showBatteryTimeOnlyOnBattery = order[i].showBatteryTimeOnlyOnBattery;
if (isObj && order[i].batteryPillStyle !== undefined)
item.batteryPillStyle = order[i].batteryPillStyle;
if (isObj && order[i].batteryPillPercentSign !== undefined)
item.batteryPillPercentSign = order[i].batteryPillPercentSign;
if (isObj && order[i].showPrinterIcon !== undefined) if (isObj && order[i].showPrinterIcon !== undefined)
item.showPrinterIcon = order[i].showPrinterIcon; item.showPrinterIcon = order[i].showPrinterIcon;
if (isObj && order[i].showScreenSharingIcon !== undefined) if (isObj && order[i].showScreenSharingIcon !== undefined)
+8 -27
View File
@@ -332,7 +332,6 @@ Singleton {
property bool greeterAutoLoginSyncRerunRequested: false property bool greeterAutoLoginSyncRerunRequested: false
property string greeterAutoLoginSyncStdout: "" property string greeterAutoLoginSyncStdout: ""
property string greeterAutoLoginSyncStderr: "" property string greeterAutoLoginSyncStderr: ""
property string greeterAutoLoginSyncTerminalFallbackStderr: ""
function scheduleGreeterAutoLoginSync() { function scheduleGreeterAutoLoginSync() {
if (!settingsRoot || settingsRoot.isGreeterMode) if (!settingsRoot || settingsRoot.isGreeterMode)
@@ -355,15 +354,16 @@ Singleton {
greeterAutoLoginSyncRerunRequested = false; greeterAutoLoginSyncRerunRequested = false;
greeterAutoLoginSyncStdout = ""; greeterAutoLoginSyncStdout = "";
greeterAutoLoginSyncStderr = ""; greeterAutoLoginSyncStderr = "";
greeterAutoLoginSyncTerminalFallbackStderr = "";
greeterAutoLoginSyncRunning = true; greeterAutoLoginSyncRunning = true;
greeterAutoLoginSyncSudoProbeProcess.running = true; greeterAutoLoginSyncSudoProbeProcess.running = true;
} }
function launchGreeterAutoLoginSyncTerminalFallback(details) { function deferGreeterAutoLoginSyncToPill(details) {
ToastService.showWarning(I18n.tr("Opening terminal to update greetd"), I18n.tr("DMS needs administrator access. The terminal closes automatically when done.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin", "greeter-autologin-sync"); ToastService.dismissCategory("greeter-autologin-sync");
greeterAutoLoginSyncTerminalFallbackStderr = ""; if (settingsRoot)
greeterAutoLoginSyncTerminalFallbackProcess.running = true; settingsRoot.set("greeterSyncPending", true);
ToastService.showWarning(I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin", "greeter-autologin-sync");
finishGreeterAutoLoginSync();
} }
function greeterAutoLoginSyncSuccessToast(details) { function greeterAutoLoginSyncSuccessToast(details) {
@@ -559,7 +559,7 @@ Singleton {
details = out; 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;
root.launchGreeterAutoLoginSyncTerminalFallback(details); root.deferGreeterAutoLoginSyncToPill(details);
} }
} }
@@ -575,26 +575,7 @@ Singleton {
return; return;
} }
root.launchGreeterAutoLoginSyncTerminalFallback(); root.deferGreeterAutoLoginSyncToPill("");
}
}
property var greeterAutoLoginSyncTerminalFallbackProcess: Process {
command: ["dms", "greeter", "sync", "--terminal", "--yes", "--autologin"]
running: false
stderr: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncTerminalFallbackStderr = text || ""
}
onExited: exitCode => {
if (exitCode === 0) {
root.greeterAutoLoginSyncSuccessToast("");
} else {
let details = (root.greeterAutoLoginSyncTerminalFallbackStderr || "").trim();
ToastService.showError(I18n.tr("Couldn't open a terminal for the auto-login update.") + " (exit " + exitCode + ")", details, "dms greeter sync --autologin", "greeter-autologin-sync");
}
root.finishGreeterAutoLoginSync();
} }
} }
+29 -13
View File
@@ -29,10 +29,12 @@ var SPEC = {
niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
niriLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" }, hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutGapsOutOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, hyprlandLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" }, hyprlandLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
hyprlandResizeOnBorder: { def: false, onChange: "updateCompositorLayout" }, hyprlandResizeOnBorder: { def: false, onChange: "updateCompositorLayout" },
mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutGapsOutOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" }, mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
@@ -100,6 +102,8 @@ var SPEC = {
showBatteryPercentOnlyOnBattery: { def: false }, showBatteryPercentOnlyOnBattery: { def: false },
showBatteryTime: { def: false }, showBatteryTime: { def: false },
showBatteryTimeOnlyOnBattery: { def: false }, showBatteryTimeOnlyOnBattery: { def: false },
batteryPillStyle: { def: false },
batteryPillPercentSign: { def: false },
showControlCenterButton: { def: true }, showControlCenterButton: { def: true },
showCapsLockIndicator: { def: true }, showCapsLockIndicator: { def: true },
@@ -184,9 +188,11 @@ var SPEC = {
audioScrollMode: { def: "volume" }, audioScrollMode: { def: "volume" },
audioWheelScrollAmount: { def: 5 }, audioWheelScrollAmount: { def: 5 },
audioDeviceScrollVolumeEnabled: { def: false }, audioDeviceScrollVolumeEnabled: { def: false },
mediaExcludePlayers: { def: [] },
clockCompactMode: { def: false }, clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false }, focusedWindowCompactMode: { def: false },
focusedWindowSize: { def: 1 }, focusedWindowSize: { def: 1 },
focusedWindowShowIcon: { def: false },
runningAppsCompactMode: { def: true }, runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 }, barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 }, barMaxVisibleRunningApps: { def: 0 },
@@ -217,18 +223,20 @@ var SPEC = {
centeringMode: { def: "index" }, centeringMode: { def: "index" },
clockDateFormat: { def: "" }, clockDateFormat: { def: "" },
lockDateFormat: { def: "" }, lockDateFormat: { def: "" },
greeterRememberLastSession: { def: true }, greeterRememberLastSession: { def: true, onChange: "markGreeterSyncPending" },
greeterRememberLastUser: { def: true }, greeterRememberLastUser: { def: true, onChange: "markGreeterSyncPending" },
greeterAutoLogin: { def: false, onChange: "scheduleGreeterAutoLoginSync" }, greeterAutoLogin: { def: false, onChange: "scheduleGreeterAutoLoginSync" },
greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" }, greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" },
greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" }, greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" },
greeterWallpaperPath: { def: "" }, greeterWallpaperPath: { def: "", onChange: "markGreeterSyncPending" },
greeterUse24HourClock: { def: true }, greeterUse24HourClock: { def: true, onChange: "markGreeterSyncPending" },
greeterShowSeconds: { def: false }, greeterShowSeconds: { def: false, onChange: "markGreeterSyncPending" },
greeterPadHours12Hour: { def: false }, greeterPadHours12Hour: { def: false, onChange: "markGreeterSyncPending" },
greeterLockDateFormat: { def: "" }, greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
greeterFontFamily: { def: "" }, greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
greeterWallpaperFillMode: { def: "" }, greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
greeterSyncPending: { def: false },
greeterSyncBaseline: { def: {} },
mediaSize: { def: 1 }, mediaSize: { def: 1 },
appLauncherViewMode: { def: "list" }, appLauncherViewMode: { def: "list" },
@@ -334,7 +342,9 @@ var SPEC = {
batteryNotifyCritical: { def: true }, batteryNotifyCritical: { def: true },
batteryLowThreshold: { def: 20 }, batteryLowThreshold: { def: 20 },
batteryNotifyLow: { def: false }, batteryNotifyLow: { def: false },
batteryNotificationType: { def: 0 }, batteryChargeLimitNotificationType: { def: 0 },
batteryLowNotificationType: { def: 0 },
batteryCriticalNotificationType: { def: 1 },
batteryAutoPowerSaver: { def: false }, batteryAutoPowerSaver: { def: false },
lockBeforeSuspend: { def: false }, lockBeforeSuspend: { def: false },
loginctlLockIntegration: { def: true }, loginctlLockIntegration: { def: true },
@@ -446,12 +456,14 @@ var SPEC = {
maxFprintTries: { def: 15 }, maxFprintTries: { def: 15 },
enableU2f: { def: false, onChange: "scheduleAuthApply" }, enableU2f: { def: false, onChange: "scheduleAuthApply" },
u2fMode: { def: "or" }, u2fMode: { def: "or" },
lockScreenActiveMonitor: { def: "all" },
lockScreenInactiveColor: { def: "#000000" }, lockScreenInactiveColor: { def: "#000000" },
lockScreenNotificationMode: { def: 0 }, lockScreenNotificationMode: { def: 0 },
lockScreenVideoEnabled: { def: false }, lockScreenVideoEnabled: { def: false },
lockScreenVideoPath: { def: "" }, lockScreenVideoPath: { def: "" },
lockScreenVideoCycling: { def: false }, lockScreenVideoCycling: { def: false },
lockScreenWallpaperPath: { def: "" },
lockScreenWallpaperFillMode: { def: "" },
lockScreenFontFamily: { def: "" },
hideBrightnessSlider: { def: false }, hideBrightnessSlider: { def: false },
notificationTimeoutLow: { def: 5000 }, notificationTimeoutLow: { def: 5000 },
@@ -569,7 +581,9 @@ var SPEC = {
shadowOpacity: 60, shadowOpacity: 60,
shadowColorMode: "default", shadowColorMode: "default",
shadowCustomColor: "#000000", shadowCustomColor: "#000000",
clickThrough: false clickThrough: false,
hoverPopouts: false,
hoverPopoutDelay: 150
}], onChange: "updateBarConfigs" }], onChange: "updateBarConfigs"
}, },
@@ -642,6 +656,7 @@ var SPEC = {
frameCloseGaps: { def: true }, frameCloseGaps: { def: true },
frameLauncherEmergeSide: { def: "bottom" }, frameLauncherEmergeSide: { def: "bottom" },
frameLauncherArcExtender: { def: false }, frameLauncherArcExtender: { def: false },
frameLauncherEdgeHover: { def: false },
frameMode: { def: "connected" }, frameMode: { def: "connected" },
barInsetPaddingShared: { def: -1 }, barInsetPaddingShared: { def: -1 },
barInsetPaddingSyncAll: { def: false }, barInsetPaddingSyncAll: { def: false },
@@ -655,10 +670,11 @@ function getValidKeys() {
function set(root, key, value, saveFn, hooks) { function set(root, key, value, saveFn, hooks) {
if (!(key in SPEC)) return; if (!(key in SPEC)) return;
if (value === undefined || value === null) value = SPEC[key].def; if (value === undefined || value === null) value = SPEC[key].def;
var oldValue = root[key];
root[key] = value; root[key] = value;
var hookName = SPEC[key].onChange; var hookName = SPEC[key].onChange;
if (hookName && hooks && hooks[hookName]) { if (hookName && hooks && hooks[hookName]) {
hooks[hookName](root); hooks[hookName](root, key, oldValue);
} }
saveFn(); saveFn();
} }
@@ -252,6 +252,17 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 11; settings.configVersion = 11;
} }
if (currentVersion < 12) {
console.info("Migrating settings from version", currentVersion, "to version 12");
if (settings.batteryNotificationType !== undefined) {
settings.batteryChargeLimitNotificationType = settings.batteryNotificationType;
settings.batteryLowNotificationType = settings.batteryNotificationType;
settings.batteryCriticalNotificationType = settings.batteryNotificationType;
delete settings.batteryNotificationType;
}
settings.configVersion = 12;
}
return settings; return settings;
} }
+39 -7
View File
@@ -173,6 +173,7 @@ Item {
} }
property bool barSurfacesLoaded: true property bool barSurfacesLoaded: true
property int pendingFrameTransitionRevision: 0
function recreateBarSurfaces() { function recreateBarSurfaces() {
log.info("Recreating bar surfaces, screens:", Quickshell.screens.length, Quickshell.screens.map(s => s.name).join(",")); log.info("Recreating bar surfaces, screens:", Quickshell.screens.length, Quickshell.screens.map(s => s.name).join(","));
@@ -181,9 +182,23 @@ Item {
barSurfaceReloadAction.schedule(); barSurfaceReloadAction.schedule();
} }
// Holds the bar rebuild until the compositor applies the layout, so the swap lands in one pass
function runPendingFrameTransition() {
if (pendingFrameTransitionRevision <= 0 || !CompositorService.frameCompositorLayoutReady)
return;
recreateBarSurfaces();
}
DeferredAction { DeferredAction {
id: barSurfaceReloadAction id: barSurfaceReloadAction
onTriggered: root.barSurfacesLoaded = true onTriggered: {
// Ack first so the latch flips and new bars build directly in the post-transition state
if (root.pendingFrameTransitionRevision > 0 && CompositorService.frameCompositorLayoutReady) {
FrameTransitionState.acknowledge(root.pendingFrameTransitionRevision);
root.pendingFrameTransitionRevision = 0;
}
root.barSurfacesLoaded = true;
}
} }
property string _barLayoutStateJson: { property string _barLayoutStateJson: {
@@ -212,14 +227,23 @@ Item {
} }
} }
Connections {
target: FrameTransitionState
function onTransitionRequested(revision) {
root.pendingFrameTransitionRevision = Math.max(root.pendingFrameTransitionRevision, revision);
root.runPendingFrameTransition();
}
}
Connections {
target: CompositorService
function onFrameCompositorLayoutReadyChanged() {
root.runPendingFrameTransition();
}
}
Connections { Connections {
target: SettingsData target: SettingsData
function onFrameEnabledChanged() {
root.recreateBarSurfaces();
}
function onConnectedFrameModeActiveChanged() {
root.recreateBarSurfaces();
}
function onForceDankBarLayoutRefresh() { function onForceDankBarLayoutRefresh() {
root.recreateBarSurfaces(); root.recreateBarSurfaces();
} }
@@ -233,6 +257,12 @@ Item {
sourceComponent: Frame {} sourceComponent: Frame {}
} }
Loader {
active: FrameTransitionState.effectiveFrameEnabled && SettingsData.frameLauncherEdgeHover
asynchronous: false
sourceComponent: FrameLauncherHoverZone {}
}
DeferredAction { DeferredAction {
id: frameSurfaceReloadAction id: frameSurfaceReloadAction
onTriggered: root.frameSurfacesLoaded = true onTriggered: root.frameSurfacesLoaded = true
@@ -245,6 +275,8 @@ Item {
values: JSON.parse(root._barLayoutStateJson) values: JSON.parse(root._barLayoutStateJson)
} }
Component.onCompleted: BarWidgetService.dankBarRepeater = dankBarRepeater
property var hyprlandOverviewLoaderRef: hyprlandOverviewLoader property var hyprlandOverviewLoaderRef: hyprlandOverviewLoader
delegate: Loader { delegate: Loader {
+16
View File
@@ -385,6 +385,22 @@ Item {
return "NOTEPAD_OPEN_FAILED"; return "NOTEPAD_OPEN_FAILED";
} }
function openFile(path: string): string {
if (!path)
return open();
if (SettingsData.notepadDefaultMode === "popout") {
PopoutService.openNotepadPopoutWithFile(path);
return "NOTEPAD_OPEN_FILE_SUCCESS";
}
var instance = getActiveNotepadInstance();
if (instance) {
instance.show();
instance.loadedItem?.openExternalFile(path);
return "NOTEPAD_OPEN_FILE_SUCCESS";
}
return "NOTEPAD_OPEN_FILE_FAILED";
}
function close(): string { function close(): string {
if (SettingsData.notepadDefaultMode === "popout") { if (SettingsData.notepadDefaultMode === "popout") {
PopoutService.notepadPopout?.hide(); PopoutService.notepadPopout?.hide();
+31 -3
View File
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell
import qs.Common import qs.Common
import qs.Modals.Common import qs.Modals.Common
import qs.Widgets import qs.Widgets
@@ -23,6 +24,7 @@ DankModal {
property var rememberMimeTypes: [] property var rememberMimeTypes: []
property bool rememberChoice: false property bool rememberChoice: false
property var mimeMatchedAppIds: [] property var mimeMatchedAppIds: []
property var mimeMatchedRawIds: []
signal applicationSelected(var app, string targetData) signal applicationSelected(var app, string targetData)
@@ -61,6 +63,7 @@ DankModal {
function fetchMimeMatches() { function fetchMimeMatches() {
mimeMatchedAppIds = []; mimeMatchedAppIds = [];
mimeMatchedRawIds = [];
const queriedMime = mimeType; const queriedMime = mimeType;
if (queriedMime.length === 0) if (queriedMime.length === 0)
return; return;
@@ -74,6 +77,7 @@ DankModal {
return; return;
} }
const ids = (response.result && response.result.desktopIds) || []; const ids = (response.result && response.result.desktopIds) || [];
mimeMatchedRawIds = ids;
mimeMatchedAppIds = ids.map(_normAppId); mimeMatchedAppIds = ids.map(_normAppId);
updateApplicationList(); updateApplicationList();
}); });
@@ -93,11 +97,13 @@ DankModal {
const hasMimeMatches = mimeMatchedAppIds.length > 0; const hasMimeMatches = mimeMatchedAppIds.length > 0;
const lowerQuery = searchQuery.toLowerCase(); const lowerQuery = searchQuery.toLowerCase();
let filteredApps = []; let filteredApps = [];
const listedIds = new Set();
for (const app of apps) { for (const app of apps) {
if (!app) if (!app)
continue; continue;
const appId = _normAppId(app.id || app.execString || app.exec || ""); const appId = _normAppId(app.id || app.execString || app.exec || "");
listedIds.add(appId);
const mimeIdMatch = hasMimeMatches && mimeMatchedAppIds.includes(appId); const mimeIdMatch = hasMimeMatches && mimeMatchedAppIds.includes(appId);
const mimeFieldMatch = hasMime && _appMatchesMime(app, mimeType); const mimeFieldMatch = hasMime && _appMatchesMime(app, mimeType);
const mimeMatch = mimeIdMatch || mimeFieldMatch; const mimeMatch = mimeIdMatch || mimeFieldMatch;
@@ -135,6 +141,28 @@ DankModal {
}); });
} }
// NoDisplay entries are excluded from DesktopEntries.applications but
// remain valid mime handlers; resolve them by id so they stay pickable
for (const rawId of mimeMatchedRawIds) {
const normId = _normAppId(rawId);
if (normId === "dms-open" || listedIds.has(normId))
continue;
const entry = DesktopEntries.byId(rawId) || DesktopEntries.heuristicLookup(rawId);
if (!entry)
continue;
const name = entry.name || "";
if (searchQuery !== "" && !name.toLowerCase().includes(lowerQuery))
continue;
filteredApps.push({
name: name,
icon: entry.icon || "application-x-executable",
exec: entry.execString || "",
startupClass: entry.startupClass || "",
appData: entry,
mimeMatch: true
});
}
filteredApps.sort((a, b) => { filteredApps.sort((a, b) => {
if (a.mimeMatch !== b.mimeMatch) { if (a.mimeMatch !== b.mimeMatch) {
return a.mimeMatch ? -1 : 1; return a.mimeMatch ? -1 : 1;
@@ -253,7 +281,7 @@ DankModal {
} }
Row { Row {
spacing: 4 spacing: Theme.spacingXS
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: Theme.spacingS anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -264,7 +292,7 @@ DankModal {
iconName: "view_list" iconName: "view_list"
iconSize: 20 iconSize: 20
iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText
backgroundColor: root.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" backgroundColor: root.viewMode === "list" ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
onClicked: { onClicked: {
root.viewMode = "list"; root.viewMode = "list";
} }
@@ -276,7 +304,7 @@ DankModal {
iconName: "grid_view" iconName: "grid_view"
iconSize: 20 iconSize: 20
iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText
backgroundColor: root.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" backgroundColor: root.viewMode === "grid" ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
onClicked: { onClicked: {
root.viewMode = "grid"; root.viewMode = "grid";
} }
+2 -2
View File
@@ -224,7 +224,7 @@ DankModal {
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent
spacing: 2 spacing: Theme.spacingXXS
StyledText { StyledText {
text: I18n.tr("Passkey:") text: I18n.tr("Passkey:")
@@ -256,7 +256,7 @@ DankModal {
width: Math.max(70, cancelText.contentWidth + Theme.spacingM * 2) width: Math.max(70, cancelText.contentWidth + Theme.spacingM * 2)
height: 36 height: 36
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: cancelArea.containsMouse ? Theme.surfaceTextHover : "transparent" color: cancelArea.containsMouse ? Theme.surfaceTextHover : Theme.withAlpha(Theme.surfaceTextHover, 0)
border.color: Theme.surfaceVariantAlpha border.color: Theme.surfaceVariantAlpha
border.width: 1 border.width: 1
@@ -48,7 +48,7 @@ Rectangle {
Column { Column {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 2 spacing: Theme.spacingXXS
width: parent.width - root.iconContainerSize - Theme.spacingS width: parent.width - root.iconContainerSize - Theme.spacingS
StyledText { StyledText {
@@ -24,6 +24,8 @@ FloatingWindow {
color: Theme.surfaceContainer color: Theme.surfaceContainer
visible: false visible: false
onClosed: visible = false
FocusScope { FocusScope {
id: contentFocusScope id: contentFocusScope
anchors.fill: parent anchors.fill: parent
@@ -8,6 +8,7 @@ Item {
id: clipboardContent id: clipboardContent
required property var modal required property var modal
property var transientSurfaceTracker: null
property alias searchField: searchField property alias searchField: searchField
property alias clipboardListView: clipboardListView property alias clipboardListView: clipboardListView
@@ -66,7 +67,7 @@ Item {
contextMenu.hide(); contextMenu.hide();
} }
readonly property bool contextMenuActive: contextMenu.openState readonly property bool contextMenuActive: contextMenu.renderActive
anchors.fill: parent anchors.fill: parent
@@ -74,6 +75,7 @@ Item {
id: contextMenu id: contextMenu
modal: clipboardContent.modal modal: clipboardContent.modal
parentHandler: clipboardContent parentHandler: clipboardContent
transientSurfaceTracker: clipboardContent.transientSurfaceTracker
} }
Column { Column {
@@ -147,7 +149,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
iconName: "filter_list" iconName: "filter_list"
iconColor: modal.activeFilter !== "all" ? Theme.primary : Theme.surfaceText iconColor: modal.activeFilter !== "all" ? Theme.primary : Theme.surfaceText
backgroundColor: modal.activeFilter !== "all" ? Theme.primarySelected : "transparent" backgroundColor: modal.activeFilter !== "all" ? Theme.primarySelected : Theme.withAlpha(Theme.primarySelected, 0)
tooltipText: I18n.tr("Filter by type", "Clipboard history type filter button tooltip") tooltipText: I18n.tr("Filter by type", "Clipboard history type filter button tooltip")
onClicked: filterMenuLoader.item?.openDropdownMenu() onClicked: filterMenuLoader.item?.openDropdownMenu()
} }
@@ -394,7 +396,7 @@ Item {
} }
sourceComponent: ClipboardKeyboardHints { sourceComponent: ClipboardKeyboardHints {
wtypeAvailable: modal.wtypeAvailable pasteAvailable: modal.pasteAvailable
enterToPaste: SettingsData.clipboardEnterToPaste enterToPaste: SettingsData.clipboardEnterToPaste
} }
} }
@@ -23,6 +23,8 @@ Item {
property real anchorY: 0 property real anchorY: 0
property bool openState: false property bool openState: false
property bool renderActive: false property bool renderActive: false
property var transientSurfaceTracker: null
readonly property alias contextWindow: menuWindow
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
@@ -90,6 +92,18 @@ Item {
readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight) readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight)
readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5 readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5
onRenderActiveChanged: transientSurfaceTracker?.setActive(root, renderActive, contextWindow)
Component.onDestruction: transientSurfaceTracker?.unregister(root)
Connections {
target: root.transientSurfaceTracker
ignoreUnknownSignals: true
function onCloseRequested() {
root.hide();
}
}
TextMetrics { TextMetrics {
id: menuTextMetrics id: menuTextMetrics
text: root.longestMenuText text: root.longestMenuText
@@ -121,12 +135,12 @@ Item {
const hostX = host?.alignedX; const hostX = host?.alignedX;
const hostY = host?.renderedAlignedY ?? host?.alignedY; const hostY = host?.renderedAlignedY ?? host?.alignedY;
const globalPos = (!isNaN(hostX) && !isNaN(hostY)) ? ({ const globalPos = (!isNaN(hostX) && !isNaN(hostY)) ? ({
x: screenX + hostX + x, x: screenX + hostX + x,
y: screenY + hostY + y y: screenY + hostY + y
}) : (parentHandler ? parentHandler.mapToGlobal(x, y) : ({ }) : (parentHandler ? parentHandler.mapToGlobal(x, y) : ({
x: screenX + x, x: screenX + x,
y: screenY + y y: screenY + y
})); }));
targetScreen = screenRef; targetScreen = screenRef;
anchorX = globalPos.x - screenX + 4; anchorX = globalPos.x - screenX + 4;
@@ -258,8 +272,8 @@ Item {
height: root.effectiveMenuHeight height: root.effectiveMenuHeight
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) border.color: BlurService.borderColor
border.width: BlurService.enabled ? BlurService.borderWidth : 1 border.width: BlurService.borderWidth
opacity: root.openState ? 1 : 0 opacity: root.openState ? 1 : 0
Behavior on opacity { Behavior on opacity {
@@ -321,7 +335,7 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width width: parent.width
height: 1 height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) color: Theme.outlineHeavy
} }
} }
@@ -330,7 +344,7 @@ Item {
width: parent.width width: parent.width
height: parent.height height: parent.height
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: itemMouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" color: itemMouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(BlurService.hoverColor(Theme.widgetBaseHoverColor), 0)
Row { Row {
anchors.left: parent.left anchors.left: parent.left
@@ -419,7 +419,7 @@ Item {
implicitWidth: saveMenuRow.implicitWidth + Theme.spacingS * 2 implicitWidth: saveMenuRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuRow.implicitHeight + Theme.spacingS * 2 implicitHeight: saveMenuRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: saveMenuSaveArea.containsMouse ? Theme.surfaceVariant : "transparent" color: saveMenuSaveArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
Row { Row {
id: saveMenuRow id: saveMenuRow
@@ -457,7 +457,7 @@ Item {
implicitWidth: saveMenuCloseRow.implicitWidth + Theme.spacingS * 2 implicitWidth: saveMenuCloseRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuCloseRow.implicitHeight + Theme.spacingS * 2 implicitHeight: saveMenuCloseRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: saveMenuCloseArea.containsMouse ? Theme.surfaceVariant : "transparent" color: saveMenuCloseArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
Row { Row {
id: saveMenuCloseRow id: saveMenuCloseRow
@@ -495,8 +495,8 @@ Item {
implicitWidth: saveMenuPasteRow.implicitWidth + Theme.spacingS * 2 implicitWidth: saveMenuPasteRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2 implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : "transparent" color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
opacity: modal.wtypeAvailable ? 1 : 0.5 opacity: modal.pasteAvailable ? 1 : 0.5
Row { Row {
id: saveMenuPasteRow id: saveMenuPasteRow
@@ -522,7 +522,7 @@ Item {
id: saveMenuPasteArea id: saveMenuPasteArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
enabled: modal.wtypeAvailable enabled: modal.pasteAvailable
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: { onClicked: {
saveMenu.close(); saveMenu.close();

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