1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-14 08:12:46 -04:00

Compare commits

...

76 Commits

Author SHA1 Message Date
Amaan Qureshi 4bb3dd8310 flake: expose mkDmsShell and buildDmsPkgs via lib output (#2409) 2026-05-14 02:40:55 -03:00
Domen Kožar 9a630fad92 fix(network): open Wi-Fi password modal upfront for enterprise networks (#2414)
NetworkManager rejects AddConnection for 802-1x without a non-empty
identity, so the new API v7 agent-prompt flow never reaches the
SecretAgent for fresh enterprise connections. Fall back to the existing
modal-upfront path (already wired to ask for username + password via
requiresEnterprise) when modelData.enterprise, mirroring the legacy
DMSService.apiVersion < 7 branch.

Fixes #2358

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:22:49 -04:00
purian23 5bde54fa89 refactor(dbar/dock): Update fullscreen hiding logic 2026-05-13 14:38:44 -04:00
Divya Jain 022f47a580 feat: add cycleProfile IPC command and default Mod+P keybind (#2411)
* feat: add cycleProfile IPC command for display outputs

Adds `dms ipc outputs cycleProfile` which cycles through configured display
profiles in order, wrapping around from the last back to the first.

Useful for binding to a compositor keybind (e.g. Super+P in niri/Hyprland)
without needing an external wrapper script.

* feat: add Mod+P default keybind for cycling display profiles

Binds Super+P to `dms ipc outputs cycleProfile` in both the niri and
Hyprland default keybind configs. Mod+Shift+P is already reserved for
powering off monitors on both compositors, so Mod+P is a natural fit.
2026-05-13 09:28:56 -04:00
Kilian Mio 0dfa95ffe4 feat: retrieve WiFi/VPN secrets from D-Bus Secret Service with unlock support (#2402)
* fix: preserve pre-existing connection profiles on cancelled WiFi activation

When a pre-existing WiFi connection activation is cancelled, do not
remove the connection profile — only remove profiles for newly created
connections.

* feat: retrieve WiFi/VPN secrets from D-Bus secret service with unlock

Wireless, 802.1x, VPN, and WireGuard secrets are now looked up from
org.freedesktop.Secret before prompting the user. If the keyring is
locked, the unlock prompt is triggered via Prompt.Prompt() and the
lookup retried after the vault is unlocked.
2026-05-13 09:27:44 -04:00
Youseffo13 e6da762870 Added Missing i18n Strings (#2398)
* Update hypr-colors.conf

* added i18n strings

* added missing i18n strings

* Update TypographyMotionTab.qml
2026-05-13 09:23:50 -04:00
purian23 8f958658dc (FrameMode): Update to load Connected Mode by default 2026-05-12 22:07:10 -04:00
Huỳnh Thiện Lộc 392eaea5fc refactor(ClipboardHistory): Sync toggle button between saved and recent tabs (#2407)
* fix(ClipboardHistory): toggle button now toggles between saved and recents tabs

- Change tooltip text to reflect current tab state ('Recent' when on saved tab, 'Saved' when on recents tab)
- Previously always switched to saved tab; now toggles between 'saved' and 'recents'

* refactor(ClipboardHistory): remove redundant History button, keep single toggle button

The Saved button now toggles between saved/recents tabs, so the separate History
button is redundant and has been removed for a cleaner UI.
2026-05-12 14:10:19 -04:00
bbedward 0aea542e8f i18n: term sync 2026-05-12 10:29:51 -04:00
bbedward 7a566e1088 tailscale: fix flaky test 2026-05-12 09:36:44 -04:00
Huỳnh Thiện Lộc 4bc62cc6ec feat: consolidate plugin directory management into a single dynamic button (#2400)
* feat: add Open Dir button to Plugins settings

* feat: consolidate plugin directory management into a single dynamic button
2026-05-12 09:29:17 -04:00
Bruno Rocha 9b68fc8213 add dms-plugin-dev agent skill for plugin development (#2394) 2026-05-12 09:26:38 -04:00
purian23 c878ffb7f9 fix(DankLauncher): Improve clipping logic for connected chrome
- Fixes #2397
2026-05-12 00:08:38 -04:00
purian23 3fc805ba53 distro(OBS): Bundled Go toolchain for dms-git Debian/openSUSE builds 2026-05-11 21:37:01 -04:00
euletheia 371a7d0cd1 fix(DankTextField): set TextInput 'fontFamily' (#2399)
This allows input fields created using the widget to inherit their font
family from Theme.
2026-05-11 18:25:10 -04:00
Bruno Rocha 189b7c84ce fix(ipc): dash and dankdash commands work without clock widget (#2392)
getPreferredBar("clockButtonRef") returns null when the clock widget
is removed from the bar. Fall back to getPreferredBar() (any bar) so
triggerDashTab can still open the popout with default positioning.
2026-05-11 14:18:07 -04:00
bbedward b8f4c350a8 quickshell: drop support for 0.2, require 0.3+
- Remove all compat code
- Rewire LegacyNetworkService to use Quickshell.Networking
- Add parentWindow to settings child windows
2026-05-11 13:05:24 -04:00
purian23 3989c7f801 fix(DisplayConfigState): Repair crash introduced in Automatic configs PR 2026-05-11 11:25:15 -04:00
bbedward 2690305724 fonts: native rendering + add settings to override renderType
fixes #2371
2026-05-11 10:49:29 -04:00
bbedward 676219bc09 screenshot: respect XDG_PICTURES_DIR
fixes #2383
2026-05-11 10:00:21 -04:00
bbedward b192b5f779 osd: fix positioning with multiple bars
fixes #2387
2026-05-11 09:51:18 -04:00
Kilian Mio a5352623fd Automatic Display Profiles Enhancement & Disabled Output Support (#2367)
* fix(niri): properly close KDL output block when disabled

* feat(display): parse off directive and sync disabled state from compositor configs

* feat(display): visual treatment and canvas filtering for disabled outputs

* feat(display): eager auto-profile generation with debounced auto-select

* refactor(display): pass target profile ID to confirmChanges and remove dead code

* fix(display): make profile dropdown reactive by binding to property directly

* i18n(display): add Disabled translation term for output state
2026-05-11 09:34:51 -04:00
Huỳnh Thiện Lộc 2b6ae58bff feat(settings): add expandable search bar to plugins tab (#2377) 2026-05-11 09:33:42 -04:00
Kostiantyn To b12511481d fix(RunningApps): add middle mouse click to close running applications in vertical orientation (#2386) 2026-05-11 09:21:45 -04:00
purian23 c7d44cfb12 fix(Settings): Update card registration logic to fix binded crash 2026-05-10 21:47:15 -04:00
purian23 4193cf51ff Refactor(DankBar): Restore previous autohide logic & add autoHideStrict setting 2026-05-10 21:14:09 -04:00
purian23 1ec0311086 feat(IPC): Add dbar toggleReveal logic for autohide modes 2026-05-09 23:49:25 -04:00
purian23 c6a1473d2f refactor(DankBar): Update dbar autohide mode reveal logic 2026-05-09 23:48:33 -04:00
purian23 ee16047e15 fix(Notepad): Blur handling on the header layer to remain in sync
- Fixes #2372
2026-05-09 21:59:08 -04:00
purian23 4968b80268 refactor(blur): Update blur layer sync handling w/Popout/Modals
- This results in better visuals so you will no longer get a brief flash of blur rending first upon load on certain displays
2026-05-09 21:21:16 -04:00
Josh Symonds e28b0c695e Theme: expose tertiary on the singleton (was undefined in dynamic mode) (#2375)
The dynamic-mode currentThemeData object at Theme.qml:447-468 omitted
tertiary entirely, even though matugen generates it and writes it to
the matugen colors JSON. As a result, Theme.tertiary returned undefined
in dynamic mode, and any QML binding through .r/.g/.b/.a defaulted to
white — visible as desaturated near-white highlights in the bar's
ChromeShader (which mixes Theme.tertiary at highlight peaks).

Two-line fix:
- Add tertiary to the dynamic-mode currentThemeData via getMatugenColor
- Add property color tertiary on the Theme singleton, falling back to
  secondary if tertiary is absent (covers the stock-theme path where
  tertiary is added separately via buildMatugenColorsFromTheme:1811)

Fallback hex #efb8c8 is the M3 baseline tertiary (light pink) for dark
mode, in case matugen output is missing.

Theme.tertiary was already referenced at LockScreenContent.qml:737 so
the property name is the right one.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:51:39 -04:00
purian23 7f6486b3e7 fix(Dock): Refactor & Fix Dock in FrameModes. Updates to Zone offset in Standalone & Connected modes
- Fixed hairline seam in some connected mode variants
- Moved dock-related properties to a new DockGeometry component for a future base
2026-05-09 17:37:42 -04:00
Josh Symonds faa30c4d48 bar: fix fullscreen-detection race during cross-monitor focus moves (#2373)
hasFullscreenToplevelOnScreen's fast-path used `NiriService.currentOutput
=== screenName` as a proxy for "the active toplevel is on screenName".
Those are two independent state channels (niri's workspace-activated
events drive currentOutput; wlr-foreign-toplevel-management drives
ToplevelManager.activeToplevel) and they don't update atomically.

When focus crosses from a fullscreen toplevel on monitor A to a
non-fullscreen toplevel on monitor B, currentOutput flips to B before
activeToplevel updates away from A's still-fullscreen-and-activated
window. For one tick, B's bar evaluates `active.fullscreen &&
active.activated && currentOutput === B` → true, hides itself, then
flips back when activeToplevel finally updates. Visible as a one-frame
bar flicker on focus changes between monitors when one has a fullscreen
window.

Replace the proxy with _toplevelOnScreen(active, screenName), the same
helper the X11 fallback path uses 25 lines below. The check now
inspects the toplevel's actual outputs instead of trusting a separate
state signal, so the race can't fire.

The per-workspace loop below was already correct; it would catch any
real fullscreen+activated toplevel on the bar's workspace regardless
of focused output. The fast-path was redundant when the assertion
held and wrong when it didn't.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:35:35 -04:00
purian23 cf641b4e08 fix(DockContextMenu): Restore jump windows 2026-05-09 01:54:23 -04:00
purian23 b75453c7d6 fix(IPC): Remove recent duplicate IPC bindings 2026-05-08 23:13:40 -04:00
bbedward 10872b5fc8 system tray: fix parent screen binding 2026-05-08 23:00:12 -04:00
purian23 80c853f16c fix(Settings): Restore Darken Modal Background ops in Standalone mode 2026-05-08 22:58:12 -04:00
purian23 6167f22837 feat(SystemUpdate): Implement system update IPC commands 2026-05-08 21:00:23 -04:00
bbedward d8835f2bc6 launcher: fix files tab actions
fixes #2357
2026-05-08 09:20:14 -04:00
purian23 1c01774fde fix(ConnectedMode): Update modal state management w/ownership Id 2026-05-07 23:03:31 -04:00
purian23 0d3eb774e2 (SystemUpdate): Ensure Polkit Prompts Top Level Auth layer 2026-05-07 16:17:12 -04:00
purian23 7fb4b6e0d9 refactor(SysUpdate): Flatpak & Cli command handling 2026-05-07 16:16:10 -04:00
bbedward 5df2b5fc33 launcher: also allow context switching with alt+1/2/3/4 2026-05-06 09:21:02 -04:00
purian23 d49c49cd99 refactor(sysupdate): Streamline DMS Updater command handling 2026-05-05 15:16:36 -04:00
purian23 b209827f38 distro:(Workflow) Save the resources 2026-05-04 23:10:12 -04:00
purian23 1b9d1c667c fix(Ubuntu): Workflow dput & SFTP 2026-05-04 22:25:45 -04:00
purian23 04d961ad0b distro(Ubuntu): Update Workflow & Dual Series Logic 2026-05-04 21:24:34 -04:00
Connor Welsh e60caf8028 feat(bar/dock): add 'Hide When Fullscreen' opt-out toggles (#2349)
Adds per-bar 'fullscreenDetection' and global 'dockHideOnFullscreen'
settings to gate CompositorService.hasFullscreenToplevelOnScreen()
2026-05-04 19:05:40 -04:00
dms-ci[bot] 1e67927f8a nix: update vendorHash for go.mod changes 2026-05-04 17:44:46 +00:00
bbedward e6e343dacb i18n: term sync 2026-05-04 13:42:27 -04:00
bbedward f87ad3d2ca core: update dependencies 2026-05-04 13:41:32 -04:00
bbedward a6ba4b1c79 dock: fix startup warning 2026-05-04 13:40:45 -04:00
dms-ci[bot] 7cf718cd50 nix: update vendorHash for go.mod changes 2026-05-04 17:39:40 +00:00
Giorgio De Trane d223a74740 feat(tailscale): add Tailscale control center widget (#1875)
* feat(tailscale): add Tailscale control center widget

Full-stack Tailscale integration for DMS control center:

Backend (Go):
- Event-driven manager via WatchIPNBus (no polling)
- Reconnects with exponential backoff when tailscaled unavailable
- Typed conversion from ipnstate.Status to QML-friendly IPC types
- Testable via tailscaleClient interface with mock watcher
- Manager cleanup in cleanupManagers()
- 19 unit tests

Frontend (QML):
- TailscaleService with WebSocket subscription
- TailscaleWidget with peer list, filter chips, search
- Copy-to-clipboard for IPs and DNS names
- Daemon lifecycle handling (offline/stopped states)

Dependencies:
- Add tailscale.com v1.96.1 (official local API client)
- Bump Go to 1.26.1 (required by tailscale.com)

* cleanups

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-05-04 13:37:25 -04:00
purian23 408beb202c refactor(sysupdate): improve cmd handling, formatted colors & progress indication
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 13:13:44 -04:00
DK cfe6e6867e feat(display): Fix and implement display auto-switching with JSON profile storage (#2275)
* feat(display): fix monitor auto config and add output disable guard

* feat(display): fixed some race conditions and sole display getting disabled.

Co-authored-by: Copilot <copilot@github.com>

* feat(display): changes console log to use new log service

* feat(display): fix trailing spaces

* prek run

* add migration, fix missing hyprland HDR parameters, use FileView >
python

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: bbedward <bbedward@gmail.com>
2026-05-04 13:07:24 -04:00
bbedward 7c991bc4e3 bluetooth: fix codec parsing names 2026-05-04 12:09:49 -04:00
bbedward 50f0cbb122 osd: fix inconsistent transparency
fixes #2330
2026-05-04 11:46:52 -04:00
Satvik Sharma 7ee0879103 Reverse session dir order so user configs override system configs as expected (#2341) 2026-05-04 11:19:01 -04:00
bbedward 56ef186ce8 notifications: add some de-duplication mechanism
related to #2334
2026-05-04 11:18:17 -04:00
purian23 5b507136e3 refactor(sysUpdate): Include the refresh flag in Fedora upgrade cmd 2026-05-04 11:06:26 -04:00
purian23 19c561da14 refactor: (Framemode) Added DeferredAction for dbar/dock state handling 2026-05-04 10:43:07 -04:00
bbedward cc47703d48 modals: fix sub-modals and power menu keyboard focus in connected mode
fixes #2346
2026-05-04 10:39:16 -04:00
purian23 31e60a3df5 fix(FrameMode): Update logic to hide the dbar/dock upon maximized windows/apps/games 2026-05-03 22:03:02 -04:00
purian23 082de6f1f0 refactor(FrameMode): Update modal background opts w/Connected Mode 2026-05-03 16:35:11 -04:00
purian23 fd24b4a36d feat(DMS FrameMode): A New Connected Unified Surface & Animation Overhaul
- Introduces Standalone & Connected Modes
- Updated Animations & Motion effects for both modes
- Numerous QOL tweaks and updates throughout the system
- Highly inspired to the OG Caelestia Shell / @Soramanew
2026-05-03 15:38:30 -04:00
ksp-apoc dd668469d7 fix: clean up orphaned idle inhibitor processes on startup (#2324)
When quickshell crashes or is force-killed, the child systemd-inhibit
process (used as fallback when native IdleInhibitor is unavailable) gets
reparented to PID 1 and continues to block idle indefinitely. This causes
hypridle to ignore all idle timeout rules even though the idle inhibitor
widget appears inactive after restart.

Add a cleanup step during initialization that kills any orphaned
systemd-inhibit processes from previous sessions.
2026-05-03 12:45:04 -04:00
bbedward 434490e100 audio: use typed PwNode list to assign audio devices
related to #2279
2026-05-03 10:04:31 -04:00
bbedward d2f6cb3ae4 process list: fix unloading
fixes #2284
2026-05-03 09:52:50 -04:00
bbedward c1cbd0994f settings: fix semvar signal moved to different service 2026-05-01 18:38:48 -04:00
bbedward c81645bacb add pre-commit hook for console.log 2026-04-30 16:59:26 -04:00
Archit Arora cdc4ca7e1f matugen: generate theme for Vencord (#2320) 2026-04-30 16:16:55 -04:00
gibbert 7d92842ff2 matugen: fix emacs template constant line number size (#2317)
Made it so line numbers don't stay a constant size when changing buffer
text scale.

See this thread:
<https://emacs.stackexchange.com/questions/74507/constant-font-size-in-display-line-numbers-mode-when-zooming-in-and-out>
2026-04-30 11:47:24 -04:00
Body d8bf3bdfe8 processes: fix list gaps and overlap when searching (#2315) 2026-04-30 11:45:46 -04:00
David Mireles 23ed795e85 Fix VPN UI for active transient entries (#2312)
Co-authored-by: louzt <18044171+louzt@users.noreply.github.com>
2026-04-30 11:41:41 -04:00
bbedward 2877c63c97 system update: make refresh synchronous 2026-04-30 11:41:07 -04:00
273 changed files with 29924 additions and 8235 deletions
+104
View File
@@ -0,0 +1,104 @@
# Agent Skills
This directory contains agent skills following the [Agent Skills](https://agentskills.io) open standard - a portable, version-controlled format for giving AI agents specialized capabilities.
Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs, scripts, and templates. Agents load skills progressively: metadata at startup, full instructions on activation, and supporting files on demand.
## Available Skills
| Skill | Description |
|-------|-------------|
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for DankMaterialShell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
## Installation
The `.agents/skills/` directory at the project root is the standard location defined by the agentskills.io spec. Many agents discover skills from this path automatically. Some agents use their own directory conventions and need a symlink or copy.
### Claude Code
Claude Code discovers skills from `.claude/skills/` (project-level) or `~/.claude/skills/` (personal). To make skills from `.agents/skills/` available, symlink them into the Claude Code skills directory:
**Project-level** (this repo only):
```bash
mkdir -p .claude/skills
ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
```
**Personal** (all your projects):
```bash
ln -s /path/to/DankMaterialShell/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
```
After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development.
See the [Claude Code skills docs](https://code.claude.com/docs/en/skills) for more on skill configuration, invocation control, and frontmatter options.
### Cursor
Cursor discovers skills from `.cursor/skills/` in the project root:
```bash
mkdir -p .cursor/skills
ln -s ../../.agents/skills/dms-plugin-dev .cursor/skills/dms-plugin-dev
```
See [Cursor skills docs](https://cursor.com/docs/context/skills) for details.
### VS Code (Copilot)
VS Code Copilot discovers skills from `.github/skills/` or `.vscode/skills/`:
```bash
mkdir -p .github/skills
ln -s ../../.agents/skills/dms-plugin-dev .github/skills/dms-plugin-dev
```
See [VS Code skills docs](https://code.visualstudio.com/docs/copilot/customization/agent-skills) for details.
### Gemini CLI
Gemini CLI discovers skills from `.gemini/skills/` in the project root:
```bash
mkdir -p .gemini/skills
ln -s ../../.agents/skills/dms-plugin-dev .gemini/skills/dms-plugin-dev
```
See [Gemini CLI skills docs](https://geminicli.com/docs/cli/skills/) for details.
### OpenAI Codex
Codex discovers skills from `.codex/skills/` in the project root:
```bash
mkdir -p .codex/skills
ln -s ../../.agents/skills/dms-plugin-dev .codex/skills/dms-plugin-dev
```
See [Codex skills docs](https://developers.openai.com/codex/skills/) for details.
### Other Agents
The Agent Skills standard is supported by 30+ tools including Goose, Roo Code, JetBrains Junie, Amp, OpenCode, OpenHands, Kiro, and more. Most discover skills from a dot-directory at the project root (e.g., `.goose/skills/`, `.roo/skills/`). Some read `.agents/skills/` directly.
Check the [Agent Skills client showcase](https://agentskills.io/clients) for setup instructions specific to your agent.
The general pattern is:
```bash
mkdir -p .<agent>/skills
ln -s ../../.agents/skills/dms-plugin-dev .<agent>/skills/dms-plugin-dev
```
## Adding New Skills
To add a new skill to this directory:
1. Create a subdirectory named with lowercase letters, numbers, and hyphens (e.g., `my-new-skill/`)
2. Add a `SKILL.md` file with YAML frontmatter (`name`, `description`) and markdown instructions
3. Optionally add `references/`, `scripts/`, and `assets/` subdirectories
4. Keep `SKILL.md` under 500 lines - move detailed content to reference files
See the [Agent Skills specification](https://agentskills.io/specification) for the full format.
+497
View File
@@ -0,0 +1,497 @@
---
name: dms-plugin-dev
description: >
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
manifest creation, QML component development, settings UI, data persistence, theme
integration, PopoutService usage, and external command execution. Use when the user wants
to create, modify, or debug a DMS plugin, or asks about the DMS plugin API.
compatibility: Designed for Claude Code (or similar products)
metadata:
author: DankMaterialShell
version: "1.0"
domain: qml-desktop-development
framework: DankMaterialShell
languages: qml, javascript
allowed-tools: Bash Read Write Edit
---
# DankMaterialShell Plugin Development
## Overview
DMS plugins extend the desktop shell with custom widgets, background services, launcher
integrations, and desktop widgets. Plugins are QML components discovered from
`~/.config/DankMaterialShell/plugins/`.
**Minimum plugin structure:**
```
~/.config/DankMaterialShell/plugins/YourPlugin/
plugin.json # Required: manifest with metadata
YourComponent.qml # Required: main QML component
YourSettings.qml # Optional: settings UI
*.js # Optional: JavaScript utilities
```
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
**Four plugin types:**
| Type | Purpose | Base Component | Bar pills | CC integration |
|------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
## Step 1: Determine Plugin Type
Choose the type based on what the plugin does:
- **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
optionally integrates with Control Center.
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
changes, notifications, battery level, etc.).
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
desktop layer.
## Step 2: Create the Manifest
Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md](references/plugin-manifest-reference.md) for the full schema.
**Minimal manifest:**
```json
{
"id": "yourPlugin",
"name": "Your Plugin Name",
"description": "Brief description of what your plugin does",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["your-capability"],
"component": "./YourWidget.qml"
}
```
**With settings and permissions:**
```json
{
"id": "yourPlugin",
"name": "Your Plugin Name",
"description": "Brief description",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["your-capability"],
"component": "./YourWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.0",
"permissions": ["settings_read", "settings_write"]
}
```
**Key rules:**
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
- `version` must be semver (e.g., `1.0.0`)
- `component` must start with `./` and end with `.qml`
- `type: "launcher"` requires a `trigger` field
- `settings_write` permission is **required** if the plugin has a settings component
## Step 3: Create the Main Component
### Widget
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: "Hello"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: "Hi"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
rotation: 90
}
}
}
}
```
See [widget-plugin-guide.md](references/widget-plugin-guide.md) for popouts, CC integration, and advanced features.
### Launcher
```qml
import QtQuick
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
function getItems(query) {
const items = [
{ name: "Item One", icon: "material:star", comment: "Description",
action: "toast:Hello!", categories: ["MyPlugin"] }
]
if (!query) return items
const q = query.toLowerCase()
return items.filter(i => i.name.toLowerCase().includes(q))
}
function executeItem(item) {
const [type, ...rest] = item.action.split(":")
const data = rest.join(":")
if (type === "toast") ToastService?.showInfo(data)
else if (type === "copy") Quickshell.execDetached(["dms", "cl", "copy", data])
}
}
```
See [launcher-plugin-guide.md](references/launcher-plugin-guide.md) for triggers, icon types, context menus, and image tiles.
### Desktop
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: 0.85
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
Text {
anchors.centerIn: parent
text: "Desktop Widget"
color: Theme.surfaceText
}
}
}
```
See [desktop-plugin-guide.md](references/desktop-plugin-guide.md) for sizing, persistence, and edit mode.
### Daemon
```qml
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
Connections {
target: SessionData
function onSomeSignal() {
console.log("Event received")
}
}
}
```
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
## Step 4: Add Settings (Optional)
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "yourPlugin"
StringSetting {
settingKey: "apiKey"
label: "API Key"
description: "Your API key"
placeholder: "sk-..."
}
ToggleSetting {
settingKey: "enabled"
label: "Enable Feature"
defaultValue: true
}
SelectionSetting {
settingKey: "interval"
label: "Refresh Interval"
options: [
{ label: "1 min", value: "60" },
{ label: "5 min", value: "300" }
]
defaultValue: "300"
}
}
```
**Available setting components:** StringSetting, ToggleSetting, SelectionSetting, SliderSetting, ColorSetting, ListSetting, ListSettingWithInput.
See [settings-components-reference.md](references/settings-components-reference.md) for full property lists.
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
## Step 5: Use Data Persistence
Three tiers of persistence:
| API | Persisted | Use case |
|-----|-----------|----------|
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
- Global vars sync across all instances (multi-monitor, multiple bar sections)
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
## Step 6: Theme Integration
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
**Essential properties:**
- Colors: `Theme.surfaceContainerHigh`, `Theme.surfaceText`, `Theme.primary`, `Theme.onPrimary`
- Fonts: `Theme.fontSizeSmall` (12), `Theme.fontSizeMedium` (14), `Theme.fontSizeLarge` (16), `Theme.fontSizeXLarge` (20)
- Spacing: `Theme.spacingXS`, `Theme.spacingS`, `Theme.spacingM`, `Theme.spacingL`, `Theme.spacingXL`
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
**Common widgets from `qs.Widgets`:** `StyledText`, `StyledRect`, `DankIcon`, `DankButton`, `DankToggle`, `DankTextField`, `DankSlider`, `DankGridView`, `CachingImage`.
See [theme-reference.md](references/theme-reference.md) for the complete property list.
## Step 7: Add Popout Content (Widgets Only)
Add a popout that opens when the bar pill is clicked:
```qml
PluginComponent {
popoutWidth: 400
popoutHeight: 300
popoutContent: Component {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: "Content here"
color: Theme.surfaceText
}
}
}
}
horizontalBarPill: Component { /* ... */ }
verticalBarPill: Component { /* ... */ }
}
```
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
## Step 8: Control Center Integration (Widgets Only)
Add your widget to the Control Center grid:
```qml
PluginComponent {
ccWidgetIcon: "toggle_on"
ccWidgetPrimaryText: "My Feature"
ccWidgetSecondaryText: isActive ? "On" : "Off"
ccWidgetIsActive: isActive
onCcWidgetToggled: {
isActive = !isActive
pluginService?.savePluginData(pluginId, "active", isActive)
}
// Optional: expandable detail panel (for CompoundPill)
ccDetailContent: Component {
Rectangle {
implicitHeight: 200
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
}
}
}
```
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
## Step 9: External Commands and Clipboard
**Run commands and capture output:**
```qml
import qs.Common
Proc.runCommand(
"myPlugin.fetch",
["curl", "-s", "https://api.example.com/data"],
(stdout, exitCode) => {
if (exitCode === 0) processData(stdout)
},
500 // debounce ms
)
```
**Fire-and-forget (clipboard, notifications):**
```qml
import Quickshell
Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
```
**Long-running processes:** Use the `Process` QML component from `Quickshell.Io` with `StdioCollector`.
**Shell commands with pipes:** `["sh", "-c", "ps aux | grep foo"]`
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
## Step 10: Validate and Test
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
3. Open Settings > Plugins > Scan for Plugins
4. Enable your plugin and add it to the DankBar layout
**Common issues:**
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
- Widget not showing: ensure it's enabled AND added to a DankBar section
- Settings error: verify `settings_write` permission is declared
- Data not persisting: check pluginService injection and permissions
## Common Mistakes
1. **Missing `settings_write` permission** - Settings UI shows error without it
2. **Missing `property var popoutService: null`** - Must declare for injection to work
3. **Missing vertical bar pill** - Widget disappears when bar is on left/right edge
4. **Hardcoded colors** - Use `Theme.*` properties, not hex values
5. **Using `globalThis.clipboard`** - Does not exist; use `Quickshell.execDetached(["dms", "cl", "copy", text])`
6. **Wrong Theme property names** - `Theme.fontSizeS` does not exist, use `Theme.fontSizeSmall`
7. **Wrong import for Quickshell** - Use `import Quickshell` (not `import QtQuick` for execDetached)
8. **Forgetting `categories` in launcher items** - Items won't display without it
9. **Not handling null pluginService** - Always use optional chaining or null checks
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
## Quick Reference: Imports
**Widget / Daemon:**
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
```
**Launcher:**
```qml
import QtQuick
import qs.Services
```
**Desktop:**
```qml
import QtQuick
import qs.Common
```
**For clipboard/exec:** `import Quickshell`
**For processes:** `import Quickshell.Io`
**For networking:** `import Quickshell.Networking`
**For toast notifications:** access `ToastService` from `qs.Services`
## Quick Reference: File Naming
- **Directory name:** PascalCase (e.g., `MyAwesomePlugin/`)
- **Plugin ID:** camelCase (e.g., `myAwesomePlugin`)
- **QML files:** PascalCase (e.g., `MyWidget.qml`, `Settings.qml`)
- **Component paths in manifest:** relative with `./` prefix (e.g., `"./MyWidget.qml"`)
- **JS utility files:** camelCase (e.g., `utils.js`, `apiAdapter.js`)
## Reference Files
Load these on demand for detailed API documentation:
- [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
- [settings-components-reference.md](references/settings-components-reference.md) - All 7 setting components with complete property lists
- [theme-reference.md](references/theme-reference.md) - Theme colors, spacing, fonts, radii, common patterns
- [data-persistence-guide.md](references/data-persistence-guide.md) - pluginData, state API, global variables
- [popout-service-reference.md](references/popout-service-reference.md) - PopoutService API for controlling shell popouts and modals
- [advanced-patterns.md](references/advanced-patterns.md) - Variants, JS utilities, qmldir, IPC, multi-file plugins
@@ -0,0 +1,115 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://danklinux.com/schemas/plugin.json",
"title": "DankMaterialShell Plugin Manifest",
"description": "Schema for DankMaterialShell plugin.json manifest files",
"type": "object",
"required": [
"id",
"name",
"description",
"version",
"author",
"type",
"capabilities",
"component"
],
"properties": {
"id": {
"type": "string",
"description": "Unique plugin identifier (camelCase, no spaces)",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
},
"name": {
"type": "string",
"description": "Human-readable plugin name",
"minLength": 1
},
"description": {
"type": "string",
"description": "Short description of plugin functionality",
"minLength": 1
},
"version": {
"type": "string",
"description": "Semantic version string (e.g., '1.0.0')",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
},
"author": {
"type": "string",
"description": "Plugin creator name or email",
"minLength": 1
},
"type": {
"type": "string",
"description": "Plugin type",
"enum": ["widget", "daemon", "launcher", "desktop"]
},
"capabilities": {
"type": "array",
"description": "Array of plugin capabilities",
"items": {
"type": "string"
},
"minItems": 1
},
"component": {
"type": "string",
"description": "Relative path to main QML component file",
"pattern": "^\\./.*\\.qml$"
},
"trigger": {
"type": "string",
"description": "Trigger string for launcher activation (required for launcher type)"
},
"icon": {
"type": "string",
"description": "Material Design icon name"
},
"settings": {
"type": "string",
"description": "Path to settings component QML file",
"pattern": "^\\./.*\\.qml$"
},
"requires_dms": {
"type": "string",
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
},
"requires": {
"type": "array",
"description": "Array of required system tools/dependencies",
"items": {
"type": "string"
}
},
"permissions": {
"type": "array",
"description": "Required capabilities",
"items": {
"type": "string",
"enum": [
"settings_read",
"settings_write",
"process",
"network"
]
}
}
},
"allOf": [
{
"if": {
"properties": {
"type": {
"const": "launcher"
}
}
},
"then": {
"required": ["trigger"]
}
}
],
"additionalProperties": true
}
@@ -0,0 +1,48 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// TODO: Read configuration from settings
property string configValue: pluginData?.configValue || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
configValue = pluginService.loadPluginData(pluginId, "configValue", "")
}
}
// TODO: Connect to the service events you need
// Connections {
// target: SessionData
// function onWallpaperPathChanged() {
// console.log("[MyDaemon] Wallpaper changed:", SessionData.wallpaperPath)
// handleEvent(SessionData.wallpaperPath)
// }
// }
function handleEvent(data) {
Proc.runCommand(
"myDaemon.handle",
["echo", "Event received:", data],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("[MyDaemon] Output:", stdout)
} else {
console.error("[MyDaemon] Failed:", exitCode)
ToastService?.showInfo("Daemon action failed")
}
}
)
}
Component.onCompleted: {
console.log("[MyDaemon] Started")
}
}
@@ -0,0 +1,16 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDaemon"
StringSetting {
settingKey: "configValue"
label: "Configuration"
description: "Value used by the daemon"
placeholder: "Enter value..."
defaultValue: ""
}
}
@@ -0,0 +1,13 @@
{
"id": "myDaemon",
"name": "My Daemon",
"description": "A background service that reacts to events",
"version": "1.0.0",
"author": "Your Name",
"type": "daemon",
"capabilities": ["background-service"],
"component": "./Daemon.qml",
"icon": "settings",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write", "process"]
}
@@ -0,0 +1,18 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDesktopWidget"
SliderSetting {
settingKey: "opacity"
label: "Opacity"
description: "Widget background opacity"
defaultValue: 85
minimum: 10
maximum: 100
unit: "%"
}
}
@@ -0,0 +1,47 @@
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
// TODO: Load settings reactively
property real bgOpacity: {
if (!pluginService) return 0.85
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
return val / 100
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
bgOpacity = val / 100
}
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: root.bgOpacity
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
// TODO: Add your widget content here
Text {
anchors.centerIn: parent
text: "Desktop Widget"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeLarge
}
}
}
@@ -0,0 +1,13 @@
{
"id": "myDesktopWidget",
"name": "My Desktop Widget",
"description": "A custom desktop widget",
"version": "1.0.0",
"author": "Your Name",
"type": "desktop",
"capabilities": ["desktop-widget"],
"component": "./Widget.qml",
"icon": "widgets",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,59 @@
import QtQuick
import Quickshell
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
// TODO: Define your items
property var allItems: [
{
name: "Example Item",
icon: "material:star",
comment: "An example launcher item",
action: "toast:Hello from my launcher!",
categories: ["MyLauncher"]
}
]
function getItems(query) {
if (!query || query.length === 0) return allItems
var q = query.toLowerCase()
return allItems.filter(function(item) {
return item.name.toLowerCase().includes(q) ||
item.comment.toLowerCase().includes(q)
})
}
function executeItem(item) {
var actionParts = item.action.split(":")
var actionType = actionParts[0]
var actionData = actionParts.slice(1).join(":")
switch (actionType) {
case "toast":
if (typeof ToastService !== "undefined")
ToastService.showInfo(actionData)
break
case "copy":
Quickshell.execDetached(["dms", "cl", "copy", actionData])
if (typeof ToastService !== "undefined")
ToastService.showInfo("Copied to clipboard")
break
default:
console.warn("Unknown action type:", actionType)
}
}
Component.onCompleted: {
if (pluginService) {
trigger = pluginService.loadPluginData("myLauncher", "trigger", "#")
}
}
}
@@ -0,0 +1,23 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myLauncher"
StringSetting {
settingKey: "trigger"
label: "Trigger"
description: "Type this prefix in the launcher to activate the plugin"
placeholder: "#"
defaultValue: "#"
}
ToggleSetting {
settingKey: "noTrigger"
label: "Always Visible"
description: "Show items alongside regular apps without needing a trigger"
defaultValue: false
}
}
@@ -0,0 +1,14 @@
{
"id": "myLauncher",
"name": "My Launcher",
"description": "Custom launcher plugin with searchable items",
"version": "1.0.0",
"author": "Your Name",
"type": "launcher",
"capabilities": ["launcher"],
"component": "./Launcher.qml",
"trigger": "#",
"icon": "search",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,23 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myWidget"
StringSetting {
settingKey: "text"
label: "Display Text"
description: "Text shown in the bar widget"
placeholder: "Hello"
defaultValue: "Hello"
}
ToggleSetting {
settingKey: "showIcon"
label: "Show Icon"
description: "Display an icon next to the text"
defaultValue: true
}
}
@@ -0,0 +1,75 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// TODO: Read settings reactively
property string displayText: pluginData?.text || "Hello"
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
displayText = pluginService.loadPluginData(pluginId, "text", "Hello")
}
}
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
rotation: 90
}
}
}
// TODO: Uncomment and customize popout content
// popoutWidth: 350
// popoutHeight: 300
// popoutContent: Component {
// PopoutComponent {
// headerText: "My Widget"
// showCloseButton: true
//
// Column {
// width: parent.width
// spacing: Theme.spacingM
//
// StyledText {
// text: "Popout content here"
// color: Theme.surfaceText
// }
// }
// }
// }
}
@@ -0,0 +1,13 @@
{
"id": "myWidget",
"name": "My Widget",
"description": "A custom bar widget",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"capabilities": ["dankbar-widget"],
"component": "./Widget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
@@ -0,0 +1,320 @@
# Advanced Patterns
Patterns observed in production DMS plugins that go beyond the basics.
## Plugin Variants
Create multiple widget instances from a single plugin definition. Each variant has its own configuration.
### Manifest
No special manifest changes needed - the variant system is built into PluginComponent.
### Widget with Variant Support
```qml
PluginComponent {
property string variantId: ""
property var variantData: ({})
property string displayText: variantData?.text || "Default"
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
}
}
}
}
```
Widget format in bar config: `pluginId:variantId` (e.g., `exampleVariants:variant_1234567890`)
### Settings with Variant Management
```qml
PluginSettings {
pluginId: "exampleVariants"
// Variant creation UI
DankButton {
text: "Add New Instance"
onClicked: {
var id = "variant_" + Date.now()
root.createVariant(id, { name: "New Instance", text: "Hello" })
}
}
// Per-variant configuration
Repeater {
model: root.variants
delegate: Column {
StringSetting {
settingKey: modelData.id + "_text"
label: modelData.name || modelData.id
}
}
}
}
```
## JavaScript Utility Files
For complex logic, split into `.js` files:
### utils.js
```javascript
.pragma library
function formatDuration(ms) {
if (ms < 60000) return "just now"
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"
return Math.floor(ms / 3600000) + "h ago"
}
function parseResponse(json) {
try {
return JSON.parse(json)
} catch (e) {
return null
}
}
```
### Using in QML
```qml
import "utils.js" as Utils
Item {
StyledText {
text: Utils.formatDuration(Date.now() - timestamp)
}
}
```
The `.pragma library` directive makes the JS file a shared singleton - it is loaded once and shared across all QML instances that import it.
## qmldir for Singleton Services
For plugins with internal singleton services:
### qmldir
```
singleton MyService 1.0 MyService.qml
```
### MyService.qml
```qml
pragma Singleton
import QtQuick
QtObject {
property var cache: ({})
function getData(key) {
return cache[key] || null
}
function setData(key, value) {
cache[key] = value
}
}
```
### Using the singleton
```qml
import "." as Local
Item {
Component.onCompleted: {
Local.MyService.setData("key", "value")
}
}
```
## Inline Component Declarations
Reusable sub-components defined inline:
```qml
Item {
component StatusBadge: Rectangle {
property string label: ""
property color badgeColor: Theme.primary
width: badgeText.implicitWidth + Theme.spacingM * 2
height: 24
radius: 12
color: badgeColor
StyledText {
id: badgeText
anchors.centerIn: parent
text: label
color: Theme.onPrimary
font.pixelSize: Theme.fontSizeSmall
}
}
Row {
spacing: Theme.spacingS
StatusBadge { label: "Running"; badgeColor: Theme.success }
StatusBadge { label: "Stopped"; badgeColor: Theme.error }
}
}
```
## Multi-Provider Adapter Pattern
For plugins supporting multiple backends (AI providers, API services):
### apiAdapters.js
```javascript
.pragma library
function createAdapter(provider) {
switch (provider) {
case "openai": return {
url: "https://api.openai.com/v1/chat/completions",
headers: (key) => ({ "Authorization": "Bearer " + key }),
formatRequest: (messages) => JSON.stringify({ model: "gpt-4", messages: messages }),
parseResponse: (text) => JSON.parse(text).choices[0].message.content
}
case "anthropic": return {
url: "https://api.anthropic.com/v1/messages",
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
formatRequest: (messages) => JSON.stringify({ model: "claude-sonnet-4-20250514", messages: messages }),
parseResponse: (text) => JSON.parse(text).content[0].text
}
default: return null
}
}
```
## IPC Integration
For plugins that respond to keyboard shortcuts or external commands:
```qml
PluginComponent {
Connections {
target: DMSIpc
function onCommandReceived(command, args) {
if (command === "myPlugin.toggle") {
doToggle()
} else if (command === "myPlugin.next") {
goNext()
}
}
}
}
```
External trigger: `dms ipc call myPlugin.toggle`
## Networking with Quickshell.Networking
For API calls using the built-in networking module:
```qml
import Quickshell.Networking
Item {
NetworkRequest {
id: request
url: "https://api.example.com/data"
method: "GET"
onResponseReceived: (response) => {
const data = JSON.parse(response.body)
processData(data)
}
onErrorOccurred: (error) => {
console.error("Network error:", error)
}
}
function fetchData() {
request.send()
}
}
```
## Toast Notifications
Show user feedback:
```qml
import qs.Services
// Info toast
ToastService?.showInfo("Operation completed")
// With title
ToastService?.showInfo("Plugin Name", "Data refreshed successfully")
```
Always use optional chaining since ToastService may not be available in all contexts.
## Clipboard Operations
```qml
import Quickshell
function copyToClipboard(text) {
Quickshell.execDetached(["dms", "cl", "copy", text])
ToastService?.showInfo("Copied to clipboard")
}
```
Do NOT use `globalThis.clipboard`, `navigator.clipboard`, or any browser API - they do not exist in the QML runtime.
## Multi-File Plugin Architecture
Large plugins can be split across multiple files:
```
MyPlugin/
plugin.json
Main.qml # Main widget component
Settings.qml # Settings UI
DetailView.qml # Popout detail view
utils.js # Utility functions
apiAdapter.js # API adapter layer
qmldir # Optional: singleton registrations
```
Import sibling files:
```qml
// In Main.qml
import "." as Local
Item {
Loader {
source: "DetailView.qml"
}
}
```
## Performance Tips
1. Use `Proc.runCommand` with appropriate debounce for external commands
2. Pre-cache images and thumbnails for image-heavy plugins
3. Limit concurrent network requests
4. Use `Timer` with reasonable intervals (don't poll faster than needed)
5. Lazy-load heavy content (use `Loader` for complex popout content)
6. Avoid blocking the UI thread with synchronous operations
@@ -0,0 +1,272 @@
# Daemon Plugin Guide
Daemon plugins are invisible background services that react to events and execute actions. They have no bar pills or desktop presence.
## Base Component
Daemons use `PluginComponent` with no bar pills:
```qml
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
// Event-driven logic goes here
}
```
## When to Use Daemons
- Monitor system events (wallpaper changes, battery level, notifications)
- Run periodic background tasks (polling APIs, checking system state)
- Execute scripts in response to events
- Control shell UI via PopoutService based on conditions
## Event-Driven Pattern
Use `Connections` to react to service signals:
```qml
PluginComponent {
property var popoutService: null
Connections {
target: SessionData
function onWallpaperPathChanged() {
console.log("Wallpaper changed to:", SessionData.wallpaperPath)
runScript(SessionData.wallpaperPath)
}
}
Connections {
target: BatteryService
function onPercentageChanged() {
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
popoutService?.openBattery()
}
}
}
}
```
## Available Services
Common services daemons can connect to:
| Service | Signals/Properties | Description |
|---------|-------------------|-------------|
| `SessionData` | `wallpaperPath`, `onWallpaperPathChanged` | Desktop session state |
| `BatteryService` | `percentage`, `isCharging`, `batteryAvailable` | Battery status |
| `NotificationService` | `onNotificationReceived(notification)` | Desktop notifications |
| `PluginService` | `onPluginLoaded`, `onGlobalVarChanged` | Plugin lifecycle |
Import services from `qs.Services`.
## Process Execution
### Simple command with Proc
```qml
import qs.Common
PluginComponent {
function runScript(arg) {
Proc.runCommand(
"myDaemon.script",
["bash", "-c", "echo 'Processing: " + arg + "'"],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("Script output:", stdout)
} else {
ToastService?.showInfo("Script failed: exit " + exitCode)
}
}
)
}
}
```
### Long-running process with Process component
```qml
import Quickshell.Io
PluginComponent {
property string scriptPath: ""
Process {
id: proc
command: ["bash", scriptPath]
running: false
stdout: StdioCollector {
onTextReceived: (text) => {
console.log("stdout:", text)
}
}
stderr: StdioCollector {
onTextReceived: (text) => {
console.error("stderr:", text)
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
ToastService?.showInfo("Process failed: exit " + exitCode)
}
}
}
function startProcess() {
if (scriptPath && !proc.running) {
proc.running = true
}
}
}
```
## Timer-Based Polling
```qml
PluginComponent {
Timer {
interval: 60000 // every minute
running: true
repeat: true
onTriggered: checkStatus()
}
function checkStatus() {
Proc.runCommand(
"myDaemon.check",
["sh", "-c", "systemctl is-active myservice"],
(stdout, exitCode) => {
const active = stdout.trim() === "active"
PluginService.setGlobalVar("myDaemon", "serviceActive", active)
}
)
}
}
```
## Data Persistence
Daemons access PluginService directly (it's injected via PluginComponent):
```qml
PluginComponent {
property string configuredScript: pluginData?.scriptPath || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId) {
configuredScript = pluginService.loadPluginData(pluginId, "scriptPath", "")
}
}
}
}
```
## PopoutService Usage
Daemons can control shell UI via the injected popoutService:
```qml
PluginComponent {
property var popoutService: null
function showAlert() {
popoutService?.openNotificationCenter()
}
function openSettings() {
popoutService?.openSettings()
}
}
```
See [popout-service-reference.md](popout-service-reference.md) for the full API.
## Complete Example
Based on the WallpaperWatcherDaemon:
```qml
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
property string scriptPath: pluginData?.scriptPath || ""
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId) {
scriptPath = pluginService.loadPluginData(pluginId, "scriptPath", "")
}
}
}
Connections {
target: SessionData
function onWallpaperPathChanged() {
if (scriptPath) {
runWallpaperScript(SessionData.wallpaperPath)
}
}
}
function runWallpaperScript(wallpaperPath) {
console.log("[WallpaperWatcher] Running script:", scriptPath, wallpaperPath)
Proc.runCommand(
"wallpaperWatcher.run",
["bash", scriptPath, wallpaperPath],
(stdout, exitCode) => {
if (exitCode === 0) {
console.log("[WallpaperWatcher] Script output:", stdout)
} else {
console.error("[WallpaperWatcher] Script failed:", exitCode)
ToastService?.showInfo("Wallpaper script failed")
}
}
)
}
Component.onCompleted: {
console.log("[WallpaperWatcher] Daemon started")
}
}
```
## Manifest Example
```json
{
"id": "wallpaperWatcher",
"name": "Wallpaper Watcher",
"description": "Runs a script when the wallpaper changes",
"version": "1.0.0",
"author": "Developer",
"type": "daemon",
"capabilities": ["wallpaper-automation"],
"component": "./WallpaperWatcher.qml",
"icon": "wallpaper",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write", "process"]
}
```
@@ -0,0 +1,176 @@
# Data Persistence Guide
DMS plugins have three tiers of data persistence, each suited for different use cases.
## Tier 1: Plugin Data (Settings)
Persisted to `settings.json`. Use for user preferences and configuration.
### Saving
```qml
pluginService.savePluginData(pluginId, "key", value)
```
### Loading
```qml
var value = pluginService.loadPluginData(pluginId, "key", defaultValue)
```
### Reactive Access via pluginData
`PluginComponent` has a reactive `pluginData` property that auto-loads from settings:
```qml
PluginComponent {
property string displayText: pluginData?.text || "Default"
property bool showIcon: pluginData?.showIcon !== undefined ? pluginData.showIcon : true
}
```
### Reacting to Settings Changes
When settings are changed (e.g., from the settings UI), react with `Connections`:
```qml
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
showIcon = pluginService.loadPluginData(pluginId, "showIcon", true)
}
}
```
## Tier 2: Plugin State
Persisted to a separate state file. Use for runtime state that should survive restarts but is not user-configurable (history, cache, counters).
### Saving
```qml
pluginService.savePluginState(pluginId, "key", value)
```
### Loading
```qml
var state = pluginService.loadPluginState(pluginId, "key", defaultValue)
```
### Additional Methods
```qml
pluginService.clearPluginState(pluginId)
pluginService.removePluginStateKey(pluginId, "key")
```
### Example: Persistent History
```qml
Item {
property var history: []
Component.onCompleted: {
history = pluginService?.loadPluginState(pluginId, "history", []) || []
}
function addToHistory(entry) {
history.unshift({
text: entry,
timestamp: Date.now()
})
if (history.length > 100) history = history.slice(0, 100)
pluginService?.savePluginState(pluginId, "history", history)
}
function clearHistory() {
history = []
pluginService?.removePluginStateKey(pluginId, "history")
}
}
```
## Tier 3: Global Variables (Runtime Only)
NOT persisted. Shared across all instances of a plugin. Use for cross-instance state synchronization (multi-monitor consistency, multi-instance widgets).
### Using PluginGlobalVar Component
```qml
import qs.Modules.Plugins
PluginComponent {
PluginGlobalVar {
id: globalCounter
varName: "counter"
defaultValue: 0
}
horizontalBarPill: Component {
StyledRect {
// ...
StyledText {
text: "Count: " + globalCounter.value
}
MouseArea {
onClicked: globalCounter.set(globalCounter.value + 1)
}
}
}
}
```
**PluginGlobalVar properties:**
| Property | Type | Description |
|----------|------|-------------|
| `varName` | string | Required: name of the global variable |
| `defaultValue` | any | Optional: default if not set |
| `value` | any | Readonly: current value |
**Methods:**
- `set(newValue)` - update the value (triggers reactivity across all instances)
### Using PluginService API Directly
```qml
import qs.Services
property int counter: PluginService.getGlobalVar("myPlugin", "counter", 0)
Connections {
target: PluginService
function onGlobalVarChanged(pluginId, varName) {
if (pluginId === "myPlugin" && varName === "counter") {
counter = PluginService.getGlobalVar("myPlugin", "counter", 0)
}
}
}
function increment() {
var current = PluginService.getGlobalVar("myPlugin", "counter", 0)
PluginService.setGlobalVar("myPlugin", "counter", current + 1)
}
```
## Decision Matrix
| Need | API | Persisted | Scope |
|------|-----|-----------|-------|
| User preferences (API keys, themes, intervals) | `savePluginData` / `loadPluginData` | Yes (settings.json) | Per plugin |
| Runtime state (history, cache, counters) | `savePluginState` / `loadPluginState` | Yes (state file) | Per plugin |
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
## Important Notes
1. **pluginData is reactive** - bindings update automatically when data changes
2. **Global vars are NOT persistent** - they reset when the shell restarts
3. **State vs Data** - data is for user-facing settings, state is for internal runtime data
4. **Null safety** - always check `pluginService` is not null before calling methods
5. **Signal namespacing** - global var signals include `pluginId` to filter for your plugin
6. **Performance** - global vars are efficient for frequent updates; settings writes are batched
@@ -0,0 +1,240 @@
# Desktop Plugin Guide
Desktop plugins are widgets that appear on the desktop background layer. They support drag-and-drop positioning and resize via corner handles.
## Base Component
Desktop widgets use a plain `Item` with injected properties:
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 200
property real widgetHeight: 200
property real minWidth: 150
property real minHeight: 150
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: 0.85
// Your content here
}
}
```
## Injected Properties
These are set automatically by the DesktopPluginWrapper:
| Property | Type | Description |
|----------|------|-------------|
| `pluginService` | object | PluginService reference for data persistence |
| `pluginId` | string | Plugin's unique identifier |
| `editMode` | bool | `true` when user is dragging/resizing |
| `widgetWidth` | real | Current widget container width |
| `widgetHeight` | real | Current widget container height |
## Optional Properties
Define these on your root item to customize behavior:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `minWidth` | real | 100 | Minimum allowed width during resize |
| `minHeight` | real | 100 | Minimum allowed height during resize |
## Position and Size Persistence
Position (`desktopX`, `desktopY`) and size (`desktopWidth`, `desktopHeight`) are automatically managed by the DesktopPluginWrapper. You do not need to handle persistence for positioning.
## Edit Mode
When `editMode` is true, the user is repositioning or resizing. Use this to:
- Show visual indicators (borders, handles)
- Disable interactive elements to prevent accidental actions
- Display additional controls
```qml
Rectangle {
anchors.fill: parent
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
MouseArea {
anchors.fill: parent
enabled: !root.editMode
onClicked: doSomething()
}
}
```
## Loading and Saving Data
Use the injected `pluginService` for data persistence:
```qml
property string displayMode: {
if (!pluginService) return "default"
return pluginService.loadPluginData(pluginId, "displayMode", "default")
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
root.displayMode = pluginService.loadPluginData(pluginId, "displayMode", "default")
}
}
function saveMode(mode) {
pluginService?.savePluginData(pluginId, "displayMode", mode)
}
```
## Settings Component
Desktop plugin settings use the same `PluginSettings` wrapper as other types:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myDesktopWidget"
SliderSetting {
settingKey: "opacity"
label: "Opacity"
description: "Widget background opacity"
defaultValue: 85
minimum: 10
maximum: 100
unit: "%"
}
SelectionSetting {
settingKey: "style"
label: "Display Style"
options: [
{ label: "Compact", value: "compact" },
{ label: "Expanded", value: "expanded" }
]
defaultValue: "compact"
}
}
```
## User Interaction
Desktop widgets support:
1. **Drag** - click and drag anywhere (in edit mode)
2. **Resize** - drag bottom-right corner handle (in edit mode)
3. **Edit mode toggle** - via the desktop edit button
## Complete Example
Based on the ExampleDesktopClock pattern:
```qml
import QtQuick
import qs.Common
Item {
id: root
property var pluginService: null
property string pluginId: ""
property bool editMode: false
property real widgetWidth: 250
property real widgetHeight: 250
property real minWidth: 150
property real minHeight: 150
property string clockStyle: {
if (!pluginService) return "digital"
return pluginService.loadPluginData(pluginId, "clockStyle", "digital")
}
property real bgOpacity: {
if (!pluginService) return 0.85
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
return val / 100
}
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId !== pluginId) return
clockStyle = pluginService.loadPluginData(pluginId, "clockStyle", "digital")
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
bgOpacity = val / 100
}
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: root.bgOpacity
border.color: root.editMode ? Theme.primary : "transparent"
border.width: root.editMode ? 2 : 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatTime(new Date(), "hh:mm:ss")
color: Theme.surfaceText
font.pixelSize: root.widgetWidth * 0.15
font.weight: Font.Bold
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatDate(new Date(), "ddd, MMM d")
color: Theme.onSurfaceVariant
font.pixelSize: Theme.fontSizeMedium
}
}
}
Timer {
interval: 1000
running: true
repeat: true
onTriggered: root.widgetWidth = root.widgetWidth // force update
}
}
```
## Manifest Example
```json
{
"id": "myDesktopClock",
"name": "Desktop Clock",
"description": "Analog and digital clock for the desktop",
"version": "1.0.0",
"author": "Developer",
"type": "desktop",
"capabilities": ["desktop-widget"],
"component": "./ClockWidget.qml",
"icon": "schedule",
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
@@ -0,0 +1,308 @@
# Launcher Plugin Guide
Launcher plugins extend the DMS launcher with custom searchable items and actions. They use trigger-based filtering and integrate directly into the app drawer.
## Base Component
Launchers use a plain `Item` (not PluginComponent):
```qml
import QtQuick
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "#"
signal itemsChanged()
function getItems(query) {
// Return array of items
return []
}
function executeItem(item) {
// Handle item selection
}
}
```
## Required Interface
| Member | Type | Description |
|--------|------|-------------|
| `pluginService` | property | Injected PluginService reference (declare as `null`) |
| `trigger` | property | Trigger string for activation |
| `itemsChanged` | signal | Emit when item list changes (triggers UI refresh) |
| `getItems(query)` | function | Return array of items matching query |
| `executeItem(item)` | function | Handle item selection |
## Item Structure
Each item returned by `getItems()`:
```javascript
{
name: "Item Display Name", // Required: shown in launcher
icon: "material:star", // Optional: icon specification
comment: "Description text", // Required: subtitle text
action: "type:data", // Required: action identifier
categories: ["MyPlugin"], // Required: array with plugin category
imageUrl: "https://..." // Optional: image for tile view
}
```
## Icon Types
### 1. Material Design Icons
```javascript
{ icon: "material:lightbulb" }
{ icon: "material:terminal" }
{ icon: "material:translate" }
```
Uses the Material Symbols Rounded font.
### 2. Unicode / Emoji Icons
```javascript
{ icon: "unicode:smile_face" }
```
Rendered at 70-80% of icon size with theming.
### 3. Desktop Theme Icons
```javascript
{ icon: "firefox" }
{ icon: "folder" }
```
Uses the user's installed icon theme.
### 4. No Icon
Omit the `icon` field entirely. The launcher hides the icon area and gives full width to the item name.
## Trigger System
**Custom trigger** (items only appear when trigger is typed):
```json
{ "trigger": "#" }
```
- Type `#` alone: shows all plugin items
- Type `# query`: filters plugin items by query
- The query string (without trigger) is passed to `getItems(query)`
**No trigger** (items always visible alongside regular apps):
```json
{ "trigger": "" }
```
Save empty trigger at runtime:
```qml
Component.onCompleted: {
trigger = pluginService?.loadPluginData(pluginId, "trigger", "#") ?? "#"
}
```
## Action Execution
Parse action strings in `executeItem()`:
```qml
function executeItem(item) {
const actionParts = item.action.split(":")
const actionType = actionParts[0]
const actionData = actionParts.slice(1).join(":")
switch (actionType) {
case "toast":
ToastService?.showInfo(actionData)
break
case "copy":
Quickshell.execDetached(["dms", "cl", "copy", actionData])
ToastService?.showInfo("Copied to clipboard")
break
case "exec":
Quickshell.execDetached(actionData.split(" "))
break
case "url":
Quickshell.execDetached(["xdg-open", actionData])
break
default:
console.warn("Unknown action type:", actionType)
}
}
```
## Search / Filtering
The `query` parameter in `getItems()` contains the user's search text (without the trigger prefix).
```qml
function getItems(query) {
const allItems = [
{ name: "Calculator", icon: "material:calculate",
comment: "Open calculator", action: "exec:gnome-calculator",
categories: ["Tools"] },
{ name: "Terminal", icon: "material:terminal",
comment: "Open terminal", action: "exec:alacritty",
categories: ["Tools"] }
]
if (!query || query.length === 0) return allItems
const q = query.toLowerCase()
return allItems.filter(item =>
item.name.toLowerCase().includes(q) ||
item.comment.toLowerCase().includes(q)
)
}
```
## Context Menu Actions
Add right-click actions to launcher items:
```qml
function getContextMenuActions(item) {
return [
{ name: "Copy", icon: "material:content_copy",
action: "copy:" + item.name },
{ name: "Open in Browser", icon: "material:open_in_new",
action: "url:" + item.url }
]
}
```
Context menu actions use the same `executeItem()` handler.
## Image Tile View
For image-heavy launchers (GIF search, sticker pickers), use tile view:
In `plugin.json`:
```json
{
"viewMode": "tile",
"viewModeEnforced": true
}
```
In items:
```javascript
{
name: "Image Title",
imageUrl: "https://example.com/image.png",
comment: "Description",
action: "copy:https://example.com/image.png",
categories: ["MyPlugin"]
}
```
## State Persistence
For plugins with persistent state (notes, history, favorites):
```qml
property var notes: []
Component.onCompleted: {
const saved = pluginService?.loadPluginState(pluginId, "notes", [])
if (saved) notes = saved
}
function addNote(text) {
notes.push({ text: text, timestamp: Date.now() })
pluginService?.savePluginState(pluginId, "notes", notes)
itemsChanged()
}
```
Use `savePluginState/loadPluginState` for runtime data and `savePluginData/loadPluginData` for user preferences.
## Settings for Trigger Configuration
Provide a PluginSettings component for trigger customization:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myLauncher"
StringSetting {
settingKey: "trigger"
label: "Trigger"
description: "Type this prefix to activate the launcher plugin"
placeholder: "#"
defaultValue: "#"
}
ToggleSetting {
settingKey: "noTrigger"
label: "Always Visible"
description: "Show items alongside regular apps (no trigger needed)"
defaultValue: false
}
}
```
## Complete Example
```qml
import QtQuick
import Quickshell
import qs.Services
Item {
id: root
property var pluginService: null
property string trigger: "!"
signal itemsChanged()
property var commands: [
{ name: "Lock Screen", icon: "material:lock",
comment: "Lock the session", action: "exec:loginctl lock-session" },
{ name: "Screenshot", icon: "material:screenshot_monitor",
comment: "Take a screenshot", action: "exec:grim" },
{ name: "File Manager", icon: "material:folder",
comment: "Open file manager", action: "exec:nautilus" }
]
function getItems(query) {
if (!query) return commands
const q = query.toLowerCase()
return commands.filter(c =>
c.name.toLowerCase().includes(q) ||
c.comment.toLowerCase().includes(q)
)
}
function executeItem(item) {
const [type, ...rest] = item.action.split(":")
const data = rest.join(":")
if (type === "exec") {
Quickshell.execDetached(data.split(" "))
}
}
Component.onCompleted: {
if (pluginService) {
trigger = pluginService.loadPluginData("quickCommands", "trigger", "!")
}
}
}
```
@@ -0,0 +1,119 @@
# Plugin Manifest Reference (plugin.json)
## Required Fields
| Field | Type | Description | Validation |
|-------|------|-------------|------------|
| `id` | string | Unique plugin identifier | camelCase, pattern `^[a-zA-Z][a-zA-Z0-9]*$` |
| `name` | string | Human-readable name | Non-empty |
| `description` | string | Short description (shown in UI) | Non-empty |
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
| `author` | string | Creator name or email | Non-empty |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop` |
| `capabilities` | array | Plugin capabilities | At least 1 string item |
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
## Conditional Requirements
| Condition | Required Field | Description |
|-----------|---------------|-------------|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
## Optional Fields
| Field | Type | Description |
|-------|------|-------------|
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
| `permissions` | array | Required permissions |
| `trigger` | string | Launcher trigger string (required for launcher type) |
## Permissions
| Permission | Description | Enforced |
|------------|-------------|----------|
| `settings_read` | Read plugin configuration | No (not currently enforced) |
| `settings_write` | Write plugin configuration / use PluginSettings | **Yes** |
| `process` | Execute system commands | No (not currently enforced) |
| `network` | Network access | No (not currently enforced) |
If your plugin has a `settings` component but does not declare `settings_write`, users will see an error instead of the settings UI.
## Capabilities
Capabilities are free-form strings that describe what the plugin does. Common values:
- `dankbar-widget` - general bar widget
- `control-center` - integrates with Control Center
- `monitoring` - system/service monitoring
- `launcher` - launcher search provider
- `desktop-widget` - desktop background widget
- `ai` - AI/LLM integration
- `slideout` - uses slideout panel
## Complete Example
```json
{
"id": "myPlugin",
"name": "My Plugin",
"description": "A sample plugin demonstrating all fields",
"version": "1.0.0",
"author": "Developer Name",
"type": "widget",
"capabilities": ["dankbar-widget", "control-center"],
"component": "./MyWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.18",
"requires": ["curl", "jq"],
"permissions": ["settings_read", "settings_write", "process", "network"]
}
```
## Launcher Example
```json
{
"id": "myLauncher",
"name": "My Launcher",
"description": "Search and execute custom actions",
"version": "1.0.0",
"author": "Developer Name",
"type": "launcher",
"capabilities": ["launcher"],
"component": "./MyLauncher.qml",
"trigger": "#",
"icon": "search",
"settings": "./Settings.qml",
"requires_dms": ">=0.1.18",
"permissions": ["settings_read", "settings_write"]
}
```
## JSON Schema
The complete JSON schema is available at `assets/plugin-schema.json` in this skill. Validate with:
```bash
# Using python
python3 -c "
import json, jsonschema
schema = json.load(open('path/to/plugin-schema.json'))
manifest = json.load(open('plugin.json'))
jsonschema.validate(manifest, schema)
print('Valid!')
"
# Using jq (syntax check only)
jq . plugin.json
```
## Additional Properties
The schema allows additional properties (`"additionalProperties": true`), so plugins can include custom fields. Common custom fields seen in production plugins:
- `viewMode` - launcher display mode (`"tile"` for image grids)
- `viewModeEnforced` - lock launcher to specific view mode (`true`/`false`)
@@ -0,0 +1,120 @@
# PopoutService Reference
The `PopoutService` singleton lets plugins control all DMS popouts and modals. It is automatically injected into widget, daemon, and settings components.
## Setup
Declare the property in your component for injection to work:
```qml
property var popoutService: null
```
Without this declaration, injection fails with: `Cannot assign to non-existent property "popoutService"`
## Popouts (DankPopout-based)
| Component | Open | Close | Toggle |
|-----------|------|-------|--------|
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
## Modals (DankModal-based)
| Modal | Show | Hide | Notes |
|-------|------|------|-------|
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
| Launcher | `openDankLauncherV2()` | `closeDankLauncherV2()` | Also has `toggleDankLauncherV2()` |
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Has `toggleProcessListModal()` |
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network auth |
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
## Slideouts
| Component | Open | Close | Toggle |
|-----------|------|-------|--------|
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
## Usage Examples
### Simple toggle
```qml
MouseArea {
onClicked: popoutService?.toggleControlCenter()
}
```
### Conditional popout
```qml
Connections {
target: BatteryService
function onPercentageChanged() {
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
popoutService?.openBattery()
}
}
}
```
### Context menu with multiple actions
```qml
MouseArea {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) contextMenu.popup()
else popoutService?.toggleControlCenter()
}
}
Menu {
id: contextMenu
MenuItem { text: "Settings"; onClicked: popoutService?.openSettings() }
MenuItem { text: "Notifications"; onClicked: popoutService?.toggleNotificationCenter() }
MenuItem { text: "Power"; onClicked: popoutService?.openPowerMenu() }
}
```
### Position-aware toggle (from bar pill)
Some toggle functions accept position parameters for proper popout placement:
```qml
pillClickAction: (x, y, width, section, screen) => {
popoutService?.toggleControlCenter(x, y, width, section, screen)
}
```
## Best Practices
1. **Always use optional chaining** (`?.`) - the service may not be injected yet
2. **Check feature availability** before opening feature-specific popouts:
```qml
if (BatteryService.batteryAvailable) {
popoutService?.openBattery()
}
```
3. **Lazy loading** - first access may activate lazy loaders; this is normal
4. **Popouts are shared** - avoid opening conflicting popouts simultaneously
5. **User intent** - only trigger popouts from user actions or critical system events
6. **Multi-monitor** - positioned popouts are screen-aware when using position parameters
## Injection Locations
The service is injected at these points:
- `DMSShell.qml` - daemon plugins
- `WidgetHost.qml` - widget plugins in left/right bar sections
- `CenterSection.qml` - center bar widgets
- `PluginsTab.qml` - settings components
@@ -0,0 +1,273 @@
# Settings Components Reference
All plugin settings use the `PluginSettings` wrapper. Setting components auto-save on change and auto-load on creation.
## PluginSettings Wrapper
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "yourPlugin" // Required: must match plugin.json id
// Setting components go here
}
```
**Important:** The plugin must declare `"permissions": ["settings_write"]` in plugin.json for the settings UI to render. Without it, users see an error.
**PluginSettings provides to children:**
- `saveValue(key, value)` - save a setting value
- `loadValue(key, defaultValue)` - load a setting value
- `saveState(key, value)` - save plugin state (separate file)
- `loadState(key, defaultValue)` - load plugin state
- `clearState()` - clear all plugin state
- Variant management functions (for variant plugins)
## StringSetting
Text input field.
```qml
StringSetting {
settingKey: "apiKey" // Required: storage key
label: "API Key" // Required: display label
description: "Your API key" // Optional: help text
placeholder: "sk-..." // Optional: input placeholder
defaultValue: "" // Optional: default (default: "")
}
```
**Layout:** Vertical stack - label, description, input field.
## ToggleSetting
Boolean toggle switch.
```qml
ToggleSetting {
settingKey: "notifications" // Required: storage key
label: "Enable Notifications" // Required: display label
description: "Show alerts" // Optional: help text
defaultValue: true // Optional: default (default: false)
}
```
**Layout:** Horizontal - label/description on left, toggle on right.
## SelectionSetting
Dropdown menu.
```qml
SelectionSetting {
settingKey: "theme" // Required: storage key
label: "Theme" // Required: display label
description: "Color scheme" // Optional: help text
options: [ // Required: array of options
{ label: "Dark", value: "dark" },
{ label: "Light", value: "light" },
{ label: "Auto", value: "auto" }
]
defaultValue: "dark" // Optional: default value
}
```
Options can be `{ label, value }` objects or simple strings. Stores the `value` field, displays the `label` field.
**Layout:** Horizontal - label/description on left, dropdown on right.
**Reacting to changes:**
```qml
SelectionSetting {
settingKey: "updateInterval"
label: "Update Interval"
options: [
{ label: "1 minute", value: "60" },
{ label: "5 minutes", value: "300" }
]
defaultValue: "300"
onValueChanged: (newValue) => {
console.log("Interval changed to:", newValue)
}
}
```
## SliderSetting
Numeric slider with min/max.
```qml
SliderSetting {
settingKey: "opacity" // Required: storage key
label: "Opacity" // Required: display label
description: "Background" // Optional: help text
defaultValue: 85 // Optional: default value
minimum: 0 // Required: min value
maximum: 100 // Required: max value
unit: "%" // Optional: unit label shown after value
leftIcon: "dark_mode" // Optional: Material icon on left
rightIcon: "light_mode" // Optional: Material icon on right
}
```
## ColorSetting
Color picker.
```qml
ColorSetting {
settingKey: "accentColor" // Required: storage key
label: "Accent Color" // Required: display label
description: "Custom accent" // Optional: help text
defaultValue: "#ff5722" // Optional: default hex color
}
```
Displays a color swatch that opens a color picker dialog.
## ListSetting
Manage a list of items with manual add/remove. Use when you need custom UI for adding items.
```qml
ListSetting {
id: itemList
settingKey: "items" // Required: storage key
label: "Saved Items" // Required: display label
description: "Your items" // Optional: help text
defaultValue: [] // Optional: default array
delegate: Component { // Optional: custom item display
StyledRect {
width: parent.width
height: 40
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.surfaceText
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: 60
height: 28
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
radius: Theme.cornerRadius
StyledText {
anchors.centerIn: parent
text: "Remove"
color: Theme.errorText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: removeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: itemList.removeItem(index)
}
}
}
}
}
```
**Methods:**
- `addItem(item)` - add an item to the list
- `removeItem(index)` - remove item at index
## ListSettingWithInput
Complete list management with built-in form. Best for collecting structured data.
```qml
ListSettingWithInput {
settingKey: "locations" // Required: storage key
label: "Locations" // Required: display label
description: "Track zones" // Optional: help text
defaultValue: [] // Optional: default array
fields: [ // Required: field definitions
{
id: "name", // Required: key in saved object
label: "Name", // Required: column header
placeholder: "Home", // Optional: input placeholder
width: 150, // Optional: column width (default: 200)
required: true // Optional: must have value to add
},
{
id: "timezone",
label: "Timezone",
placeholder: "America/New_York",
width: 200,
required: true
}
]
}
```
Automatically generates: column headers, input fields, add button with validation, list display, remove buttons.
## Mixing Custom UI with Settings
You can interleave regular QML elements with setting components:
```qml
PluginSettings {
pluginId: "myPlugin"
StyledText {
width: parent.width
text: "General Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
StringSetting {
settingKey: "name"
label: "Display Name"
}
StyledText {
width: parent.width
text: "Advanced Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
topPadding: Theme.spacingL
}
ToggleSetting {
settingKey: "debug"
label: "Debug Mode"
defaultValue: false
}
}
```
## Default Values
Define sensible defaults in every setting component. The default is used when no saved value exists:
```qml
StringSetting { settingKey: "text"; defaultValue: "Hello" }
ToggleSetting { settingKey: "enabled"; defaultValue: true }
SelectionSetting { settingKey: "mode"; defaultValue: "auto" }
SliderSetting { settingKey: "opacity"; defaultValue: 85 }
ColorSetting { settingKey: "color"; defaultValue: "#ff5722" }
ListSetting { settingKey: "items"; defaultValue: [] }
ListSettingWithInput { settingKey: "data"; defaultValue: [] }
```
@@ -0,0 +1,216 @@
# Theme Property Reference
All theme properties are accessed via the `Theme` singleton from `qs.Common`. Always use these instead of hardcoded values.
## Font Sizes
```qml
Theme.fontSizeSmall // 12px (scaled by SettingsData.fontScale)
Theme.fontSizeMedium // 14px (scaled)
Theme.fontSizeLarge // 16px (scaled)
Theme.fontSizeXLarge // 20px (scaled)
```
## Icon Sizes
```qml
Theme.iconSizeSmall // 16px
Theme.iconSize // 24px (default)
Theme.iconSizeLarge // 32px
```
## Spacing
```qml
Theme.spacingXS // Extra small
Theme.spacingS // Small
Theme.spacingM // Medium
Theme.spacingL // Large
Theme.spacingXL // Extra large
```
## Border Radius
```qml
Theme.cornerRadius // Standard
Theme.cornerRadiusSmall // Smaller
Theme.cornerRadiusLarge // Larger
```
## Surface Colors
```qml
Theme.surface
Theme.surfaceContainerLow
Theme.surfaceContainer
Theme.surfaceContainerHigh
Theme.surfaceContainerHighest
```
## Text Colors
```qml
Theme.onSurface // Primary text on surface
Theme.onSurfaceVariant // Secondary text on surface
Theme.surfaceText // Alias for primary surface text
Theme.surfaceVariantText // Alias for secondary surface text
Theme.outline // Border/divider color
```
## Semantic Colors
```qml
Theme.primary
Theme.onPrimary
Theme.secondary
Theme.onSecondary
Theme.error
Theme.errorHover
Theme.errorText
Theme.warning
Theme.success
```
## Special Functions
```qml
Theme.popupBackground() // Popup background with proper opacity
```
## Common Widget Patterns
### Icon with Text
```qml
import qs.Widgets
Row {
spacing: Theme.spacingS
DankIcon {
name: "icon_name"
color: Theme.onSurface
font.pixelSize: Theme.iconSize
}
StyledText {
text: "Label"
color: Theme.onSurface
font.pixelSize: Theme.fontSizeMedium
}
}
```
### Container with Border
```qml
Rectangle {
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
}
```
### Hover Effect
```qml
Rectangle {
id: container
color: Theme.surfaceContainerHigh
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: container.color = Qt.lighter(Theme.surfaceContainerHigh, 1.1)
onExited: container.color = Theme.surfaceContainerHigh
}
}
```
### Clickable Pill
```qml
StyledRect {
width: content.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: mouseArea.containsMouse
? Qt.lighter(Theme.surfaceContainerHigh, 1.1)
: Theme.surfaceContainerHigh
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
Row {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Label"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
anchors.verticalCenter: parent.verticalCenter
}
}
}
```
## Common Mistakes
**Wrong property names** (these do NOT exist):
```qml
Theme.fontSizeS // Use Theme.fontSizeSmall
Theme.iconSizeS // Use Theme.iconSizeSmall
Theme.spacingSmall // Use Theme.spacingS
Theme.borderRadius // Use Theme.cornerRadius
```
**Hardcoded values** (do NOT do this):
```qml
color: "#1e1e1e" // Use Theme.surfaceContainerHigh
color: "white" // Use Theme.surfaceText
font.pixelSize: 14 // Use Theme.fontSizeMedium
```
## Available Widgets from qs.Widgets
| Widget | Description |
|--------|-------------|
| `StyledText` | Themed text with proper color defaults |
| `StyledRect` | Themed rectangle |
| `DankIcon` | Material Symbols icon renderer |
| `DankNFIcon` | Nerd Font icon renderer |
| `DankButton` | Themed button |
| `DankToggle` | Toggle switch |
| `DankTextField` | Text input field |
| `DankSlider` | Slider control |
| `DankDropdown` | Dropdown menu |
| `DankGridView` | Grid layout view |
| `DankListView` | List layout view |
| `DankFlickable` | Scrollable container |
| `DankTabBar` | Tab bar navigation |
| `DankCollapsibleSection` | Collapsible content section |
| `DankTooltip` | Hover tooltip |
| `DankNumberStepper` | Number +/- control |
| `DankFilterChips` | Filter chip row |
| `CachingImage` | Image with disk cache |
| `NumericText` | Fixed-width numeric display |
## Checking All Properties
```bash
grep "property" Common/Theme.qml
```
@@ -0,0 +1,369 @@
# Widget Plugin Guide
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
## Base Component
Widgets use `PluginComponent` from `qs.Modules.Plugins`.
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
property var popoutService: null
horizontalBarPill: Component { /* ... */ }
verticalBarPill: Component { /* ... */ }
popoutContent: Component { /* ... */ }
popoutWidth: 400
popoutHeight: 300
}
```
## Injected Properties
These are automatically set by the plugin host:
| Property | Type | Description |
|----------|------|-------------|
| `axis` | object | Bar axis info (horizontal/vertical) |
| `section` | string | Bar section: `"left"`, `"center"`, or `"right"` |
| `parentScreen` | object | Screen reference for multi-monitor |
| `widgetThickness` | real | Widget size perpendicular to bar edge |
| `barThickness` | real | Bar thickness parallel to edge |
| `pluginId` | string | This plugin's ID |
| `pluginService` | object | PluginService reference |
| `pluginData` | object | Reactive plugin settings data |
## Bar Pills
Define `horizontalBarPill` (for top/bottom bars) and `verticalBarPill` (for left/right bars).
### Horizontal Bar Pill
```qml
horizontalBarPill: Component {
StyledRect {
width: content.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Row {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Label"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
```
### Vertical Bar Pill
```qml
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: content.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSizeSmall
}
}
}
}
```
**Important:** Always define both pills. If a pill is missing, the widget disappears when the bar is on that orientation's edge.
## Popout Content
Open a popout window when the bar pill is clicked:
```qml
PluginComponent {
popoutWidth: 400
popoutHeight: 300
popoutContent: Component {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: "Content here"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
}
}
```
**PopoutComponent properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
| `closePopout` | function | (injected) | Call to close the popout programmatically. |
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
**Content sizing:** Content children render below the header/details. Calculate available height: `popoutHeight - headerHeight - detailsHeight - spacing`
## Custom Click Actions
Override the default popout behavior:
```qml
PluginComponent {
// Simple no-args handler
pillClickAction: () => {
popoutService?.toggleControlCenter()
}
// With position params (x, y, width, section, screen)
pillClickAction: (x, y, width, section, screen) => {
popoutService?.toggleControlCenter(x, y, width, section, screen)
}
pillRightClickAction: () => {
popoutService?.openSettings()
}
}
```
## Control Center Integration
Add CC properties to show your widget in the Control Center grid:
```qml
PluginComponent {
ccWidgetIcon: "toggle_on"
ccWidgetPrimaryText: "Feature Name"
ccWidgetSecondaryText: isActive ? "Active" : "Off"
ccWidgetIsActive: isActive
onCcWidgetToggled: {
isActive = !isActive
pluginService?.savePluginData(pluginId, "active", isActive)
}
}
```
**CC properties:**
| Property | Type | Description |
|----------|------|-------------|
| `ccWidgetIcon` | string | Material icon name |
| `ccWidgetPrimaryText` | string | Main label |
| `ccWidgetSecondaryText` | string | Subtitle / status text |
| `ccWidgetIsActive` | bool | Active state (changes styling) |
**CC signals:**
| Signal | When fired |
|--------|-----------|
| `ccWidgetToggled()` | Icon area clicked |
| `ccWidgetExpanded()` | Expand area clicked (CompoundPill only) |
**CC sizing rules:**
- 25% width - SmallToggleButton (icon only)
- 50% width - ToggleButton (no detail) or CompoundPill (with ccDetailContent)
- Users can resize in CC edit mode
### Detail Content (CompoundPill)
Add an expandable panel below the CC widget:
```qml
ccDetailContent: Component {
Rectangle {
implicitHeight: 200
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
// Detail UI here
}
}
}
```
## Visibility Control
Conditionally show/hide the bar pill:
```qml
PluginComponent {
visibilityCommand: "pgrep -x myapp"
visibilityInterval: 5000 // check every 5 seconds
}
```
## Popout Namespace
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
```qml
PluginComponent {
layerNamespacePlugin: true
}
```
## Reading Plugin Data
Access saved settings reactively via the injected `pluginData`:
```qml
PluginComponent {
property string displayText: pluginData?.text || "Default"
Connections {
target: pluginService
function onPluginDataChanged(changedId) {
if (changedId === pluginId)
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
}
}
}
```
## Complete Example
Based on the ExampleEmojiPlugin pattern:
```qml
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property var popoutService: null
property var emojis: ["star", "heart", "smile"]
property int currentIndex: 0
Timer {
interval: 2000
running: true
repeat: true
onTriggered: currentIndex = (currentIndex + 1) % emojis.length
}
popoutWidth: 350
popoutHeight: 400
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.emojis[root.currentIndex]
font.pixelSize: Theme.fontSizeLarge
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: label.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: label
anchors.centerIn: parent
text: root.emojis[root.currentIndex]
font.pixelSize: Theme.fontSizeMedium
}
}
}
popoutContent: Component {
PopoutComponent {
headerText: "Emoji Picker"
showCloseButton: true
DankGridView {
width: parent.width
height: 300
cellWidth: 50
cellHeight: 50
model: root.emojis
delegate: Rectangle {
width: 48
height: 48
radius: Theme.cornerRadius
color: mouseArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
Text {
anchors.centerIn: parent
text: modelData
font.pixelSize: 24
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Quickshell.execDetached(["dms", "cl", "copy", modelData])
ToastService?.showInfo("Copied " + modelData)
}
}
}
}
}
}
}
```
+10
View File
@@ -367,6 +367,16 @@ jobs:
EOF
chmod 600 ~/.config/osc/oscrc
# Cache OBS bundled Go toolchains
- name: Cache OBS bundled Go toolchains (dms-git)
if: contains(steps.packages.outputs.packages, 'dms-git')
uses: actions/cache@v4
with:
path: /home/runner/.cache/dms-obs-go-toolchain
key: dms-obs-go-toolchain-${{ runner.os }}-${{ hashFiles('core/go.mod') }}
restore-keys: |
dms-obs-go-toolchain-${{ runner.os }}-
- name: Upload to OBS
id: upload
env:
+65 -205
View File
@@ -22,12 +22,13 @@ on:
jobs:
check-updates:
name: Check for updates
name: Check package/series updates
runs-on: ubuntu-latest
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
packages: ${{ steps.check.outputs.packages }}
targets: ${{ steps.check.outputs.targets }}
targets_json: ${{ steps.check.outputs.targets_json }}
steps:
- name: Checkout
@@ -35,125 +36,57 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq curl git
- name: Check for updates
id: check
run: |
# Helper function to check dms-git commit
check_dms_git() {
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/dms-git?ws.op=getPublishedSources&source_name=dms-git&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
local PPA_COMMIT=$(echo "$PPA_VERSION" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
if [[ -n "$PPA_COMMIT" && "$CURRENT_COMMIT" == "$PPA_COMMIT" ]]; then
echo "📋 dms-git: Commit $CURRENT_COMMIT already exists, skipping"
return 1 # No update needed
else
echo "📋 dms-git: New commit $CURRENT_COMMIT (PPA has ${PPA_COMMIT:-none})"
return 0 # Update needed
fi
}
# Helper function to check stable package tag
check_stable_package() {
local PKG="$1"
local PPA_NAME="$2"
# Use git ls-remote to find the latest tag, sorted by version (descending)
local LATEST_TAG=$(git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git | head -n1 | awk -F/ '{print $NF}' | sed 's/^v//')
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$PKG&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
local PPA_BASE_VERSION=$(echo "$PPA_VERSION" | sed 's/ppa[0-9]*$//')
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$PPA_BASE_VERSION" ]]; then
echo "📋 $PKG: Tag $LATEST_TAG already exists, skipping"
return 1 # No update needed
else
echo "📋 $PKG: New tag ${LATEST_TAG:-unknown} (PPA has ${PPA_BASE_VERSION:-none})"
return 0 # Update needed
fi
}
# Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}"
chmod +x distro/scripts/ppa-sync-plan.sh
if [[ "${{ github.event_name }}" == "schedule" ]]; then
# Scheduled run - check dms-git only
echo "packages=dms-git" >> $GITHUB_OUTPUT
if check_dms_git; then
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
# Manual workflow trigger
PKG="${{ github.event.inputs.package }}"
if [[ -n "$REBUILD" ]]; then
# Rebuild requested - always proceed
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "🔄 Manual rebuild requested: $PKG (ppa$REBUILD)"
elif [[ "$PKG" == "all" ]]; then
# Check each package and build list of those needing updates
PACKAGES_TO_UPDATE=()
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
check_stable_package "dms" "dms" && PACKAGES_TO_UPDATE+=("dms")
check_stable_package "dms-greeter" "danklinux" && PACKAGES_TO_UPDATE+=("dms-greeter")
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}"
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
echo "✓ All packages up to date"
fi
elif [[ "$PKG" == "dms-git" ]]; then
if check_dms_git; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms" ]]; then
if check_stable_package "dms" "dms"; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
elif [[ "$PKG" == "dms-greeter" ]]; then
if check_stable_package "dms-greeter" "danklinux"; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
else
# Unknown package - proceed anyway
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "Manual trigger: $PKG"
fi
PACKAGE="dms-git"
else
# Fallback
echo "packages=dms" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
PACKAGE="${{ github.event.inputs.package }}"
fi
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
ARGS=(--package "$PACKAGE" --json)
if [[ -n "$REBUILD_RELEASE" ]]; then
ARGS+=(--rebuild "$REBUILD_RELEASE")
fi
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
cat ppa-audit.log
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
if [[ "$TARGETS_JSON" != "[]" ]]; then
echo "has_updates=true" >> "$GITHUB_OUTPUT"
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
echo "targets_json=$TARGETS_JSON" >> "$GITHUB_OUTPUT"
echo "Package/series targets: $TARGETS"
else
echo "has_updates=false" >> "$GITHUB_OUTPUT"
echo "targets=" >> "$GITHUB_OUTPUT"
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
echo "No package/series uploads needed"
fi
upload-ppa:
name: Upload to PPA
name: Upload ${{ matrix.target }}
needs: check-updates
runs-on: ubuntu-latest
if: needs.check-updates.outputs.has_updates == 'true'
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
concurrency:
group: ppa-dms-${{ matrix.target }}
cancel-in-progress: false
steps:
- name: Checkout
@@ -177,7 +110,8 @@ jobs:
lftp \
build-essential \
fakeroot \
dpkg-dev
dpkg-dev \
openssh-client
- name: Configure GPG
env:
@@ -185,106 +119,32 @@ jobs:
run: |
echo "$GPG_KEY" | gpg --import
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
- name: Determine packages to upload
id: packages
- name: Upload target
env:
TARGET: ${{ matrix.target }}
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
run: |
# Use packages determined by check-updates job
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
if [[ "${{ github.event_name }}" == "schedule" ]]; then
echo "Triggered by schedule: uploading git package"
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
fi
case "$PACKAGE" in
dms) PPA_NAME="dms" ;;
dms-git) PPA_NAME="dms-git" ;;
dms-greeter) PPA_NAME="danklinux" ;;
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
esac
- name: Upload to PPA
run: |
PACKAGES="${{ steps.packages.outputs.packages }}"
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
if [[ -z "$PACKAGES" ]]; then
echo "✓ No packages need uploading. All up to date!"
exit 0
fi
# Export REBUILD_RELEASE so ppa-build.sh can use it
if [[ -n "$REBUILD_RELEASE" ]]; then
export REBUILD_RELEASE
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
fi
# PACKAGES can be space-separated list (e.g., "dms-git dms dms-greeter" from "all" check)
# Loop through each package and upload
for PKG in $PACKAGES; do
# Map package to PPA name
case "$PKG" in
dms)
PPA_NAME="dms"
;;
dms-git)
PPA_NAME="dms-git"
;;
dms-greeter)
PPA_NAME="danklinux"
;;
*)
echo "⚠️ Unknown package: $PKG, skipping"
continue
;;
esac
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Uploading $PKG to PPA $PPA_NAME..."
if [[ -n "$REBUILD_RELEASE" ]]; then
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ppa-upload.sh uploads to questing + resolute when series is omitted
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" "" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
echo "::error::Upload failed for $PKG"
exit 1
fi
done
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
- name: Summary
if: always()
run: |
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
PACKAGES="${{ steps.packages.outputs.packages }}"
if [[ -z "$PACKAGES" ]]; then
echo "**Status:** ✅ All packages up to date (no uploads needed)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "All packages are current. Run will complete successfully." >> $GITHUB_STEP_SUMMARY
else
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
for PKG in $PACKAGES; do
case "$PKG" in
dms)
echo "- ✅ **dms** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages)" >> $GITHUB_STEP_SUMMARY
;;
dms-git)
echo "- ✅ **dms-git** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages)" >> $GITHUB_STEP_SUMMARY
;;
dms-greeter)
echo "- ✅ **dms-greeter** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages)" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
fi
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
+3
View File
@@ -107,6 +107,9 @@ vim/
bin/
# Core dumps
core.*
# direnv
.envrc
.direnv/
+8
View File
@@ -20,3 +20,11 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
pass_filenames: false
- repo: local
hooks:
- id: no-console-in-qml
name: no console.* in QML (use Log service)
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
language: system
files: ^quickshell/.*\.qml$
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
+4 -17
View File
@@ -1,26 +1,13 @@
repos:
- repo: local
- repo: https://github.com/golangci/golangci-lint
rev: v2.10.1
hooks:
- id: golangci-lint-fmt
name: golangci-lint-fmt
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt
language: system
require_serial: true
types: [go]
pass_filenames: false
- id: golangci-lint-full
name: golangci-lint-full
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix
language: system
require_serial: true
types: [go]
pass_filenames: false
- id: golangci-lint-config-verify
name: golangci-lint-config-verify
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 config verify
language: system
files: \.golangci\.(?:yml|yaml|toml|json)
pass_filenames: false
- repo: local
hooks:
- id: go-test
name: go test
entry: go test ./...
+112 -24
View File
@@ -12,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
@@ -98,27 +99,14 @@ func runSystemUpdateCheck() {
log.Fatal("No supported package manager found")
}
type backendResult struct {
ID string `json:"id"`
Display string `json:"displayName"`
Packages []sysupdate.Package `json:"packages"`
}
var results []backendResult
var allPkgs []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs})
allPkgs = append(allPkgs, pkgs...)
}
stopSpin := startSpinner("Checking for updates… ")
allPkgs, firstErr := collectUpdates(ctx, backends)
stopSpin()
allPkgs = filterUpdateTargets(allPkgs)
if sysUpdateJSON {
out, _ := json.MarshalIndent(map[string]any{
"backends": results,
"backends": backendResults(backends, allPkgs),
"packages": allPkgs,
"error": errOrEmpty(firstErr),
"count": len(allPkgs),
@@ -137,10 +125,30 @@ func runSystemUpdateCheck() {
}
fmt.Println()
for _, p := range allPkgs {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
printPackage(p)
}
}
type backendResult struct {
ID string `json:"id"`
Display string `json:"displayName"`
Packages []sysupdate.Package `json:"packages"`
}
func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult {
results := make([]backendResult, 0, len(backends))
for _, b := range backends {
var backendPkgs []sysupdate.Package
for _, p := range pkgs {
if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) {
backendPkgs = append(backendPkgs, p)
}
}
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs})
}
return results
}
func runSystemUpdateApply() {
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer checkCancel()
@@ -150,7 +158,10 @@ func runSystemUpdateApply() {
log.Fatal("No supported package manager found")
}
stopSpin := startSpinner("Checking for updates…")
pkgs, firstErr := collectUpdates(checkCtx, backends)
stopSpin()
pkgs = filterUpdateTargets(pkgs)
if firstErr != nil {
fmt.Printf("Warning: %v\n\n", firstErr)
}
@@ -163,12 +174,12 @@ func runSystemUpdateApply() {
}
fmt.Println()
for _, p := range pkgs {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
printPackage(p)
}
fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [y/N]: ") {
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
fmt.Println("Aborted.")
return
}
@@ -178,18 +189,30 @@ func runSystemUpdateApply() {
defer cancel()
opts := sysupdate.UpgradeOptions{
Targets: pkgs,
IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR,
DryRun: sysUpdateDry,
UseSudo: true,
}
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
onLine := func(line string) { fmt.Println(line) }
ran := false
for _, b := range backends {
if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) {
continue
}
ran = true
fmt.Printf("\n== %s ==\n", b.DisplayName())
if err := b.Upgrade(ctx, opts, onLine); err != nil {
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
}
}
if !ran {
fmt.Println("Nothing to upgrade.")
return
}
if sysUpdateDry {
fmt.Println("\nDry run complete (no changes applied).")
return
@@ -210,6 +233,20 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
return all, firstErr
}
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR {
return pkgs
}
out := pkgs[:0]
for _, p := range pkgs {
if p.Repo == sysupdate.RepoAUR {
continue
}
out = append(out, p)
}
return out
}
func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(models.Request{
ID: 1,
@@ -236,10 +273,10 @@ func promptYesNo(prompt string) bool {
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
case "n", "no":
return false
default:
return true
}
}
@@ -262,6 +299,57 @@ func stdinIsTTY() bool {
return (fi.Mode() & os.ModeCharDevice) != 0
}
func stdoutIsTTY() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
// startSpinner prints an animated spinner to stdout for progress indication
func startSpinner(msg string) func() {
if !stdoutIsTTY() {
return func() {}
}
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
done := make(chan struct{})
go func() {
for i := 0; ; i++ {
select {
case <-done:
fmt.Print("\r\033[K")
return
case <-time.After(80 * time.Millisecond):
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
}
}
}()
return func() { close(done) }
}
var (
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
)
func printPackage(p sysupdate.Package) {
if !stdoutIsTTY() {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
return
}
fmt.Printf(" %s %s %s %s %s\n",
styleRepo.Render("["+string(p.Repo)+"]"),
styleName.Render(p.Name),
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
styleArrow.Render("->"),
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
)
}
func errOrEmpty(err error) string {
if err == nil {
return ""
-3
View File
@@ -202,9 +202,6 @@ func runShellInteractive(session bool) {
}
}
// ! TODO - remove when QS 0.3 is up and we can use the pragma
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
}
+1 -1
View File
@@ -15,7 +15,7 @@ func isReadOnlyCommand(args []string) bool {
continue
}
switch arg {
case "completion", "help", "__complete":
case "completion", "help", "__complete", "system":
return true
}
return false
+26 -10
View File
@@ -1,19 +1,17 @@
module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.0
toolchain go1.26.1
go 1.26.1
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.24.0
github.com/alecthomas/chroma/v2 v2.24.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/fsnotify/fsnotify v1.9.0
github.com/fsnotify/fsnotify v1.10.1
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
@@ -23,34 +21,52 @@ require (
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
tailscale.com v1.96.5
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/mdlayher/netlink v1.11.1 // indirect
github.com/mdlayher/socket v0.6.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
)
require (
@@ -61,7 +77,7 @@ require (
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-alpha.2
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -72,7 +88,7 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
+68 -16
View File
@@ -1,14 +1,18 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998EGi6Llk=
github.com/alecthomas/chroma/v2 v2.24.0/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
@@ -38,18 +42,27 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
@@ -60,18 +73,26 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 h1:QRpwB1ans3fB3Cmeuog1ATzvXg/xhqubqiQi97xNO6E=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -79,22 +100,28 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -107,6 +134,12 @@ github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZ
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -115,16 +148,17 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
@@ -144,6 +178,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
@@ -160,12 +200,18 @@ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.m
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -177,6 +223,10 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -185,3 +235,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
@@ -158,5 +158,8 @@ bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === Display Profiles ===
bind = SUPER, P, exec, dms ipc outputs cycleProfile
# === System Controls ===
bind = SUPER SHIFT, P, dpms, toggle
@@ -107,10 +107,6 @@ windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$
# DMS windows floating by default
# ! Hyprland doesn't size these windows correctly so disabling by default here
# windowrule = float on, match:class ^(org.quickshell)$
layerrule = no_anim on, match:namespace ^(quickshell)$
layerrule = no_anim on, match:namespace ^dms:.*
@@ -215,6 +215,11 @@ binds {
Print { screenshot; }
Ctrl+Print { screenshot-screen; }
Alt+Print { screenshot-window; }
// === Display Profiles ===
Mod+P hotkey-overlay-title="Cycle Display Profile" {
spawn "dms" "ipc" "outputs" "cycleProfile";
}
// === System Controls ===
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
Mod+Shift+P { power-off-monitors; }
-6
View File
@@ -250,12 +250,6 @@ window-rule {
match app-id="zoom"
open-floating true
}
// Open dms windows as floating by default
window-rule {
match app-id=r#"org.quickshell$"#
match app-id=r#"com.danklinux.dms$"#
open-floating true
}
debug {
honor-xdg-activation-with-invalid-serial
}
+21 -2
View File
@@ -208,8 +208,7 @@ func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) Pac
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
}
// ! TODO - for now we're only forcing quickshell-git on ARCH, as other distros use DL repos which pin a newer quickshell
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
@@ -332,6 +331,12 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
}
if slices.Contains(systemPkgs, "quickshell") && a.packageInstalled("quickshell-git") {
if err := a.removeQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to remove quickshell-git: %w", err)
}
}
// Phase 3: System Packages
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
@@ -449,6 +454,20 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
return systemPkgs, aurPkgs, manualPkgs, variantMap
}
func (a *ArchDistribution) removeQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.33,
Step: "Removing quickshell-git...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell-git",
LogOutput: "Removing quickshell-git so stable quickshell can be installed",
}
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell-git")
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.33, 0.35)
}
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if a.packageInstalled("quickshell-git") {
return nil
+1 -1
View File
@@ -232,7 +232,7 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency {
}
versionStr := string(output)
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
versionRegex := regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
matches := versionRegex.FindStringSubmatch(versionStr)
if len(matches) < 2 {
+1
View File
@@ -60,6 +60,7 @@ var templateRegistry = []TemplateDef{
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
+2 -34
View File
@@ -9,10 +9,8 @@ import (
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
@@ -105,7 +103,7 @@ func GetOutputDir() string {
return dir
}
if xdgPics := getXDGPicturesDir(); xdgPics != "" {
if xdgPics := utils.XDGPicturesDir(); xdgPics != "" {
screenshotDir := filepath.Join(xdgPics, "Screenshots")
if err := os.MkdirAll(screenshotDir, 0o755); err == nil {
return screenshotDir
@@ -113,42 +111,12 @@ func GetOutputDir() string {
return xdgPics
}
if home := os.Getenv("HOME"); home != "" {
if home, err := os.UserHomeDir(); err == nil {
return home
}
return "."
}
func getXDGPicturesDir() string {
userConfigDir, err := os.UserConfigDir()
if err != nil {
log.Error("failed to get user config dir", "err", err)
return ""
}
userDirsFile := filepath.Join(userConfigDir, "user-dirs.dirs")
data, err := os.ReadFile(userDirsFile)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
if len(line) == 0 || line[0] == '#' {
continue
}
const prefix = "XDG_PICTURES_DIR="
if !strings.HasPrefix(line, prefix) {
continue
}
path := strings.Trim(line[len(prefix):], "\"")
expanded, err := utils.ExpandPath(path)
if err != nil {
return ""
}
return expanded
}
return ""
}
func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error {
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
}
+6 -5
View File
@@ -64,12 +64,13 @@ func SendNotification(result NotifyResult) {
summary := "Screenshot captured"
body := ""
if result.Clipboard && result.FilePath != "" {
body = fmt.Sprintf("Copied to clipboard\n%s", filepath.Base(result.FilePath))
} else if result.Clipboard {
body = "Copied to clipboard"
} else if result.FilePath != "" {
switch {
case result.FilePath != "" && result.Clipboard:
body = fmt.Sprintf("%s\nCopied to clipboard", filepath.Base(result.FilePath))
case result.FilePath != "":
body = filepath.Base(result.FilePath)
case result.Clipboard:
body = "Copied to clipboard"
}
obj := conn.Object(notifyDest, notifyPath)
@@ -23,6 +23,13 @@ const (
agentIdentifier = "com.danklinux.NMAgent"
)
const (
nmSecretAgentFlagAllowInteraction = 0x1
nmSecretAgentFlagRequestNew = 0x2
nmSecretAgentFlagUserRequested = 0x4
nmSecretAgentFlagOnlySystem = 0x80000000
)
type SecretAgent struct {
conn *dbus.Conn
objPath dbus.ObjectPath
@@ -129,6 +136,21 @@ func (a *SecretAgent) GetSecrets(
log.Infof("[SecretAgent] connType=%s, name=%s, vpnSvc=%s, fields=%v, flags=%d, vpnPasswordFlags=%d", connType, displayName, vpnSvc, fields, flags, vpnPasswordFlags)
if flags&nmSecretAgentFlagOnlySystem != 0 {
log.Infof("[SecretAgent] ONLY_SYSTEM flag set, deferring to system secret storage")
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
}
var connUuid string
if c, ok := conn["connection"]; ok {
if v, ok := c["uuid"]; ok {
if s, ok2 := v.Value().(string); ok2 {
connUuid = s
}
}
}
// Phase 1: Determine if this connection is ours and what fields we need.
if a.backend != nil {
a.backend.stateMutex.RLock()
isConnecting := a.backend.state.IsConnecting
@@ -145,15 +167,6 @@ func (a *SecretAgent) GetSecrets(
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
}
case "vpn", "wireguard":
var connUuid string
if c, ok := conn["connection"]; ok {
if v, ok := c["uuid"]; ok {
if s, ok2 := v.Value().(string); ok2 {
connUuid = s
}
}
}
// If we're connecting to a VPN, only respond if it's the one we're connecting to
// This prevents interfering with nmcli/other tools when our app isn't connecting
if isConnectingVPN && connUuid != connectingVPNUUID {
@@ -163,6 +176,7 @@ func (a *SecretAgent) GetSecrets(
}
}
// Phase 2: Resolve fields from hints or password-flags.
if len(fields) == 0 {
if settingName == "vpn" {
if a.backend != nil {
@@ -230,41 +244,19 @@ func (a *SecretAgent) GetSecrets(
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
}
log.Infof("[SecretAgent] Agent-owned secrets, inferred fields: %v", fields)
} else if passwordFlags&NM_SETTING_SECRET_FLAG_NOT_SAVED != 0 {
log.Infof("[SecretAgent] Secrets not saved, will need to prompt (flags=%d)", passwordFlags)
// Fall through — fields remain empty, prompt will be required.
} else {
log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags)
out := nmSettingMap{}
out[settingName] = nmVariantMap{}
return out, nil
}
}
}
reason := reasonFromFlags(flags)
if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) {
reason = "wrong-password"
}
if settingName == "vpn" && isPKCS11Auth(conn, vpnSvc) {
reason = "pkcs11"
}
var connId, connUuid string
if c, ok := conn["connection"]; ok {
if v, ok := c["id"]; ok {
if s, ok2 := v.Value().(string); ok2 {
connId = s
}
}
if v, ok := c["uuid"]; ok {
if s, ok2 := v.Value().(string); ok2 {
connUuid = s
log.Infof("[SecretAgent] Secrets stored in NM config (flags=%d), deferring to system", passwordFlags)
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
}
}
}
// Phase 3: Cached VPN credentials — user-provided, take priority.
if settingName == "vpn" && a.backend != nil {
// Check for cached PKCS11 PIN first
isPKCS11Request := len(fields) == 1 && fields[0] == "key_pass"
if isPKCS11Request {
if isPKCS11Request := len(fields) == 1 && fields[0] == "key_pass"; isPKCS11Request {
a.backend.cachedPKCS11Mu.Lock()
cached := a.backend.cachedPKCS11PIN
if cached != nil && cached.ConnectionUUID == connUuid {
@@ -283,7 +275,6 @@ func (a *SecretAgent) GetSecrets(
a.backend.cachedPKCS11Mu.Unlock()
}
// Check for cached VPN password
a.backend.cachedVPNCredsMu.Lock()
cached := a.backend.cachedVPNCreds
if cached != nil && cached.ConnectionUUID == connUuid {
@@ -314,6 +305,7 @@ func (a *SecretAgent) GetSecrets(
a.backend.cachedGPSamlMu.Lock()
cachedGPSaml := a.backend.cachedGPSamlCookie
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
a.backend.cachedGPSamlCookie = nil
a.backend.cachedGPSamlMu.Unlock()
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
@@ -369,6 +361,37 @@ func (a *SecretAgent) GetSecrets(
}
}
// Phase 4: Non-interactive secret retrieval (keyring).
// Always try the keyring even when REQUEST_NEW is set — the vault may have
// been unlocked by a prior call's Prompt flow, making the lookup non-interactive.
if secretOut := a.trySecretService(connUuid, settingName, fields); secretOut != nil {
return secretOut, nil
}
// Phase 5: If interaction is not allowed, we're done.
if flags&nmSecretAgentFlagAllowInteraction == 0 {
log.Infof("[SecretAgent] ALLOW_INTERACTION not set, cannot prompt user")
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
}
// Phase 6: Prepare prompt.
reason := reasonFromFlags(flags)
if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) {
reason = "wrong-password"
}
if settingName == "vpn" && isPKCS11Auth(conn, vpnSvc) {
reason = "pkcs11"
}
var connId string
if c, ok := conn["connection"]; ok {
if v, ok := c["id"]; ok {
if s, ok2 := v.Value().(string); ok2 {
connId = s
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -403,6 +426,7 @@ func (a *SecretAgent) GetSecrets(
wasConnectingVPN := a.backend.state.IsConnectingVPN
cancelledSSID := a.backend.state.ConnectingSSID
cancelledVPNUUID := a.backend.state.ConnectingVPNUUID
connPreExisting := a.backend.state.ConnectingPreExisting
if wasConnecting || wasConnectingVPN {
log.Infof("[SecretAgent] Clearing connecting state due to cancelled prompt")
a.backend.state.IsConnecting = false
@@ -414,7 +438,8 @@ func (a *SecretAgent) GetSecrets(
// If this was a WiFi connection that was just cancelled, remove the connection profile
// (it was created with AddConnection but activation was cancelled)
if wasConnecting && cancelledSSID != "" && connType == "802-11-wireless" {
// Only do this for newly created connections, not pre-existing ones.
if wasConnecting && cancelledSSID != "" && connType == "802-11-wireless" && !connPreExisting {
log.Infof("[SecretAgent] Removing connection profile for cancelled WiFi connection: %s", cancelledSSID)
if err := a.backend.ForgetWiFiNetwork(cancelledSSID); err != nil {
log.Warnf("[SecretAgent] Failed to remove cancelled connection profile: %v", err)
@@ -623,7 +648,7 @@ func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap)
return hints
}
return infer8021xFields(conn)
case "vpn":
case "vpn", "wireguard":
return hints
default:
return []string{}
@@ -339,6 +339,41 @@ func TestInferVPNFields_GPSaml(t *testing.T) {
}
}
func TestSecretAgent_GetSecrets_OnlySystemFlag(t *testing.T) {
agent := &SecretAgent{}
conn := map[string]nmVariantMap{
"connection": {
"id": dbus.MakeVariant("TestWiFi"),
"type": dbus.MakeVariant("802-11-wireless"),
},
"802-11-wireless": {
"ssid": dbus.MakeVariant("TestSSID"),
},
}
_, err := agent.GetSecrets(conn, "/test/path", "802-11-wireless-security", nil, 0x80000000)
assert.Error(t, err)
assert.Contains(t, err.Error(), "NoSecrets")
}
func TestSecretAgent_GetSecrets_NoInteractionFlag(t *testing.T) {
agent := &SecretAgent{}
conn := map[string]nmVariantMap{
"connection": {
"id": dbus.MakeVariant("TestWiFi"),
"type": dbus.MakeVariant("802-11-wireless"),
},
"802-11-wireless": {
"ssid": dbus.MakeVariant("TestSSID"),
},
}
// flags=0 means ALLOW_INTERACTION is not set
_, err := agent.GetSecrets(conn, "/test/path", "802-11-wireless-security", nil, 0x0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "NoSecrets")
}
func TestNmVariantMap(t *testing.T) {
// Test that nmVariantMap and nmSettingMap work correctly
settingMap := make(nmSettingMap)
+1
View File
@@ -74,6 +74,7 @@ type BackendState struct {
IsConnecting bool
ConnectingSSID string
ConnectingDevice string
ConnectingPreExisting bool
IsConnectingVPN bool
ConnectingVPNUUID string
LastError string
@@ -245,18 +245,34 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error
return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType)
}
var psk string
secrets, err := conn.GetSecrets("802-11-wireless-security")
if err != nil {
return "", fmt.Errorf("failed to retrieve connection secrets for `%s`: %w", ssid, err)
log.Debugf("[GetWiFiQRCodeContent] conn.GetSecrets failed: %v, falling back to secret service", err)
} else if secSecrets, ok := secrets["802-11-wireless-security"]; ok {
if s, ok := secSecrets["psk"].(string); ok {
psk = s
}
}
secSecrets, ok := secrets["802-11-wireless-security"]
if !ok {
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
if psk == "" {
uuid := ""
if connMeta, ok := connSettings["connection"]; ok {
if u, ok := connMeta["uuid"].(string); ok {
uuid = u
}
}
if uuid != "" {
sess, err := openSecretService()
if err == nil {
psk = sess.lookup(uuid, "802-11-wireless-security", "psk")
sess.close()
}
}
}
psk, ok := secSecrets["psk"].(string)
if !ok {
if psk == "" {
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
}
@@ -281,6 +297,7 @@ func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
b.state.IsConnecting = true
b.state.ConnectingSSID = req.SSID
b.state.ConnectingDevice = req.Device
b.state.ConnectingPreExisting = false
b.state.LastError = ""
b.stateMutex.Unlock()
@@ -292,6 +309,9 @@ func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
existingConn, err := b.findConnection(req.SSID)
if err == nil && existingConn != nil {
b.stateMutex.Lock()
b.state.ConnectingPreExisting = true
b.stateMutex.Unlock()
_, err := nm.ActivateConnection(existingConn, devInfo.device, nil)
if err != nil {
log.Warnf("[ConnectWiFi] Failed to activate existing connection: %v", err)
@@ -607,6 +627,7 @@ func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Co
if bytes.Equal(candidateSSID, ssidBytes) {
return conn, nil
}
log.Debugf("[findConnection] SSID mismatch: stored=%q, request=%q", string(candidateSSID), ssid)
}
}
}
@@ -0,0 +1,255 @@
package network
import (
"context"
"fmt"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5"
)
const (
secretServiceBusName = "org.freedesktop.secrets"
secretServicePath = "/org/freedesktop/secrets"
secretServiceIface = "org.freedesktop.Secret.Service"
secretItemIface = "org.freedesktop.Secret.Item"
secretPromptIface = "org.freedesktop.Secret.Prompt"
)
type secretServiceSession struct {
conn *dbus.Conn
svc dbus.BusObject
sessionPath dbus.ObjectPath
}
func openSecretService() (*secretServiceSession, error) {
c, err := dbus.ConnectSessionBus()
if err != nil {
return nil, err
}
svc := c.Object(secretServiceBusName, dbus.ObjectPath(secretServicePath))
var sessionPath dbus.ObjectPath
call := svc.Call(secretServiceIface+".OpenSession", 0, "plain", dbus.MakeVariant(""))
if call.Err != nil {
c.Close()
return nil, call.Err
}
if err := call.Store(new(dbus.Variant), &sessionPath); err != nil {
c.Close()
return nil, err
}
return &secretServiceSession{
conn: c,
svc: svc,
sessionPath: sessionPath,
}, nil
}
func (s *secretServiceSession) unlock(items []dbus.ObjectPath) error {
var prompt dbus.ObjectPath
var unlocked []dbus.ObjectPath
call := s.svc.Call(secretServiceIface+".Unlock", 0, items)
if call.Err != nil {
return call.Err
}
if err := call.Store(&unlocked, &prompt); err != nil {
return err
}
if prompt == "/" {
return nil
}
if err := s.conn.AddMatchSignal(
dbus.WithMatchInterface(secretPromptIface),
dbus.WithMatchObjectPath(prompt),
); err != nil {
return err
}
defer s.conn.RemoveMatchSignal(
dbus.WithMatchInterface(secretPromptIface),
dbus.WithMatchObjectPath(prompt),
)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
ch := make(chan *dbus.Signal, 10)
s.conn.Signal(ch)
go func() {
defer s.conn.RemoveSignal(ch)
for {
select {
case v := <-ch:
if v.Path == prompt && v.Name == secretPromptIface+".Completed" {
if len(v.Body) < 2 {
log.Debugf("[SecretAgent] Unlock prompt Completed signal has %d body element(s), expected >= 2", len(v.Body))
} else {
if dismissed, ok := v.Body[0].(bool); ok && dismissed {
log.Debugf("[SecretAgent] Unlock prompt dismissed by user")
}
}
cancel()
return
}
case <-ctx.Done():
return
}
}
}()
promptObj := s.conn.Object(secretServiceBusName, prompt)
if err := promptObj.Call(secretPromptIface+".Prompt", 0, "").Store(); err != nil {
cancel()
return err
}
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
promptObj.Call(secretPromptIface+".Dismiss", 0)
return fmt.Errorf("timed out waiting for unlock prompt")
}
return nil
}
func (s *secretServiceSession) lookup(connUuid, settingName, settingKey string) string {
attrs := map[string]string{
"connection-uuid": connUuid,
"setting-name": settingName,
"setting-key": settingKey,
}
var unlocked []dbus.ObjectPath
var locked []dbus.ObjectPath
call := s.svc.Call(secretServiceIface+".SearchItems", 0, attrs)
if call.Err != nil {
log.Debugf("[SecretAgent] Secret service SearchItems failed: %v", call.Err)
return ""
}
if err := call.Store(&unlocked, &locked); err != nil {
log.Debugf("[SecretAgent] Failed to store SearchItems result: %v", err)
return ""
}
if len(unlocked) == 0 && len(locked) > 0 {
log.Debugf("[SecretAgent] Attempting to unlock %d locked item(s) for %s", len(locked), connUuid)
if err := s.unlock(locked); err != nil {
log.Debugf("[SecretAgent] Failed to unlock items: %v", err)
return ""
}
unlocked = locked
}
if len(unlocked) == 0 {
log.Debugf("[SecretAgent] No secret service items found for %s", connUuid)
return ""
}
item := s.conn.Object(secretServiceBusName, unlocked[0])
var secret struct {
Session dbus.ObjectPath
Parameters []byte
Value []byte
ContentType string
}
call = item.Call(secretItemIface+".GetSecret", 0, s.sessionPath)
if call.Err != nil {
log.Debugf("[SecretAgent] Secret service GetSecret failed: %v", call.Err)
return ""
}
if err := call.Store(&secret); err != nil {
log.Debugf("[SecretAgent] Failed to store GetSecret result: %v", err)
return ""
}
secretValue := string(secret.Value)
if secretValue == "" {
log.Debugf("[SecretAgent] Secret service returned empty value for %s/%s", connUuid, settingKey)
return ""
}
log.Infof("[SecretAgent] Retrieved secret from secret service for %s/%s", connUuid, settingKey)
return secretValue
}
func (s *secretServiceSession) close() {
s.conn.Close()
}
func (a *SecretAgent) trySecretService(
connUuid string,
settingName string,
fields []string,
) nmSettingMap {
if connUuid == "" {
log.Debugf("[SecretAgent] trySecretService: connUuid is empty, skipping keyring lookup")
return nil
}
if len(fields) == 0 {
log.Debugf("[SecretAgent] trySecretService: no fields requested, skipping keyring lookup")
return nil
}
switch settingName {
case "802-11-wireless-security", "802-1x", "vpn", "wireguard":
default:
log.Debugf("[SecretAgent] trySecretService: setting %s not supported for keyring lookup", settingName)
return nil
}
sess, err := openSecretService()
if err != nil {
log.Debugf("[SecretAgent] Failed to open secret service session: %v", err)
return nil
}
defer sess.close()
found := make(map[string]string)
for _, field := range fields {
val := sess.lookup(connUuid, settingName, field)
if val == "" {
log.Debugf("[SecretAgent] Secret service missing field '%s' for %s", field, connUuid)
return nil
}
found[field] = val
}
out := nmSettingMap{}
sec := nmVariantMap{}
for k, v := range found {
sec[k] = dbus.MakeVariant(v)
}
switch settingName {
case "vpn":
secretsDict := make(map[string]string)
for k, v := range found {
if k != "username" {
secretsDict[k] = v
}
}
vpnSec := nmVariantMap{}
vpnSec["secrets"] = dbus.MakeVariant(secretsDict)
out[settingName] = vpnSec
log.Infof("[SecretAgent] Returning VPN secrets from secret service with %d fields", len(secretsDict))
case "802-1x":
secretsOnly := nmVariantMap{}
for k, v := range found {
switch k {
case "password", "private-key-password", "phase2-private-key-password", "pin":
secretsOnly[k] = dbus.MakeVariant(v)
}
}
out[settingName] = secretsOnly
log.Infof("[SecretAgent] Returning 802-1x secrets from secret service with %d fields", len(secretsOnly))
default:
out[settingName] = sec
log.Infof("[SecretAgent] Returning %s secrets from secret service", settingName)
}
return out
}
+10
View File
@@ -21,6 +21,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -110,6 +111,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "tailscale.") {
if tailscaleManager == nil {
models.RespondError(conn, req.ID, "Tailscale not available")
return
}
tailscale.HandleRequest(conn, req, tailscaleManager)
return
}
if strings.HasPrefix(req.Method, "dwl.") {
if dwlManager == nil {
models.RespondError(conn, req.ID, "dwl manager not initialized")
+53
View File
@@ -31,6 +31,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -65,6 +66,7 @@ var waylandManager *wayland.Manager
var bluezManager *bluez.Manager
var appPickerManager *apppicker.Manager
var cupsManager *cups.Manager
var tailscaleManager *tailscale.Manager
var dwlManager *dwl.Manager
var extWorkspaceManager *extworkspace.Manager
var brightnessManager *brightness.Manager
@@ -489,6 +491,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "cups")
}
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
caps = append(caps, "tailscale")
}
if dwlManager != nil {
caps = append(caps, "dwl")
}
@@ -559,6 +565,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "cups")
}
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
caps = append(caps, "tailscale")
}
if dwlManager != nil {
caps = append(caps, "dwl")
}
@@ -1039,6 +1049,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}
}
if shouldSubscribe("tailscale") && tailscaleManager != nil && tailscaleManager.IsAvailable() {
wg.Add(1)
tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale")
go func() {
defer wg.Done()
defer tailscaleManager.Unsubscribe(clientID + "-tailscale")
initialState := tailscaleManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "tailscale", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-tailscaleChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "tailscale", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("dwl") && dwlManager != nil {
wg.Add(1)
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
@@ -1409,11 +1451,22 @@ func cleanupManagers() {
if geoClientInstance != nil {
geoClientInstance.Close()
}
if tailscaleManager != nil {
tailscaleManager.Close()
}
}
func Start(printDocs bool) error {
cleanupStaleSockets()
// Tailscale manager always starts — reconnects internally via WatchIPNBus.
// The capability is only advertised once tailscaled is reachable; the
// callback wakes capability subscribers so QML clients see it transition.
tailscaleManager = tailscale.NewManager("")
tailscaleManager.SetAvailabilityCallback(func(bool) {
notifyCapabilityChange()
})
socketPath := GetSocketPath()
os.Remove(socketPath)
@@ -45,12 +45,14 @@ func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(
OnLine: onLine,
})
}
names := pickTargetNames(opts.Targets, "apt", true)
if len(names) == 0 {
if !BackendHasTargets(aptBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
argv := append([]string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "install", "-y", "--only-upgrade"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
return Run(ctx, aptUpgradeArgv(bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func aptUpgradeArgv(bin string, opts UpgradeOptions) []string {
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
}
func parseAptUpgradable(text string) []Package {
+19 -8
View File
@@ -45,26 +45,37 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun
if opts.DryRun {
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.bin, true)
if len(names) == 0 {
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
argv := append([]string{"pkexec", b.bin, "upgrade", "-y"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
return Run(ctx, dnfUpgradeArgv(b.bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y")
}
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--quiet")
out, err := cmd.Output()
argv := dnfCheckUpdatesArgv(bin)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.CombinedOutput()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 {
return "", nil
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
return string(out), nil
}
return "", err
}
func dnfCheckUpdatesArgv(bin string) []string {
subcommand := "check-update"
if bin == "dnf5" {
subcommand = "check-upgrade"
}
return []string{bin, subcommand, "--refresh", "--quiet"}
}
func rpmInstalledVersions(ctx context.Context) map[string]string {
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
if err != nil {
@@ -67,6 +67,21 @@ bash.x86_64 5.2.40-1.fc41 updates`,
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "skips dnf warning lines while keeping package rows",
input: `Failed to expire repository cache in path "/home/user/.cache/libdnf5/updates": cannot open file
example-driver.x86_64 2:9.8.7-1.fc99 updates
example-tool.noarch 1.2.3^45.gitabcdef-1.fc99 copr`,
backendID: "dnf5",
installed: map[string]string{
"example-driver": "2:9.8.6-1.fc99",
"example-tool": "1.2.2^44.gitabcdef-1.fc99",
},
want: []Package{
{Name: "example-driver.x86_64", Repo: RepoSystem, Backend: "dnf5", FromVersion: "2:9.8.6-1.fc99", ToVersion: "2:9.8.7-1.fc99"},
{Name: "example-tool.noarch", Repo: RepoSystem, Backend: "dnf5", FromVersion: "1.2.2^44.gitabcdef-1.fc99", ToVersion: "1.2.3^45.gitabcdef-1.fc99"},
},
},
}
for _, tt := range tests {
@@ -78,3 +93,22 @@ bash.x86_64 5.2.40-1.fc41 updates`,
})
}
}
func TestDnfCheckUpdatesArgv(t *testing.T) {
tests := []struct {
bin string
want []string
}{
{bin: "dnf5", want: []string{"dnf5", "check-upgrade", "--refresh", "--quiet"}},
{bin: "dnf", want: []string{"dnf", "check-update", "--refresh", "--quiet"}},
}
for _, tt := range tests {
t.Run(tt.bin, func(t *testing.T) {
got := dnfCheckUpdatesArgv(tt.bin)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("dnfCheckUpdatesArgv(%q) = %#v, want %#v", tt.bin, got, tt.want)
}
})
}
}
@@ -2,6 +2,7 @@ package sysupdate
import (
"context"
"errors"
"os/exec"
"strings"
)
@@ -20,17 +21,44 @@ func (flatpakBackend) RunsInTerminal() bool { return false }
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name")
// Run `flatpak update`
cmd := exec.CommandContext(ctx, "flatpak", "update")
cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts
out, err := cmd.Output()
if err != nil {
return nil, err
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(out) > 0 {
} else if len(out) == 0 {
return nil, err
}
}
installed := flatpakInstalled(ctx)
return parseFlatpakUpdates(string(out), installed), nil
return parseFlatpakUpdateOutput(string(out), installed), nil
}
type flatpakInstalledEntry struct {
version string
branch string
}
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").Output()
m := flatpakListInstalled(ctx, false)
if m == nil {
m = make(map[string]flatpakInstalledEntry)
}
for k, v := range flatpakListInstalled(ctx, true) {
if _, exists := m[k]; !exists {
m[k] = v
}
}
return m
}
func flatpakListInstalled(ctx context.Context, system bool) map[string]flatpakInstalledEntry {
args := []string{"flatpak", "list", "--columns=application,version,branch"}
if system {
args = append(args, "--system")
}
out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
if err != nil {
return nil
}
@@ -51,9 +79,6 @@ func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
if len(fields) > 2 {
entry.branch = fields[2]
}
if len(fields) > 3 {
entry.commit = fields[3]
}
key := appID
if entry.branch != "" {
key = appID + "//" + entry.branch
@@ -63,107 +88,85 @@ func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
return m
}
type flatpakInstalledEntry struct {
version string
branch string
commit string
}
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine})
}
refs := flatpakTargetRefs(opts.Targets)
if len(refs) == 0 {
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
argv := append([]string{"flatpak", "update", "-y", "--noninteractive"}, refs...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
}
func flatpakTargetRefs(targets []Package) []string {
out := make([]string, 0, len(targets))
for _, p := range targets {
if p.Backend != "flatpak" {
continue
}
ref := p.Ref
if ref == "" {
ref = p.Name
}
out = append(out, ref)
}
return out
func flatpakUpgradeArgv() []string {
return []string{"flatpak", "update", "-y", "--noninteractive"}
}
func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package {
if text == "" {
return nil
}
func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package {
var pkgs []Package
seen := make(map[string]bool)
for line := range strings.SplitSeq(text, "\n") {
if line == "" {
p := parseFlatpakUpdateRow(strings.TrimRight(line, "\r"), installed)
if p == nil || seen[p.Ref] {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
version, branch, commit := "", "", ""
if len(fields) > 1 {
version = fields[1]
}
if len(fields) > 2 {
branch = fields[2]
}
if len(fields) > 3 {
commit = fields[3]
}
display := appID
if len(fields) > 4 && fields[4] != "" {
display = fields[4]
}
key := appID
if branch != "" {
key = appID + "//" + branch
}
inst := installed[key]
if inst.commit != "" && commit != "" && strings.HasPrefix(commit, inst.commit) {
continue
}
from, to := flatpakVersionPair(inst.version, inst.commit, version, commit)
ref := appID
if branch != "" {
ref = appID + "//" + branch
}
pkgs = append(pkgs, Package{
Name: display,
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: from,
ToVersion: to,
Ref: ref,
})
seen[p.Ref] = true
pkgs = append(pkgs, *p)
}
return pkgs
}
func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) {
if remoteVer != "" {
return installedVer, remoteVer
func parseFlatpakUpdateRow(line string, installed map[string]flatpakInstalledEntry) *Package {
// Row format: " N.\t<name>\t<appID>\t<branch>\t<op>\t<remote>\t<size>"
fields := strings.Split(line, "\t")
if len(fields) < 5 {
return nil
}
// First field must look like " N." (optional whitespace, digits, period)
rowField := strings.TrimSpace(fields[0])
if len(rowField) < 2 || rowField[len(rowField)-1] != '.' {
return nil
}
for _, c := range rowField[:len(rowField)-1] {
if c < '0' || c > '9' {
return nil
}
}
return shortCommit(installedCommit), shortCommit(remoteCommit)
}
func shortCommit(c string) string {
if len(c) > 8 {
return c[:8]
appID := strings.TrimSpace(fields[2])
branch := strings.TrimSpace(fields[3])
op := strings.TrimSpace(fields[4])
if appID == "" || op == "" {
return nil
}
switch op {
case "i", "u", "r": // install, update, reinstall
default:
return nil
}
ref := appID
if branch != "" {
ref = appID + "//" + branch
}
name := strings.TrimSpace(fields[1])
if name == "" {
name = appID
}
var from string
if op != "i" {
if inst, ok := installed[ref]; ok {
from = inst.version
}
}
return &Package{
Name: name,
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: from,
Ref: ref,
}
return c
}
@@ -5,7 +5,9 @@ import (
"testing"
)
func TestParseFlatpakUpdates(t *testing.T) {
func TestParseFlatpakUpdateOutput(t *testing.T) {
realOutput := "Looking for updates…\n\n\n 1.\t \torg.gtk.Gtk3theme.adw-gtk3-dark\t3.22\ti\tflathub\t< 131.4 kB\n\nProceed with these changes to the system installation? [Y/n]: n\n"
tests := []struct {
name string
input string
@@ -13,137 +15,92 @@ func TestParseFlatpakUpdates(t *testing.T) {
want []Package
}{
{
name: "empty",
name: "empty output",
input: "",
want: nil,
},
{
name: "real flathub-style row with empty version, falls back to commit",
// columns: application,version,branch,commit,name
input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord",
installed: map[string]flatpakInstalledEntry{
"com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"},
},
want: []Package{
{
Name: "Discord",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "8b16fa1a",
ToVersion: "43a1e5d2",
Ref: "com.discordapp.Discord//stable",
},
},
name: "nothing to do",
input: "Looking for updates…\n\nNothing to do.\n",
want: nil,
},
{
name: "remote provides version, installed version known",
input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App",
installed: map[string]flatpakInstalledEntry{
"com.example.App//stable": {version: "1.4.2"},
},
name: "real flatpak update output — new install",
input: realOutput,
want: []Package{
{
Name: "Example App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "1.4.2",
ToVersion: "1.5.0",
Ref: "com.example.App//stable",
},
},
},
{
name: "no installed entry, remote has no version, falls back to commit on both sides",
input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform",
installed: nil,
want: []Package{
{
Name: "gnome platform",
Name: "org.gtk.Gtk3theme.adw-gtk3-dark",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "badcd4af",
Ref: "org.gnome.Platform//49",
Ref: "org.gtk.Gtk3theme.adw-gtk3-dark//3.22",
},
},
},
{
name: "missing display name falls back to application id",
input: "com.example.NoName\t2.0\tstable\tabcdef123456\t",
want: []Package{
{
Name: "com.example.NoName",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "2.0",
Ref: "com.example.NoName//stable",
},
},
},
{
name: "skips blank lines and rows with empty application id",
input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App",
want: []Package{
{
Name: "Real App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "1.0",
Ref: "org.real.App//stable",
},
},
},
{
name: "skips phantom updates where remote commit matches installed",
input: "com.phantom.App\t\tstable\tabc12345deadbeef\tPhantom",
name: "update with installed version",
input: "Looking for updates…\n\n 1.\tSlack\tcom.slack.Slack\tstable\tu\tflathub\t< 5.2 MB\n\nProceed? [Y/n]: n\n",
installed: map[string]flatpakInstalledEntry{
"com.phantom.App//stable": {commit: "abc12345"},
"com.slack.Slack//stable": {version: "4.40.0"},
},
want: []Package{
{
Name: "Slack",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "4.40.0",
Ref: "com.slack.Slack//stable",
},
},
},
{
name: "reinstall op included",
input: " 1.\t\torg.freedesktop.Platform\t25.08\tr\tflathub\t< 100 MB\n",
want: []Package{
{
Name: "org.freedesktop.Platform",
Repo: RepoFlatpak,
Backend: "flatpak",
Ref: "org.freedesktop.Platform//25.08",
},
},
},
{
name: "unknown op excluded",
input: " 1.\t\torg.freedesktop.Platform\t25.08\te\tflathub\t0\n",
want: nil,
},
{
name: "deduplicates same ref",
input: " 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n 2.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n",
want: []Package{
{
Name: "com.example.App",
Repo: RepoFlatpak,
Backend: "flatpak",
Ref: "com.example.App//stable",
},
},
},
{
name: "non-table lines ignored",
input: "Looking for updates…\nSome warning line\nID\tBranch\tOp\n 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\nProceed? [Y/n]: n\n",
want: []Package{
{
Name: "com.example.App",
Repo: RepoFlatpak,
Backend: "flatpak",
Ref: "com.example.App//stable",
},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFlatpakUpdates(tt.input, tt.installed)
got := parseFlatpakUpdateOutput(tt.input, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}
func TestFlatpakVersionPair(t *testing.T) {
tests := []struct {
name string
installedVer, installedCommit, remoteVer, remoteCommit string
wantFrom, wantTo string
}{
{
name: "remote has version - prefer versions",
installedVer: "1.0.0", remoteVer: "1.1.0",
wantFrom: "1.0.0", wantTo: "1.1.0",
},
{
name: "remote has no version - both sides fall to short commit",
installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855",
remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586",
wantFrom: "8b16fa1a", wantTo: "43a1e5d2",
},
{
name: "short commits left as-is",
installedCommit: "abc123", remoteCommit: "def456",
wantFrom: "abc123", wantTo: "def456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit)
if from != tt.wantFrom || to != tt.wantTo {
t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo)
t.Errorf("parseFlatpakUpdateOutput() = %#v\nwant %#v", got, tt.want)
}
})
}
@@ -43,12 +43,14 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
if opts.DryRun {
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.ID(), opts.IncludeAUR)
if len(names) == 0 {
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
argv := append([]string{"pkexec", "pacman", "-Sy", "--noconfirm", "--needed"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
return Run(ctx, pacmanUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
}
type archHelperBackend struct {
@@ -93,35 +95,28 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
if opts.DryRun {
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.id, opts.IncludeAUR)
if len(names) == 0 {
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
argv := append([]string{"pkexec", b.id, "-Sy", "--noconfirm", "--needed"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...)
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
cmd := fmt.Sprintf("%s -Sy --noconfirm --needed %s", b.id, strings.Join(names, " "))
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ")
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
}
func pickTargetNames(targets []Package, backendID string, includeAUR bool) []string {
out := make([]string, 0, len(targets))
for _, p := range targets {
if p.Backend != backendID {
continue
}
if !includeAUR && p.Repo == RepoAUR {
continue
}
out = append(out, p.Name)
func archHelperUpgradeArgv(id string, includeAUR bool) []string {
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
if !includeAUR {
argv = append(argv, "--repo")
}
return out
return argv
}
func pacmanRepoUpdates(ctx context.Context) (string, error) {
@@ -117,9 +117,16 @@ func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
}
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if !BackendHasTargets(rpmOstreeBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
return Run(ctx, rpmOstreeUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func rpmOstreeUpgradeArgv(opts UpgradeOptions) []string {
argv := []string{"rpm-ostree", "upgrade"}
if opts.DryRun {
argv = append(argv, "--check")
}
return Run(ctx, argv, RunOptions{OnLine: onLine})
return argv
}
@@ -74,10 +74,12 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu
if opts.DryRun {
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, "zypper", true)
if len(names) == 0 {
if !BackendHasTargets(zypperBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
argv := append([]string{"pkexec", "zypper", "--non-interactive", "update"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
return Run(ctx, zypperUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
func zypperUpgradeArgv(opts UpgradeOptions) []string {
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
}
+29 -2
View File
@@ -9,11 +9,14 @@ import (
"os/exec"
"sync"
"syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
type RunOptions struct {
Env []string
OnLine func(string)
Env []string
OnLine func(string)
AttachStdio bool
}
func Run(ctx context.Context, argv []string, opts RunOptions) error {
@@ -25,6 +28,18 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error {
if len(opts.Env) > 0 {
cmd.Env = append(cmd.Environ(), opts.Env...)
}
if opts.AttachStdio {
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return cmd.Process.Kill()
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
@@ -77,6 +92,18 @@ func Capture(ctx context.Context, argv []string) (string, error) {
return string(out), err
}
// privescBin returns the binary to use for privilege escalation.
// When useSudo is true it auto-detects the best available tool (sudo/doas/run0).
// When false it falls back to pkexec for GUI callers.
func privescBin(useSudo bool) string {
if useSudo {
if t, err := privesc.Detect(); err == nil {
return t.Name()
}
}
return "pkexec"
}
func findTerminal(override string) string {
if override != "" && commandExists(override) {
return override
+49 -21
View File
@@ -16,11 +16,12 @@ import (
)
const (
defaultIntervalSeconds = 30 * 60
minIntervalSeconds = 5 * 60
recentLogCapacity = 200
checkTimeout = 5 * time.Minute
upgradeTimeout = 30 * time.Minute
defaultIntervalSeconds = 30 * 60
minIntervalSeconds = 5 * 60
recentLogCapacity = 200
checkTimeout = 5 * time.Minute
upgradeTimeout = 30 * time.Minute
postUpgradeCompleteDelay = 3 * time.Second
)
type Manager struct {
@@ -38,6 +39,8 @@ type Manager struct {
acquireCount int32
wakeSched chan struct{}
refreshSerial sync.Mutex
opMu sync.Mutex
opCtx context.Context
opCancel context.CancelFunc
@@ -143,9 +146,11 @@ func (m *Manager) Refresh(opts RefreshOptions) {
case phase == PhaseUpgrading:
return
case phase == PhaseRefreshing && !opts.Force:
m.refreshSerial.Lock()
m.refreshSerial.Unlock()
return
}
go m.runRefresh(context.Background())
m.runRefresh(context.Background())
}
func (m *Manager) Upgrade(opts UpgradeOptions) error {
@@ -226,6 +231,9 @@ func (m *Manager) scheduler() {
}
func (m *Manager) runRefresh(parent context.Context) {
m.refreshSerial.Lock()
defer m.refreshSerial.Unlock()
if len(m.selection.All()) == 0 {
return
}
@@ -303,18 +311,18 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
return
}
backends := upgradeBackends(m.selection, opts)
if len(backends) == 0 {
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
return
}
if len(opts.Targets) == 0 {
m.mu.RLock()
opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock()
}
backends := upgradeBackends(m.selection, opts)
if len(backends) == 0 {
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
return
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
@@ -344,13 +352,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
}
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
m.finishSuccessfulUpgrade(true)
}
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
@@ -388,10 +390,29 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
return
}
m.finishSuccessfulUpgrade(false)
}
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
m.appendLog("Upgrade complete.")
timer := time.NewTimer(postUpgradeCompleteDelay)
defer timer.Stop()
select {
case <-m.stopChan:
return
case <-timer.C:
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
if clearPackages {
m.state.Packages = m.state.Packages[:0]
m.state.Count = 0
}
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
@@ -400,18 +421,25 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
var out []Backend
if sel.System != nil {
out = append(out, sel.System)
out = appendUpgradeBackend(out, sel.System, opts)
}
for _, b := range sel.Overlay {
switch {
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
continue
}
out = append(out, b)
out = appendUpgradeBackend(out, b, opts)
}
return out
}
func appendUpgradeBackend(out []Backend, b Backend, opts UpgradeOptions) []Backend {
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return out
}
return append(out, b)
}
func (m *Manager) appendLog(line string) {
m.mu.Lock()
if cap(m.state.RecentLog) == 0 {
+60
View File
@@ -0,0 +1,60 @@
package sysupdate
func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool {
if b == nil || len(targets) == 0 {
return false
}
id := b.ID()
repo := b.Repo()
for _, p := range targets {
switch p.Repo {
case RepoFlatpak:
if !includeFlatpak {
continue
}
case RepoAUR:
if !includeAUR {
continue
}
}
switch repo {
case RepoFlatpak:
if p.Repo == RepoFlatpak || p.Backend == id {
return true
}
case RepoOSTree:
if p.Repo == RepoOSTree || p.Backend == id {
return true
}
default:
if p.Backend == id {
return true
}
}
}
return false
}
func UpgradeNeedsPrivilege(backends []Backend, targets []Package, opts UpgradeOptions) bool {
if opts.DryRun {
return false
}
for _, b := range backends {
if b == nil {
continue
}
if b.NeedsAuth() && BackendHasTargets(b, targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return true
}
}
return false
}
func privilegedArgv(opts UpgradeOptions, argv ...string) []string {
privesc := privescBin(opts.UseSudo)
out := make([]string, 0, len(argv)+1)
out = append(out, privesc)
out = append(out, argv...)
return out
}
+2
View File
@@ -76,6 +76,8 @@ type UpgradeOptions struct {
IncludeFlatpak bool
IncludeAUR bool
DryRun bool
UseSudo bool
AttachStdio bool
CustomCommand string
Terminal string
Targets []Package
@@ -0,0 +1,147 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestUpgradeCommandBuilders(t *testing.T) {
pkexecOpts := UpgradeOptions{UseSudo: false}
tests := []struct {
name string
got []string
want []string
}{
{
name: "dnf full upgrade",
got: dnfUpgradeArgv("dnf5", pkexecOpts),
want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y"},
},
{
name: "apt full upgrade",
got: aptUpgradeArgv("apt-get", pkexecOpts),
want: []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get", "upgrade", "-y"},
},
{
name: "zypper full update",
got: zypperUpgradeArgv(pkexecOpts),
want: []string{"pkexec", "zypper", "--non-interactive", "update"},
},
{
name: "pacman full sync upgrade",
got: pacmanUpgradeArgv(pkexecOpts),
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
},
{
name: "aur helper full update with aur",
got: archHelperUpgradeArgv("paru", true),
want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
},
{
name: "aur helper repo-only full update",
got: archHelperUpgradeArgv("yay", false),
want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"},
},
{
name: "flatpak full update",
got: flatpakUpgradeArgv(),
want: []string{"flatpak", "update", "-y", "--noninteractive"},
},
{
name: "rpm-ostree upgrade",
got: rpmOstreeUpgradeArgv(UpgradeOptions{}),
want: []string{"rpm-ostree", "upgrade"},
},
{
name: "rpm-ostree check",
got: rpmOstreeUpgradeArgv(UpgradeOptions{DryRun: true}),
want: []string{"rpm-ostree", "upgrade", "--check"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !reflect.DeepEqual(tt.got, tt.want) {
t.Fatalf("argv = %#v, want %#v", tt.got, tt.want)
}
})
}
}
func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) {
targets := []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
{Name: "google-chrome", Repo: RepoAUR, Backend: "paru"},
{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"},
{Name: "silverblue", Repo: RepoOSTree, Backend: "rpm-ostree"},
}
if !BackendHasTargets(dnfBackend{bin: "dnf5"}, targets, true, true) {
t.Fatal("dnf5 target was not detected")
}
if BackendHasTargets(dnfBackend{bin: "dnf"}, targets, true, true) {
t.Fatal("dnf target should not match dnf5 package targets")
}
if !BackendHasTargets(archHelperBackend{id: "paru"}, targets, true, true) {
t.Fatal("AUR helper target was not detected")
}
if BackendHasTargets(archHelperBackend{id: "paru"}, targets, false, true) {
t.Fatal("AUR helper should not match AUR-only target when AUR is disabled")
}
if !BackendHasTargets(flatpakBackend{}, targets, true, true) {
t.Fatal("Flatpak target was not detected")
}
if BackendHasTargets(flatpakBackend{}, targets, true, false) {
t.Fatal("Flatpak target should not match when Flatpak is disabled")
}
if !BackendHasTargets(rpmOstreeBackend{}, targets, true, true) {
t.Fatal("rpm-ostree target was not detected")
}
}
func TestUpgradeNeedsPrivilegeSkipsFlatpakOnly(t *testing.T) {
backends := []Backend{dnfBackend{bin: "dnf5"}, flatpakBackend{}}
opts := UpgradeOptions{IncludeAUR: true, IncludeFlatpak: true}
flatpakOnly := []Package{{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}}
if UpgradeNeedsPrivilege(backends, flatpakOnly, opts) {
t.Fatal("Flatpak-only updates should not need privileged auth")
}
mixed := []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"},
}
if !UpgradeNeedsPrivilege(backends, mixed, opts) {
t.Fatal("mixed system updates should need privileged auth")
}
opts.DryRun = true
if UpgradeNeedsPrivilege(backends, mixed, opts) {
t.Fatal("dry-run updates should not need privileged auth")
}
}
func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
sel := Selection{
System: dnfBackend{bin: "dnf5"},
Overlay: []Backend{flatpakBackend{}},
}
opts := UpgradeOptions{
IncludeAUR: true,
IncludeFlatpak: true,
Targets: []Package{{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}},
}
got := upgradeBackends(sel, opts)
if len(got) != 1 || got[0].ID() != "flatpak" {
t.Fatalf("upgradeBackends(flatpak-only) = %#v, want only flatpak", got)
}
opts.Targets = append(opts.Targets, Package{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"})
got = upgradeBackends(sel, opts)
if len(got) != 2 || got[0].ID() != "dnf5" || got[1].ID() != "flatpak" {
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
}
}
+135
View File
@@ -0,0 +1,135 @@
package tailscale
import (
"fmt"
"sort"
"strings"
"time"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
// convertStatus converts an ipnstate.Status into our TailscaleState IPC type.
func convertStatus(status *ipnstate.Status) *TailscaleState {
connected := status.BackendState == "Running"
state := &TailscaleState{
Connected: connected,
BackendState: status.BackendState,
Version: status.Version,
}
if status.CurrentTailnet != nil {
state.TailnetName = status.CurrentTailnet.Name
state.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
}
if !connected {
return state
}
users := status.User
if status.Self != nil {
state.Self = convertPeerStatus(status.Self, users)
}
if len(status.Peer) > 0 {
peers := make([]Peer, 0, len(status.Peer))
for _, ps := range status.Peer {
peers = append(peers, convertPeerStatus(ps, users))
}
sort.Slice(peers, func(i, j int) bool {
if peers[i].Online != peers[j].Online {
return peers[i].Online
}
return strings.ToLower(peers[i].Hostname) < strings.ToLower(peers[j].Hostname)
})
state.Peers = peers
}
return state
}
// convertPeerStatus converts an ipnstate.PeerStatus into our Peer IPC type.
func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg.UserProfile) Peer {
dnsName := strings.TrimSuffix(ps.DNSName, ".")
// DNSName first label is unique per node; OS HostName is not.
hostname := ps.HostName
if dnsName != "" {
parts := strings.SplitN(dnsName, ".", 2)
if len(parts) > 0 && parts[0] != "" {
hostname = parts[0]
}
}
peer := Peer{
ID: string(ps.ID),
Hostname: hostname,
DNSName: dnsName,
OS: ps.OS,
Online: ps.Online,
Active: ps.Active,
ExitNode: ps.ExitNode,
Relay: ps.Relay,
RxBytes: ps.RxBytes,
TxBytes: ps.TxBytes,
}
for _, ip := range ps.TailscaleIPs {
if ip.Is4() {
if peer.TailscaleIP == "" {
peer.TailscaleIP = ip.String()
}
} else {
if peer.TailscaleIPv6 == "" {
peer.TailscaleIPv6 = ip.String()
}
}
}
if ps.Tags != nil {
peer.Tags = ps.Tags.AsSlice()
}
if ps.UserID > 0 {
if user, ok := users[ps.UserID]; ok {
peer.Owner = user.LoginName
}
}
if !ps.LastSeen.IsZero() {
peer.LastSeen = formatRelativeTime(ps.LastSeen)
}
return peer
}
// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago").
func formatRelativeTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
default:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
}
@@ -0,0 +1,223 @@
package tailscale
import (
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go4.org/mem"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
func makeTestStatus() *ipnstate.Status {
return &ipnstate.Status{
Version: "1.94.2",
BackendState: "Running",
MagicDNSSuffix: "example.ts.net",
CurrentTailnet: &ipnstate.TailnetStatus{
Name: "user@example.com",
MagicDNSSuffix: "example.ts.net",
},
Self: &ipnstate.PeerStatus{
ID: "node1",
HostName: "cachyos",
DNSName: "cachyos.example.ts.net.",
OS: "linux",
TailscaleIPs: []netip.Addr{
netip.MustParseAddr("100.85.254.40"),
netip.MustParseAddr("fd7a:115c:a1e0::1"),
},
Online: true,
UserID: 12345,
},
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NodePublicFromRaw32(mem.B(make([]byte, 32))): {
ID: "node2",
HostName: "thinkpad-x390",
DNSName: "thinkpad-x390.example.ts.net.",
OS: "linux",
TailscaleIPs: []netip.Addr{
netip.MustParseAddr("100.97.21.17"),
netip.MustParseAddr("fd7a:115c:a1e0::2"),
},
Online: true,
Active: true,
Relay: "fra",
RxBytes: 1024,
TxBytes: 2048,
UserID: 12345,
ExitNode: false,
LastSeen: time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC),
},
},
User: map[tailcfg.UserID]tailcfg.UserProfile{
12345: {
ID: 12345,
LoginName: "user@example.com",
DisplayName: "User",
},
},
}
}
func TestConvertStatus_Running(t *testing.T) {
status := makeTestStatus()
state := convertStatus(status)
require.NotNil(t, state)
assert.True(t, state.Connected)
assert.Equal(t, "1.94.2", state.Version)
assert.Equal(t, "Running", state.BackendState)
assert.Equal(t, "example.ts.net", state.MagicDNSSuffix)
assert.Equal(t, "user@example.com", state.TailnetName)
// Self
assert.Equal(t, "cachyos", state.Self.Hostname)
assert.Equal(t, "cachyos.example.ts.net", state.Self.DNSName)
assert.Equal(t, "100.85.254.40", state.Self.TailscaleIP)
assert.Equal(t, "fd7a:115c:a1e0::1", state.Self.TailscaleIPv6)
assert.Equal(t, "linux", state.Self.OS)
assert.True(t, state.Self.Online)
// Peers
require.Len(t, state.Peers, 1)
peer := state.Peers[0]
assert.Equal(t, "thinkpad-x390", peer.Hostname)
assert.Equal(t, "100.97.21.17", peer.TailscaleIP)
assert.Equal(t, "fra", peer.Relay)
assert.Equal(t, "user@example.com", peer.Owner)
assert.Equal(t, int64(1024), peer.RxBytes)
assert.True(t, peer.Online)
}
func TestConvertStatus_NotRunning(t *testing.T) {
status := &ipnstate.Status{
BackendState: "Stopped",
}
state := convertStatus(status)
assert.False(t, state.Connected)
assert.Equal(t, "Stopped", state.BackendState)
assert.Empty(t, state.Peers)
}
func TestConvertStatus_NilSelf(t *testing.T) {
status := &ipnstate.Status{
BackendState: "Running",
}
state := convertStatus(status)
assert.True(t, state.Connected)
assert.Equal(t, Peer{}, state.Self)
}
func TestConvertPeerStatus_Tags(t *testing.T) {
tags := views.SliceOf([]string{"tag:k8s", "tag:server"})
ps := &ipnstate.PeerStatus{
ID: "node3",
HostName: "k8s-node",
DNSName: "k8s-node.example.ts.net.",
OS: "linux",
Online: false,
Tags: &tags,
}
users := map[tailcfg.UserID]tailcfg.UserProfile{}
peer := convertPeerStatus(ps, users)
assert.Equal(t, "k8s-node", peer.Hostname)
assert.Contains(t, peer.Tags, "tag:k8s")
assert.Contains(t, peer.Tags, "tag:server")
assert.Equal(t, "", peer.Owner)
}
func TestConvertPeerStatus_HostnameFromDNS(t *testing.T) {
// Hostname should always be derived from DNSName, not OS HostName
ps := &ipnstate.PeerStatus{
HostName: "GL-MT6000",
DNSName: "gl-mt6000-2.example.ts.net.",
}
users := map[tailcfg.UserID]tailcfg.UserProfile{}
peer := convertPeerStatus(ps, users)
assert.Equal(t, "gl-mt6000-2", peer.Hostname)
}
func TestConvertPeerStatus_FallbackToHostName(t *testing.T) {
// When DNSName is empty, fall back to OS HostName
ps := &ipnstate.PeerStatus{
HostName: "my-device",
}
users := map[tailcfg.UserID]tailcfg.UserProfile{}
peer := convertPeerStatus(ps, users)
assert.Equal(t, "my-device", peer.Hostname)
}
func TestConvertPeerStatus_LastSeen(t *testing.T) {
ps := &ipnstate.PeerStatus{
HostName: "recent-node",
LastSeen: time.Now().Add(-5 * time.Minute),
}
users := map[tailcfg.UserID]tailcfg.UserProfile{}
peer := convertPeerStatus(ps, users)
assert.NotEmpty(t, peer.LastSeen)
assert.Contains(t, peer.LastSeen, "minutes ago")
}
func TestPeerSorting(t *testing.T) {
b1 := make([]byte, 32)
b2 := make([]byte, 32)
b2[0] = 1
b3 := make([]byte, 32)
b3[0] = 2
k1 := key.NodePublicFromRaw32(mem.B(b1))
k2 := key.NodePublicFromRaw32(mem.B(b2))
k3 := key.NodePublicFromRaw32(mem.B(b3))
status := &ipnstate.Status{
BackendState: "Running",
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
k1: {HostName: "zebra", Online: false},
k2: {HostName: "alpha", Online: true},
k3: {HostName: "beta", Online: true},
},
}
state := convertStatus(status)
// Online peers first (alpha, beta), then offline (zebra)
require.Len(t, state.Peers, 3)
assert.True(t, state.Peers[0].Online)
assert.True(t, state.Peers[1].Online)
assert.False(t, state.Peers[2].Online)
assert.Equal(t, "alpha", state.Peers[0].Hostname)
assert.Equal(t, "beta", state.Peers[1].Hostname)
assert.Equal(t, "zebra", state.Peers[2].Hostname)
}
func TestFormatRelativeTime(t *testing.T) {
tests := []struct {
name string
duration string
contains string
}{
{"minutes", "5m", "minutes ago"},
{"hours", "3h", "hours ago"},
{"days", "48h", "days ago"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, _ := time.ParseDuration(tt.duration)
result := formatRelativeTime(time.Now().Add(-d))
assert.Contains(t, result, tt.contains)
})
}
}
@@ -0,0 +1,30 @@
package tailscale
import (
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
// HandleRequest routes an IPC request to the appropriate handler.
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
switch req.Method {
case "tailscale.getStatus":
handleGetStatus(conn, req, manager)
case "tailscale.refresh":
handleRefresh(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleGetStatus(conn net.Conn, req models.Request, manager *Manager) {
state := manager.GetState()
models.Respond(conn, req.ID, state)
}
func handleRefresh(conn net.Conn, req models.Request, manager *Manager) {
manager.RefreshState()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
}
@@ -0,0 +1,97 @@
package tailscale
import (
"bytes"
"context"
"encoding/json"
"net"
"testing"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
type mockConn struct {
*bytes.Buffer
}
func (m *mockConn) Close() error { return nil }
func (m *mockConn) LocalAddr() net.Addr { return nil }
func (m *mockConn) RemoteAddr() net.Addr { return nil }
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func handlerTestManager() *Manager {
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
<-ctx.Done()
return nil, ctx.Err()
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
return runningStatus(), nil
},
}
m := newManager(client)
m.RefreshState()
return m
}
func TestHandleGetStatus(t *testing.T) {
m := handlerTestManager()
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: "tailscale.getStatus"}
handleGetStatus(conn, req, m)
var resp models.Response[TailscaleState]
err := json.NewDecoder(buf).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, 1, resp.ID)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Connected)
assert.Equal(t, "cachyos", resp.Result.Self.Hostname)
}
func TestHandleRefresh(t *testing.T) {
m := handlerTestManager()
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: "tailscale.refresh"}
handleRefresh(conn, req, m)
var resp models.Response[models.SuccessResult]
err := json.NewDecoder(buf).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, 1, resp.ID)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Success)
}
func TestHandleRequest_UnknownMethod(t *testing.T) {
m := handlerTestManager()
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: "tailscale.unknownMethod"}
HandleRequest(conn, req, m)
var resp models.Response[any]
err := json.NewDecoder(buf).Decode(&resp)
require.NoError(t, err)
assert.Nil(t, resp.Result)
assert.NotEmpty(t, resp.Error)
assert.Contains(t, resp.Error, "unknown method")
}
+277
View File
@@ -0,0 +1,277 @@
package tailscale
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
const (
statusTimeout = 3 * time.Second
debounceWindow = 150 * time.Millisecond
)
// tailscaleClient abstracts the Tailscale local API for testing.
type tailscaleClient interface {
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
Status(ctx context.Context) (*ipnstate.Status, error)
}
// ipnBusWatcher abstracts the IPN bus watcher for testing.
type ipnBusWatcher interface {
Next() (ipn.Notify, error)
Close() error
}
// localClientWrapper wraps local.Client to satisfy tailscaleClient.
type localClientWrapper struct {
client *local.Client
}
func (w *localClientWrapper) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
return w.client.WatchIPNBus(ctx, mask)
}
func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, error) {
return w.client.Status(ctx)
}
// Manager manages Tailscale state via IPN bus events and subscriber notifications.
type Manager struct {
state *TailscaleState
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan TailscaleState]
client tailscaleClient
ctx context.Context
cancel context.CancelFunc
watchWG sync.WaitGroup
closed atomic.Bool
dirty chan struct{}
available atomic.Bool
availabilityCallback atomic.Pointer[func(bool)]
}
// NewManager creates a new Tailscale manager and starts watching the IPN bus.
func NewManager(socketPath string) *Manager {
lc := &local.Client{Socket: socketPath}
return newManager(&localClientWrapper{client: lc})
}
func newManager(client tailscaleClient) *Manager {
ctx, cancel := context.WithCancel(context.Background())
m := &Manager{
state: &TailscaleState{},
client: client,
ctx: ctx,
cancel: cancel,
dirty: make(chan struct{}, 1),
}
m.watchWG.Add(2)
go m.watchLoop(ctx)
go m.debounceLoop(ctx)
return m
}
func (m *Manager) watchLoop(ctx context.Context) {
defer m.watchWG.Done()
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
backoff := time.Second
unreachableSent := false
for {
select {
case <-ctx.Done():
return
default:
}
watcher, err := m.client.WatchIPNBus(ctx, mask)
if err != nil {
if !unreachableSent {
m.updateState(&TailscaleState{Connected: false, BackendState: "Unreachable"})
unreachableSent = true
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, 30*time.Second)
continue
}
unreachableSent = false
backoff = time.Second
log.Info("[Tailscale] Connected to IPN bus")
m.markAvailable()
for {
notify, err := watcher.Next()
if err != nil {
log.Warnf("[Tailscale] IPN bus error: %v", err)
break
}
if notify.State == nil && notify.NetMap == nil {
continue
}
select {
case m.dirty <- struct{}{}:
default:
}
}
watcher.Close()
}
}
// debounceLoop coalesces rapid bus notifications into a single Status RPC
// per debounceWindow, since NetMap events can fire many times per second
// on busy tailnets.
func (m *Manager) debounceLoop(ctx context.Context) {
defer m.watchWG.Done()
for {
select {
case <-ctx.Done():
return
case <-m.dirty:
}
timer := time.NewTimer(debounceWindow)
collecting := true
for collecting {
select {
case <-ctx.Done():
timer.Stop()
return
case <-m.dirty:
case <-timer.C:
collecting = false
}
}
m.fetchAndBroadcast(ctx)
}
}
func (m *Manager) fetchAndBroadcast(ctx context.Context) {
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel()
status, err := m.client.Status(statusCtx)
if err != nil {
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
return
}
state := convertStatus(status)
m.updateState(state)
}
func (m *Manager) updateState(state *TailscaleState) {
m.stateMutex.Lock()
m.state = state
m.stateMutex.Unlock()
m.broadcastState(*state)
}
func (m *Manager) broadcastState(state TailscaleState) {
if m.closed.Load() {
return
}
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
select {
case ch <- state:
default:
}
return true
})
}
// IsAvailable reports whether tailscaled has been reachable via the IPN bus
// at least once since the manager started. False means tailscaled appears
// to not be installed or has never been running.
func (m *Manager) IsAvailable() bool {
return m.available.Load()
}
// SetAvailabilityCallback registers a callback fired when the manager
// transitions from unavailable to available. Replaces any previously set
// callback. Must be set before the manager has a chance to detect tailscaled.
func (m *Manager) SetAvailabilityCallback(cb func(bool)) {
m.availabilityCallback.Store(&cb)
}
func (m *Manager) markAvailable() {
if m.available.Swap(true) {
return
}
if cb := m.availabilityCallback.Load(); cb != nil {
(*cb)(true)
}
}
// GetState returns a copy of the current Tailscale state.
func (m *Manager) GetState() TailscaleState {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
return TailscaleState{}
}
return *m.state
}
// Subscribe creates a buffered channel for the given client ID.
func (m *Manager) Subscribe(clientID string) chan TailscaleState {
ch := make(chan TailscaleState, 64)
m.subscribers.Store(clientID, ch)
return ch
}
// Unsubscribe removes and closes the subscriber channel.
func (m *Manager) Unsubscribe(clientID string) {
if val, ok := m.subscribers.LoadAndDelete(clientID); ok {
close(val)
}
}
// Close stops the watch loop and closes all subscriber channels.
func (m *Manager) Close() {
m.closed.Store(true)
m.cancel()
m.watchWG.Wait()
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
// RefreshState triggers an immediate status fetch and broadcasts.
func (m *Manager) RefreshState() {
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
defer cancel()
status, err := m.client.Status(ctx)
if err != nil {
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
return
}
state := convertStatus(status)
m.updateState(state)
}
@@ -0,0 +1,298 @@
package tailscale
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
// mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel.
type mockWatcher struct {
events []ipn.Notify
idx int
err error
done chan struct{}
ctx context.Context
mu sync.Mutex
closed bool
}
func newMockWatcher(ctx context.Context, events []ipn.Notify, err error) *mockWatcher {
return &mockWatcher{
events: events,
err: err,
done: make(chan struct{}),
ctx: ctx,
}
}
func (w *mockWatcher) Next() (ipn.Notify, error) {
w.mu.Lock()
if w.idx < len(w.events) {
n := w.events[w.idx]
w.idx++
w.mu.Unlock()
return n, nil
}
if w.err != nil {
err := w.err
w.mu.Unlock()
return ipn.Notify{}, err
}
w.mu.Unlock()
select {
case <-w.done:
return ipn.Notify{}, fmt.Errorf("watcher closed")
case <-w.ctx.Done():
return ipn.Notify{}, w.ctx.Err()
}
}
func (w *mockWatcher) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if !w.closed {
w.closed = true
close(w.done)
}
return nil
}
// mockClient implements tailscaleClient for testing.
type mockClient struct {
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
statusFn func(ctx context.Context) (*ipnstate.Status, error)
}
func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
return c.watchFn(ctx, mask)
}
func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) {
return c.statusFn(ctx)
}
func runningStatus() *ipnstate.Status {
return &ipnstate.Status{
Version: "1.94.2",
BackendState: "Running",
MagicDNSSuffix: "example.ts.net",
CurrentTailnet: &ipnstate.TailnetStatus{
Name: "user@example.com",
MagicDNSSuffix: "example.ts.net",
},
Self: &ipnstate.PeerStatus{
HostName: "cachyos",
DNSName: "cachyos.example.ts.net.",
OS: "linux",
Online: true,
},
}
}
func TestWatchLoop_StateChange(t *testing.T) {
stateVal := ipn.Running
var watchCount int32
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
watchCount++
if watchCount == 1 {
return newMockWatcher(ctx,
[]ipn.Notify{{State: &stateVal}},
fmt.Errorf("done"),
), nil
}
return newMockWatcher(ctx, nil, nil), nil
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
return runningStatus(), nil
},
}
m := newManager(client)
defer m.Close()
require.Eventually(t, func() bool {
s := m.GetState()
return s.Connected && s.BackendState == "Running" && s.Self.Hostname == "cachyos"
}, 2*time.Second, 10*time.Millisecond)
}
func TestWatchLoop_CoalescesNotifies(t *testing.T) {
stateVal := ipn.Running
var statusCalls atomic.Int32
notifies := make([]ipn.Notify, 0, 20)
for range 20 {
notifies = append(notifies, ipn.Notify{State: &stateVal})
}
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
return newMockWatcher(ctx, notifies, nil), nil
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
statusCalls.Add(1)
return runningStatus(), nil
},
}
m := newManager(client)
defer m.Close()
// Wait for the debounce window to expire plus margin so the burst settles.
time.Sleep(debounceWindow + 100*time.Millisecond)
calls := statusCalls.Load()
assert.Less(t, int(calls), 5,
"20 rapid notifies should coalesce to a small number of Status RPCs, got %d", calls)
assert.Greater(t, int(calls), 0, "expected at least one Status RPC")
}
func TestWatchLoop_Reconnect(t *testing.T) {
watchCalled := make(chan struct{}, 4)
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
select {
case watchCalled <- struct{}{}:
default:
}
if len(watchCalled) <= 1 {
return nil, fmt.Errorf("connection refused")
}
return newMockWatcher(ctx, nil, nil), nil
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
return runningStatus(), nil
},
}
m := newManager(client)
defer m.Close()
require.Eventually(t, func() bool {
state := m.GetState()
return state.BackendState == "Unreachable"
}, 2*time.Second, 10*time.Millisecond)
require.Eventually(t, func() bool {
return len(watchCalled) >= 2
}, 3*time.Second, 50*time.Millisecond)
}
func TestManager_Subscribe(t *testing.T) {
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
<-ctx.Done()
return nil, ctx.Err()
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
return runningStatus(), nil
},
}
m := newManager(client)
defer m.Close()
ch := m.Subscribe("test-1")
assert.NotNil(t, ch)
ch2 := m.Subscribe("test-2")
assert.NotNil(t, ch2)
m.Unsubscribe("test-1")
m.Unsubscribe("test-2")
}
func TestManager_Close(t *testing.T) {
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
<-ctx.Done()
return nil, ctx.Err()
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
return runningStatus(), nil
},
}
m := newManager(client)
ch := m.Subscribe("test")
assert.NotNil(t, ch)
assert.NotPanics(t, func() {
m.Close()
})
}
func TestManager_Availability(t *testing.T) {
var watchAttempts atomic.Int32
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
n := watchAttempts.Add(1)
if n == 1 {
return nil, fmt.Errorf("tailscaled socket not found")
}
return newMockWatcher(ctx, nil, nil), nil
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
return runningStatus(), nil
},
}
m := newManager(client)
defer m.Close()
cbFired := make(chan bool, 1)
m.SetAvailabilityCallback(func(b bool) {
select {
case cbFired <- b:
default:
}
})
assert.False(t, m.IsAvailable())
require.Eventually(t, func() bool {
return m.IsAvailable()
}, 3*time.Second, 50*time.Millisecond)
select {
case b := <-cbFired:
assert.True(t, b)
case <-time.After(time.Second):
t.Fatal("availability callback did not fire")
}
}
func TestManager_RefreshState(t *testing.T) {
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
<-ctx.Done()
return nil, ctx.Err()
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
return runningStatus(), nil
},
}
m := newManager(client)
defer m.Close()
m.RefreshState()
state := m.GetState()
assert.True(t, state.Connected)
assert.Equal(t, "cachyos", state.Self.Hostname)
}
+31
View File
@@ -0,0 +1,31 @@
package tailscale
// TailscaleState represents the current state of the Tailscale daemon.
type TailscaleState struct {
Connected bool `json:"connected"`
Version string `json:"version"`
BackendState string `json:"backendState"`
MagicDNSSuffix string `json:"magicDnsSuffix"`
TailnetName string `json:"tailnetName"`
Self Peer `json:"self"`
Peers []Peer `json:"peers"`
}
// Peer represents a single node in the Tailscale network.
type Peer struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
DNSName string `json:"dnsName"`
TailscaleIP string `json:"tailscaleIp"`
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
OS string `json:"os"`
Online bool `json:"online"`
LastSeen string `json:"lastSeen,omitempty"`
ExitNode bool `json:"exitNode"`
Tags []string `json:"tags,omitempty"`
Owner string `json:"owner"`
Relay string `json:"relay,omitempty"`
Active bool `json:"active"`
RxBytes int64 `json:"rxBytes"`
TxBytes int64 `json:"txBytes"`
}
+30
View File
@@ -38,6 +38,36 @@ func XDGConfigHome() string {
return filepath.Join(home, ".config")
}
func XDGPicturesDir() string {
if dir := os.Getenv("XDG_PICTURES_DIR"); dir != "" {
if expanded, err := ExpandPath(dir); err == nil {
return expanded
}
}
data, err := os.ReadFile(filepath.Join(XDGConfigHome(), "user-dirs.dirs"))
if err != nil {
return ""
}
const prefix = "XDG_PICTURES_DIR="
for line := range strings.SplitSeq(string(data), "\n") {
if len(line) == 0 || line[0] == '#' {
continue
}
if !strings.HasPrefix(line, prefix) {
continue
}
path := strings.Trim(line[len(prefix):], "\"")
expanded, err := ExpandPath(path)
if err != nil {
return ""
}
return expanded
}
return ""
}
func EmacsConfigDir() string {
home, _ := os.UserHomeDir()
-1
View File
@@ -3,7 +3,6 @@ Section: x11
Priority: optional
Maintainer: Avenge Media <AvengeMedia.US@gmail.com>
Build-Depends: debhelper-compat (= 13),
golang-go | golang (>= 2:1.22~) | golang-any
Standards-Version: 4.6.2
Homepage: https://github.com/AvengeMedia/DankMaterialShell
Vcs-Browser: https://github.com/AvengeMedia/DankMaterialShell
+19 -8
View File
@@ -27,20 +27,30 @@ override_dh_auto_build:
# Verify core directory exists (native package format has source at root)
test -d core || (echo "ERROR: core directory not found!" && exit 1)
# Pin go.mod and vendor/modules.txt to the installed Go toolchain version
GO_INSTALLED=$$(go version | grep -oP 'go\K[0-9]+\.[0-9]+'); \
sed -i "s/^go [0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$$/go $${GO_INSTALLED}/" core/go.mod; \
sed -i "s/^\(## explicit; go \)[0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$$/\1$${GO_INSTALLED}/" core/vendor/modules.txt
# Build dms-cli (single shell to preserve variables; arch: Debian amd64/arm64 -> Makefile amd64/arm64)
# Bundled Go at repo root (go$$VER.linux-{amd64,arm64}.tar.gz); packaged via obs-upload.sh
GO_TOOLCHAIN_VERSION=$$(grep -m1 '^go ' core/go.mod | awk '{print $$2}'); \
case "$(DEB_HOST_ARCH)" in \
amd64) GO_LINUX_ARCH=amd64 ;; \
arm64) GO_LINUX_ARCH=arm64 ;; \
*) echo "ERROR: Unsupported architecture: $(DEB_HOST_ARCH)" && exit 1 ;; \
esac; \
GO_TARBALL="$(CURDIR)/go$$GO_TOOLCHAIN_VERSION.linux-$$GO_LINUX_ARCH.tar.gz"; \
test -f "$$GO_TARBALL" || (echo "ERROR: Missing bundled Go toolchain $$GO_TARBALL" && exit 1); \
rm -rf "$(CURDIR)/go-bootstrap" "$(CURDIR)/.go-toolchain"; \
mkdir -p "$(CURDIR)/go-bootstrap"; \
tar -xzf "$$GO_TARBALL" -C "$(CURDIR)/go-bootstrap"; \
mv "$(CURDIR)/go-bootstrap/go" "$(CURDIR)/.go-toolchain"; \
export PATH="$(CURDIR)/.go-toolchain/bin:$$PATH"; \
export GOROOT="$(CURDIR)/.go-toolchain"; \
go version; \
sed -i "s/^go [0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$$/go $$GO_TOOLCHAIN_VERSION/" core/go.mod; \
sed -i "s/^\(## explicit; go \)[0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$$/\1$$GO_TOOLCHAIN_VERSION/" core/vendor/modules.txt; \
VERSION="$(UPSTREAM_VERSION)"; \
COMMIT=$$(echo "$(UPSTREAM_VERSION)" | grep -oP '(?<=git)[0-9]+\.[a-f0-9]+' | cut -d. -f2 | head -c8 || echo "unknown"); \
if [ "$(DEB_HOST_ARCH)" = "amd64" ]; then \
MAKE_ARCH=amd64; \
BINARY_NAME=dms-linux-amd64; \
elif [ "$(DEB_HOST_ARCH)" = "arm64" ]; then \
MAKE_ARCH=arm64; \
BINARY_NAME=dms-linux-arm64; \
else \
echo "ERROR: Unsupported architecture: $(DEB_HOST_ARCH)" && exit 1; \
fi; \
@@ -80,4 +90,5 @@ override_dh_auto_clean:
rm -f dms
rm -rf core/bin
rm -rf debian/tmp-home
rm -rf go-bootstrap .go-toolchain
dh_auto_clean
+31 -7
View File
@@ -1,7 +1,8 @@
%global debug_package %{nil}
%global go_toolchain_version 1.26.1
Name: dms-git
Version: 1.0.2+git2528.d336866f
Version: 1.4.0+git2528.d336866f
Release: 1%{?dist}
Epoch: 2
Summary: DankMaterialShell - Material 3 inspired shell (git nightly)
@@ -9,9 +10,9 @@ Summary: DankMaterialShell - Material 3 inspired shell (git nightly)
License: MIT
URL: https://github.com/AvengeMedia/DankMaterialShell
Source0: dms-git-source.tar.gz
Source1: go%{go_toolchain_version}.linux-amd64.tar.gz
Source2: go%{go_toolchain_version}.linux-arm64.tar.gz
BuildRequires: golang >= 1.22
BuildRequires: golang-packaging
BuildRequires: git-core
BuildRequires: systemd-rpm-macros
@@ -47,6 +48,28 @@ and fixes. Includes pre-built dms CLI binary and QML shell files.
test -d core/vendor || (echo "ERROR: Go vendor directory missing!" && exit 1)
%build
# Bundled Go toolchain
case "%{_arch}" in
x86_64)
GO_TARBALL="%{_sourcedir}/go%{go_toolchain_version}.linux-amd64.tar.gz"
;;
aarch64)
GO_TARBALL="%{_sourcedir}/go%{go_toolchain_version}.linux-arm64.tar.gz"
;;
*)
echo "Unsupported architecture for bundled Go: %{_arch}"
exit 1
;;
esac
rm -rf "%{_builddir}/go-bootstrap" "%{_builddir}/.go-toolchain"
mkdir -p "%{_builddir}/go-bootstrap"
tar -xzf "$GO_TARBALL" -C "%{_builddir}/go-bootstrap"
mv "%{_builddir}/go-bootstrap/go" "%{_builddir}/.go-toolchain"
export GOROOT="%{_builddir}/.go-toolchain"
export PATH="$GOROOT/bin:$PATH"
# Create Go cache directories (OBS build env may have restricted HOME)
export HOME=%{_builddir}/go-home
export GOCACHE=%{_builddir}/go-cache
@@ -56,10 +79,11 @@ mkdir -p $HOME $GOCACHE $GOMODCACHE
# OBS has no network access, so use local toolchain only
export GOTOOLCHAIN=local
# Pin go.mod and vendor/modules.txt to the installed Go toolchain version
GO_INSTALLED=$(go version | grep -oP 'go\K[0-9]+\.[0-9]+')
sed -i "s/^go [0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$/go ${GO_INSTALLED}/" core/go.mod
sed -i "s/^\(## explicit; go \)[0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$/\1${GO_INSTALLED}/" core/vendor/modules.txt
go version
# Pin go.mod and vendor/modules.txt to the bundled Go toolchain version
sed -i "s/^go [0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$/go %{go_toolchain_version}/" core/go.mod
sed -i "s/^\(## explicit; go \)[0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$/\1%{go_toolchain_version}/" core/vendor/modules.txt
# Extract version info for embedding in binary
VERSION="%{version}"
+67 -5
View File
@@ -115,6 +115,40 @@ osc_retry() {
done
}
# Bundled Go for dms-git OBS builds (offline VM); filenames must match distro/opensuse/dms-git.spec Source1/2.
GO_TOOLCHAIN_CACHE="${GO_TOOLCHAIN_CACHE:-$HOME/.cache/dms-obs-go-toolchain}"
dms_git_go_toolchain_version() {
grep -m1 '^go ' "$REPO_ROOT/core/go.mod" 2>/dev/null | awk '{print $2}'
}
ensure_dms_git_go_tarballs() {
local dest="$1"
local ver arch url cached
ver="$(dms_git_go_toolchain_version)"
if [[ -z "$ver" ]]; then
echo "ERROR: Could not read Go version from core/go.mod"
exit 1
fi
mkdir -p "$GO_TOOLCHAIN_CACHE/$ver"
for arch in amd64 arm64; do
url="https://go.dev/dl/go${ver}.linux-${arch}.tar.gz"
cached="$GO_TOOLCHAIN_CACHE/$ver/go${ver}.linux-${arch}.tar.gz"
if [[ ! -f "$cached" ]]; then
echo " Downloading Go ${ver} (${arch})…"
if wget -q -O "${cached}.tmp" "$url" 2>/dev/null || curl -L -f -s -o "${cached}.tmp" "$url"; then
mv "${cached}.tmp" "$cached"
else
rm -f "${cached}.tmp"
echo "ERROR: Failed to download $url"
exit 1
fi
fi
cp -f "$cached" "$dest/go${ver}.linux-${arch}.tar.gz"
echo " ✓ Go toolchain ready: $dest/go${ver}.linux-${arch}.tar.gz"
done
}
# Parameters:
# $1 = PROJECT
# $2 = PACKAGE
@@ -205,9 +239,15 @@ update_debian_dms_greeter_service() {
update_opensuse_git_spec() {
local spec_path="$1"
local go_ver
if [[ -z "$spec_path" || ! -f "$spec_path" ]]; then
return 0
fi
go_ver="$(dms_git_go_toolchain_version)"
if [[ -n "$go_ver" ]] && grep -q '^%global go_toolchain_version' "$spec_path"; then
sed -i "s/^%global go_toolchain_version .*/%global go_toolchain_version ${go_ver}/" "$spec_path"
echo " Synced %global go_toolchain_version to ${go_ver} (core/go.mod)"
fi
if [[ -n "$CHANGELOG_VERSION" ]]; then
echo " Updating OpenSUSE spec to version $CHANGELOG_VERSION"
sed -i "s/^Version:.*/Version: $CHANGELOG_VERSION/" "$spec_path"
@@ -438,7 +478,7 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ -f "distro/opensuse/$PACKAGE.spec" ]];
echo " - Copying $PACKAGE.spec for OpenSUSE"
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
if [[ "$PACKAGE" == *"-git" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
if [[ "$PACKAGE" == *"-git" ]]; then
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
DMS_GREETER_BASE_VERSION=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
@@ -570,6 +610,11 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
cd "$REPO_ROOT"
rm -rf "$OBS_TARBALL_DIR"
if [[ "$PACKAGE" == "dms-git" ]]; then
echo " - Staging bundled Go toolchains for RPM (Source1/Source2)"
ensure_dms_git_go_tarballs "$WORK_DIR"
fi
fi
else
echo " - Warning: Could not obtain source for OpenSUSE tarball"
@@ -830,12 +875,18 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
esac
cd "$REPO_ROOT"
rm -rf "$OBS_TARBALL_DIR"
if [[ "$PACKAGE" == "dms-git" ]]; then
echo " - Staging bundled Go toolchains for RPM (Source1/Source2)"
ensure_dms_git_go_tarballs "$WORK_DIR"
fi
echo " - OpenSUSE source tarballs created"
fi
# Copy and update OpenSUSE spec file with the correct version
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
if [[ "$PACKAGE" == *"-git" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
if [[ "$PACKAGE" == *"-git" ]]; then
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
DMS_GREETER_BASE_VERSION=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
@@ -891,6 +942,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
fi
fi
if [[ "$PACKAGE" == "dms-git" ]]; then
echo " Bundling Go toolchains into Debian source tree (offline build)"
ensure_dms_git_go_tarballs "$SOURCE_DIR"
fi
rm -f "$WORK_DIR/$COMBINED_TARBALL"
echo " Creating combined tarball: $COMBINED_TARBALL"
@@ -1055,6 +1111,12 @@ if [[ -n "$OBS_FILES" ]]; then
continue
fi
# Keep pinned Go toolchain archives (bundled for dms-git offline builds)
if [[ "$old_file" =~ ^go[0-9].+\.linux-(amd64|arm64)\.tar\.gz$ ]]; then
echo " - Keeping Go toolchain tarball: $old_file"
continue
fi
# Keep current orig tarball for dms-greeter (Debian 3.0 quilt needs it)
UPSTREAM_VER_CLEAN=$(echo "$CHANGELOG_VERSION" | sed 's/-[^-]*$//' 2>/dev/null)
if [[ "$PACKAGE" == "dms-greeter" ]] && [[ "$old_file" == "${PACKAGE}_${UPSTREAM_VER_CLEAN}.orig.tar.gz" ]]; then
@@ -1130,11 +1192,11 @@ ls -la 2>&1 | head -20
echo "==> Staging changes"
echo "Files to upload:"
if [[ "$UPLOAD_DEBIAN" == true ]] && [[ "$UPLOAD_OPENSUSE" == true ]]; then
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec ./*.dsc _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec ./*.dsc _service ./go*.linux-*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
elif [[ "$UPLOAD_DEBIAN" == true ]]; then
ls -lh ./*.tar.gz ./*.dsc _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
ls -lh ./*.tar.gz ./*.dsc _service ./go*.linux-*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
elif [[ "$UPLOAD_OPENSUSE" == true ]]; then
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec _service ./go*.linux-*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
fi
echo ""
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Build a DMS per-series upload plan by comparing Git/GitHub with Launchpad.
set -euo pipefail
PPA_OWNER="avengemedia"
LAUNCHPAD_API="https://api.launchpad.net/1.0"
SERIES_LIST=(questing resolute)
PACKAGE_FILTER="dms-git"
REBUILD_RELEASE=""
JSON=false
PACKAGES=(
"dms:dms:release"
"dms-git:dms-git:git"
"dms-greeter:danklinux:release"
)
while [[ $# -gt 0 ]]; do
case "$1" in
--package)
PACKAGE_FILTER="$2"
shift 2
;;
--rebuild)
REBUILD_RELEASE="$2"
shift 2
;;
--json)
JSON=true
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
latest_tag() {
git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git |
sed -n '1s|.*/v\{0,1\}||p'
}
published_version() {
local package="$1"
local ppa="$2"
local series="$3"
local series_url="https%3A%2F%2Fapi.launchpad.net%2F1.0%2Fubuntu%2F${series}"
local url="${LAUNCHPAD_API}/~${PPA_OWNER}/+archive/ubuntu/${ppa}?ws.op=getPublishedSources&source_name=${package}&status=Published&distro_series=${series_url}"
curl -fsSL "$url" 2>/dev/null | jq -r '.entries[0].source_package_version // empty'
}
release_base() {
echo "$1" | sed -E 's/ppa[0-9]+$//' | sed -E 's/-[0-9]+$//'
}
ppa_suffix() {
local version="$1"
if [[ "$version" =~ ppa([0-9]+)$ ]]; then
echo "${BASH_REMATCH[1]}"
else
echo "0"
fi
}
embedded_commit() {
echo "$1" | sed -nE 's/.*[+~]git[0-9]+\.([a-f0-9]{7,12}).*/\1/p'
}
target_ppa() {
local series="$1"
if [[ -n "$REBUILD_RELEASE" ]]; then
if [[ "$series" == "resolute" ]]; then
echo $((REBUILD_RELEASE + 1))
else
echo "$REBUILD_RELEASE"
fi
elif [[ "$series" == "resolute" ]]; then
echo "2"
else
echo "1"
fi
}
rebuild_release_is_newer() {
local series="$1"
local published="$2"
local requested current
[[ -n "$REBUILD_RELEASE" ]] || return 1
requested="$(target_ppa "$series")"
current="$(ppa_suffix "$published")"
[[ "$requested" -gt "$current" ]]
}
include_package() {
local package="$1"
[[ "$PACKAGE_FILTER" == "all" || "$PACKAGE_FILTER" == "$package" ]]
}
CURRENT_COMMIT="$(git rev-parse --short=8 HEAD)"
LATEST_TAG=""
TARGETS=()
for pkg_info in "${PACKAGES[@]}"; do
IFS=':' read -r package ppa type <<< "$pkg_info"
include_package "$package" || continue
for series in "${SERIES_LIST[@]}"; do
ppa_version="$(published_version "$package" "$ppa" "$series")"
needs_update=false
reason=""
if [[ -z "$ppa_version" ]]; then
needs_update=true
reason="missing from ${series}"
elif [[ "$type" == "git" ]]; then
ppa_commit="$(embedded_commit "$ppa_version")"
if [[ "$ppa_commit" != "$CURRENT_COMMIT" ]]; then
needs_update=true
reason="commit ${ppa_commit:-none} -> ${CURRENT_COMMIT}"
fi
else
if [[ -z "$LATEST_TAG" ]]; then
LATEST_TAG="$(latest_tag)"
fi
ppa_base="$(release_base "$ppa_version")"
if [[ "$ppa_base" != "$LATEST_TAG" ]]; then
needs_update=true
reason="version ${ppa_base:-none} -> ${LATEST_TAG}"
fi
fi
if [[ "$needs_update" != "true" ]] && rebuild_release_is_newer "$series" "$ppa_version"; then
needs_update=true
reason="rebuild ppa$(ppa_suffix "$ppa_version") -> ppa$(target_ppa "$series")"
fi
if [[ "$needs_update" == "true" ]]; then
target="${package}:${series}:$(target_ppa "$series")"
TARGETS+=("$target")
echo "${package}/${series}: ${reason} (published: ${ppa_version:-none})" >&2
else
echo "${package}/${series}: current (${ppa_version})" >&2
fi
done
done
if [[ "$JSON" == "true" ]]; then
if [[ ${#TARGETS[@]} -eq 0 ]]; then
echo "[]"
else
printf '%s\n' "${TARGETS[@]}" | jq -R -s -c 'split("\n")[:-1]'
fi
else
echo "${TARGETS[*]}"
fi
+68 -9
View File
@@ -217,6 +217,42 @@ fi
PACKAGE_DIR=$(cd "$PACKAGE_DIR" && pwd)
PARENT_DIR=$(dirname "$PACKAGE_DIR")
setup_launchpad_sftp() {
if [[ -z "${LAUNCHPAD_SSH_PRIVATE_KEY:-}" ]]; then
error "LAUNCHPAD_SSH_PRIVATE_KEY is required for CI SFTP uploads."
error "Add a GitHub Actions secret containing a private SSH key whose public key is registered in Launchpad."
error "Optional: set LAUNCHPAD_SSH_LOGIN if the Launchpad login is not 'avengemedia'."
exit 1
fi
local ssh_dir="$HOME/.ssh"
local key_file="$ssh_dir/launchpad_ppa"
local login="${LAUNCHPAD_SSH_LOGIN:-avengemedia}"
local strict_host_key_checking="yes"
mkdir -p "$ssh_dir"
chmod 700 "$ssh_dir"
printf '%s\n' "$LAUNCHPAD_SSH_PRIVATE_KEY" > "$key_file"
chmod 600 "$key_file"
if ssh-keyscan -H ppa.launchpad.net >> "$ssh_dir/known_hosts" 2>/dev/null; then
chmod 600 "$ssh_dir/known_hosts"
else
warn "Could not prefetch ppa.launchpad.net SSH host key; allowing OpenSSH to trust it on first SFTP connection"
strict_host_key_checking="accept-new"
fi
cat > "$ssh_dir/config" <<EOF
Host ppa.launchpad.net
HostName ppa.launchpad.net
User ${login}
IdentityFile ${key_file}
IdentitiesOnly yes
StrictHostKeyChecking ${strict_host_key_checking}
EOF
chmod 600 "$ssh_dir/config"
}
if [[ ${#SERIES_LIST[@]} -gt 1 ]]; then
SOURCE_FORMAT_LINE=$(head -1 "$PACKAGE_DIR/debian/source/format" 2>/dev/null || echo "")
IS_NATIVE_DUAL=false
@@ -330,8 +366,30 @@ if [ "$PPA_NAME" = "danklinux" ] || [ "$PPA_NAME" = "dms" ] || [ "$PPA_NAME" = "
info " - $BUILDINFO"
echo
LFTP_SCRIPT=$(mktemp)
cat >"$LFTP_SCRIPT" <<EOF
if [[ -n "${GITHUB_ACTIONS:-}" || -n "${CI:-}" ]] && command -v dput >/dev/null 2>&1; then
setup_launchpad_sftp
DPUT_CONFIG=$(mktemp)
cat >"$DPUT_CONFIG" <<EOF
[avengemedia-${PPA_NAME}]
fqdn = ppa.launchpad.net
method = sftp
incoming = ~avengemedia/ubuntu/${PPA_NAME}/
login = ${LAUNCHPAD_SSH_LOGIN:-avengemedia}
allow_unsigned_uploads = 0
EOF
info "Using dput for CI upload (SFTP)"
if dput -c "$DPUT_CONFIG" "avengemedia-${PPA_NAME}" "$CHANGES_FILE"; then
success "Upload successful!"
rm -f "$DPUT_CONFIG"
else
rm -f "$DPUT_CONFIG"
error "dput upload failed!"
exit 1
fi
else
LFTP_SCRIPT=$(mktemp)
cat >"$LFTP_SCRIPT" <<EOF
cd ~avengemedia/ubuntu/$PPA_NAME/
lcd $BUILD_DIR
mput $CHANGES_BASENAME
@@ -341,13 +399,14 @@ mput $BUILDINFO
bye
EOF
if lftp -d ftp://anonymous:@ppa.launchpad.net <"$LFTP_SCRIPT"; then
success "Upload successful!"
rm -f "$LFTP_SCRIPT"
else
error "Upload failed!"
rm -f "$LFTP_SCRIPT"
exit 1
if lftp -d ftp://anonymous:@ppa.launchpad.net <"$LFTP_SCRIPT"; then
success "Upload successful!"
rm -f "$LFTP_SCRIPT"
else
error "Upload failed!"
rm -f "$LFTP_SCRIPT"
exit 1
fi
fi
else
# This branch should not be reached for DMS packages
+19 -2
View File
@@ -396,6 +396,10 @@ Top bar visibility control.
- Toggle top bar visibility
- Returns: Success confirmation with current state
**`toggleReveal`**
- Toggle the runtime reveal/tuck state for an autohidden bar
- Returns: Success confirmation with current reveal state
**`status`**
- Get current top bar visibility status
- Returns: "visible" or "hidden"
@@ -403,22 +407,35 @@ Top bar visibility control.
### Examples
```bash
dms ipc call bar toggle
dms ipc call bar toggleReveal index 0
dms ipc call bar hide
dms ipc call bar status
```
## Target: `systemupdater`
System updater external check request.
System updater widget control and background update checks.
### Functions
**`toggle`**
- Toggle the system updater popout open/closed
**`open`**
- Open the system updater popout
**`close`**
- Close the system updater popout
**`updatestatus`**
- Trigger a system update check
- Trigger a background update check
- Returns: Success confirmation
### Examples
```bash
dms ipc call systemupdater toggle
dms ipc call systemupdater open
dms ipc call systemupdater close
dms ipc call systemupdater updatestatus
```
+3 -1
View File
@@ -111,7 +111,7 @@
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-kPu3MLqhLaCaBpCwIP8JXep0J/Z45kxDFOEY8JvcWdU=";
vendorHash = "sha256-nvxFHQhOfBGl3h51fgYDb39K0NCj+H8mAEyKr1qOwJQ=";
subPackages = [ "cmd/dms" ];
@@ -192,6 +192,8 @@
}
);
lib = { inherit mkDmsShell buildDmsPkgs; };
homeModules.dank-material-shell = mkModuleWithDmsPkgs ./distro/nix/home.nix;
homeModules.default = self.homeModules.dank-material-shell;
+62
View File
@@ -0,0 +1,62 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
// AnimVariants central tuning for animation variants (Material/Fluent/Dynamic)
// and motion effects (Standard/Directional/Depth). Lookups are indexed by enum
// value: animationVariant 0=Material, 1=Fluent, 2=Dynamic; motionEffect
// 0=Standard, 1=Directional, 2=Depth.
Singleton {
id: root
readonly property int _variant: (typeof SettingsData === "undefined") ? 0 : SettingsData.animationVariant
readonly property int _effect: (typeof SettingsData === "undefined") ? 0 : SettingsData.motionEffect
readonly property var _enterCurves: [Anims.expressiveDefaultSpatial, Anims.standardDecel, Anims.expressiveFastSpatial]
readonly property var _exitCurves: [Anims.emphasized, Anims.standard, Anims.emphasized]
readonly property var _directionalExitCurves: [Anims.emphasized, Anims.emphasizedAccel, Anims.emphasizedAccel]
readonly property var _enterDurationFactors: [1.0, 0.9, 1.08]
readonly property var _exitDurationFactors: [1.0, 0.85, 0.92]
readonly property var _cleanupPaddings: [50, 8, 24]
readonly property var _effectScaleCollapsed: [0.96, 1.0, 0.88]
readonly property var _effectAnimOffsets: [16, 144, 56]
readonly property list<real> variantEnterCurve: _enterCurves[_variant] || _enterCurves[0]
readonly property list<real> variantExitCurve: _exitCurves[_variant] || _exitCurves[0]
readonly property list<real> variantModalEnterCurve: isDirectionalEffect && _variant !== 0 ? (_enterCurves[_variant] || _enterCurves[0]) : variantEnterCurve
readonly property list<real> variantModalExitCurve: isDirectionalEffect ? (_directionalExitCurves[_variant] || _exitCurves[0]) : variantExitCurve
readonly property list<real> variantPopoutEnterCurve: isDirectionalEffect ? (_variant === 0 ? Anims.standardDecel : (_enterCurves[_variant] || _enterCurves[0])) : variantEnterCurve
readonly property list<real> variantPopoutExitCurve: isDirectionalEffect ? (_directionalExitCurves[_variant] || _exitCurves[0]) : variantExitCurve
readonly property real variantEnterDurationFactor: _enterDurationFactors[_variant] !== undefined ? _enterDurationFactors[_variant] : 1.0
readonly property real variantExitDurationFactor: _exitDurationFactors[_variant] !== undefined ? _exitDurationFactors[_variant] : 1.0
// Fluent: opacity at ~55% of duration; Material/Dynamic: 1:1 with position
readonly property real variantOpacityDurationScale: _variant === 1 ? 0.55 : 1.0
function variantDuration(baseDuration, entering) {
const factor = entering ? variantEnterDurationFactor : variantExitDurationFactor;
return Math.max(0, Math.round(baseDuration * factor));
}
function variantExitCleanupPadding() {
return _cleanupPaddings[_effect] !== undefined ? _cleanupPaddings[_effect] : 50;
}
function variantCloseInterval(baseDuration) {
return variantDuration(baseDuration, false) + variantExitCleanupPadding();
}
readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1
readonly property bool isDepthEffect: _effect === 2
readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.connectedFrameModeActive
readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96
readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16
}
+5
View File
@@ -22,4 +22,9 @@ Singleton {
readonly property var standard: [0.20, 0.00, 0.00, 1.00, 1.00, 1.00]
readonly property var standardDecel: [0.00, 0.00, 0.00, 1.00, 1.00, 1.00]
readonly property var standardAccel: [0.30, 0.00, 1.00, 1.00, 1.00, 1.00]
// Used by AnimVariants for variant/effect logic
readonly property var expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1]
readonly property var expressiveFastSpatial: [0.34, 1.5, 0.2, 1.0, 1.0, 1.0]
readonly property var expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1]
}
+6 -17
View File
@@ -1,40 +1,29 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
Singleton {
id: root
// Clear all image cache
function clearImageCache() {
Quickshell.execDetached(["rm", "-rf", Paths.stringify(Paths.imagecache)]);
Paths.mkdir(Paths.imagecache);
}
// Clear cache older than specified minutes
function clearOldCache(ageInMinutes) {
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", "*.png", "-mmin", `+${ageInMinutes}`, "-delete"]);
}
// Clear cache for specific size
function clearCacheForSize(size) {
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", `*@${size}x${size}.png`, "-delete"]);
}
// Get cache size in MB
function getCacheSize(callback) {
var process = Qt.createQmlObject(`
import Quickshell.Io
Process {
command: ["du", "-sm", "${Paths.stringify(Paths.imagecache)}"]
running: true
stdout: StdioCollector {
onStreamFinished: {
var sizeMB = parseInt(text.split("\\t")[0]) || 0
callback(sizeMB)
}
}
}
`, root);
Proc.runCommand("cache_size", ["du", "-sm", Paths.stringify(Paths.imagecache)], function (output, exitCode) {
const sizeMB = parseInt(output.split("\t")[0]) || 0;
callback(sizeMB);
});
}
}
+518
View File
@@ -0,0 +1,518 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
Singleton {
id: root
readonly property var emptyDockState: ({
"reveal": false,
"barSide": "bottom",
"bodyX": 0,
"bodyY": 0,
"bodyW": 0,
"bodyH": 0,
"slideX": 0,
"slideY": 0
})
// Popout state (updated by DankPopout when connectedFrameModeActive)
property string popoutOwnerId: ""
property bool popoutVisible: false
property string popoutBarSide: "top"
property real popoutBodyX: 0
property real popoutBodyY: 0
property real popoutBodyW: 0
property real popoutBodyH: 0
property real popoutAnimX: 0
property real popoutAnimY: 0
property string popoutScreen: ""
property bool popoutOmitStartConnector: false
property bool popoutOmitEndConnector: false
// Dock state (updated by Dock when connectedFrameModeActive), keyed by screen.name
property var dockStates: ({})
// Dock slide offsets hot-path updates separated from full geometry state
property var dockSlides: ({})
function _cloneDict(src) {
const next = {};
for (const k in src)
next[k] = src[k];
return next;
}
function hasPopoutOwner(claimId) {
return !!claimId && popoutOwnerId === claimId;
}
function claimPopout(claimId, state) {
if (!claimId)
return false;
popoutOwnerId = claimId;
return updatePopout(claimId, state);
}
function updatePopout(claimId, state) {
if (!hasPopoutOwner(claimId) || !state)
return false;
if (state.visible !== undefined)
popoutVisible = !!state.visible;
if (state.barSide !== undefined)
popoutBarSide = state.barSide || "top";
if (state.bodyX !== undefined)
popoutBodyX = Number(state.bodyX);
if (state.bodyY !== undefined)
popoutBodyY = Number(state.bodyY);
if (state.bodyW !== undefined)
popoutBodyW = Number(state.bodyW);
if (state.bodyH !== undefined)
popoutBodyH = Number(state.bodyH);
if (state.animX !== undefined)
popoutAnimX = Number(state.animX);
if (state.animY !== undefined)
popoutAnimY = Number(state.animY);
if (state.screen !== undefined)
popoutScreen = state.screen || "";
if (state.omitStartConnector !== undefined)
popoutOmitStartConnector = !!state.omitStartConnector;
if (state.omitEndConnector !== undefined)
popoutOmitEndConnector = !!state.omitEndConnector;
return true;
}
function releasePopout(claimId) {
if (!hasPopoutOwner(claimId))
return false;
popoutOwnerId = "";
popoutVisible = false;
popoutBarSide = "top";
popoutBodyX = 0;
popoutBodyY = 0;
popoutBodyW = 0;
popoutBodyH = 0;
popoutAnimX = 0;
popoutAnimY = 0;
popoutScreen = "";
popoutOmitStartConnector = false;
popoutOmitEndConnector = false;
return true;
}
function setPopoutAnim(claimId, animX, animY) {
if (!hasPopoutOwner(claimId))
return false;
if (animX !== undefined) {
const nextX = Number(animX);
if (!isNaN(nextX) && popoutAnimX !== nextX)
popoutAnimX = nextX;
}
if (animY !== undefined) {
const nextY = Number(animY);
if (!isNaN(nextY) && popoutAnimY !== nextY)
popoutAnimY = nextY;
}
return true;
}
function setPopoutBody(claimId, bodyX, bodyY, bodyW, bodyH) {
if (!hasPopoutOwner(claimId))
return false;
if (bodyX !== undefined) {
const nextX = Number(bodyX);
if (!isNaN(nextX) && popoutBodyX !== nextX)
popoutBodyX = nextX;
}
if (bodyY !== undefined) {
const nextY = Number(bodyY);
if (!isNaN(nextY) && popoutBodyY !== nextY)
popoutBodyY = nextY;
}
if (bodyW !== undefined) {
const nextW = Number(bodyW);
if (!isNaN(nextW) && popoutBodyW !== nextW)
popoutBodyW = nextW;
}
if (bodyH !== undefined) {
const nextH = Number(bodyH);
if (!isNaN(nextH) && popoutBodyH !== nextH)
popoutBodyH = nextH;
}
return true;
}
function _normalizeDockState(state) {
return {
"reveal": !!(state && state.reveal),
"barSide": state && state.barSide ? state.barSide : "bottom",
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
"slideX": Number(state && state.slideX !== undefined ? state.slideX : 0),
"slideY": Number(state && state.slideY !== undefined ? state.slideY : 0)
};
}
function _sameDockState(a, b) {
if (!a || !b)
return false;
return a.reveal === b.reveal && a.barSide === b.barSide && Math.abs(a.bodyX - b.bodyX) < 0.5 && Math.abs(a.bodyY - b.bodyY) < 0.5 && Math.abs(a.bodyW - b.bodyW) < 0.5 && Math.abs(a.bodyH - b.bodyH) < 0.5 && Math.abs(a.slideX - b.slideX) < 0.5 && Math.abs(a.slideY - b.slideY) < 0.5;
}
function setDockState(screenName, state) {
if (!screenName || !state)
return false;
const normalized = _normalizeDockState(state);
if (_sameDockState(dockStates[screenName], normalized))
return true;
const next = _cloneDict(dockStates);
next[screenName] = normalized;
dockStates = next;
return true;
}
function clearDockState(screenName) {
if (!screenName || !dockStates[screenName])
return false;
const next = _cloneDict(dockStates);
delete next[screenName];
dockStates = next;
// Also clear corresponding slide
if (dockSlides[screenName]) {
const nextSlides = _cloneDict(dockSlides);
delete nextSlides[screenName];
dockSlides = nextSlides;
}
return true;
}
function setDockSlide(screenName, x, y) {
if (!screenName)
return false;
const numX = Number(x);
const numY = Number(y);
const cur = dockSlides[screenName];
if (cur && Math.abs(cur.x - numX) < 0.5 && Math.abs(cur.y - numY) < 0.5)
return true;
const next = _cloneDict(dockSlides);
next[screenName] = {
"x": numX,
"y": numY
};
dockSlides = next;
return true;
}
readonly property var emptyNotificationState: ({
"visible": false,
"barSide": "top",
"bodyX": 0,
"bodyY": 0,
"bodyW": 0,
"bodyH": 0,
"omitStartConnector": false,
"omitEndConnector": false
})
property var notificationStates: ({})
function _normalizeNotificationState(state) {
return {
"visible": !!(state && state.visible),
"barSide": state && state.barSide ? state.barSide : "top",
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
"omitStartConnector": !!(state && state.omitStartConnector),
"omitEndConnector": !!(state && state.omitEndConnector)
};
}
function _sameNotificationGeometry(a, b) {
if (!a || !b)
return false;
return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5;
}
function _sameNotificationState(a, b) {
if (!a || !b)
return false;
return a.visible === b.visible && a.barSide === b.barSide && a.omitStartConnector === b.omitStartConnector && a.omitEndConnector === b.omitEndConnector && _sameNotificationGeometry(a, b);
}
function setNotificationState(screenName, state) {
if (!screenName || !state)
return false;
const normalized = _normalizeNotificationState(state);
if (_sameNotificationState(notificationStates[screenName], normalized))
return true;
const next = _cloneDict(notificationStates);
next[screenName] = normalized;
notificationStates = next;
return true;
}
function clearNotificationState(screenName) {
if (!screenName || !notificationStates[screenName])
return false;
const next = _cloneDict(notificationStates);
delete next[screenName];
notificationStates = next;
return true;
}
// DankModal / DankLauncherV2Modal State
readonly property var emptyModalState: ({
"visible": false,
"barSide": "bottom",
"bodyX": 0,
"bodyY": 0,
"bodyW": 0,
"bodyH": 0,
"animX": 0,
"animY": 0,
"omitStartConnector": false,
"omitEndConnector": false
})
property var modalStates: ({})
property var modalOwners: ({})
function _normalizeModalState(state) {
return {
"visible": !!(state && state.visible),
"barSide": state && state.barSide ? state.barSide : "bottom",
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
"animX": Number(state && state.animX !== undefined ? state.animX : 0),
"animY": Number(state && state.animY !== undefined ? state.animY : 0),
"omitStartConnector": !!(state && state.omitStartConnector),
"omitEndConnector": !!(state && state.omitEndConnector)
};
}
function _sameModalGeometry(a, b) {
if (!a || !b)
return false;
return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5 && Math.abs(Number(a.animX) - Number(b.animX)) < 0.5 && Math.abs(Number(a.animY) - Number(b.animY)) < 0.5;
}
function _sameModalState(a, b) {
if (!a || !b)
return false;
return a.visible === b.visible && a.barSide === b.barSide && a.omitStartConnector === b.omitStartConnector && a.omitEndConnector === b.omitEndConnector && _sameModalGeometry(a, b);
}
function claimModalState(screenName, state, ownerId) {
if (!screenName || !state)
return false;
if (ownerId) {
const nextOwners = _cloneDict(modalOwners);
nextOwners[screenName] = ownerId;
modalOwners = nextOwners;
}
const normalized = _normalizeModalState(state);
if (_sameModalState(modalStates[screenName], normalized))
return true;
const next = _cloneDict(modalStates);
next[screenName] = normalized;
modalStates = next;
return true;
}
function updateModalState(screenName, state, ownerId) {
if (!screenName || !state)
return false;
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
return false;
const normalized = _normalizeModalState(state);
if (_sameModalState(modalStates[screenName], normalized))
return true;
const next = _cloneDict(modalStates);
next[screenName] = normalized;
modalStates = next;
return true;
}
function setModalState(screenName, state) {
return updateModalState(screenName, state, null);
}
function clearModalState(screenName, ownerId) {
if (!screenName || !modalStates[screenName])
return false;
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
return false;
const next = _cloneDict(modalStates);
delete next[screenName];
modalStates = next;
if (modalOwners[screenName]) {
const nextOwners = _cloneDict(modalOwners);
delete nextOwners[screenName];
modalOwners = nextOwners;
}
return true;
}
function setModalAnim(screenName, animX, animY, ownerId) {
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
return false;
const cur = screenName ? modalStates[screenName] : null;
if (!cur)
return false;
const nax = animX !== undefined ? Number(animX) : cur.animX;
const nay = animY !== undefined ? Number(animY) : cur.animY;
if (Math.abs(nax - cur.animX) < 0.5 && Math.abs(nay - cur.animY) < 0.5)
return false;
const next = _cloneDict(modalStates);
next[screenName] = Object.assign({}, cur, {
"animX": nax,
"animY": nay
});
modalStates = next;
return true;
}
function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH, ownerId) {
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
return false;
const cur = screenName ? modalStates[screenName] : null;
if (!cur)
return false;
const nx = bodyX !== undefined ? Number(bodyX) : cur.bodyX;
const ny = bodyY !== undefined ? Number(bodyY) : cur.bodyY;
const nw = bodyW !== undefined ? Number(bodyW) : cur.bodyW;
const nh = bodyH !== undefined ? Number(bodyH) : cur.bodyH;
if (Math.abs(nx - cur.bodyX) < 0.5 && Math.abs(ny - cur.bodyY) < 0.5 && Math.abs(nw - cur.bodyW) < 0.5 && Math.abs(nh - cur.bodyH) < 0.5)
return false;
const next = _cloneDict(modalStates);
next[screenName] = Object.assign({}, cur, {
"bodyX": nx,
"bodyY": ny,
"bodyW": nw,
"bodyH": nh
});
modalStates = next;
return true;
}
property var dockRetractRequests: ({})
function requestDockRetract(requesterId, screenName, side) {
if (!requesterId || !screenName || !side)
return false;
const existing = dockRetractRequests[requesterId];
if (existing && existing.screenName === screenName && existing.side === side)
return true;
const next = _cloneDict(dockRetractRequests);
next[requesterId] = {
"screenName": screenName,
"side": side
};
dockRetractRequests = next;
return true;
}
function releaseDockRetract(requesterId) {
if (!requesterId || !dockRetractRequests[requesterId])
return false;
const next = _cloneDict(dockRetractRequests);
delete next[requesterId];
dockRetractRequests = next;
return true;
}
function dockRetractActiveForSide(screenName, side) {
if (!screenName || !side)
return false;
for (const k in dockRetractRequests) {
const r = dockRetractRequests[k];
if (r && r.screenName === screenName && r.side === side)
return true;
}
return false;
}
// Prune state for screens that are no longer connected. Stale entries
// accumulate across hotplug cycles otherwise Frame's per-screen
// FrameInstance doesn't notice when its peer dicts go orphan.
function _pruneToLiveScreens() {
const live = {};
const screens = Quickshell.screens || [];
for (let i = 0; i < screens.length; i++) {
const s = screens[i];
if (s && s.name)
live[s.name] = true;
}
function pruneKeyed(dict) {
let changed = false;
const next = {};
for (const k in dict) {
if (live[k])
next[k] = dict[k];
else
changed = true;
}
return changed ? next : null;
}
const nextDock = pruneKeyed(dockStates);
if (nextDock !== null)
dockStates = nextDock;
const nextSlides = pruneKeyed(dockSlides);
if (nextSlides !== null)
dockSlides = nextSlides;
const nextNotif = pruneKeyed(notificationStates);
if (nextNotif !== null)
notificationStates = nextNotif;
const nextModal = pruneKeyed(modalStates);
if (nextModal !== null)
modalStates = nextModal;
const nextModalOwners = pruneKeyed(modalOwners);
if (nextModalOwners !== null)
modalOwners = nextModalOwners;
let retractChanged = false;
const nextRetract = {};
for (const k in dockRetractRequests) {
const r = dockRetractRequests[k];
if (r && live[r.screenName])
nextRetract[k] = r;
else
retractChanged = true;
}
if (retractChanged)
dockRetractRequests = nextRetract;
if (popoutOwnerId && popoutScreen && !live[popoutScreen])
releasePopout(popoutOwnerId);
}
Connections {
target: Quickshell
function onScreensChanged() {
root._pruneToLiveScreens();
}
}
}
+68
View File
@@ -0,0 +1,68 @@
.pragma library
// Geometry for connected-frame arc connectors.
// `barSide` is one of "top" | "bottom" | "left" | "right" — the edge where the
// host bar/dock sits. `placement` is "left" (start) or "right" (end) of the
// body's far edge. `radius` is the connector's arc radius. `spacing` is the
// gap between the host edge and the body.
function isVertical(barSide) {
return barSide === "left" || barSide === "right";
}
function isHorizontal(barSide) {
return barSide === "top" || barSide === "bottom";
}
function connectorWidth(barSide, spacing, radius) {
return isVertical(barSide) ? (spacing + radius) : radius;
}
function connectorHeight(barSide, spacing, radius) {
return isVertical(barSide) ? radius : (spacing + radius);
}
function seamX(barSide, baseX, bodyWidth, placement) {
if (!isVertical(barSide))
return placement === "left" ? baseX : baseX + bodyWidth;
return barSide === "left" ? baseX : baseX + bodyWidth;
}
function seamY(barSide, baseY, bodyHeight, placement) {
if (barSide === "top")
return baseY;
if (barSide === "bottom")
return baseY + bodyHeight;
return placement === "left" ? baseY : baseY + bodyHeight;
}
function connectorX(barSide, baseX, bodyWidth, placement, spacing, radius) {
var s = seamX(barSide, baseX, bodyWidth, placement);
var w = connectorWidth(barSide, spacing, radius);
if (!isVertical(barSide))
return placement === "left" ? s - w : s;
return barSide === "left" ? s : s - w;
}
function connectorY(barSide, baseY, bodyHeight, placement, spacing, radius) {
var s = seamY(barSide, baseY, bodyHeight, placement);
var h = connectorHeight(barSide, spacing, radius);
if (barSide === "top")
return s;
if (barSide === "bottom")
return s - h;
return placement === "left" ? s - h : s;
}
// Which corner of the connector's bounding rect hosts the concave arc that
// carves into the body. Used for arc-sweep orientation.
function arcCorner(barSide, placement) {
var left = placement === "left";
if (barSide === "top")
return left ? "bottomLeft" : "bottomRight";
if (barSide === "bottom")
return left ? "topLeft" : "topRight";
if (barSide === "left")
return left ? "topRight" : "bottomRight";
return left ? "topLeft" : "bottomLeft";
}
+55
View File
@@ -0,0 +1,55 @@
import QtQuick
Item {
id: root
visible: false
width: 0
height: 0
property int interval: 0
property bool pending: false
signal triggered
function schedule() {
if (!root.enabled || root.pending)
return;
root.pending = true;
deferTimer.restart();
}
function restart() {
if (!root.enabled)
return;
root.pending = true;
deferTimer.restart();
}
function flush() {
if (!root.pending)
return;
deferTimer.stop();
root.pending = false;
root.triggered();
}
function cancel() {
deferTimer.stop();
root.pending = false;
}
onEnabledChanged: {
if (!enabled)
cancel();
}
Timer {
id: deferTimer
interval: root.interval
repeat: false
onTriggered: root.flush()
}
Component.onDestruction: cancel()
}
+10 -1
View File
@@ -13,8 +13,13 @@ Item {
property color targetColor: "white"
property real targetRadius: Theme.cornerRadius
property real topLeftRadius: targetRadius
property real topRightRadius: targetRadius
property real bottomLeftRadius: targetRadius
property real bottomRightRadius: targetRadius
property color borderColor: "transparent"
property real borderWidth: 0
property bool useCustomSource: false
property bool shadowEnabled: Theme.elevationEnabled
property real shadowBlurPx: level && level.blurPx !== undefined ? level.blurPx : 0
@@ -46,7 +51,11 @@ Item {
Rectangle {
id: sourceRect
anchors.fill: parent
radius: root.targetRadius
visible: !root.useCustomSource
topLeftRadius: root.topLeftRadius
topRightRadius: root.topRightRadius
bottomLeftRadius: root.bottomLeftRadius
bottomRightRadius: root.bottomRightRadius
color: root.targetColor
border.color: root.borderColor
border.width: root.borderWidth
+1
View File
@@ -83,6 +83,7 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call bar toggle index 0", label: "Bar: Toggle (Primary)" },
{ id: "spawn dms ipc call bar reveal index 0", label: "Bar: Reveal (Primary)" },
{ id: "spawn dms ipc call bar hide index 0", label: "Bar: Hide (Primary)" },
{ id: "spawn dms ipc call bar toggleReveal index 0", label: "Bar: Toggle Autohide Reveal (Primary)" },
{ id: "spawn dms ipc call bar toggleAutoHide index 0", label: "Bar: Toggle Auto-Hide (Primary)" },
{ id: "spawn dms ipc call bar autoHide index 0", label: "Bar: Enable Auto-Hide (Primary)" },
{ id: "spawn dms ipc call bar manualHide index 0", label: "Bar: Disable Auto-Hide (Primary)" },
+26 -13
View File
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
Singleton {
@@ -21,7 +22,7 @@ Singleton {
const isRandomId = !id;
if (!_procDebouncers[procId]) {
const t = Qt.createQmlObject('import QtQuick; Timer { repeat: false }', root);
const t = debounceTimerComp.createObject(root);
t.triggered.connect(function () {
_launchProc(procId, isRandomId);
});
@@ -49,14 +50,10 @@ Singleton {
const entry = _procDebouncers[id];
if (!entry)
return;
const proc = Qt.createQmlObject('import Quickshell.Io; Process { running: false }', root);
const out = Qt.createQmlObject('import Quickshell.Io; StdioCollector {}', proc);
const err = Qt.createQmlObject('import Quickshell.Io; StdioCollector {}', proc);
const timeoutTimer = Qt.createQmlObject('import QtQuick; Timer { repeat: false }', root);
proc.stdout = out;
proc.stderr = err;
proc.command = entry.command;
const proc = procComp.createObject(root, {
command: entry.command
});
const timeoutTimer = debounceTimerComp.createObject(root);
let capturedOut = "";
let capturedErr = "";
@@ -77,9 +74,9 @@ Singleton {
}
});
out.streamFinished.connect(function () {
proc.stdout.streamFinished.connect(function () {
try {
capturedOut = out.text || "";
capturedOut = proc.stdout.text || "";
} catch (e) {
capturedOut = "";
}
@@ -87,9 +84,9 @@ Singleton {
maybeComplete();
});
err.streamFinished.connect(function () {
proc.stderr.streamFinished.connect(function () {
try {
capturedErr = err.text || "";
capturedErr = proc.stderr.text || "";
} catch (e) {
capturedErr = "";
}
@@ -140,4 +137,20 @@ Singleton {
if (entry.timeoutMs !== noTimeout)
timeoutTimer.start();
}
Component {
id: debounceTimerComp
Timer {
repeat: false
}
}
Component {
id: procComp
Process {
running: false
stdout: StdioCollector {}
stderr: StdioCollector {}
}
}
}
+363 -11
View File
@@ -15,7 +15,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 5
readonly property int settingsConfigVersion: 11
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -38,6 +38,18 @@ Singleton {
Custom
}
enum AnimationVariant {
Material,
Fluent,
Dynamic
}
enum AnimationEffect {
Standard, // 0 M3: scale-in, rises from below
Directional, // 1 pure large slide, no scale
Depth // 2 medium slide with deep depth scale pop
}
enum SuspendBehavior {
Suspend,
Hibernate,
@@ -49,6 +61,20 @@ Singleton {
Colorful
}
enum TextRenderType {
Qt,
Native,
Curve
}
enum TextRenderQuality {
Default,
Low,
Normal,
High,
VeryHigh
}
readonly property string _homeUrl: StandardPaths.writableLocation(StandardPaths.HomeLocation)
readonly property string _configUrl: StandardPaths.writableLocation(StandardPaths.ConfigLocation)
readonly property string _configDir: Paths.strip(_configUrl)
@@ -169,6 +195,10 @@ Singleton {
property int modalCustomAnimationDuration: 150
property bool enableRippleEffects: true
onEnableRippleEffectsChanged: saveSettings()
property int animationVariant: SettingsData.AnimationVariant.Material
onAnimationVariantChanged: saveSettings()
property int motionEffect: SettingsData.AnimationEffect.Standard
onMotionEffectChanged: saveSettings()
property bool m3ElevationEnabled: true
onM3ElevationEnabledChanged: saveSettings()
property int m3ElevationIntensity: 12
@@ -187,6 +217,7 @@ Singleton {
onPopoutElevationEnabledChanged: saveSettings()
property bool barElevationEnabled: true
onBarElevationEnabledChanged: saveSettings()
property bool blurEnabled: false
onBlurEnabledChanged: saveSettings()
property bool blurForegroundLayers: true
@@ -203,6 +234,53 @@ Singleton {
property bool blurredWallpaperLayer: false
property bool blurWallpaperOnOverview: false
property bool frameEnabled: false
onFrameEnabledChanged: saveSettings()
property real frameThickness: 16
onFrameThicknessChanged: saveSettings()
property real frameRounding: 23
onFrameRoundingChanged: saveSettings()
property string frameColor: ""
onFrameColorChanged: saveSettings()
property real frameOpacity: 1.0
onFrameOpacityChanged: saveSettings()
property var frameScreenPreferences: ["all"]
onFrameScreenPreferencesChanged: saveSettings()
property real frameBarSize: 40
onFrameBarSizeChanged: saveSettings()
property bool frameShowOnOverview: false
onFrameShowOnOverviewChanged: saveSettings()
property bool frameBlurEnabled: true
onFrameBlurEnabledChanged: saveSettings()
property bool frameCloseGaps: true
onFrameCloseGapsChanged: saveSettings()
property string frameLauncherEmergeSide: "bottom"
onFrameLauncherEmergeSideChanged: saveSettings()
property bool frameLauncherArcExtender: false
onFrameLauncherArcExtenderChanged: saveSettings()
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
property string frameMode: "connected"
onFrameModeChanged: saveSettings()
property var connectedFrameBarStyleBackups: ({})
onConnectedFrameBarStyleBackupsChanged: saveSettings()
readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected"
onConnectedFrameModeActiveChanged: {
if (_loading)
return;
_reconcileConnectedFrameBarStyles();
}
readonly property color effectiveFrameColor: {
const fc = frameColor;
if (!fc || fc === "default")
return Theme.surfaceContainer;
if (fc === "primary")
return Theme.primary;
if (fc === "surface")
return Theme.surface;
return fc;
}
property bool showLauncherButton: true
property bool showWorkspaceSwitcher: true
property bool showFocusedWindow: true
@@ -419,6 +497,8 @@ Singleton {
property int fontWeight: Font.Normal
property real fontScale: 1.0
property real dankBarFontScale: 1.0
property int textRenderType: SettingsData.TextRenderType.Native
property int textRenderQuality: SettingsData.TextRenderQuality.Default
property bool notepadUseMonospace: true
property string notepadFontFamily: ""
@@ -494,6 +574,7 @@ Singleton {
property bool matugenTemplatePywalfox: true
property bool matugenTemplateZenBrowser: true
property bool matugenTemplateVesktop: true
property bool matugenTemplateVencord: true
property bool matugenTemplateEquibop: true
property bool matugenTemplateGhostty: true
property bool matugenTemplateKitty: true
@@ -522,6 +603,7 @@ Singleton {
property bool showDock: false
property bool dockAutoHide: false
property bool dockSmartAutoHide: false
property bool dockHideOnFullscreen: true
property bool dockGroupByApp: false
property bool dockRestoreSpecialWorkspaceOnClick: false
property bool dockOpenOnOverview: false
@@ -655,6 +737,7 @@ Singleton {
property bool displayProfileAutoSelect: false
property bool displayShowDisconnected: false
property bool displaySnapToEdge: true
property var barIpcRevealStates: ({})
property var barConfigs: [
{
@@ -692,6 +775,7 @@ Singleton {
"fontScale": 1.0,
"iconScale": 1.0,
"autoHide": false,
"autoHideStrict": false,
"autoHideDelay": 250,
"showOnWindowsOpen": false,
"openOnOverview": false,
@@ -1275,6 +1359,9 @@ Singleton {
Store.parse(root, obj);
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
frameMode = "connected";
if (obj?.weatherLocation !== undefined)
_legacyWeatherLocation = obj.weatherLocation;
if (obj?.weatherCoordinates !== undefined)
@@ -1302,6 +1389,7 @@ Singleton {
_loading = false;
}
loadPluginSettings();
Qt.callLater(() => _reconcileConnectedFrameBarStyles());
}
property var _pendingMigration: null
@@ -1415,6 +1503,140 @@ Singleton {
pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2));
}
function _connectedFrameBarStyleSnapshot(config) {
return {
"shadowIntensity": config?.shadowIntensity ?? 0,
"squareCorners": config?.squareCorners ?? false,
"gothCornersEnabled": config?.gothCornersEnabled ?? false,
"borderEnabled": config?.borderEnabled ?? false
};
}
function _hasConnectedFrameBarStyleBackups() {
return connectedFrameBarStyleBackups && Object.keys(connectedFrameBarStyleBackups).length > 0;
}
function _captureConnectedFrameBarStyleBackups(configs, overwriteExisting) {
if (!Array.isArray(configs))
return;
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
const validIds = {};
let changed = false;
for (let i = 0; i < configs.length; i++) {
const config = configs[i];
if (!config?.id)
continue;
validIds[config.id] = true;
if (!overwriteExisting && nextBackups[config.id] !== undefined)
continue;
const snapshot = _connectedFrameBarStyleSnapshot(config);
if (JSON.stringify(nextBackups[config.id]) !== JSON.stringify(snapshot)) {
nextBackups[config.id] = snapshot;
changed = true;
}
}
if (overwriteExisting) {
for (const barId in nextBackups) {
if (validIds[barId])
continue;
delete nextBackups[barId];
changed = true;
}
}
if (changed)
connectedFrameBarStyleBackups = nextBackups;
}
function _restoreConnectedFrameBarStyleBackups() {
if (!_hasConnectedFrameBarStyleBackups())
return;
const backups = connectedFrameBarStyleBackups || {};
const configs = JSON.parse(JSON.stringify(barConfigs));
let changed = false;
for (let i = 0; i < configs.length; i++) {
const backup = backups[configs[i].id];
if (!backup)
continue;
for (const key in backup) {
if (configs[i][key] === backup[key])
continue;
configs[i][key] = backup[key];
changed = true;
}
}
if (changed)
barConfigs = configs;
connectedFrameBarStyleBackups = ({});
if (changed)
updateBarConfigs();
}
// Zeroes out connected-mode-hostile fields (shadow, square/goth corners, border).
// Returns { configs, changed } `configs` is the same ref when no change.
function _sanitizeBarConfigsForConnectedFrame(configs) {
if (!connectedFrameModeActive || !Array.isArray(configs))
return {
"configs": configs,
"changed": false
};
let anyChanged = false;
const out = configs.map(cfg => {
if (!cfg)
return cfg;
let dirty = false;
const s = Object.assign({}, cfg);
if ((s.shadowIntensity ?? 0) !== 0) {
s.shadowIntensity = 0;
dirty = true;
}
if (s.squareCorners ?? false) {
s.squareCorners = false;
dirty = true;
}
if (s.gothCornersEnabled ?? false) {
s.gothCornersEnabled = false;
dirty = true;
}
if (s.borderEnabled ?? false) {
s.borderEnabled = false;
dirty = true;
}
if (dirty)
anyChanged = true;
return dirty ? s : cfg;
});
return {
"configs": anyChanged ? out : configs,
"changed": anyChanged
};
}
// Single entry point for connected-mode settings state.
// !active restore backups
function _reconcileConnectedFrameBarStyles() {
if (!connectedFrameModeActive) {
_restoreConnectedFrameBarStyleBackups();
return;
}
if (!_hasConnectedFrameBarStyleBackups())
_captureConnectedFrameBarStyleBackups(barConfigs, true);
const result = _sanitizeBarConfigsForConnectedFrame(barConfigs);
if (result.changed) {
barConfigs = result.configs;
updateBarConfigs();
}
}
function detectAvailableIconThemes() {
const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS") || "";
const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation));
@@ -1562,35 +1784,37 @@ Singleton {
const spacing = barSpacing !== undefined ? barSpacing : (defaultBar?.spacing ?? 4);
const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top);
const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
const bottomGap = Math.max(0, rawBottomGap);
const isConnected = connectedFrameModeActive;
const bottomGap = isConnected ? 0 : Math.max(0, rawBottomGap);
const useAutoGaps = (barConfig && barConfig.popupGapsAuto !== undefined) ? barConfig.popupGapsAuto : (defaultBar?.popupGapsAuto ?? true);
const manualGapValue = (barConfig && barConfig.popupGapsManual !== undefined) ? barConfig.popupGapsManual : (defaultBar?.popupGapsManual ?? 4);
const popupGap = useAutoGaps ? Math.max(4, spacing) : manualGapValue;
const popupGap = isConnected ? 0 : (useAutoGaps ? Math.max(4, spacing) : manualGapValue);
const edgeSpacing = isConnected ? 0 : spacing;
switch (position) {
case SettingsData.Position.Left:
return {
"x": barThickness + spacing + popupGap,
"x": barThickness + edgeSpacing + popupGap,
"y": relativeY,
"width": widgetWidth
};
case SettingsData.Position.Right:
return {
"x": (screen?.width || 0) - (barThickness + spacing + popupGap),
"x": (screen?.width || 0) - (barThickness + edgeSpacing + popupGap),
"y": relativeY,
"width": widgetWidth
};
case SettingsData.Position.Bottom:
return {
"x": relativeX,
"y": (screen?.height || 0) - (barThickness + spacing + bottomGap + popupGap),
"y": (screen?.height || 0) - (barThickness + edgeSpacing + bottomGap + popupGap),
"width": widgetWidth
};
default:
return {
"x": relativeX,
"y": barThickness + spacing + bottomGap + popupGap,
"y": barThickness + edgeSpacing + bottomGap + popupGap,
"width": widgetWidth
};
}
@@ -1684,7 +1908,9 @@ Singleton {
const screenWidth = screen.width;
const screenHeight = screen.height;
const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top);
const bottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
const isConnected = connectedFrameModeActive;
const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
const bottomGap = isConnected ? 0 : rawBottomGap;
let topOffset = 0;
let bottomOffset = 0;
@@ -1706,7 +1932,7 @@ Singleton {
const otherSpacing = other.spacing !== undefined ? other.spacing : (defaultBar?.spacing ?? 4);
const otherPadding = other.innerPadding !== undefined ? other.innerPadding : (defaultBar?.innerPadding ?? 4);
const otherThickness = Math.max(26 + otherPadding * 0.6, Theme.barHeight - 4 - (8 - otherPadding)) + otherSpacing + wingSize;
const otherBottomGap = other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0);
const otherBottomGap = isConnected ? 0 : (other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0));
switch (other.position) {
case SettingsData.Position.Top:
@@ -1794,10 +2020,39 @@ Singleton {
return barConfigs.find(cfg => cfg.id === barId) || null;
}
function isBarIpcRevealed(barId) {
if (!barId)
return false;
return !!barIpcRevealStates[barId];
}
function setBarIpcReveal(barId, revealed) {
if (!barId)
return;
const nextRevealed = !!revealed;
if (!!barIpcRevealStates[barId] === nextRevealed)
return;
const states = Object.assign({}, barIpcRevealStates);
if (nextRevealed) {
states[barId] = true;
} else {
delete states[barId];
}
barIpcRevealStates = states;
}
function toggleBarIpcReveal(barId) {
const revealed = !isBarIpcRevealed(barId);
setBarIpcReveal(barId, revealed);
return revealed;
}
function addBarConfig(config) {
const configs = JSON.parse(JSON.stringify(barConfigs));
configs.push(config);
barConfigs = configs;
if (connectedFrameModeActive)
_captureConnectedFrameBarStyleBackups(configs, false);
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
updateBarConfigs();
}
@@ -1807,9 +2062,11 @@ Singleton {
if (index === -1)
return;
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
if (updates.autoHide === false || updates.visible === false)
setBarIpcReveal(barId, false);
Object.assign(configs[index], updates);
barConfigs = configs;
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
updateBarConfigs();
if (positionChanged) {
@@ -1863,6 +2120,12 @@ Singleton {
return;
const configs = barConfigs.filter(cfg => cfg.id !== barId);
barConfigs = configs;
if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
delete nextBackups[barId];
connectedFrameBarStyleBackups = nextBackups;
}
setBarIpcReveal(barId, false);
updateBarConfigs();
}
@@ -1957,6 +2220,95 @@ Singleton {
return filtered;
}
function getFrameFilteredScreens() {
var prefs = frameScreenPreferences || ["all"];
if (!prefs || prefs.length === 0 || prefs.includes("all")) {
return Quickshell.screens;
}
return Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
}
function getActiveBarEdgeForScreen(screen) {
if (!screen)
return "";
for (var i = 0; i < barConfigs.length; i++) {
var bc = barConfigs[i];
if (!bc.enabled)
continue;
var prefs = bc.screenPreferences || ["all"];
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
continue;
switch (bc.position ?? 0) {
case SettingsData.Position.Top:
return "top";
case SettingsData.Position.Bottom:
return "bottom";
case SettingsData.Position.Left:
return "left";
case SettingsData.Position.Right:
return "right";
}
}
return "";
}
function getActiveBarEdgesForScreen(screen) {
if (!screen)
return [];
var edges = [];
for (var i = 0; i < barConfigs.length; i++) {
var bc = barConfigs[i];
if (!bc.enabled)
continue;
var prefs = bc.screenPreferences || ["all"];
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
continue;
switch (bc.position ?? 0) {
case SettingsData.Position.Top:
edges.push("top");
break;
case SettingsData.Position.Bottom:
edges.push("bottom");
break;
case SettingsData.Position.Left:
edges.push("left");
break;
case SettingsData.Position.Right:
edges.push("right");
break;
}
}
return edges;
}
function frameEdgeInsetForSide(screen, side) {
if (!frameEnabled || !screen)
return 0;
const edges = getActiveBarEdgesForScreen(screen);
return edges.includes(side) ? frameBarSize : frameThickness;
}
function getActiveBarThicknessForScreen(screen) {
if (frameEnabled)
return frameBarSize;
if (!screen)
return frameThickness;
for (var i = 0; i < barConfigs.length; i++) {
var bc = barConfigs[i];
if (!bc.enabled)
continue;
var prefs = bc.screenPreferences || ["all"];
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
continue;
const innerPadding = bc.innerPadding ?? 4;
const barT = Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding));
const spacing = bc.spacing ?? 4;
const bottomGap = bc.bottomGap ?? 0;
return barT + spacing + bottomGap;
}
return frameThickness;
}
function sendTestNotifications() {
NotificationService.dismissAllPopups();
sendTestNotification(0);
+71 -12
View File
@@ -450,6 +450,7 @@ Singleton {
"primaryText": getMatugenColor("on_primary", "#ffffff"),
"primaryContainer": getMatugenColor("primary_container", "#1976d2"),
"secondary": getMatugenColor("secondary", "#8ab4f8"),
"tertiary": getMatugenColor("tertiary", "#efb8c8"),
"surface": getMatugenColor("surface", "#1a1c1e"),
"surfaceText": getMatugenColor("on_background", "#e3e8ef"),
"surfaceVariant": getMatugenColor("surface_variant", "#44464f"),
@@ -522,6 +523,7 @@ Singleton {
property color primaryText: currentThemeData.primaryText
property color primaryContainer: currentThemeData.primaryContainer
property color secondary: currentThemeData.secondary
property color tertiary: currentThemeData.tertiary || currentThemeData.secondary
property color surface: currentThemeData.surface
property color surfaceText: currentThemeData.surfaceText
property color surfaceVariant: currentThemeData.surfaceVariant
@@ -986,6 +988,46 @@ Singleton {
"expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1]
}
// Theme is the canonical access point for animation variant state. The
// aliases below forward to AnimVariants.qml so consumers don't need two
// imports. ~200 call sites read through Theme.variantEnterCurve /
// Theme.isConnectedEffect / etc. do NOT migrate to AnimVariants directly.
readonly property list<real> variantEnterCurve: AnimVariants.variantEnterCurve
readonly property list<real> variantExitCurve: AnimVariants.variantExitCurve
readonly property list<real> variantModalEnterCurve: AnimVariants.variantModalEnterCurve
readonly property list<real> variantModalExitCurve: AnimVariants.variantModalExitCurve
readonly property list<real> variantPopoutEnterCurve: AnimVariants.variantPopoutEnterCurve
readonly property list<real> variantPopoutExitCurve: AnimVariants.variantPopoutExitCurve
readonly property real variantEnterDurationFactor: AnimVariants.variantEnterDurationFactor
readonly property real variantExitDurationFactor: AnimVariants.variantExitDurationFactor
readonly property real variantOpacityDurationScale: AnimVariants.variantOpacityDurationScale
readonly property bool isDirectionalEffect: AnimVariants.isDirectionalEffect
readonly property bool isDepthEffect: AnimVariants.isDepthEffect
readonly property bool isConnectedEffect: AnimVariants.isConnectedEffect
readonly property real connectedCornerRadius: {
if (typeof SettingsData === "undefined")
return 12;
return SettingsData.connectedFrameModeActive ? SettingsData.frameRounding : cornerRadius;
}
readonly property color connectedSurfaceColor: {
if (typeof SettingsData === "undefined")
return withAlpha(surfaceContainer, popupTransparency);
return isConnectedEffect ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : withAlpha(surfaceContainer, popupTransparency);
}
readonly property real connectedSurfaceRadius: isConnectedEffect ? connectedCornerRadius : cornerRadius
readonly property bool connectedSurfaceBlurEnabled: (typeof SettingsData === "undefined") ? true : (!isConnectedEffect || SettingsData.frameBlurEnabled)
readonly property real effectScaleCollapsed: AnimVariants.effectScaleCollapsed
readonly property real effectAnimOffset: AnimVariants.effectAnimOffset
function variantDuration(baseDuration, entering) {
return AnimVariants.variantDuration(baseDuration, entering);
}
function variantExitCleanupPadding() {
return AnimVariants.variantExitCleanupPadding();
}
function variantCloseInterval(baseDuration) {
return AnimVariants.variantCloseInterval(baseDuration);
}
readonly property var animationPresetDurations: {
"none": 0,
"short": 250,
@@ -1061,6 +1103,9 @@ Singleton {
return base === 0 ? 0 : Math.round(base * 0.85);
}
readonly property int notificationInlineExpandDuration: notificationAnimationBaseDuration === 0 ? 0 : 185
readonly property int notificationInlineCollapseDuration: notificationAnimationBaseDuration === 0 ? 0 : 150
readonly property real notificationIconSizeNormal: 56
readonly property real notificationIconSizeCompact: 48
readonly property real notificationExpandedIconSizeNormal: 48
@@ -1151,7 +1196,13 @@ Singleton {
property real iconSizeLarge: 32
property real panelTransparency: 0.85
property real popupTransparency: typeof SettingsData !== "undefined" && SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0
property real popupTransparency: {
if (typeof SettingsData === "undefined")
return 1.0;
if (isConnectedEffect)
return SettingsData.frameOpacity !== undefined ? SettingsData.frameOpacity : 1.0;
return SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0;
}
function screenTransition() {
if (CompositorService.isNiri) {
@@ -1582,7 +1633,7 @@ Singleton {
if (typeof SettingsData !== "undefined") {
const skipTemplates = [];
if (!SettingsData.runDmsMatugenTemplates) {
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "vencord", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
} else {
if (!SettingsData.matugenTemplateGtk)
skipTemplates.push("gtk");
@@ -1604,6 +1655,8 @@ Singleton {
skipTemplates.push("zenbrowser");
if (!SettingsData.matugenTemplateVesktop)
skipTemplates.push("vesktop");
if (!SettingsData.matugenTemplateVencord)
skipTemplates.push("vencord");
if (!SettingsData.matugenTemplateEquibop)
skipTemplates.push("equibop");
if (!SettingsData.matugenTemplateGhostty)
@@ -1806,7 +1859,7 @@ Singleton {
function applyGtkColors() {
if (!matugenAvailable) {
if (typeof ToastService !== "undefined") {
ToastService.showError("matugen not available or disabled - cannot apply GTK colors");
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply GTK colors"));
}
return;
}
@@ -1815,11 +1868,11 @@ Singleton {
Proc.runCommand("gtkApplier", [shellDir + "/scripts/gtk.sh", configDir, isLight, shellDir], (output, exitCode) => {
if (exitCode === 0) {
if (typeof ToastService !== "undefined" && typeof NiriService !== "undefined" && !NiriService.matugenSuppression) {
ToastService.showInfo("GTK colors applied successfully");
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
}
} else {
if (typeof ToastService !== "undefined") {
ToastService.showError("Failed to apply GTK colors");
ToastService.showError(I18n.tr("Failed to apply GTK colors"));
}
}
});
@@ -1828,7 +1881,7 @@ Singleton {
function applyQtColors() {
if (!matugenAvailable) {
if (typeof ToastService !== "undefined") {
ToastService.showError("matugen not available or disabled - cannot apply Qt colors");
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply Qt colors"));
}
return;
}
@@ -1836,11 +1889,11 @@ Singleton {
Proc.runCommand("qtApplier", [shellDir + "/scripts/qt.sh", configDir], (output, exitCode) => {
if (exitCode === 0) {
if (typeof ToastService !== "undefined") {
ToastService.showInfo("Qt colors applied successfully");
ToastService.showInfo(I18n.tr("Qt colors applied successfully"));
}
} else {
if (typeof ToastService !== "undefined") {
ToastService.showError("Failed to apply Qt colors");
ToastService.showError(I18n.tr("Failed to apply Qt colors"));
}
}
});
@@ -1850,6 +1903,12 @@ Singleton {
return Qt.rgba(c.r, c.g, c.b, a);
}
function popupLayerColor(baseColor) {
if (isConnectedEffect)
return connectedSurfaceColor;
return withAlpha(baseColor, popupTransparency);
}
function blendAlpha(c, a) {
return Qt.rgba(c.r, c.g, c.b, c.a * a);
}
@@ -1975,7 +2034,7 @@ Singleton {
break;
default:
if (typeof ToastService !== "undefined") {
ToastService.showError("Theme worker failed (" + exitCode + ")");
ToastService.showError(I18n.tr("Theme worker failed (%1)").arg(exitCode));
}
log.warn("Matugen worker failed with exit code:", exitCode);
root.matugenCompleted(currentMode, "error");
@@ -2001,7 +2060,7 @@ Singleton {
var themeData = JSON.parse(customThemeFileView.text());
loadCustomTheme(themeData);
} catch (e) {
ToastService.showError("Invalid JSON format: " + e.message);
ToastService.showError(I18n.tr("Invalid JSON format: %1").arg(e.message));
}
}
@@ -2015,7 +2074,7 @@ Singleton {
onLoadFailed: function (error) {
if (typeof ToastService !== "undefined") {
ToastService.showError("Failed to read theme file: " + error);
ToastService.showError(I18n.tr("Failed to read theme file: %1").arg(error));
}
}
}
@@ -2043,7 +2102,7 @@ Singleton {
log.error("Failed to parse dynamic colors:", e);
if (typeof ToastService !== "undefined") {
ToastService.wallpaperErrorStatus = "error";
ToastService.showError("Dynamic colors parse error: " + e.message);
ToastService.showError(I18n.tr("Dynamic colors parse error: %1").arg(e.message));
}
}
}
+24 -1
View File
@@ -49,6 +49,8 @@ var SPEC = {
modalAnimationSpeed: { def: 1 },
modalCustomAnimationDuration: { def: 150 },
enableRippleEffects: { def: true },
animationVariant: { def: 0 },
motionEffect: { def: 0 },
m3ElevationEnabled: { def: true },
m3ElevationIntensity: { def: 12 },
m3ElevationOpacity: { def: 30 },
@@ -240,6 +242,8 @@ var SPEC = {
monoFontFamily: { def: "Fira Code" },
fontWeight: { def: 400 },
fontScale: { def: 1.0 },
textRenderType: { def: 1 },
textRenderQuality: { def: 0 },
notepadUseMonospace: { def: true },
notepadFontFamily: { def: "" },
@@ -302,6 +306,7 @@ var SPEC = {
matugenTemplatePywalfox: { def: true },
matugenTemplateZenBrowser: { def: true },
matugenTemplateVesktop: { def: true },
matugenTemplateVencord: { def: true },
matugenTemplateEquibop: { def: true },
matugenTemplateGhostty: { def: true },
matugenTemplateKitty: { def: true },
@@ -326,6 +331,7 @@ var SPEC = {
showDock: { def: false },
dockAutoHide: { def: false },
dockSmartAutoHide: { def: false },
dockHideOnFullscreen: { def: true },
dockGroupByApp: { def: false },
dockRestoreSpecialWorkspaceOnClick: { def: false },
dockOpenOnOverview: { def: false },
@@ -442,6 +448,7 @@ var SPEC = {
displayProfileAutoSelect: { def: false },
displayShowDisconnected: { def: false },
displaySnapToEdge: { def: true },
connectedFrameBarStyleBackups: { def: {} },
barConfigs: {
def: [{
@@ -479,6 +486,7 @@ var SPEC = {
fontScale: 1.0,
iconScale: 1.0,
autoHide: false,
autoHideStrict: false,
autoHideDelay: 250,
showOnWindowsOpen: false,
openOnOverview: false,
@@ -486,6 +494,7 @@ var SPEC = {
popupGapsAuto: true,
popupGapsManual: 4,
maximizeDetection: true,
fullscreenDetection: true,
scrollEnabled: true,
scrollXBehavior: "column",
scrollYBehavior: "workspace",
@@ -548,7 +557,21 @@ var SPEC = {
clipboardEnterToPaste: { def: false },
launcherPluginVisibility: { def: {} },
launcherPluginOrder: { def: [] }
launcherPluginOrder: { def: [] },
frameEnabled: { def: false },
frameThickness: { def: 16 },
frameRounding: { def: 23 },
frameColor: { def: "" },
frameOpacity: { def: 1.0 },
frameScreenPreferences: { def: ["all"] },
frameBarSize: { def: 40 },
frameShowOnOverview: { def: false },
frameBlurEnabled: { def: true },
frameCloseGaps: { def: true },
frameLauncherEmergeSide: { def: "bottom" },
frameLauncherArcExtender: { def: false },
frameMode: { def: "connected" }
};
function getValidKeys() {
@@ -248,6 +248,10 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 6;
}
if (currentVersion < 11) {
settings.configVersion = 11;
}
return settings;
}
+46 -3
View File
@@ -22,7 +22,9 @@ import qs.Modules.OSD
import qs.Modules.ProcessList
import qs.Modules.DankBar
import qs.Modules.DankBar.Popouts
import qs.Modules.Frame
import qs.Modules.WorkspaceOverlays
import qs.Modules.Settings.DisplayConfig
import qs.Services
Item {
@@ -163,7 +165,22 @@ Item {
}
}
property bool barSurfacesLoaded: true
function recreateBarSurfaces() {
if (barSurfacesLoaded)
barSurfacesLoaded = false;
barSurfaceReloadAction.schedule();
}
DeferredAction {
id: barSurfaceReloadAction
onTriggered: root.barSurfacesLoaded = true
}
property string _barLayoutStateJson: {
if (!barSurfacesLoaded)
return "[]";
const configs = SettingsData.barConfigs;
const mapped = configs.map(c => ({
id: c.id,
@@ -187,6 +204,21 @@ Item {
}
}
Connections {
target: SettingsData
function onFrameEnabledChanged() {
root.recreateBarSurfaces();
}
function onConnectedFrameModeActiveChanged() {
root.recreateBarSurfaces();
}
function onForceDankBarLayoutRefresh() {
root.recreateBarSurfaces();
}
}
Frame {}
Repeater {
id: dankBarRepeater
model: ScriptModel {
@@ -200,7 +232,7 @@ Item {
id: barLoader
required property var modelData
property var barConfig: SettingsData.barConfigs.find(cfg => cfg.id === modelData.id) || null
active: barConfig?.enabled ?? false
active: root.barSurfacesLoaded && (barConfig?.enabled ?? false)
asynchronous: false
sourceComponent: DankBar {
@@ -273,6 +305,8 @@ Item {
dockRecreateDebounce.start();
// Force PolkitService singleton to initialize
PolkitService.polkitAvailable;
// Force DisplayConfigState singleton to initialize so auto-config runs at startup
DisplayConfigState.hasOutputBackend;
loginSoundTimer.start();
}
@@ -331,7 +365,6 @@ Item {
sourceComponent: Component {
DankDashPopout {
id: dankDashPopout
onPopoutClosed: PopoutService.unloadDankDash()
}
}
}
@@ -490,6 +523,8 @@ Item {
enabled: PolkitService.polkitAvailable
function onAuthenticationRequestStarted() {
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
return;
polkitAuthModalLoader.active = true;
if (polkitAuthModalLoader.item)
polkitAuthModalLoader.item.show();
@@ -878,10 +913,19 @@ Item {
ProcessListModal {
id: processListModal
property bool wasShown: false
Component.onCompleted: {
PopoutService.processListModal = processListModal;
}
onVisibleChanged: {
if (visible) {
wasShown = true;
} else if (wasShown) {
PopoutService.unloadProcessListModal();
}
}
}
}
@@ -920,7 +964,6 @@ Item {
slideoutWidth: 480
expandable: true
expandedWidthValue: 960
customTransparency: SettingsData.notepadTransparencyOverride
content: Component {
Notepad {
+114 -45
View File
@@ -162,37 +162,36 @@ Item {
}
IpcHandler {
function resolveTabIndex(tab: string): int {
switch ((tab || "").toLowerCase()) {
case "media":
return 1;
case "wallpaper":
return 2;
case "weather":
return SettingsData.weatherEnabled ? 3 : 0;
default:
return 0;
}
}
function open(tab: string): string {
const bar = root.getPreferredBar("clockButtonRef");
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
if (!bar)
return "DASH_OPEN_FAILED";
const tabIndex = resolveTabIndex(tab);
const dash = root.dankDashPopoutLoader.item;
const onSameScreen = dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name;
if (!onSameScreen) {
bar.triggerWallpaperBrowser();
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
dash.currentTabIndex = tabIndex;
if (dash.updateSurfacePosition)
dash.updateSurfacePosition();
return "DASH_OPEN_SUCCESS";
}
if (!root.dankDashPopoutLoader.item)
if (!bar.triggerDashTab(tabIndex))
return "DASH_OPEN_FAILED";
switch (tab.toLowerCase()) {
case "media":
root.dankDashPopoutLoader.item.currentTabIndex = 1;
break;
case "wallpaper":
root.dankDashPopoutLoader.item.currentTabIndex = 2;
break;
case "weather":
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
break;
default:
root.dankDashPopoutLoader.item.currentTabIndex = 0;
break;
}
root.dankDashPopoutLoader.item.dashVisible = true;
return "DASH_OPEN_SUCCESS";
}
@@ -210,25 +209,10 @@ Item {
return "DASH_TOGGLE_SUCCESS";
}
const bar = root.getPreferredBar("clockButtonRef");
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
if (bar) {
bar.triggerWallpaperBrowser();
if (root.dankDashPopoutLoader.item) {
switch (tab.toLowerCase()) {
case "media":
root.dankDashPopoutLoader.item.currentTabIndex = 1;
break;
case "wallpaper":
root.dankDashPopoutLoader.item.currentTabIndex = 2;
break;
case "weather":
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
break;
default:
root.dankDashPopoutLoader.item.currentTabIndex = 0;
break;
}
}
if (!bar.triggerDashTab(resolveTabIndex(tab)))
return "DASH_TOGGLE_FAILED";
return "DASH_TOGGLE_SUCCESS";
}
return "DASH_TOGGLE_FAILED";
@@ -598,7 +582,7 @@ Item {
IpcHandler {
function wallpaper(): string {
const bar = root.getPreferredBar("clockButtonRef");
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
if (bar) {
bar.triggerWallpaperBrowser();
return "SUCCESS: Toggled wallpaper browser";
@@ -715,6 +699,26 @@ Item {
return barConfig.autoHide ? "BAR_MANUAL_HIDE_SUCCESS" : "BAR_AUTO_HIDE_SUCCESS";
}
function toggleReveal(selector: string, value: string): string {
const {
barConfig,
error
} = getBarConfig(selector, value);
if (error)
return error;
if (!barConfig.autoHide)
return "BAR_AUTO_HIDE_DISABLED";
if (!(barConfig.visible ?? true)) {
SettingsData.updateBarConfig(barConfig.id, {
visible: true
});
SettingsData.setBarIpcReveal(barConfig.id, true);
return "BAR_REVEAL_SUCCESS";
}
const revealed = SettingsData.toggleBarIpcReveal(barConfig.id);
return revealed ? "BAR_REVEAL_SUCCESS" : "BAR_TUCK_SUCCESS";
}
function getPosition(selector: string, value: string): string {
const {
barConfig,
@@ -1176,6 +1180,50 @@ Item {
target: "plugins"
}
IpcHandler {
function toggle(): string {
if (PopoutService.systemUpdatePopout?.shouldBeVisible) {
PopoutService.systemUpdatePopout.close();
return "SYSTEMUPDATER_TOGGLE_SUCCESS";
}
const bar = root.getPreferredBar("systemUpdateButtonRef");
if (bar) {
bar.triggerSystemUpdate();
return "SYSTEMUPDATER_TOGGLE_SUCCESS";
}
return "SYSTEMUPDATER_TOGGLE_FAILED";
}
function open(): string {
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
return "SYSTEMUPDATER_ALREADY_OPEN";
const bar = root.getPreferredBar("systemUpdateButtonRef");
if (bar) {
bar.triggerSystemUpdate();
return "SYSTEMUPDATER_OPEN_SUCCESS";
}
return "SYSTEMUPDATER_OPEN_FAILED";
}
function close(): string {
PopoutService.closeSystemUpdate();
return "SYSTEMUPDATER_CLOSE_SUCCESS";
}
function updatestatus(): string {
if (SystemUpdateService.isChecking) {
return "ERROR: already checking";
}
if (SystemUpdateService.backends.length === 0) {
return "ERROR: no package manager available";
}
SystemUpdateService.checkForUpdates();
return "SUCCESS: Now checking...";
}
target: "systemupdater"
}
IpcHandler {
function open(): string {
if (!PopoutService.clipboardHistoryModal) {
@@ -1638,13 +1686,15 @@ Item {
for (const id in profiles) {
const p = profiles[id];
if (!p.name)
continue;
const flags = [];
if (id === activeId)
flags.push("active");
if (id === matchedId)
flags.push("matched");
const flagStr = flags.length > 0 ? " [" + flags.join(",") + "]" : "";
lines.push(p.name + flagStr + " -> " + JSON.stringify(p.outputSet));
lines.push(p.name + flagStr + " -> " + JSON.stringify(Object.keys(p.outputs)));
}
if (lines.length === 0)
@@ -1676,13 +1726,32 @@ Item {
return `PROFILE_SET_SUCCESS: ${profileName}`;
}
// ! TODO - auto profile switching is buggy on niri and other compositors
function cycleProfile(): string {
if (SettingsData.displayProfileAutoSelect)
return "ERROR: Auto profile selection is enabled. Use toggleAuto first";
const profiles = DisplayConfigState.validatedProfiles;
const ids = Object.keys(profiles).filter(id => profiles[id].name);
if (ids.length === 0)
return "ERROR: No profiles configured";
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
const idx = ids.indexOf(activeId);
const nextId = ids[(idx + 1) % ids.length];
DisplayConfigState.activateProfile(nextId);
return `PROFILE_SET_SUCCESS: ${profiles[nextId].name}`;
}
function toggleAuto(): string {
return "ERROR: Auto profile selection is temporarily disabled due to compositor bugs";
SettingsData.displayProfileAutoSelect = !SettingsData.displayProfileAutoSelect;
SettingsData.saveSettings();
if (SettingsData.displayProfileAutoSelect)
DisplayConfigState.applyAutoConfig();
return `Auto profile selection: ${SettingsData.displayProfileAutoSelect ? "enabled" : "disabled"}`;
}
function status(): string {
const auto = "off"; // disabled for now
const auto = SettingsData.displayProfileAutoSelect ? "on" : "off";
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
const matchedId = DisplayConfigState.matchedProfile;
const profiles = DisplayConfigState.validatedProfiles;
@@ -67,7 +67,7 @@ FloatingWindow {
spacing: Theme.spacingXS
DankActionButton {
visible: windowControls.supported && windowControls.canMaximize
visible: windowControls.canMaximize
iconName: root.maximized ? "fullscreen_exit" : "fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
@@ -49,16 +49,8 @@ Item {
iconSize: Theme.iconSize - 4
iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText
visible: header.pinnedCount > 0
tooltipText: I18n.tr("Saved")
onClicked: tabChanged("saved")
}
DankActionButton {
iconName: "history"
iconSize: Theme.iconSize - 4
iconColor: header.activeTab === "recents" ? Theme.primary : Theme.surfaceText
tooltipText: I18n.tr("History")
onClicked: tabChanged("recents")
tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved")
onClicked: tabChanged(header.activeTab === "saved" ? "recents" : "saved")
}
DankActionButton {
@@ -64,11 +64,19 @@ DankModal {
activeImageLoads = 0;
shouldHaveFocus = true;
ClipboardService.reset();
if (clipboardAvailable)
ClipboardService.refresh();
keyboardController.reset();
Qt.callLater(function () {
if (clipboardAvailable) {
if (Theme.isConnectedEffect) {
Qt.callLater(() => {
if (clipboardHistoryModal.shouldBeVisible)
ClipboardService.refresh();
});
} else {
ClipboardService.refresh();
}
}
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
@@ -53,8 +53,6 @@ DankPopout {
open();
activeImageLoads = 0;
ClipboardService.reset();
if (clipboardAvailable)
ClipboardService.refresh();
keyboardController.reset();
Qt.callLater(function () {
@@ -121,8 +119,16 @@ DankPopout {
onShouldBeVisibleChanged: {
if (!shouldBeVisible)
return;
if (clipboardAvailable)
ClipboardService.refresh();
if (clipboardAvailable) {
if (Theme.isConnectedEffect) {
Qt.callLater(() => {
if (root.shouldBeVisible)
ClipboardService.refresh();
});
} else {
ClipboardService.refresh();
}
}
keyboardController.reset();
Qt.callLater(function () {
if (contentLoader.item?.searchField) {
@@ -26,9 +26,7 @@ Rectangle {
spacing: 2
StyledText {
text: keyboardHints.enterToPaste
? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled")
: I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter

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