Compare commits

..
13 Commits
Author SHA1 Message Date
bbedward 84bb4d4753 add aqueous logo 2026-09-07 11:14:51 -04:00
bbedward f5acb716b9 aqueous: consolidate more aqueous specific stuff 2026-09-07 10:32:58 -04:00
bbedward b138ec1b0f fix staticcheck 2026-09-07 09:53:02 -04:00
bbedward 5a72cd9616 aqueous: move IPC integration to QML, delete test fixtures (since we
dont have a QML test structure even setup), delete in-repo docs
2026-09-07 09:08:46 -04:00
Zoey ce1ecfc6bc swapped to ipc 2026-09-06 23:07:29 -04:00
Zoey 80472c4db8 clean up per claud review 2026-09-06 16:12:34 -04:00
Zoey 49513047b2 remove unneeded blur 2026-09-06 15:23:30 -04:00
Zoey 045c5c3a89 added keybinding update 2026-09-06 02:33:50 -04:00
Zoey 398b360c48 added blur to dms shell for aqueous 2026-09-05 23:18:19 -04:00
Zoey a39196e992 updated to support workspace options better in aqueous 2026-09-05 20:55:09 -04:00
Zoey 2ac1fe670c aqueous support work 2026-09-05 20:39:19 -04:00
Zoey f39529d58c further updates 2026-09-05 17:03:57 -04:00
Zoey 8874a804b6 updated dank to support aqueous 2026-09-05 17:03:36 -04:00
1986 changed files with 268709 additions and 303154 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs,
| Skill | Description | | Skill | Description |
|-------|-------------| |-------|-------------|
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for Dux Shell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. | | [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 ## Installation
@@ -28,7 +28,7 @@ ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
**Personal** (all your projects): **Personal** (all your projects):
```bash ```bash
ln -s /path/to/dms/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev 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. 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.
+30 -110
View File
@@ -1,71 +1,65 @@
--- ---
name: dms-plugin-dev name: dms-plugin-dev
description: > description: >
Develop plugins for Dux Shell (DMS), a QML-based Linux desktop shell built on Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports widget (bar, dock + Control Center), daemon (background service), Quickshell. Supports five plugin types: widget (bar + Control Center), daemon (background
launcher (search + actions), desktop (draggable desktop widgets), dash (dash tab), service), launcher (search + actions), desktop (draggable desktop widgets), and composite
dashCard (dash overview card), and composite (multi-surface) plugins. Covers manifest (multi-surface). Covers manifest creation, QML component development, startup checks,
creation, QML component development, startup checks,
settings UI, data persistence, theme integration, PopoutService usage, IPC runtime settings UI, data persistence, theme integration, PopoutService usage, IPC runtime
discovery, and external command execution. Use when the user wants to create, modify, discovery, and external command execution. Use when the user wants to create, modify,
or debug a DMS plugin, or asks about the DMS plugin API. or debug a DMS plugin, or asks about the DMS plugin API.
compatibility: Designed for Claude Code (or similar products) compatibility: Designed for Claude Code (or similar products)
metadata: metadata:
author: Dux Shell author: DankMaterialShell
version: "1.2" version: "1.1"
domain: qml-desktop-development domain: qml-desktop-development
framework: Dux Shell framework: DankMaterialShell
languages: qml, javascript languages: qml, javascript
allowed-tools: Bash Read Write Edit allowed-tools: Bash Read Write Edit
--- ---
# Dux Shell Plugin Development # DankMaterialShell Plugin Development
## Overview ## Overview
DMS plugins extend the desktop shell with bar and dock widgets, background services, launcher DMS plugins extend the desktop shell with custom widgets, background services, launcher
integrations, desktop widgets, and dash tabs and cards. Plugins are QML components discovered from integrations, and desktop widgets. Plugins are QML components discovered from
`~/.config/dms/plugins/`. `~/.config/DankMaterialShell/plugins/`.
**Minimum plugin structure:** **Minimum plugin structure:**
``` ```
~/.config/dms/plugins/YourPlugin/ ~/.config/DankMaterialShell/plugins/YourPlugin/
plugin.json # Required: manifest with metadata plugin.json # Required: manifest with metadata
YourComponent.qml # Required: main QML component YourComponent.qml # Required: main QML component
YourSettings.qml # Optional: settings UI YourSettings.qml # Optional: settings UI
*.js # Optional: JavaScript utilities *.js # Optional: JavaScript utilities
``` ```
**Plugin registry:** Community plugins are available at https://plugins.duxlabs.dev/ **Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
**Plugin types:** **Five plugin types:**
| Type | Purpose | Base Component | Bar/dock pills | CC integration | | Type | Purpose | Base Component | Bar pills | CC integration |
|-------------|--------------------------------|----------------------------|----------------|----------------| |-------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar or dock widget + popout | `PluginComponent` | Yes | Yes | | `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional | | `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No | | `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No | | `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
| `dash` | Tab in the dash popout | `DashTabComponent` | No | No | | `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
| `dashCard` | Card in the dash overview grid | `DashCardComponent` | No | No |
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
## Step 1: Determine Plugin Type ## Step 1: Determine Plugin Type
Choose the type based on what the plugin does: Choose the type based on what the plugin does:
- **Shows in the bar or a dock?** - Use `widget`. Displays a pill in DankBar or a dock (same - **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
component), optionally opens a popout or an attached dock panel, optionally integrates with optionally integrates with Control Center.
Control Center.
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper - **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
changes, notifications, battery level, etc.). changes, notifications, battery level, etc.).
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher - **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji). with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the - **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
desktop layer. desktop layer.
- **Full page in the dash?** - Use `dash`. A tab in the dash popout, id `plugin_<pluginId>`.
- **Small tile on the dash overview?** - Use `dashCard`. A resizable card in the overview grid.
- **Needs multiple surfaces?** - Use `composite`. A single plugin that registers any combination - **Needs multiple surfaces?** - Use `composite`. A single plugin that registers any combination
of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own
QML component file. QML component file.
@@ -132,11 +126,6 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
} }
``` ```
**Standalone dash tab or card:** same single file form, `"type": "dash"` or `"type": "dashCard"`
plus `"component"`. A tab and a card together (or with a bar widget) use `components` with the
keys `dash` and `dashCard`. Both accept an optional `dash` block for labels, card size and
options. See [dash-plugin-guide.md](references/dash-plugin-guide.md).
**Key rules:** **Key rules:**
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$` - `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
- `version` must be semver (e.g., `1.0.0`) - `version` must be semver (e.g., `1.0.0`)
@@ -291,62 +280,6 @@ PluginComponent {
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution. See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
### Dash Tab
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
property date now: new Date()
implicitHeight: DashMetrics.tabMinHeight
Timer {
interval: 1000
repeat: true
running: root.live
onTriggered: root.now = new Date()
}
StyledText {
anchors.centerIn: parent
text: root.now.toLocaleTimeString(I18n.locale())
color: Theme.surfaceText
}
}
```
### Dash Card
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
DashCardComponent {
id: root
tone: options.tone ?? ""
StyledText {
anchors.centerIn: parent
text: I18n.trFor("myPlugin", "Hello")
color: root.contentColor
}
}
```
Gate timers and animations on `live`; the dash content stays alive after the popout closes.
Cards must lay out for every size in their manifest range. Use the card colors (`accentColor`,
`contentColor`, `mutedColor`) so tones work. See [dash-plugin-guide.md](references/dash-plugin-guide.md).
### Composite ### Composite
For composite plugins, create a separate QML file per surface. Each surface uses the same For composite plugins, create a separate QML file per surface. Each surface uses the same
@@ -362,7 +295,8 @@ MyCompositePlugin/
Settings.qml # Shared settings for all surfaces Settings.qml # Shared settings for all surfaces
``` ```
`pluginService.availablePlugins[pluginId].surfaces` lists the surfaces a plugin registered. Use `pluginService.pluginHasSurface(pluginId, "widget")` to check whether a specific surface
is registered for a plugin at runtime.
## Step 4: Add Startup Check (Optional) ## Step 4: Add Startup Check (Optional)
@@ -480,7 +414,7 @@ Always use `Theme.*` properties from `qs.Common` - never hardcode colors or size
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge` - Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32) - Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
**Common widgets from `qs.DCommon.Widgets`:** `StyledText`, `StyledRect`, `DIcon`, `DButton`, `DToggle`, `DTextField`, `DSlider`, `DGridView`, `CachingImage`. The old `Dank*` names in `qs.Widgets` are deprecated. **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. See [theme-reference.md](references/theme-reference.md) for the complete property list.
@@ -497,6 +431,7 @@ PluginComponent {
PopoutComponent { PopoutComponent {
headerText: "My Plugin" headerText: "My Plugin"
detailsText: "Optional subtitle" detailsText: "Optional subtitle"
showCloseButton: true
Column { Column {
width: parent.width width: parent.width
@@ -515,7 +450,7 @@ PluginComponent {
} }
``` ```
**PopoutComponent properties:** `headerText`, `detailsText`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly). **PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing` Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
@@ -615,7 +550,7 @@ Lookup order: plugin file, then the global DMS catalog, then the English term. `
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json) 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` 2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
3. Open Settings > Plugins > Scan for Plugins 3. Open Settings > Plugins > Scan for Plugins
4. Enable your plugin and add it to the DankBar layout, a dock, or the dash (tabs show up on their own; add cards from the dash edit mode) 4. Enable your plugin and add it to the DankBar layout
**Runtime plugin discovery via IPC:** **Runtime plugin discovery via IPC:**
@@ -653,10 +588,6 @@ Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
11. **Using `requires` instead of `dependencies`** - `requires` is deprecated; use `dependencies` 11. **Using `requires` instead of `dependencies`** - `requires` is deprecated; use `dependencies`
12. **Providing both `component` and `components`** - Use one or the other, not both 12. **Providing both `component` and `components`** - Use one or the other, not both
13. **Missing `trigger` on composite with launcher surface** - Still required when `components` has a `launcher` key 13. **Missing `trigger` on composite with launcher surface** - Still required when `components` has a `launcher` key
14. **Using `"type": "widget"` for a dash tab** - Dash surfaces need `"type": "dash"` / `"dashCard"` or a `components` key
15. **Timers in dash tabs or cards that ignore `live`** - The dash content outlives the popout; gate on `live`
16. **Reading `dash.options` from a bar widget** - `options` only reach dash surfaces; share values through `settings` and `pluginData`
17. **Raw theme colors in a dash card** - Use `accentColor` / `contentColor` / `mutedColor` so the card follows its tone
## Quick Reference: Imports ## Quick Reference: Imports
@@ -680,16 +611,6 @@ import QtQuick
import qs.Common import qs.Common
``` ```
**Dash tab / card:**
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash // DashMetrics, DashWidgetGrid
import qs.Modules.DankDash.Overview // Card, for tiles inside a tab
```
**For clipboard/exec:** `import Quickshell` **For clipboard/exec:** `import Quickshell`
**For processes:** `import Quickshell.Io` **For processes:** `import Quickshell.Io`
**For networking:** `import Quickshell.Networking` **For networking:** `import Quickshell.Networking`
@@ -708,8 +629,7 @@ import qs.Modules.DankDash.Overview // Card, for tiles inside a tab
Load these on demand for detailed API documentation: 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 - [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 and dock pills, attached dock panels, popouts, click actions, CC integration - [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
- [dash-plugin-guide.md](references/dash-plugin-guide.md) - DashTabComponent, DashCardComponent, DashWidgetGrid, dash options, card sizing
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view - [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 - [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 - [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
@@ -1,8 +1,8 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://duxlabs.dev/schemas/plugin.json", "$id": "https://danklinux.com/schemas/plugin.json",
"title": "Dux Shell Plugin Manifest", "title": "DankMaterialShell Plugin Manifest",
"description": "Schema for Dux Shell plugin.json manifest files", "description": "Schema for DankMaterialShell plugin.json manifest files",
"type": "object", "type": "object",
"required": [ "required": [
"id", "id",
@@ -42,7 +42,7 @@
"type": { "type": {
"type": "string", "type": "string",
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.", "description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
"enum": ["widget", "daemon", "launcher", "desktop", "dash", "dashCard", "composite"] "enum": ["widget", "daemon", "launcher", "desktop", "composite"]
}, },
"capabilities": { "capabilities": {
"type": "array", "type": "array",
@@ -80,83 +80,11 @@
"type": "string", "type": "string",
"description": "Launcher provider component (requires 'trigger')", "description": "Launcher provider component (requires 'trigger')",
"pattern": "^\\./.*\\.qml$" "pattern": "^\\./.*\\.qml$"
},
"dash": {
"type": "string",
"description": "Dashboard tab component (DashTabComponent)",
"pattern": "^\\./.*\\.qml$"
},
"dashCard": {
"type": "string",
"description": "Dashboard overview card component (DashCardComponent)",
"pattern": "^\\./.*\\.qml$"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1 "minProperties": 1
}, },
"dash": {
"type": "object",
"description": "Presentation for the dashboard surfaces. Falls back to 'icon' and 'name'.",
"properties": {
"icon": {
"type": "string",
"description": "Material icon for the dashboard tab"
},
"title": {
"type": "string",
"description": "Tab label"
},
"card": {
"type": "object",
"description": "Overview card presentation and default size in grid units. Widths are clamped to the user's column count (3 to 8).",
"properties": {
"icon": { "type": "string" },
"title": { "type": "string" },
"w": { "type": "integer", "minimum": 1, "maximum": 8 },
"h": { "type": "integer", "minimum": 1 },
"minW": { "type": "integer", "minimum": 1, "maximum": 8 },
"minH": { "type": "integer", "minimum": 1 },
"maxW": { "type": "integer", "minimum": 1, "maximum": 8 },
"maxH": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"options": {
"type": "array",
"description": "User options shown in the dashboard options sheet and Settings > Dashboard; values reach the tab and card as `options.<key>`",
"items": {
"type": "object",
"required": ["key", "type"],
"properties": {
"key": { "type": "string", "not": { "const": "widgets" }, "description": "Option key, read as options.<key>. 'widgets' is reserved for DashWidgetGrid layouts" },
"text": { "type": "string", "description": "Row label, translated through the plugin catalog" },
"description": { "type": "string", "description": "Secondary text under a toggle row, translated through the plugin catalog" },
"type": { "type": "string", "enum": ["toggle", "choice", "number"] },
"def": { "description": "Default value: boolean for toggle, choice value for choice, number for number" },
"choices": {
"type": "array",
"items": {
"type": "object",
"required": ["value"],
"properties": {
"value": { "type": "string" },
"text": { "type": "string" }
},
"additionalProperties": false
}
},
"min": { "type": "number" },
"max": { "type": "number" },
"step": { "type": "number", "exclusiveMinimum": 0 },
"unit": { "type": "string" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"trigger": { "trigger": {
"type": "string", "type": "string",
"description": "Trigger string for launcher activation (required for launcher type)" "description": "Trigger string for launcher activation (required for launcher type)"
@@ -1,33 +0,0 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashCardComponent {
id: root
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
tone: options.tone ?? ""
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "extension"
size: Theme.iconSizeLarge
color: root.accentColor
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
visible: root.wide
text: I18n.trFor("myDashCard", "Hello")
font.pixelSize: Theme.fontSizeLarge
color: root.contentColor
}
}
}
@@ -1,27 +0,0 @@
{
"id": "myDashCard",
"name": "My Dash Card",
"description": "A custom dash overview card",
"version": "1.0.0",
"author": "Your Name",
"type": "dashCard",
"capabilities": ["dash-card"],
"component": "./Card.qml",
"icon": "extension",
"dash": {
"card": { "w": 2, "h": 1, "minW": 1, "minH": 1, "maxW": 3, "maxH": 2 },
"options": [
{
"key": "tone",
"text": "Tone",
"type": "choice",
"def": "",
"choices": [
{ "value": "", "text": "Surface" },
{ "value": "primary", "text": "Primary" }
]
}
]
},
"requires_dms": ">=1.7.0"
}
@@ -1,37 +0,0 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
focusTarget: button
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
function restoreFocus() {
button.forceActiveFocus(Qt.OtherFocusReason);
}
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingM
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
visible: !root.options.compact
text: I18n.trFor("myDashTab", "Hello from a dash tab")
font.pixelSize: Theme.fontSizeXLarge
color: Theme.surfaceText
}
DankButton {
id: button
anchors.horizontalCenter: parent.horizontalCenter
text: I18n.trFor("myDashTab", "Open overview")
onClicked: root.tabRequested("overview")
}
}
}
@@ -1,18 +0,0 @@
{
"id": "myDashTab",
"name": "My Dash Tab",
"description": "A custom dash tab",
"version": "1.0.0",
"author": "Your Name",
"type": "dash",
"capabilities": ["dash-tab"],
"component": "./Tab.qml",
"icon": "extension",
"dash": {
"title": "My Tab",
"options": [
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false }
]
},
"requires_dms": ">=1.7.0"
}
@@ -59,6 +59,7 @@ PluginComponent {
// popoutContent: Component { // popoutContent: Component {
// PopoutComponent { // PopoutComponent {
// headerText: "My Widget" // headerText: "My Widget"
// showCloseButton: true
// //
// Column { // Column {
// width: parent.width // width: parent.width
@@ -43,7 +43,7 @@ PluginSettings {
pluginId: "exampleVariants" pluginId: "exampleVariants"
// Variant creation UI // Variant creation UI
DButton { DankButton {
text: "Add New Instance" text: "Add New Instance"
onClicked: { onClicked: {
var id = "variant_" + Date.now() var id = "variant_" + Date.now()
@@ -1,179 +0,0 @@
# Dash Plugin Guide
The dash popout (`dms ipc call dash open`) holds tabs and an overview grid of cards. A plugin can add a tab (`dash` surface), a card (`dashCard` surface), or both. Built-in tabs and cards use the same registry (`quickshell/Modules/DankDash/DashRegistry.qml`).
Requires DMS 1.7 or later: set `"requires_dms": ">=1.7.0"`.
## Picking the manifest form
| Plugin provides | Manifest |
|-----------------|----------|
| Tab only | `"type": "dash"`, `"component": "./Tab.qml"` |
| Card only | `"type": "dashCard"`, `"component": "./Card.qml"` |
| Tab and card | `"type": "composite"`, `"components": { "dash": ..., "dashCard": ... }` |
| Dash plus bar/dock widget, daemon, ... | `"type": "composite"` with every surface in `components` |
Add the optional `dash` block for labels, card size and options (see plugin-manifest-reference.md, Dash Block).
## Rules that trip people up
- Only enabled plugins appear in the dash. There is no "disabled" placeholder.
- A plugin tab shows in the tab bar as soon as the plugin loads. Cards are never placed automatically; users add them in edit mode (three-dot menu, Edit, Add widget).
- Tab id is `plugin_<pluginId>`. Use it with `dms ipc call dash open plugin_<pluginId>` and `popoutService.toggleDankDash("plugin_" + pluginId, x, y, width, section, screen)`.
- A tab is created when it becomes current and destroyed when the user leaves it. The dash content stays alive after the popout closes, so gate every Timer, animation and service ref on `live`.
- Widths are grid columns (user picks 3 to 8, default 6). Rows are 96 px (`DashMetrics.gridRowUnit`). Cards must lay out for every size in `minW..maxW` x `minH..maxH`.
- `dash.options` values reach only the dash surfaces as `options.<key>`. A bar widget or daemon in the same plugin cannot read them; use a `settings` component and `pluginData` for values every surface needs.
- `widgets` is a reserved option key.
- Use `I18n.trFor("<plugin id literal>", "...")` for every string.
## Tab: DashTabComponent
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.DCommon.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
focusTarget: button
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
menuActions: [
{
label: I18n.trFor("myPlugin", "Refresh"),
iconName: "refresh",
action: () => root.refresh()
}
]
function refresh() {
}
function restoreFocus() {
button.forceActiveFocus(Qt.OtherFocusReason);
}
function handleKeyEvent(event) {
if (event.key !== Qt.Key_R)
return false;
refresh();
return true;
}
Timer {
interval: 1000
repeat: true
running: root.live
onTriggered: root.refresh()
}
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingM
StyledText {
text: root.options.compact ? "" : I18n.trFor("myPlugin", "Hello")
color: Theme.surfaceText
}
DButton {
id: button
text: I18n.trFor("myPlugin", "Refresh")
onClicked: root.refresh()
}
}
}
```
Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`, `dashHost` (`dashVisible`, `requestTab(id)`, `editMode`), `editMode`, `live`. Also `targetScreen`, `active` and `rowBudget` when the tab declares them.
Base provides: `options`, `pluginData`, `getData(key, def)`, `setData(key, value)`.
Optional: `implicitHeight`, `focusTarget`, `restoreFocus()`, `handleKeyEvent(event)` (return true to consume; Ctrl+Tab is reserved), `blocksTabNavigation`, `menuActions`, `signal tabRequested(string id)`, `signal navFocusRequested`.
For tiles inside a tab, use `Card` from `qs.Modules.DankDash.Overview` so colors, radius and tones match the overview.
### Editable widgets in a tab
Assign a `DashWidgetGrid` (from `qs.Modules.DankDash`) to `widgetGrid` to get Edit, Add widget, Reset and Clear All:
```qml
DashTabComponent {
id: tab
widgetGrid: grid
implicitHeight: grid.implicitHeight
focusTarget: grid.focusTarget
DashWidgetGrid {
id: grid
width: parent.width
entryId: tab.entryId
live: tab.live
editMode: tab.editMode
definitions: [
{ id: "main", text: I18n.trFor("myPlugin", "Main"), icon: "star", component: mainWidget, w: 2, h: 2, minW: 1, minH: 1, maxW: 4, maxH: 3 }
]
}
Component {
id: mainWidget
Card {}
}
}
```
The grid is 4 columns (2 when narrower than `Theme.smallBreakpoint`). `enabled: false` on a definition leaves it in the Add menu. The layout is saved in `dashOptions[entryId].widgets`.
## Card: DashCardComponent
```qml
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashCardComponent {
id: root
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
readonly property bool tall: height >= DashMetrics.gridRowUnit * 2
tone: options.tone ?? ""
SystemClock {
id: clock
enabled: root.live
precision: SystemClock.Minutes
}
StyledText {
anchors.centerIn: parent
text: clock.date.toLocaleTimeString(I18n.locale(), "hh:mm")
font.pixelSize: root.tall ? Theme.fontSizeDisplay : Theme.fontSizeXLarge
color: root.accentColor
}
}
```
Base (`Card`) provides: `pad`, `title`, `tone` (`""`, `"primary"`, `"secondary"`, `"tertiary"`), `containerColor`, `contentColor`, `accentColor`, `onAccentColor`, `mutedColor`, `chipColor`, `tinted`, `clickable`, `signal clicked`, `options`, `pluginData`, `getData`, `setData`.
Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`; bound: `live` (overview visible), `interactive` (false in edit mode).
Clicks: when the plugin also has a tab, a click on a `clickable` card opens it. Set `opensTab: false` to handle the click yourself. Non-clickable cards are skipped by keyboard navigation unless `focusTarget` points at a child control.
Use the card colors, never raw `Theme.primary` or `Theme.surfaceText`, so tones work.
## Testing
```bash
dms ipc call plugins reload myPlugin
dms ipc call dash open plugin_myPlugin
```
Working examples in `quickshell/PLUGINS/`: `DashTabExample` (standalone tab), `DashCardExample` (standalone card), `DashCounterExample` (tab + card + bar/dock widget).
@@ -174,7 +174,7 @@ Retrieve a plugin's installation directory at runtime:
var dir = pluginService.getPluginPath(pluginId) var dir = pluginService.getPluginPath(pluginId)
``` ```
Returns the absolute path to the plugin's directory (e.g., `~/.config/dms/plugins/MyPlugin`), or an empty string if the plugin is not found. Useful for loading bundled assets (images, data files) relative to the plugin's location. Returns the absolute path to the plugin's directory (e.g., `~/.config/DankMaterialShell/plugins/MyPlugin`), or an empty string if the plugin is not found. Useful for loading bundled assets (images, data files) relative to the plugin's location.
## Important Notes ## Important Notes
@@ -9,7 +9,7 @@
| `description` | string | Short description (shown in UI) | 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.-]+)?$` | | `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
| `author` | string | Creator name or email | Non-empty | | `author` | string | Creator name or email | Non-empty |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `dash`, `dashCard`, `composite` | | `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
| `capabilities` | array | Plugin capabilities | At least 1 string item | | `capabilities` | array | Plugin capabilities | At least 1 string item |
One of `component` or `components` is required (not both): One of `component` or `components` is required (not both):
@@ -17,7 +17,7 @@ One of `component` or `components` is required (not both):
| Field | Type | Description | Validation | | Field | Type | Description | Validation |
|-------|------|-------------|------------| |-------|------|-------------|------------|
| `component` | string | Path to main QML file (single-surface plugins) | Must start with `./`, end with `.qml` | | `component` | string | Path to main QML file (single-surface plugins) | Must start with `./`, end with `.qml` |
| `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher`, `dash`, `dashCard` | | `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher` |
## Conditional Requirements ## Conditional Requirements
@@ -38,7 +38,6 @@ One of `component` or `components` is required (not both):
| `requires` | array | Deprecated alias for `dependencies` | | `requires` | array | Deprecated alias for `dependencies` |
| `permissions` | array | Required permissions | | `permissions` | array | Required permissions |
| `trigger` | string | Launcher trigger string (required for launcher type) | | `trigger` | string | Launcher trigger string (required for launcher type) |
| `dash` | object | Dash tab label, overview card size range and dash options. See Dash Block below. |
## Permissions ## Permissions
@@ -60,8 +59,6 @@ Capabilities are free-form strings that describe what the plugin does. Common va
- `monitoring` - system/service monitoring - `monitoring` - system/service monitoring
- `launcher` - launcher search provider - `launcher` - launcher search provider
- `desktop-widget` - desktop background widget - `desktop-widget` - desktop background widget
- `dash-tab` - dash tab
- `dash-card` - dash overview card
- `ai` - AI/LLM integration - `ai` - AI/LLM integration
- `slideout` - uses slideout panel - `slideout` - uses slideout panel
@@ -122,68 +119,7 @@ The `components` field maps surface names to QML paths, allowing a single plugin
} }
``` ```
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`, `dash`, `dashCard`. Provide any subset. Each surface is loaded independently in the appropriate registry. Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
## Dash Block
Plugins with a `dash` or `dashCard` surface can describe them in a `dash` object. Everything is optional.
```json
"dash": {
"icon": "timer",
"title": "Stopwatch",
"card": { "title": "Progress", "icon": "hourglass_bottom", "w": 2, "h": 1, "minW": 1, "minH": 1, "maxW": 4, "maxH": 2 },
"options": [
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false, "description": "Hide the secondary line" },
{ "key": "style", "text": "Style", "type": "choice", "def": "bars", "choices": [{ "value": "bars", "text": "Bars" }, { "value": "dots", "text": "Dots" }] },
{ "key": "limit", "text": "Items", "type": "number", "def": 5, "min": 1, "max": 20, "step": 1 }
]
}
```
| Field | Description |
|-------|-------------|
| `icon`, `title` | Tab icon and label. Fall back to the top-level `icon` and `name` |
| `card.title`, `card.icon` | Card name and icon in the Add widget menu. Fall back to the tab label and icon |
| `card.w`, `card.h` | Default size in grid cells. Columns are 3 to 8 (default 6), rows are 96 px |
| `card.minW` .. `card.maxH` | Resize range. Widths clamp to the user's column count; heights have no fixed limit |
| `options` | Rows in the dash options sheet and Settings > Dashboard. Read as `options.<key>` in the tab and card. Types: `toggle`, `choice`, `number`. `widgets` is a reserved key |
## Dash Examples
Standalone tab and standalone card use the single file form:
```json
{
"id": "dashTabExample",
"name": "Stopwatch Example",
"description": "A standalone dash tab",
"version": "1.0.0",
"author": "Developer Name",
"type": "dash",
"capabilities": ["dash-tab"],
"component": "./StopwatchTab.qml",
"dash": { "title": "Stopwatch" },
"requires_dms": ">=1.7.0"
}
```
```json
{
"id": "dashCardExample",
"name": "Progress Example",
"description": "A standalone overview card",
"version": "1.0.0",
"author": "Developer Name",
"type": "dashCard",
"capabilities": ["dash-card"],
"component": "./ProgressCard.qml",
"dash": { "card": { "w": 2, "h": 1, "maxW": 4, "maxH": 2 } },
"requires_dms": ">=1.7.0"
}
```
A tab plus a card, or dash surfaces plus a bar widget, use `components` with `"type": "composite"`.
## Complete Example ## Complete Example
@@ -83,12 +83,11 @@ Theme.popupBackground() // Popup background with proper opacity
```qml ```qml
import qs.Widgets import qs.Widgets
import qs.DCommon.Widgets
Row { Row {
spacing: Theme.spacingS spacing: Theme.spacingS
DIcon { DankIcon {
name: "icon_name" name: "icon_name"
color: Theme.onSurface color: Theme.onSurface
font.pixelSize: Theme.iconSize font.pixelSize: Theme.iconSize
@@ -152,7 +151,7 @@ StyledRect {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingS spacing: Theme.spacingS
DIcon { DankIcon {
name: "star" name: "star"
color: Theme.surfaceText color: Theme.surfaceText
font.pixelSize: Theme.iconSize font.pixelSize: Theme.iconSize
@@ -192,17 +191,17 @@ font.pixelSize: 14 // Use Theme.fontSizeMedium
|--------|-------------| |--------|-------------|
| `StyledText` | Themed text with proper color defaults | | `StyledText` | Themed text with proper color defaults |
| `StyledRect` | Themed rectangle | | `StyledRect` | Themed rectangle |
| `DIcon` | Material Symbols icon renderer | | `DankIcon` | Material Symbols icon renderer |
| `DankNFIcon` | Nerd Font icon renderer | | `DankNFIcon` | Nerd Font icon renderer |
| `DButton` | Themed button | | `DankButton` | Themed button |
| `DToggle` | Toggle switch | | `DankToggle` | Toggle switch |
| `DTextField` | Text input field | | `DankTextField` | Text input field |
| `DSlider` | Slider control | | `DankSlider` | Slider control |
| `DDropdown` | Dropdown menu | | `DankDropdown` | Dropdown menu |
| `DGridView` | Grid layout view | | `DankGridView` | Grid layout view |
| `DListView` | List layout view | | `DankListView` | List layout view |
| `DFlickable` | Scrollable container | | `DankFlickable` | Scrollable container |
| `DTabBar` | Tab bar navigation | | `DankTabBar` | Tab bar navigation |
| `DankCollapsibleSection` | Collapsible content section | | `DankCollapsibleSection` | Collapsible content section |
| `DankTooltip` | Hover tooltip | | `DankTooltip` | Hover tooltip |
| `DankNumberStepper` | Number +/- control | | `DankNumberStepper` | Number +/- control |
@@ -1,6 +1,6 @@
# Widget Plugin Guide # Widget Plugin Guide
Widgets display pills in DankBar and in docks (same component, no extra code), optionally open popouts, and can integrate with the Control Center. Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
## Base Component ## Base Component
@@ -37,8 +37,6 @@ These are automatically set by the plugin host:
| `pluginId` | string | This plugin's ID | | `pluginId` | string | This plugin's ID |
| `pluginService` | object | PluginService reference | | `pluginService` | object | PluginService reference |
| `pluginData` | object | Reactive plugin settings data | | `pluginData` | object | Reactive plugin settings data |
| `surfaceContext` | object | The host surface: `kind` (`"bar"` or `"dock"`), `id`, `screen`, `isVertical`, `thickness`, `live`, `editMode`, `inlineExpansion` |
| `widgetInstanceId` | string | This occurrence; the same widget can appear on several bars and docks |
## Bar Pills ## Bar Pills
@@ -59,7 +57,7 @@ horizontalBarPill: Component {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingS spacing: Theme.spacingS
DIcon { DankIcon {
name: "star" name: "star"
color: Theme.surfaceText color: Theme.surfaceText
font.pixelSize: Theme.iconSize font.pixelSize: Theme.iconSize
@@ -92,7 +90,7 @@ verticalBarPill: Component {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingS spacing: Theme.spacingS
DIcon { DankIcon {
name: "star" name: "star"
color: Theme.surfaceText color: Theme.surfaceText
font.pixelSize: Theme.iconSizeSmall font.pixelSize: Theme.iconSizeSmall
@@ -117,6 +115,7 @@ PluginComponent {
PopoutComponent { PopoutComponent {
headerText: "My Plugin" headerText: "My Plugin"
detailsText: "Optional subtitle" detailsText: "Optional subtitle"
showCloseButton: true
Column { Column {
width: parent.width width: parent.width
@@ -139,6 +138,7 @@ PluginComponent {
|----------|------|---------|-------------| |----------|------|---------|-------------|
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. | | `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
| `detailsText` | string | `""` | Subtitle below header. 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. | | `closePopout` | function | (injected) | Call to close the popout programmatically. |
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). | | `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). | | `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
@@ -241,49 +241,16 @@ PluginComponent {
**Bar reveal optimization:** The visibility timer automatically pauses while the bar is hidden (auto-hide mode) and resumes checks when the bar is revealed. This is handled via the internal `_barRevealed` property - no plugin code needed. Plugins using `visibilityCommand` with `visibilityInterval` benefit from this automatically. **Bar reveal optimization:** The visibility timer automatically pauses while the bar is hidden (auto-hide mode) and resumes checks when the bar is revealed. This is handled via the internal `_barRevealed` property - no plugin code needed. Plugins using `visibilityCommand` with `visibilityInterval` benefit from this automatically.
## Docks
Users add any loaded widget plugin to a dock from the dock's edit mode or Settings > Dock > Apps & widgets. Nothing in the manifest is needed.
- Define both `horizontalBarPill` and `verticalBarPill`; docks sit on any edge.
- Size icons with `root.iconSize` and text with `Theme.barTextSize(barThickness, barConfig?.fontScale, barConfig?.maximizeWidgetText)`. The dock hands its own thickness and scale in those properties.
- Gate timers and animations on `surfaceLive` (host visible and `visibilityCommand` passing). Docks auto-hide.
- Don't assume one instance. Use `widgetInstanceId` if an instance needs its own state.
Optional attached panel: set `attachedContent: Component { ... }`. When the dock's "Open widgets" setting is Inline (`surfaceContext.inlineExpansion`), a click grows the dock and shows the panel next to the apps. With Popout (the default) the normal `popoutContent` opens instead; a widget with no popout still expands inline. Bars always use `popoutContent`. A `pillClickAction` always wins over both.
```qml
PluginComponent {
id: root
attachedContent: Component {
Column {
padding: Theme.spacingM
spacing: Theme.spacingM
DButton {
text: I18n.trFor("myPlugin", "Close")
onClicked: root.surfaceContext.dismissExpansion()
}
}
}
}
```
The panel spans the dock's length and grows up to 320 px away from the edge (at most half the screen). The plugin cannot size it. `attachedActive` is true while this widget owns the panel. Escape closes it. See `quickshell/PLUGINS/AttachedPanelExample` and `quickshell/PLUGINS/SURFACE_WIDGETS.md`.
## Popout Namespace ## Popout Namespace
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state: For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
```qml ```qml
PluginComponent { PluginComponent {
layerNamespacePlugin: "my-plugin" layerNamespacePlugin: true
} }
``` ```
The shell prefixes it, so the popout's namespace becomes `dms:plugins:my-plugin`.
## Reading Plugin Data ## Reading Plugin Data
Access saved settings reactively via the injected `pluginData`: Access saved settings reactively via the injected `pluginData`:
@@ -311,7 +278,6 @@ import QtQuick
import Quickshell import Quickshell
import qs.Common import qs.Common
import qs.Widgets import qs.Widgets
import qs.DCommon.Widgets
import qs.Services import qs.Services
import qs.Modules.Plugins import qs.Modules.Plugins
@@ -367,8 +333,9 @@ PluginComponent {
popoutContent: Component { popoutContent: Component {
PopoutComponent { PopoutComponent {
headerText: "Emoji Picker" headerText: "Emoji Picker"
showCloseButton: true
DGridView { DankGridView {
width: parent.width width: parent.width
height: 300 height: 300
cellWidth: 50 cellWidth: 50
+1 -1
View File
@@ -3,7 +3,7 @@
github: [avengemedia] github: [avengemedia]
patreon: # Replace with a single Patreon username patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username open_collective: # Replace with a single Open Collective username
ko_fi: duxlabs ko_fi: danklinux
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username liberapay: # Replace with a single Liberapay username
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
## Dux Shell Bug Report ## DankMaterialShell Bug Report
Limit your report to one issue per submission unless similarly related Limit your report to one issue per submission unless similarly related
- type: dropdown - type: dropdown
id: compositor id: compositor
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
## Dux Shell Feature Request ## DankMaterialShell Feature Request
- type: textarea - type: textarea
id: feature_description id: feature_description
attributes: attributes:
+2 -2
View File
@@ -1,12 +1,12 @@
name: Support Request name: Support Request
description: Help with installation, usage, or general questions about Dux Shell description: Help with installation, usage, or general questions about DankMaterialShell
labels: labels:
- support - support
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
## Dux Shell Support Request ## DankMaterialShell Support Request
- type: dropdown - type: dropdown
id: compositor id: compositor
attributes: attributes:
+1 -1
View File
@@ -28,4 +28,4 @@
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible - [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean - [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
- [ ] QML changes: ran `make lint-qml` with no new warnings - [ ] QML changes: ran `make lint-qml` with no new warnings
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/duxlabs-dev/dux-docs - [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/AvengeMedia/DankLinux-Docs
+12 -37
View File
@@ -64,14 +64,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
- name: Resolve base branch - uses: anthropics/claude-code-action@v1
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: echo "base=$(gh pr view ${{ github.event.issue.number }} --json baseRefName --jq .baseRefName)" >> "$GITHUB_OUTPUT"
- uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507
id: claude
with: with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: | prompt: |
@@ -82,13 +75,12 @@ jobs:
--- ---
Text after `/claude review` is an optional focus. Honor it but still report anything serious outside it. Text after `/claude review` is an optional focus. Honor it but still report anything serious outside it.
The PR head is checked out and the base branch is fetched as `origin/${{ steps.pr.outputs.base }}`. Use `gh pr view ${{ github.event.issue.number }}` for title and description. The PR head is checked out. Use `gh pr view ${{ github.event.issue.number }}` and `gh pr diff ${{ github.event.issue.number }}`, read surrounding code as needed.
Get the diff from git, never `gh pr diff` (GitHub refuses diffs over 20000 lines): `git diff --stat origin/${{ steps.pr.outputs.base }}...HEAD`, then `git diff origin/${{ steps.pr.outputs.base }}...HEAD -- <path>` per file or directory. Read surrounding code as needed. Split a large PR across Task agents by area.
Steps: Steps:
1. Read CONTRIBUTING.md. 1. Read CONTRIBUTING.md.
2. Read the PR title, description and diff. 2. Read the PR title, description and diff.
3. Check existing comments so you don't repeat them: `gh api --method GET repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments` and `gh pr view ${{ github.event.issue.number }} --comments`. Skip findings already covered by a comment that still applies. 3. Check existing comments so you don't repeat them: `gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments` and `gh pr view ${{ github.event.issue.number }} --comments`. Skip findings already covered by a comment that still applies.
4. Verify each candidate finding against the code. Drop anything you can't confirm. 4. Verify each candidate finding against the code. Drop anything you can't confirm.
5. Post one inline comment per confirmed finding with `mcp__github_inline_comment__create_inline_comment` (`confirmed: true`). Say what's wrong, why, and the fix. Add a committable suggestion only if it alone fully fixes the issue. 5. Post one inline comment per confirmed finding with `mcp__github_inline_comment__create_inline_comment` (`confirmed: true`). Say what's wrong, why, and the fix. Add a committable suggestion only if it alone fully fixes the issue.
6. Post one new summary comment with `gh pr comment ${{ github.event.issue.number }} --body ...`. Never edit earlier comments. Format: 6. Post one new summary comment with `gh pr comment ${{ github.event.issue.number }} --body ...`. Never edit earlier comments. Format:
@@ -96,43 +88,26 @@ jobs:
## Claude review ## Claude review
<one line verdict> <one line verdict>
- <finding, file:line> per inline comment posted this run - <finding, file:line> per inline comment posted this run
Checked: <one line>. Model: claude-opus-5-5. Checked: <one line>. Model: claude-opus-5.
No findings: "No issues found." plus what was checked. No findings: "No issues found." plus what was checked.
Flag only issues introduced by this PR: Flag only issues introduced by this PR:
- Bugs: wrong logic, crashes, nil/undefined access, races, leaks (timers, connections, processes, handles), bad Go error handling, edge cases that will clearly be hit. - Bugs: wrong logic, crashes, nil/undefined access, races, leaks (timers, connections, processes, handles), bad Go error handling, edge cases that will clearly be hit.
- QML: undefined refs, missing imports, binding loops, bindings that never update, handlers on the wrong object, raw Flickable/ListView/ScrollView instead of the D* wrappers (DFlickable, DListView, DGridView). - QML: undefined refs, missing imports, binding loops, bindings that never update, handlers on the wrong object, raw Flickable/ListView/ScrollView instead of the Dank* wrappers.
- Deprecated shims: added code that uses a deprecated type or imports a deprecated module. Every shim calls `Deprecation.type(` or `Deprecation.module(`; list them with `git grep -h -e "Deprecation.type(" -e "Deprecation.module(" origin/${{ steps.pr.outputs.base }} -- quickshell`, which gives the old and new name. Grep the base, not HEAD, since a PR branched before the rename has no shims. Flag it even when the rest of the file still uses old names on the PR's base; suggest the new name and a rebase if the base predates it.
- Security: command injection, path traversal, unsafe file permissions, secrets in code. - Security: command injection, path traversal, unsafe file permissions, secrets in code.
- CONTRIBUTING.md rules: user-facing strings in `I18n.tr()` with translator context, reuse existing terms from quickshell/translations/en.json; en.json and template.json must not be touched in a PR; mocks under core/internal/mocks/ are regenerated not hand-edited; new dux-qml-common contract properties need the matching library change. - CONTRIBUTING.md rules: user-facing strings in `I18n.tr()` with translator context, reuse existing terms from quickshell/translations/en.json; en.json and template.json must not be touched in a PR; mocks under core/internal/mocks/ are regenerated not hand-edited; new dank-qml-common contract properties need the matching library change.
- Go: unformatted code, go.mod/go.sum out of sync with imports, tests that no longer compile.
Don't flag style or naming (deprecated shims are not naming), pre-existing issues, anything a linter or compiler catches, speculative input-dependent problems, missing tests or docs, or refactor ideas. Not sure it's real: don't post it. Don't flag style or naming, pre-existing issues, anything a linter or compiler catches, speculative input-dependent problems, missing tests or docs, or refactor ideas. Not sure it's real: don't post it.
Review only. Never edit files, commit, push, approve or request changes. Do not build, vet, format or run tests; CI already does that and the tools are not available here. Post each finding once. Keep comments short. Review only. Never edit files, commit, push, approve or request changes. Post each finding once. Keep comments short.
claude_args: | claude_args: |
--model claude-opus-5-5 --model claude-opus-5
--max-turns 120 --max-turns 80
--allowedTools "Read,Grep,Glob,LS,Task,mcp__github_inline_comment__create_inline_comment,Bash(gh pr view:*),Bash(gh pr comment:*),Bash(gh api --method GET repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git merge-base:*),Bash(git grep:*)" --allowedTools "Read,Grep,Glob,LS,Task,mcp__github_inline_comment__create_inline_comment,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*)"
--disallowedTools "Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch" --disallowedTools "Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch"
- name: Upload transcript
if: always() && steps.claude.outputs.execution_file != ''
uses: actions/upload-artifact@v4
with:
name: claude-review-${{ github.event.issue.number }}-${{ github.run_id }}
path: ${{ steps.claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: ignore
- name: Require a summary comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SINCE: ${{ github.event.comment.created_at }}
run: |
gh api "repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments?since=$SINCE&per_page=100" \
--jq '[.[] | select(.body | startswith("## Claude review"))] | length' | grep -qv '^0$'
- if: failure() - if: failure()
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+8 -9
View File
@@ -1,13 +1,12 @@
name: Sync flake.lock to dux-qml-common name: Sync flake.lock to dank-qml-common
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
paths: paths:
- "dux-qml-common" - "dank-qml-common"
branches: branches:
- master - master
- "stable-*"
permissions: permissions:
contents: write contents: write
@@ -18,7 +17,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v3 uses: actions/create-github-app-token@v1
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -35,10 +34,10 @@ jobs:
- name: Point flake input at the submodule commit - name: Point flake input at the submodule commit
run: | run: |
set -euo pipefail set -euo pipefail
submodule_rev=$(git ls-tree HEAD dux-qml-common --object-only) submodule_rev=$(git ls-tree HEAD dank-qml-common --object-only)
flake_rev=$(python3 -c "import json; print(json.load(open('flake.lock'))['nodes']['dank-qml-common']['locked']['rev'])") flake_rev=$(python3 -c "import json; print(json.load(open('flake.lock'))['nodes']['dank-qml-common']['locked']['rev'])")
[ "$submodule_rev" = "$flake_rev" ] && { echo "flake.lock already matches $submodule_rev"; exit 0; } [ "$submodule_rev" = "$flake_rev" ] && { echo "flake.lock already matches $submodule_rev"; exit 0; }
nix flake lock --override-input dank-qml-common "github:duxlabs-dev/dux-qml-common/$submodule_rev" nix flake lock --override-input dank-qml-common "github:AvengeMedia/dank-qml-common/$submodule_rev"
- name: Commit and push flake.lock update - name: Commit and push flake.lock update
env: env:
@@ -49,9 +48,9 @@ jobs:
echo "No changes to flake.lock" echo "No changes to flake.lock"
exit 0 exit 0
fi fi
git config user.name "dux-ci[bot]" git config user.name "dms-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com" git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.lock git add flake.lock
git commit -m "nix: sync flake.lock to dux-qml-common submodule" git commit -m "nix: sync flake.lock to dank-qml-common submodule"
git pull --rebase origin ${{ github.ref_name }} git pull --rebase origin ${{ github.ref_name }}
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }} git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
cache-dependency-path: ./core/go.sum cache-dependency-path: ./core/go.sum
- name: Test - name: Test
run: go test -race -v ./... run: go test -v ./...
- name: Build dms - name: Build dms
run: go build -v ./cmd/dms run: go build -v ./cmd/dms
+2 -2
View File
@@ -81,8 +81,8 @@ jobs:
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::tag ${TAG} already exists"; exit 1 echo "::error::tag ${TAG} already exists"; exit 1
fi fi
git config user.name "dux-ci[bot]" git config user.name "dms-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com" git config user.email "dms-ci[bot]@users.noreply.github.com"
echo "${TAG}" > quickshell/VERSION echo "${TAG}" > quickshell/VERSION
git add quickshell/VERSION git add quickshell/VERSION
+1 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
target: target:
description: "Release branch to audit (default: newest stable-*)" description: "Release branch to audit (default: newest release/*)"
required: false required: false
type: string type: string
+4 -4
View File
@@ -48,8 +48,8 @@ jobs:
COMMITS: ${{ toJSON(github.event.commits) }} COMMITS: ${{ toJSON(github.event.commits) }}
run: | run: |
set -euo pipefail set -euo pipefail
git config user.name "dux-ci[bot]" git config user.name "dms-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com" git config user.email "dms-ci[bot]@users.noreply.github.com"
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
git cat-file -e "$sha" 2>/dev/null || continue git cat-file -e "$sha" 2>/dev/null || continue
@@ -86,8 +86,8 @@ jobs:
PORT_SOURCE_PR: ${{ github.event.pull_request.number }} PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
run: | run: |
set -euo pipefail set -euo pipefail
git config user.name "dux-ci[bot]" git config user.name "dms-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com" git config user.email "dms-ci[bot]@users.noreply.github.com"
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" | targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
sed -E 's,^stable-,,' | sort -u) sed -E 's,^stable-,,' | sort -u)
-2
View File
@@ -29,5 +29,3 @@ jobs:
- name: run pre-commit hooks - name: run pre-commit hooks
uses: j178/prek-action@v2 uses: j178/prek-action@v2
env:
SKIP: qml-tests
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
prune: prune:
name: Prune archive snapshots name: Prune archive snapshots
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell' if: github.repository == 'AvengeMedia/DankMaterialShell'
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
-62
View File
@@ -1,62 +0,0 @@
name: QML tests
on:
push:
paths:
- "quickshell/**"
- "dux-qml-common"
- "Makefile"
- "core/Makefile"
- "scripts/shell-test-excludes.txt"
- "flake.nix"
- ".pre-commit-config.yaml"
- ".github/workflows/qml-tests.yml"
- ".github/workflows/prek.yml"
pull_request:
branches: [master, main, "stable-*"]
paths:
- "quickshell/**"
- "dux-qml-common"
- "Makefile"
- "core/Makefile"
- "scripts/shell-test-excludes.txt"
- "flake.nix"
- ".pre-commit-config.yaml"
- ".github/workflows/qml-tests.yml"
- ".github/workflows/prek.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
test-qml:
runs-on: ubuntu-latest
container: archlinux:base
timeout-minutes: 10
steps:
- name: Install test dependencies
run: |
pacman-key --init
pacman -Syu --noconfirm --needed git make nodejs python lua quickshell qt6-declarative ttf-dejavu
- name: Record runtime versions
run: |
pacman -Q quickshell qt6-declarative
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- name: Run QML pre-commit hook
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16
with:
prek-version: "0.5.5"
extra-args: qml-tests --all-files
cache: ${{ github.event_name != 'pull_request' }}
env:
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: safe.directory
GIT_CONFIG_VALUE_0: ${{ github.workspace }}
+19 -22
View File
@@ -207,8 +207,8 @@ jobs:
# GH_TOKEN: ${{ steps.app_token.outputs.token }} # GH_TOKEN: ${{ steps.app_token.outputs.token }}
# run: | # run: |
# set -euo pipefail # set -euo pipefail
# git config user.name "dux-ci[bot]" # git config user.name "dms-ci[bot]"
# git config user.email "344434142+dux-ci[bot]@users.noreply.github.com" # git config user.email "dms-ci[bot]@users.noreply.github.com"
# version="${GITHUB_REF#refs/tags/}" # version="${GITHUB_REF#refs/tags/}"
# echo "Updating to version: $version" # echo "Updating to version: $version"
@@ -280,7 +280,7 @@ jobs:
## Installation ## Installation
```bash ```bash
curl -fsSL https://install.duxlabs.dev | sh curl -fsSL https://install.danklinux.com | sh
``` ```
## Assets ## Assets
@@ -304,7 +304,7 @@ jobs:
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems - **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging) - **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
- **`dms-qml.tar.gz`** - QML source code only - **`dms-qml.tar.gz`** - QML source code only
- **`dms-source.tar.gz`** - Full repository source with bundled DCommon (for distro packaging) - **`dms-source.tar.gz`** - Full repository source with bundled DankCommon (for distro packaging)
### Checksums ### Checksums
- **`*.sha256`** - SHA256 checksums for verifying download integrity - **`*.sha256`** - SHA256 checksums for verifying download integrity
@@ -352,15 +352,15 @@ jobs:
# Copy completions # Copy completions
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
# Replace the DCommon symlink with real submodule content for packaging # Replace the DankCommon symlink with real submodule content for packaging
rm quickshell/DCommon rm quickshell/DankCommon
cp -r dux-qml-common/DCommon quickshell/DCommon cp -r dank-qml-common/DankCommon quickshell/DankCommon
# Create full source tarball (GitHub tag archives never contain submodule content) # Create full source tarball (GitHub tag archives never contain submodule content)
VERSION_NUM=${TAG#v} VERSION_NUM=${TAG#v}
tar --exclude='.git' \ tar --exclude='.git' \
--exclude='.github' \ --exclude='.github' \
--exclude='dux-qml-common' \ --exclude='dank-qml-common' \
--exclude='_release_assets' \ --exclude='_release_assets' \
--exclude='_core_assets' \ --exclude='_core_assets' \
--exclude='RELEASE_BODY.md' \ --exclude='RELEASE_BODY.md' \
@@ -378,9 +378,7 @@ jobs:
# Tar the CONTENTS of quickshell/, not the directory itself # Tar the CONTENTS of quickshell/, not the directory itself
(cd quickshell && tar --exclude='.git' \ (cd quickshell && tar --exclude='.git' \
--exclude='.github' \ --exclude='.github' \
--exclude='./tests' \
--exclude='*.tar.gz' \ --exclude='*.tar.gz' \
--exclude-from=../scripts/shell-test-excludes.txt \
-czf ../_release_assets/dms-qml.tar.gz .) -czf ../_release_assets/dms-qml.tar.gz .)
# Generate checksum for QML package # Generate checksum for QML package
@@ -420,7 +418,7 @@ jobs:
# Create installation guide # Create installation guide
cat > _temp_full/INSTALL.md << 'EOFINSTALL' cat > _temp_full/INSTALL.md << 'EOFINSTALL'
# Dux Shell Installation # DankMaterialShell Installation
## Requirements ## Requirements
@@ -458,8 +456,8 @@ jobs:
## Configuration ## Configuration
- Settings are stored in `~/.config/dms/settings.json` - Settings are stored in `~/.config/DankMaterialShell/settings.json`
- Plugins go in `~/.config/dms/plugins/` - Plugins go in `~/.config/DankMaterialShell/plugins/`
- See the documentation in the `dms/` directory for more details - See the documentation in the `dms/` directory for more details
## Troubleshooting ## Troubleshooting
@@ -520,8 +518,8 @@ jobs:
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG" bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
# Pin the satellite repos to this release's version. Each dispatch target # Pin the satellite repos to this release's version. Each dispatch target
# decides for itself whether to tag: X.Y.0 always tags master and cuts # decides for itself whether to tag: X.Y.0 always tags, X.Y.Z skips when the
# stable-X.Y, X.Y.Z tags stable-X.Y and skips when it has nothing new. # repo has no commits of its own since its last tag.
fanout: fanout:
name: Pin ${{ matrix.repo }} name: Pin ${{ matrix.repo }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -531,13 +529,12 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
repo: repo:
- dux-gop - dgop
- dux-go - dankgo
- dux-qml-common - dank-qml-common
- dux-calendar - dankcalendar
- dux-search - danksearch
- dux-greeter - dank-greeter
- dux-files
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
echo "📦 Downloading DMS QML source for v${VERSION}..." echo "📦 Downloading DMS QML source for v${VERSION}..."
# Download DMS QML source # Download DMS QML source
wget "https://github.com/duxlabs-dev/dux-shell/releases/download/v${VERSION}/dms-qml.tar.gz" || { wget "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}" echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}"
exit 1 exit 1
} }
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
check-updates: check-updates:
name: Check for updates name: Check for updates
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell' if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs: outputs:
has_updates: ${{ steps.check.outputs.has_updates }} has_updates: ${{ steps.check.outputs.has_updates }}
@@ -61,7 +61,7 @@ jobs:
# Helper function to check dms stable tag # Helper function to check dms stable tag
# Sets LATEST_TAG variable in parent scope if update needed # Sets LATEST_TAG variable in parent scope if update needed
check_dms_stable() { check_dms_stable() {
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "") local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "") local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
@@ -192,7 +192,7 @@ jobs:
# Determine version for dms stable using the API # Determine version for dms stable using the API
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref # GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Using latest release from API: $LATEST_TAG" echo "Using latest release from API: $LATEST_TAG"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
check-updates: check-updates:
name: Check package/series updates name: Check package/series updates
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell' if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs: outputs:
has_updates: ${{ steps.check.outputs.has_updates }} has_updates: ${{ steps.check.outputs.has_updates }}
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
name: Build & Deploy XBPS packages name: Build & Deploy XBPS packages
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: preflight needs: preflight
if: github.repository == 'duxlabs-dev/dux-shell' && needs.preflight.outputs.should_build == 'true' if: github.repository == 'AvengeMedia/DankMaterialShell' && needs.preflight.outputs.should_build == 'true'
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+17 -4
View File
@@ -8,7 +8,6 @@ on:
- "core/go.sum" - "core/go.sum"
branches: branches:
- master - master
- "stable-*"
permissions: permissions:
contents: write contents: write
@@ -34,7 +33,21 @@ jobs:
uses: cachix/install-nix-action@v31 uses: cachix/install-nix-action@v31
- name: Update vendorHash in flake.nix - name: Update vendorHash in flake.nix
run: scripts/update-vendor-hash.sh run: |
set -euo pipefail
echo "Attempting nix build to get new vendorHash..."
if output=$(nix build .#dms-shell 2>&1); then
echo "Build succeeded, no hash update needed"
exit 0
fi
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1 || true)
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
sed -i "s|vendorHash = \"$current_hash\"|vendorHash = \"$new_hash\"|" flake.nix
echo "Verifying build with new vendorHash..."
nix build .#dms-shell
echo "vendorHash updated successfully!"
- name: Commit and push vendorHash update - name: Commit and push vendorHash update
env: env:
@@ -42,8 +55,8 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
if ! git diff --quiet flake.nix; then if ! git diff --quiet flake.nix; then
git config user.name "dux-ci[bot]" git config user.name "dms-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com" git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.nix git add flake.nix
git commit -m "nix: update vendorHash for go.mod changes" || exit 0 git commit -m "nix: update vendorHash for go.mod changes" || exit 0
git pull --rebase origin ${{ github.ref_name }} git pull --rebase origin ${{ github.ref_name }}
+1 -2
View File
@@ -64,7 +64,7 @@ AGENTS-temp.md
niri-colors.generated.kdl niri-colors.generated.kdl
ghostty-colors.generated.conf ghostty-colors.generated.conf
# Notepad files (should be in ~/.local/state/dms/) # Notepad files (should be in ~/.local/state/DankMaterialShell/)
untitled-*.txt untitled-*.txt
file:* file:*
notepad-files/ notepad-files/
@@ -130,6 +130,5 @@ distro/void/masterdir*/
# Often gets built # Often gets built
core/dms core/dms
core/dankinstall
core/internal/shellembed/dist core/internal/shellembed/dist
+2 -2
View File
@@ -1,4 +1,4 @@
[submodule "dank-qml-common"] [submodule "dank-qml-common"]
path = dux-qml-common path = dank-qml-common
url = https://github.com/duxlabs-dev/dux-qml-common.git url = https://github.com/AvengeMedia/dank-qml-common.git
branch = master branch = master
+1 -17
View File
@@ -5,16 +5,6 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
- repo: local
hooks:
- id: qml-tests
name: shell logic and QML behavior tests
entry: python3 quickshell/tests/run-qml.py
language: system
types: []
files: ^(quickshell/|dux-qml-common$|Makefile$|core/Makefile$|scripts/shell-test-excludes\.txt$|flake\.nix$|\.pre-commit-config\.yaml$|\.github/workflows/(qml-tests|prek)\.yml$)
pass_filenames: false
require_serial: true
- repo: local - repo: local
hooks: hooks:
- id: shellcheck - id: shellcheck
@@ -30,12 +20,6 @@ repos:
language: system language: system
files: ^core/.*\.(go|mod|sum)$ files: ^core/.*\.(go|mod|sum)$
pass_filenames: false pass_filenames: false
- id: nix-vendor-hash
name: update Nix vendor hash
entry: bash -c 'command -v nix >/dev/null 2>&1 || exit 0; scripts/update-vendor-hash.sh --stage'
language: system
files: ^core/go\.(mod|sum)$
pass_filenames: false
- repo: local - repo: local
hooks: hooks:
- id: settings-search-index - id: settings-search-index
@@ -67,4 +51,4 @@ repos:
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' -- 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 language: system
files: ^quickshell/.*\.qml$ files: ^quickshell/.*\.qml$
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/|tests/) exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
+49 -33
View File
@@ -1,46 +1,62 @@
## What is this? ## What is this?
DMS is an open-source desktop shell for Wayland compositors on linux, built to work with many compositors. DMS is an open-source, MIT-licensed desktop shell for Wayland compositors on linux. Like KWin, gnome-shell, or cosmic-shell - but designed to work with many different compositors.
## Repo Structure ## Repo Structure:
- core/ - Go. `cmd/dms` runs quickshell, CLI utilities, and the unix socket server the UI depends on. `cmd/dankinstall` is a separate installer TUI. There are 2 main apps in the repo, they are designed to operate together. Additionally there is a dank-qml-common submodule which contains common QML widgets, like a shared library, that is leveraged by other quickshell apps in the Dank Desktop suite.
- quickshell/ - the entire UI: Common/ (singletons, Theme, settings), Services/ (system and core access), Modules/ (bar, dock, lock, ...), Modals/, Widgets/ (shell-specific widgets and deprecated Dank* shims), PLUGINS/, translations/
- dux-qml-common/ - submodule of shared QML widgets
- docs/, distro/ - IPC and theme docs, packaging
## Rules There is a GO module (dankgo) that is used by core for certain common go functionality.
- Keep it simple. ### 1- core:
- Do not edit code in response to a question.
- Never commit, push, or open a PR unless asked.
- Point out glaring issues we missed.
- The shell runs 24/7. Audit every change for idle CPU, extra processes, timers, and retained memory.
- `core` and `quickshell` share the socket protocol. Changing one means checking the other.
- QML uses Theme tokens, never hardcoded colors, spacing, or constants.
- Use the D* widgets from qs.DCommon.Widgets (DListView, DFlickable, and so on) instead of raw ListView/Flickable/ScrollView. The Dank* names in Widgets/ are deprecated shims.
- User-facing text goes through I18n.tr(). Reuse catalog terms. Never edit translation catalogs.
- Guard clauses, early returns.
- Comment only what the code cannot say: a non-obvious why, a trap, a workaround. Never narrate what the code does or restate a name. Keep it to a line or two, and delete useless or paragraph comments you come across.
- Do not edit generated code. Mocks come from mockery.
- No library for a simple problem. Use a modern one for a complex, common problem.
## Tests Contains 2 apps:
- A test must fail on the broken code. Otherwise do not write it. 1. cmd/dms - The parent process for quickshell (shell runner), and general purpose tools and utilities (such as screenshot, clipboard). It also runs the unix socket server the UI uses for functionality quickshell doesn't provide.
- No useless tests: "main.go contains func main()", defaults, "it instantiates", behavior the change didn't touch. 2. cmd/dankinstall - An entirely separate app, a TUI for quickly installing DMS
- QML tests must be fast. Call logic directly, load the smallest component, no fixed delays or animation waits, few meaningful assertions over a matrix.
- Tests cover `.js` modules, functions of `Services/` and `Common/` singletons, and IPC handlers. Never load, slice or mock anything under `Modules/`, `Modals/` or `Widgets/`, and never spawn a compositor, display server or shell. Those tests only ever broke on UI tweaks.
## Connections vs bound properties ### 2- quickshell:
- In per-item or per-screen components (delegates, bar widgets, popout and modal hosts, OSDs), no `Connections` for `on<Prop>Changed` handlers. A quickshell application, the entirety of the UI for DMS. It uses quickshells own services supplemented by the core unix socket server for additional functionality.
- Source in the same file: put the handler on it.
- Source outside (singleton, loader item, delegate parent): bind to a typed readonly property, never `var`, and handle that. Layout:
- Keep `Connections` for plain signals, hand-emitted change signals (`SettingsData.barConfigsChanged()`), constant-pointer targets, reassignable targets, and per-frame `var` sources.
- A bound handler fires once at creation if the initial value differs from the type default, and never on a same-value write. Convert only idempotent bodies. `enabled:` conditions become a guard inside the handler. - Common/ - app-wide singletons and helpers (Theme, SettingsData, SessionData, I18n, PopoutManager)
- Singletons under Services/ and Common/ keep their `Connections`. - Services/ - headless singletons that talk to the system and to core
- Modules/ - the visible shell: DankBar, ControlCenter, Dock, Lock, Notifications, OSD, and so on
- Modals/ - large standalone surfaces: Settings, DankLauncherV2, Clipboard, Greeter
- Widgets/ - reusable Dank* components (DankListView, DankIcon, DankToggle, ...)
- PLUGINS/ - the plugin system; third-party plugins load from the user's config dir
- translations/ - POEditor-synced catalogs
### Other directories:
- docs/ - IPC reference, custom theme docs
- distro/ - packaging for debian, fedora, nix, opensuse, ubuntu, void
## General Rules:
- Keep it simple. Do not overcomplicate things.
- Follow each apps' own conventions. For example, QML widgets use Theme tokens instead of hardcoding colors, spacing, or other constants.
- Resource usage is extremely important to DMS. The shell runs 24/7 on every machine it's installed on - audit any change for idle CPU cost, extra processes, timers, and retained memory.
- `core` and `quickshell` are tightly coupled through the unix socket protocol. When you are changing one, be mindful of the consequences for the other.
- Use the Dank* wrappers in Widgets/ (DankListView, DankFlickable, and so on) instead of raw ListView/Flickable/ScrollView.
- All user-facing text goes through I18n.tr(). Prefer reusing existing catalog terms over adding new ones. Never edit the translation catalogs by hand, they are synced with POEditor.
- Do not start editing code in response to a question. We'll tell you when to edit code.
- Do not leave paragraphs of comments on top of the code. You should try to avoid them as much as possible with understandable function names and code. If they are necessary even then, make them concise. Remove such comments when you come by them in the codebase. Comments should always move with code, not be left behind.
- Use guard statement patterns in any code you write.
- Do not write any useless tests, tests should cover input output validation - not useless things like "main.go contains func main()"
- Do not edit generated code directly, mocks are generated with mockery. Not edited by hand
- If we are missing a glaring issue when we ask you to do something, do not hesitate to point it out.
- Reinvent the wheel but do not reinvent the car. If you are solving a simple problem do not introduce a library. If you are solving a complex but a common problem, there is likely a modern library for it, if so, use it.
- Never commit or push code unless explicitly asked to do so.
- Never make a PR unless explicitly asked to do so.
## Commit Messages ## Commit Messages
`area: short lowercase explanation`, for example `network: report wifi state from the associated adapter`. Description only if required, in simple language. Commit messages start with the part of the system they touched, followed by a short lowercase explanation of the work:
network: report wifi state from the associated adapter
doctor: flag config files that fail to parse
The title should be concise. Description should explain the work in more detail (only if required) while still being concise. Use simple language, do not try to sound smart.
-1
View File
@@ -1,7 +1,6 @@
This file is more of a quick reference so I know what to account for before next releases. This file is more of a quick reference so I know what to account for before next releases.
# Next # Next
- notifications: timed per-app popup mutes (1h / 4h) from the notification context menu
- Go core migrated to dankgo (shared log/paths/errdefs/ipc/shellapp) - Go core migrated to dankgo (shared log/paths/errdefs/ipc/shellapp)
- Quickshell UI embedded in the dms binary, -c / DMS_SHELL_DIR override - breaking for setups relying on ~/.config/quickshell/dms auto-discovery (XDG search removed) - Quickshell UI embedded in the dms binary, -c / DMS_SHELL_DIR override - breaking for setups relying on ~/.config/quickshell/dms auto-discovery (XDG search removed)
- release binaries + all -git packages build with withshell; sockets/pidfiles/env names unchanged, IPC APIVersion 28 wire-compatible - release binaries + all -git packages build with withshell; sockets/pidfiles/env names unchanged, IPC APIVersion 28 wire-compatible
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+18 -27
View File
@@ -6,10 +6,10 @@ To contribute fork this repository, make your changes, and open a pull request.
## Setup ## Setup
Clone with submodules — the shared widget library ([dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common)) is vendored at `dux-qml-common/` and symlinked into `quickshell/DCommon`: Clone with submodules — the shared widget library ([dank-qml-common](https://github.com/AvengeMedia/dank-qml-common)) is vendored at `dank-qml-common/` and symlinked into `quickshell/DankCommon`:
```bash ```bash
git clone --recurse-submodules https://github.com/duxlabs-dev/dux-shell.git git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.git
# or, in an existing clone: # or, in an existing clone:
git submodule update --init git submodule update --init
``` ```
@@ -54,29 +54,34 @@ make run # dev build, then launch against the live quickshell/ tree
The UI config dir resolves in order: `-c <dir>`, `DMS_SHELL_DIR`, the dir a running instance is using, then the embedded UI. Each candidate must contain `shell.qml`. `make run` uses `-c $(pwd)/quickshell`, so QML edits hot-reload from the working tree. The UI config dir resolves in order: `-c <dir>`, `DMS_SHELL_DIR`, the dir a running instance is using, then the embedded UI. Each candidate must contain `shell.qml`. `make run` uses `-c $(pwd)/quickshell`, so QML edits hot-reload from the working tree.
The Go core depends on [dankgo](https://github.com/duxlabs-dev/dux-go) for logging, XDG paths, the IPC transport, and the quickshell process lifecycle. To develop against a local dankgo checkout, create a gitignored `go.work` at the repo root: The Go core depends on [dankgo](https://github.com/AvengeMedia/dankgo) for logging, XDG paths, the IPC transport, and the quickshell process lifecycle. To develop against a local dankgo checkout, create a gitignored `go.work` at the repo root:
``` ```
go work init ./core ../dankgo go 1.26.1
use (
./core
../dankgo
)
``` ```
## Shared widgets (dux-qml-common) ## Shared widgets (dank-qml-common)
Everything under `quickshell/DCommon/` (core widgets, the file browser, scroll physics, bundled fonts) is shared across the DMS suite and lives in the `dux-qml-common` submodule. It is a normal git worktree: Everything under `quickshell/DankCommon/` (core widgets, the file browser, scroll physics, bundled fonts) is shared across the DMS suite and lives in the `dank-qml-common` submodule. It is a normal git worktree:
1. Edit files under `dux-qml-common/` (or through the `quickshell/DCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dux-qml-common`. 1. Edit files under `dank-qml-common/` (or through the `quickshell/DankCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dank-qml-common`.
2. Commit and PR those changes in the `dux-qml-common` repo: `cd dux-qml-common && git switch -c my-change`, push, open the PR there. 2. Commit and PR those changes in the `dank-qml-common` repo: `cd dank-qml-common && git switch -c my-change`, push, open the PR there.
3. Once merged, bump the pointer here: `make update-common` (updates the submodule and the nix flake input together), then commit alongside any DMS-side changes. If you only bump the submodule, CI syncs `flake.lock` to it automatically on master. 3. Once merged, bump the pointer here: `make update-common` (updates the submodule and the nix flake input together), then commit alongside any DMS-side changes. If you only bump the submodule, CI syncs `flake.lock` to it automatically on master.
The submodule URL in `.gitmodules` is HTTPS so CI and anonymous clones keep working. To push over SSH instead of being prompted for credentials, add a push rewrite to your git config — fetches stay HTTPS, pushes use SSH: The submodule URL in `.gitmodules` is HTTPS so CI and anonymous clones keep working. To push over SSH instead of being prompted for credentials, add a push rewrite to your git config — fetches stay HTTPS, pushes use SSH:
```bash ```bash
git config --global url."[email protected]:duxlabs-dev/".pushInsteadOf "https://github.com/duxlabs-dev/" git config --global url."[email protected]:AvengeMedia/".pushInsteadOf "https://github.com/AvengeMedia/"
``` ```
Shared widgets read app-provided singletons (`Theme`, `SettingsData`, ...) through a documented contract — see the dux-qml-common README. If your change needs a new contract property, add it to the library's stub singletons in the same PR, then to `quickshell/Common/` here when you bump. Shared widgets read app-provided singletons (`Theme`, `SettingsData`, ...) through a documented contract — see the dank-qml-common README. If your change needs a new contract property, add it to the library's stub singletons in the same PR, then to `quickshell/Common/` here when you bump.
Files in `quickshell/Widgets/`, `quickshell/Common/`, and `quickshell/Modals/FileBrowser/` that moved to the library remain in place as thin wrappers, so existing `qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` imports keep working for the shell and for plugins. The `Dank*` widget names are deprecated: each one logs a warning naming its replacement. New code imports `qs.DCommon.Widgets` and uses the `D*` names (`DButton`, `DIcon`, `DListView`, and so on). Files in `quickshell/Widgets/`, `quickshell/Common/`, and `quickshell/Modals/FileBrowser/` that moved to the library remain in place as thin wrappers, so `import qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` keep working for the shell and for plugins.
## VSCode Setup ## VSCode Setup
@@ -138,9 +143,7 @@ touch .qmlls.ini
5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`. 5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`.
6. Run `make test-qml` for QML unit tests (`nix develop --command make test-qml` with Nix). These use Qt 6 `qmltestrunner` offscreen and do not require a running shell. Set `QMLTESTRUNNER=/path/to/qmltestrunner` if needed. 6. Make your changes, test, and open a pull request.
7. Make your changes, test, and open a pull request.
### I18n/Localization ### I18n/Localization
@@ -158,7 +161,7 @@ Preferably, try to keep new terms to a minimum and re-use existing terms where p
Don't re-extract the translations. `en.json` and `template.json` are synced with POEditor by a maintainer script, so running `extract_translations.py` in your PR just makes a diff that fights the next sync. Add your `I18n.tr()` calls and leave the catalogs alone. (`settings_search_index.json` is the exception, the pre-commit hook regenerates that one when you touch settings QML.) Don't re-extract the translations. `en.json` and `template.json` are synced with POEditor by a maintainer script, so running `extract_translations.py` in your PR just makes a diff that fights the next sync. Add your `I18n.tr()` calls and leave the catalogs alone. (`settings_search_index.json` is the exception, the pre-commit hook regenerates that one when you touch settings QML.)
Strings inside `quickshell/DCommon/` are owned by the dux-qml-common repo but stay in the DMS POEditor project — extraction here deliberately skips them, and `scripts/i18nsync.py sync` uploads the union of app terms and the submodule's terms instead (common terms carry the `dux-qml-common` tag). On download the sync splits the exports: app translations go to `quickshell/translations/poexports/`, common translations go to `dux-qml-common/DCommon/translations/poexports/` for you to commit in that repo and bump. At runtime `I18n` merges both catalogs (app terms win). Other apps (dankcalendar) keep their own POEditor projects and merge the `dux-qml-common`-tagged terms from the DMS project. Strings inside `quickshell/DankCommon/` are owned by the dank-qml-common repo but stay in the DMS POEditor project — extraction here deliberately skips them, and `scripts/i18nsync.py sync` uploads the union of app terms and the submodule's terms instead (common terms carry the `dank-qml-common` tag). On download the sync splits the exports: app translations go to `quickshell/translations/poexports/`, common translations go to `dank-qml-common/DankCommon/translations/poexports/` for you to commit in that repo and bump. At runtime `I18n` merges both catalogs (app terms win). Other apps (dankcalendar) keep their own POEditor projects and merge the `dank-qml-common`-tagged terms from the DMS project.
### GO (`core` directory) ### GO (`core` directory)
@@ -183,18 +186,6 @@ mockery
`core/.mockery.yml` lists every mocked interface and where its mock goes (e.g. `network.Backend` -> `internal/mocks/network/mock_Backend.go`). To mock a new interface, add it there under its package and regenerate. `core/.mockery.yml` lists every mocked interface and where its mock goes (e.g. `network.Backend` -> `internal/mocks/network/mock_Backend.go`). To mock a new interface, add it there under its package and regenerate.
## Generative AI
Using an LLM to help write code is fine. Submitting its output unread is not.
- You are responsible for every line you submit. You have read it, tested it, and can explain it in review.
- Say in the PR when a meaningful part of the code was AI generated.
- Write PR descriptions, issues, and comments yourself. No AI-written or AI-rewritten text. A few plain sentences in your own words beat a page of generated summary. PRs and issues with AI-written text get closed without review.
- You can use AI to troubleshoot, gather data, and information for new issues. The description should be written by a human.
- If you are not an english-native speaker you can use AI to translate your request or PR description
- Do not file issues or leave comments you have not verified yourself. Reports that do not reproduce get closed.
- PRs that read like unreviewed output, with narrating comments, invented APIs, or style that ignores the file they are in, get closed without review.
## Pull request ## Pull request
Include screenshots/video if applicable in your pull request if applicable, to visualize what your change is affecting. Include screenshots/video if applicable in your pull request if applicable, to visualize what your change is affecting.
+8 -19
View File
@@ -1,4 +1,4 @@
# Root Makefile for Dux Shell (DMS) # Root Makefile for DankMaterialShell (DMS)
# Orchestrates building, installation, and systemd management # Orchestrates building, installation, and systemd management
# Build configuration # Build configuration
@@ -14,7 +14,6 @@ USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6)
SYSTEMD_USER_DIR=$(USER_HOME)/.config/systemd/user SYSTEMD_USER_DIR=$(USER_HOME)/.config/systemd/user
SHELL_DIR=quickshell SHELL_DIR=quickshell
QMLTESTRUNNER ?=
SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications APPLICATIONS_DIR=$(DATA_DIR)/applications
@@ -42,14 +41,10 @@ clean:
lint-qml: lint-qml:
@./quickshell/scripts/qmllint-entrypoints.sh @./quickshell/scripts/qmllint-entrypoints.sh
.PHONY: test-qml # Pull the latest dank-qml-common and pin it everywhere it is consumed
test-qml:
QMLTESTRUNNER="$(QMLTESTRUNNER)" python3 quickshell/tests/run-qml.py
# Pull the latest dux-qml-common and pin it everywhere it is consumed
# (submodule pointer + nix flake input). Commit both in one change. # (submodule pointer + nix flake input). Commit both in one change.
update-common: update-common:
git submodule update --remote --merge dux-qml-common git submodule update --remote --merge dank-qml-common
nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common
# Installation targets # Installation targets
@@ -83,17 +78,15 @@ endif
install-icon: install-icon:
@echo "Installing icon..." @echo "Installing icon..."
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.svg $(ICON_DIR)/dev.duxlabs.shell.svg @install -D -m 644 $(ASSETS_DIR)/danklogo.svg $(ICON_DIR)/danklogo.svg
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true @gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
@echo "Icon installed" @echo "Icon installed"
install-desktop: install-desktop:
@echo "Installing desktop entries..." @echo "Installing desktop entries..."
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop @install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop @install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.notepad.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop @install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed" @echo "Desktop entries installed"
@@ -129,8 +122,6 @@ uninstall-systemd:
uninstall-icon: uninstall-icon:
@echo "Removing icon..." @echo "Removing icon..."
@rm -f $(ICON_DIR)/dev.duxlabs.shell.svg
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
@rm -f $(ICON_DIR)/danklogo.svg @rm -f $(ICON_DIR)/danklogo.svg
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true @gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
@echo "Icon removed" @echo "Icon removed"
@@ -138,9 +129,8 @@ uninstall-icon:
uninstall-desktop: uninstall-desktop:
@echo "Removing desktop entries..." @echo "Removing desktop entries..."
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop @rm -f $(APPLICATIONS_DIR)/dms-open.desktop
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop @rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop @rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries removed" @echo "Desktop entries removed"
@@ -157,7 +147,6 @@ help:
@echo " build - Same as 'all'" @echo " build - Same as 'all'"
@echo " clean - Clean build artifacts" @echo " clean - Clean build artifacts"
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS" @echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
@echo " test-qml - Run shell logic and Qt unit tests"
@echo "" @echo ""
@echo "Install:" @echo "Install:"
@echo " install - Build and install everything (requires sudo)" @echo " install - Build and install everything (requires sudo)"
+33 -36
View File
@@ -1,32 +1,32 @@
# Dux Shell # DankMaterialShell
<div align="center"> <div align="center">
<a href="https://duxlabs.dev"> <a href="https://danklinux.com">
<img src="assets/danklogo.svg" alt="Dux Shell" width="200"> <img src="assets/danklogo.svg" alt="DankMaterialShell" width="200">
</a> </a>
### A modern desktop shell for Wayland ### A modern desktop shell for Wayland
Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/) Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![Documentation](https://img.shields.io/badge/docs-duxlabs.dev-9ccbfb?style=for-the-badge&labelColor=101418)](https://duxlabs.dev/docs) [![Documentation](https://img.shields.io/badge/docs-danklinux.com-9ccbfb?style=for-the-badge&labelColor=101418)](https://danklinux.com/docs)
[![GitHub stars](https://img.shields.io/github/stars/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/duxlabs-dev/dux-shell/stargazers) [![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![GitHub License](https://img.shields.io/github/license/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE) [![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/duxlabs-dev/dux-shell/releases) [![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases)
[![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/) [![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/)
[![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git) [![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fduxlabs)](https://ko-fi.com/duxlabs) [![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fdanklinux)](https://ko-fi.com/danklinux)
</div> </div>
Dux Shell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop. DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
## Repository Structure ## Repository Structure
This is a monorepo containing both the shell interface and the core backend services: This is a monorepo containing both the shell interface and the core backend services:
``` ```
dux-shell/ DankMaterialShell/
├── quickshell/ # QML-based shell interface ├── quickshell/ # QML-based shell interface
│ ├── Modules/ # UI components (panels, widgets, overlays) │ ├── Modules/ # UI components (panels, widgets, overlays)
│ ├── Services/ # System integration (audio, network, bluetooth) │ ├── Services/ # System integration (audio, network, bluetooth)
@@ -70,14 +70,12 @@ https://github.com/user-attachments/assets/d310418b-cc44-4ace-ac0d-2fc0c851291d
## Installation ## Installation
```bash ```bash
curl -fsSL https://install.duxlabs.dev | sh curl -fsSL https://install.danklinux.com | sh
``` ```
One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, openSUSE, or Gentoo. One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, openSUSE, or Gentoo.
**FreeBSD:** native support is included in-tree; see [distro/freebsd/README.md](distro/freebsd/README.md) for supported integrations and setup notes. **[Manual installation guide](https://danklinux.com/docs/dankmaterialshell/installation)**
**[Manual installation guide](https://duxlabs.dev/docs/dms/installation)**
## Features ## Features
@@ -85,10 +83,10 @@ One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, o
Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16. Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16.
**System Monitoring** **System Monitoring**
Real-time CPU, RAM, GPU metrics and temperatures built into the dms daemon (powered by [dgop](https://github.com/duxlabs-dev/dux-gop)). Process list with search and management. Real-time CPU, RAM, GPU metrics and temperatures built into the dms daemon (powered by [dgop](https://github.com/AvengeMedia/dgop)). Process list with search and management.
**Powerful Launcher** **Powerful Launcher**
Spotlight-style search for applications, files ([dsearch](https://github.com/duxlabs-dev/dux-search)), emojis, running windows, calculator, and commands. Extensible with plugins. Spotlight-style search for applications, files ([dsearch](https://github.com/AvengeMedia/danksearch)), emojis, running windows, calculator, and commands. Extensible with plugins.
**Control Center** **Control Center**
Unified interface for network, Bluetooth, audio devices, display settings, and night mode. Unified interface for network, Bluetooth, audio devices, display settings, and night mode.
@@ -100,18 +98,18 @@ Notification center with grouping, rich text support, and keyboard navigation.
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews. MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
**Session Management** **Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/duxlabs-dev/dux-greeter). Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter).
**Plugin System** **Plugin System**
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
`~/.config/dms/plugins.lock.json` synchronized with managed plugin installs and `~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and
their exact Git commits, so the same plugins can be reproduced on another machine. their exact Git commits, so the same plugins can be reproduced on another machine.
## Supported Compositors ## Supported Compositors
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and [Miracle WM](https://github.com/miracle-wm-org/miracle-wm) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features. Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and [Miracle WM](https://github.com/miracle-wm-org/miracle-wm) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
[Compositor configuration guide](https://duxlabs.dev/docs/dms/compositors) [Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
## Command Line Interface ## Command Line Interface
@@ -128,26 +126,26 @@ dms plugins lock # Refresh the portable plugin lockfile
dms plugins restore ~/plugins.lock.json dms plugins restore ~/plugins.lock.json
``` ```
[Full CLI and IPC documentation](https://duxlabs.dev/docs/dms/keybinds-ipc) [Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
## Documentation ## Documentation
- **Website:** [duxlabs.dev](https://duxlabs.dev) - **Website:** [danklinux.com](https://danklinux.com)
- **Docs:** [duxlabs.dev/docs](https://duxlabs.dev/docs/) - **Docs:** [danklinux.com/docs](https://danklinux.com/docs/)
- **Theming:** [Application themes](https://duxlabs.dev/docs/dms/application-themes) | [Custom themes](https://duxlabs.dev/docs/dms/custom-themes) - **Theming:** [Application themes](https://danklinux.com/docs/dankmaterialshell/application-themes) | [Custom themes](https://danklinux.com/docs/dankmaterialshell/custom-themes)
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview) - **Plugins:** [Development guide](https://danklinux.com/docs/dankmaterialshell/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs) - **Support:** [Ko-fi](https://ko-fi.com/avengemediallc)
## Dank Projects ## Dank Projects
DMS is one piece of the suite. The rest lives in its own repos: DMS is one piece of the suite. The rest lives in its own repos:
- [dank-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it. - [dank-greeter](https://github.com/AvengeMedia/dank-greeter) - greetd login screen with the Dank Material aesthetic. The Greeter tab in DMS settings is the front-end for it.
- [dankcalendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop. - [dankcalendar](https://github.com/AvengeMedia/dankcalendar) - Local, Google, Microsoft, and CalDAV calendars for the dank desktop.
- [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon. - [dgop](https://github.com/AvengeMedia/dgop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
- [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher. - [dsearch](https://github.com/AvengeMedia/danksearch) - Fast file search that powers file results in the launcher.
- [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar. - [dank-qml-common](https://github.com/AvengeMedia/dank-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar.
- [dankgo](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps. - [dankgo](https://github.com/AvengeMedia/dankgo) - Common Go modules behind the single binary apps.
## Development ## Development
@@ -177,7 +175,7 @@ quickshell -p quickshell/
```nix ```nix
{ {
inputs.dms.url = "github:duxlabs-dev/dux-shell"; inputs.dms.url = "github:AvengeMedia/DankMaterialShell";
# Use in home-manager or NixOS configuration # Use in home-manager or NixOS configuration
imports = [ inputs.dms.homeModules.dank-material-shell ]; imports = [ inputs.dms.homeModules.dank-material-shell ];
@@ -193,7 +191,7 @@ Contributions welcome. Bug fixes, widgets, features, documentation, and plugins
3. Test thoroughly 3. Test thoroughly
4. Open a pull request 4. Open a pull request
For documentation contributions, see [DankLinux-Docs](https://github.com/duxlabs-dev/dux-docs). For documentation contributions, see [DankLinux-Docs](https://github.com/AvengeMedia/DankLinux-Docs).
## Credits ## Credits
@@ -202,11 +200,10 @@ For documentation contributions, see [DankLinux-Docs](https://github.com/duxlabs
- [Ly-sec](http://github.com/ly-sec) - Wallpaper effects from [Noctalia](https://github.com/noctalia-dev/noctalia-shell) - [Ly-sec](http://github.com/ly-sec) - Wallpaper effects from [Noctalia](https://github.com/noctalia-dev/noctalia-shell)
- [soramanew](https://github.com/soramanew) - [Caelestia](https://github.com/caelestia-dots/shell) inspiration - [soramanew](https://github.com/soramanew) - [Caelestia](https://github.com/caelestia-dots/shell) inspiration
- [end-4](https://github.com/end-4) - [dots-hyprland](https://github.com/end-4/dots-hyprland) inspiration - [end-4](https://github.com/end-4) - [dots-hyprland](https://github.com/end-4/dots-hyprland) inspiration
- [Reverier-Xu](https://github.com/Reverier-Xu) - [Ori-fcitx5](https://github.com/Reverier-Xu/Ori-fcitx5) inspiration for Fcitx5 theme
## Star History ## Star History
[![Star History Chart](https://star-history.dera.page/svg?repos=duxlabs-dev/dux-shell&type=date&legend=top-left)](https://star-history.dera.page/#duxlabs-dev/dux-shell&type=date&legend=top-left) [![Star History Chart](https://star-history.dera.page/svg?repos=AvengeMedia/DankMaterialShell&type=date&legend=top-left)](https://star-history.dera.page/#AvengeMedia/DankMaterialShell&type=date&legend=top-left)
## License ## License
@@ -1,10 +1,10 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Dux Shell Name=DankMaterialShell
GenericName=Desktop Shell GenericName=Desktop Shell
Comment=Material 3 inspired shell for Wayland compositors Comment=Material 3 inspired shell for Wayland compositors
Exec=dms run Exec=dms run
Icon=dev.duxlabs.shell Icon=danklogo
Terminal=false Terminal=false
NoDisplay=true NoDisplay=true
StartupNotify=false StartupNotify=false
@@ -2,9 +2,9 @@
Type=Application Type=Application
Name=DMS Notepad Name=DMS Notepad
GenericName=Text Editor GenericName=Text Editor
Comment=Quick notes and text files in Dux Shell Comment=Quick notes and text files in the dank desktop shell
Exec=dms ipc call notepad openFile %f Exec=dms ipc call notepad openFile %f
Icon=dev.duxlabs.shell Icon=danklogo
Terminal=false Terminal=false
NoDisplay=true NoDisplay=true
Categories=Utility;TextEditor; Categories=Utility;TextEditor;
-113
View File
@@ -1,113 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="482.90668"
height="558.15088"
viewBox="0 0 482.90667 558.15088"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
shape-rendering="auto"
style="image-rendering: auto; filter: url(#smoothing);">
<defs
id="defs1">
<filter id="smoothing" x="-0.05" y="-0.05" width="1.1" height="1.1">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.5" />
</filter>
<color-profile
name="sRGB IEC61966-2.1"
xlink:href="data:application/vnd.iccprofile;base64,AAAMbExpbm8CEAAAbW50clJHQiBYWVogB84AAgAJAAYAMQAAYWNzcE1TRlQAAAAASUVDIHNSR0IAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1IUCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARY3BydAAAAVAAAAAzZGVzYwAAAYQAAACQd3RwdAAAAhQAAAAUYmtwdAAAAigAAAAUclhZWgAAAjwAAAAUZ1hZWgAAAlAAAAAUYlhZWgAAAmQAAAAUZG1uZAAAAngAAABwZG1kZAAAAugAAACIdnVlZAAAA3AAAACGdmlldwAAA/gAAAAkbHVtaQAABBwAAAAUbWVhcwAABDAAAAAkdGVjaAAABFQAAAAMclRSQwAABGAAAAgMZ1RSQwAABGAAAAgMYlRSQwAABGAAAAgMdGV4dAAAAABDb3B5cmlnaHQgKGMpIDE5OTggSGV3bGV0dC1QYWNrYXJkIENvbXBhbnkAAGRlc2MAAAAAAAAAEnNSR0IgSUVDNjE5NjYtMi4xAAAAAAAAAAASAHMAUgBHAEIAIABJAEUAQwA2ADEAOQA2ADYALQAyAC4AMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAAAAAAAAAAAAAABYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9kZXNjAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGRlc2MAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAACxSZWZlcmVuY2UgVmlld2luZyBDb25kaXRpb24gaW4gSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2aWV3AAAAAAATpP4AFF8uABDPFAAD7cwABBMLAANcngAAAAFYWVogAAAAAABMCVYAUAAAAFcf521lYXMAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAKPAAAAAnNpZyAAAAAAQ1JUIGN1cnYAAAAAAAAEAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4AYwBoAG0AcgB3AHwAgQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2wDgAOUA6wDwAPYA+wEBAQcBDQETARkBHwElASsBMgE4AT4BRQFMAVIBWQFgAWcBbgF1AXwBgwGLAZIBmgGhAakBsQG5AcEByQHRAdkB4QHpAfIB+gIDAgwCFAIdAiYCLwI4AkECSwJUAl0CZwJxAnoChAKOApgCogKsArYCwQLLAtUC4ALrAvUDAAMLAxYDIQMtAzgDQwNPA1oDZgNyA34DigOWA6IDrgO6A8cD0wPgA+wD+QQGBBMEIAQtBDsESARVBGMEcQR+BIwEmgSoBLYExATTBOEE8AT+BQ0FHAUrBToFSQVYBWcFdwWGBZYFpgW1BcUF1QXlBfYGBgYWBicGNwZIBlkGagZ7BowGnQavBsAG0QbjBvUHBwcZBysHPQdPB2EHdAeGB5kHrAe/B9IH5Qf4CAsIHwgyCEYIWghuCIIIlgiqCL4I0gjnCPsJEAklCToJTwlkCXkJjwmkCboJzwnlCfsKEQonCj0KVApqCoEKmAquCsUK3ArzCwsLIgs5C1ELaQuAC5gLsAvIC+EL+QwSDCoMQwxcDHUMjgynDMAM2QzzDQ0NJg1ADVoNdA2ODakNww3eDfgOEw4uDkkOZA5/DpsOtg7SDu4PCQ8lD0EPXg96D5YPsw/PD+wQCRAmEEMQYRB+EJsQuRDXEPURExExEU8RbRGMEaoRyRHoEgcSJhJFEmQShBKjEsMS4xMDEyMTQxNjE4MTpBPFE+UUBhQnFEkUahSLFK0UzhTwFRIVNBVWFXgVmxW9FeAWAxYmFkkWbBaPFrIW1hb6Fx0XQRdlF4kXrhfSF/cYGxhAGGUYihivGNUY+hkgGUUZaxmRGbcZ3RoEGioaURp3Gp4axRrsGxQbOxtjG4obshvaHAIcKhxSHHscoxzMHPUdHh1HHXAdmR3DHeweFh5AHmoelB6+HukfEx8+H2kflB+/H+ogFSBBIGwgmCDEIPAhHCFIIXUhoSHOIfsiJyJVIoIiryLdIwojOCNmI5QjwiPwJB8kTSR8JKsk2iUJJTglaCWXJccl9yYnJlcmhya3JugnGCdJJ3onqyfcKA0oPyhxKKIo1CkGKTgpaymdKdAqAio1KmgqmyrPKwIrNitpK50r0SwFLDksbiyiLNctDC1BLXYtqy3hLhYuTC6CLrcu7i8kL1ovkS/HL/4wNTBsMKQw2zESMUoxgjG6MfIyKjJjMpsy1DMNM0YzfzO4M/E0KzRlNJ402DUTNU01hzXCNf02NzZyNq426TckN2A3nDfXOBQ4UDiMOMg5BTlCOX85vDn5OjY6dDqyOu87LTtrO6o76DwnPGU8pDzjPSI9YT2hPeA+ID5gPqA+4D8hP2E/oj/iQCNAZECmQOdBKUFqQaxB7kIwQnJCtUL3QzpDfUPARANER0SKRM5FEkVVRZpF3kYiRmdGq0bwRzVHe0fASAVIS0iRSNdJHUljSalJ8Eo3Sn1KxEsMS1NLmkviTCpMcky6TQJNSk2TTdxOJU5uTrdPAE9JT5NP3VAnUHFQu1EGUVBRm1HmUjFSfFLHUxNTX1OqU/ZUQlSPVNtVKFV1VcJWD1ZcVqlW91dEV5JX4FgvWH1Yy1kaWWlZuFoHWlZaplr1W0VblVvlXDVchlzWXSddeF3JXhpebF69Xw9fYV+zYAVgV2CqYPxhT2GiYfViSWKcYvBjQ2OXY+tkQGSUZOllPWWSZedmPWaSZuhnPWeTZ+loP2iWaOxpQ2maafFqSGqfavdrT2una/9sV2yvbQhtYG25bhJua27Ebx5veG/RcCtwhnDgcTpxlXHwcktypnMBc11zuHQUdHB0zHUodYV14XY+dpt2+HdWd7N4EXhueMx5KnmJeed6RnqlewR7Y3vCfCF8gXzhfUF9oX4BfmJ+wn8jf4R/5YBHgKiBCoFrgc2CMIKSgvSDV4O6hB2EgITjhUeFq4YOhnKG14c7h5+IBIhpiM6JM4mZif6KZIrKizCLlov8jGOMyo0xjZiN/45mjs6PNo+ekAaQbpDWkT+RqJIRknqS45NNk7aUIJSKlPSVX5XJljSWn5cKl3WX4JhMmLiZJJmQmfyaaJrVm0Kbr5wcnImc951kndKeQJ6unx2fi5/6oGmg2KFHobaiJqKWowajdqPmpFakx6U4pammGqaLpv2nbqfgqFKoxKk3qamqHKqPqwKrdavprFys0K1ErbiuLa6hrxavi7AAsHWw6rFgsdayS7LCszizrrQltJy1E7WKtgG2ebbwt2i34LhZuNG5SrnCuju6tbsuu6e8IbybvRW9j74KvoS+/796v/XAcMDswWfB48JfwtvDWMPUxFHEzsVLxcjGRsbDx0HHv8g9yLzJOsm5yjjKt8s2y7bMNcy1zTXNtc42zrbPN8+40DnQutE80b7SP9LB00TTxtRJ1MvVTtXR1lXW2Ndc1+DYZNjo2WzZ8dp22vvbgNwF3IrdEN2W3hzeot8p36/gNuC94UThzOJT4tvjY+Pr5HPk/OWE5g3mlucf56noMui86Ubp0Opb6uXrcOv77IbtEe2c7ijutO9A78zwWPDl8XLx//KM8xnzp/Q09ML1UPXe9m32+/eK+Bn4qPk4+cf6V/rn+3f8B/yY/Sn9uv5L/tz/bf//"
id="color-profile1" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath21">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-673.87432,-704.25842)"
id="path21" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath25">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path25" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath27">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-695.28002,-473.92741)"
id="path27" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath29">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-457.93881,-632.99062)"
id="path29" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath31">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path31" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath33">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-614.51722,-638.93302)"
id="path33" />
</clipPath>
</defs>
<g
id="layer-MC0"
transform="translate(-486.31024,-515.02722)">
<path
id="path20"
d="M 0,0 C -1.568,1.568 -3.163,3.098 -4.787,4.61 -5.944,3.966 -7.185,3.35 -8.529,2.762 -9.658,2.277 -10.815,1.82 -11.981,1.4 c 1.885,-1.689 3.742,-3.425 5.552,-5.207 0.448,-0.429 0.886,-0.868 1.325,-1.306 2.221,-2.221 4.386,-4.498 6.476,-6.84 27.639,-30.784 44.453,-71.459 44.453,-116.089 0,-29.347 -7.259,-56.977 -20.09,-81.21 -2.192,-4.134 -4.544,-8.174 -7.054,-12.102 -6.83,-10.74 -14.827,-20.669 -23.785,-29.636 -5.944,-5.944 -12.317,-11.459 -19.073,-16.498 -0.56,-0.42 -1.12,-0.83 -1.689,-1.231 -28.675,-20.893 -63.975,-33.201 -102.186,-33.201 -48.018,0 -91.464,19.456 -122.948,50.93 -0.737,0.737 -1.465,1.474 -2.174,2.221 -0.55,0.569 -1.101,1.147 -1.633,1.726 -15.545,16.553 -27.881,36.14 -36.018,57.779 -3.098,8.211 -5.58,16.712 -7.409,25.464 -2.417,11.534 -3.686,23.496 -3.686,35.758 0.01,42.326 15.117,81.097 40.246,111.246 -2.072,1.278 -3.975,2.809 -5.534,4.637 -26.174,-31.399 -41.934,-71.822 -41.934,-115.883 0,-18.187 2.678,-35.748 7.67,-52.311 3.359,-11.142 7.754,-21.835 13.092,-31.95 8.528,-16.208 19.446,-30.961 32.276,-43.801 1.251,-1.25 2.529,-2.491 3.817,-3.685 0.662,-0.644 1.334,-1.26 2.006,-1.876 10.862,-9.938 22.945,-18.578 36,-25.661 25.632,-13.913 55.016,-21.816 86.229,-21.816 2.454,0 4.908,0.047 7.334,0.14 36.056,1.446 69.424,13.427 97.054,32.976 0.569,0.392 1.148,0.793 1.698,1.204 7.82,5.655 15.164,11.925 21.966,18.718 7.904,7.904 15.07,16.526 21.406,25.773 2.556,3.723 4.973,7.558 7.25,11.477 15.499,26.697 24.382,57.723 24.382,90.812 C 53.038,-78.055 32.762,-32.762 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,898.49907,660.9888)"
clip-path="url(#clipPath21)" />
<path
id="path24"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath25)" />
<path
id="path26"
d="m 0,0 c -6.336,-9.247 -13.502,-17.869 -21.406,-25.773 -6.802,-6.793 -14.146,-13.063 -21.965,-18.718 -0.551,-0.411 -1.129,-0.812 -1.699,-1.204 -27.629,-19.549 -60.998,-31.53 -97.053,-32.976 -33.247,1.129 -64.852,8.762 -93.564,21.676 -13.054,7.082 -25.138,15.723 -36,25.661 -0.672,0.616 -1.343,1.232 -2.006,1.876 -1.288,1.194 -2.566,2.435 -3.816,3.685 -12.831,12.84 -23.748,27.593 -32.277,43.801 -1.092,7.651 -1.941,15.378 -2.445,23.039 -0.102,1.502 -0.186,3.004 -0.261,4.497 0,0 -2.865,29.795 23.944,36.634 26.827,6.84 65.654,19.745 87.722,50.305 0,0 0.327,-8.38 5.506,-15.779 8.034,-11.422 46.674,-100.46 46.674,-100.46 l 6.121,51.135 -13.978,15.079 -4.553,-1.987 15.228,16.544 c 0,0 8.94,4.218 16.554,-0.653 7.605,-4.899 14.146,-16.153 14.146,-16.153 l -5.879,3.892 -4.227,-12.364 c -0.756,-2.184 -0.83,-4.535 -0.233,-6.784 l 16.796,-62.687 c 0,0 -0.243,87.415 -2.781,111.685 0,0 14.221,-10.367 21.621,-14.454 7.381,-4.078 47.215,-20.407 53.738,-22.395 6.504,-1.987 13.222,-5.841 15.882,-11.916 1.026,-2.351 8.594,-26.939 17.486,-56.229 C -1.829,6.028 -0.914,3.023 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,927.04,968.0968)"
clip-path="url(#clipPath27)" />
<path
id="path28"
d="m 0,0 c 0,0 -3.081,-67.22 -8.64,-90.616 -5.559,-23.397 30.316,0 30.316,0 l 20.9,68.629 z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,610.58507,756.01253)"
clip-path="url(#clipPath29)" />
<path
id="path30"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath31)" />
<path
id="path32"
d="m 0,0 c 0,0 12.178,7.604 15.029,18.355 0,0 36.913,-9.243 52.904,-26.719 C 66.887,-8.091 29.642,2.031 0,0 m -36.152,-5.643 c -26.791,0.404 -31.781,16.05 -34.086,29.523 -0.511,2.958 -3.148,5.073 -6.13,4.883 l -46.275,-4.764 14.922,7.14 c 3.505,1.687 7.164,3.077 10.895,4.17 3.742,1.093 7.556,1.901 11.429,2.376 4.051,0.499 8.542,1.01 13.46,1.509 0,0 6.19,2.459 7.414,-4.479 1.259,-6.926 0.499,-31.864 28.287,-31.103 27.777,0.76 32.612,19.412 32.612,19.412 0,0 3.458,14.471 9.232,14.257 5.263,-0.202 7.27,-6.142 7.853,-10.479 0.142,-1.045 -0.06,-3.54 0.582,-4.324 -0.939,1.152 -2.162,2.032 -3.564,2.554 C 7.604,26.113 3.065,26.945 2.471,21.658 1.616,13.877 -9.599,-6.059 -36.152,-5.643 m 13.306,48.354 9.528,-2.377 c 0,0 -2.388,-17.5 -11.227,-21.979 -0.25,-0.13 4.194,14.281 1.699,24.356 m 59.022,3.041 3.79,-1.687 c 0,0 2.198,-14.209 -3.79,-20.577 0,0 2.174,12.474 0,22.264 m -187.451,35.63 c 12.225,2.067 24.45,4.229 36.664,6.332 l 36.663,6.392 73.303,12.748 c 2.958,0.522 5.774,-1.462 6.285,-4.408 0.522,-2.947 -1.462,-5.762 -4.42,-6.285 -0.119,-0.024 -0.261,-0.036 -0.38,-0.047 H -3.184 L -114.243,85.029 c -12.344,-1.224 -24.676,-2.495 -37.032,-3.647 M 43.97,16.93 c 3.54,4.693 5.215,23.096 5.215,23.096 l 1.497,2.518 v 8.744 C 29.381,61.327 11.869,50.682 11.869,50.682 l -16.491,0.749 c -3.112,0.142 -7.449,2.637 -10.644,3.433 -4.42,1.093 -8.804,2.068 -13.342,2.566 -16.407,1.735 -32.933,0 -49.091,-2.934 -7.021,-1.271 -13.971,-2.851 -20.957,-4.325 -6.701,-1.425 -12.878,-3.908 -19.151,-6.605 -4.313,-1.842 -8.649,-3.659 -12.641,-6.119 -2.436,-1.496 -4.741,-3.243 -6.737,-5.31 -2.126,-2.21 -3.659,-4.859 -5.476,-7.319 -1.545,-2.091 -4.907,-0.463 -4.147,2.032 0.024,0.059 0.048,0.119 0.06,0.154 0.867,2.044 2.221,4.646 3.659,6.345 3.588,4.241 8.958,8.292 13.686,11.12 10.039,6.071 21.48,9.766 32.767,12.985 24.771,7.057 51.442,8.138 77.009,10.55 14.114,1.331 28.43,2.091 42.473,4.016 12.784,1.734 37.935,4.859 36.176,22.882 -1.71,18.129 -59.355,18.712 -59.355,18.712 0,0 -21.943,45.027 -27.372,50.468 -5.418,5.418 -16.503,18.474 -74.254,4.384 -57.739,-14.078 -55.327,-42.259 -55.327,-42.259 l -2.412,-39.622 c 0,0 -43.198,-11.179 -41.832,-23.321 1.391,-12.142 39.171,-12.819 39.171,-12.819 0,0 8.577,0.439 20.696,1.152 l -5.382,-9.683 c -6.749,-12.13 -6.38,-25.258 -8.851,-38.908 -4.277,-23.631 11.963,-52.702 33.978,-62.147 21.1,-9.053 53.949,-13.782 99.012,6.368 11.085,4.954 20.328,13.282 26.66,23.618 l 0.499,0.796 c 22.728,26.125 80.574,9.386 80.574,9.386 C 81.774,1.699 43.97,16.93 43.97,16.93"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,819.35627,748.08933)"
clip-path="url(#clipPath33)" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

+1 -1
View File
@@ -3,7 +3,7 @@ Type=Application
Name=DMS Name=DMS
Comment=Select an application to open links and files Comment=Select an application to open links and files
Exec=dms open %u Exec=dms open %u
Icon=dev.duxlabs.shell Icon=danklogo
Terminal=false Terminal=false
NoDisplay=true NoDisplay=true
MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/dms;text/html;application/xhtml+xml; MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/dms;text/html;application/xhtml+xml;
+3 -4
View File
@@ -1,13 +1,12 @@
[Unit] [Unit]
Description=Dux Shell (DMS) Description=Dank Material Shell (DMS)
PartOf=graphical-session.target PartOf=graphical-session.target
After=graphical-session.target After=graphical-session.target
Requisite=graphical-session.target Requisite=graphical-session.target
[Service] [Service]
Type=notify Type=dbus
NotifyAccess=main BusName=org.freedesktop.Notifications
Environment=QS_DISABLE_CRASH_HANDLER=1
ExecStart=/usr/bin/dms run --session ExecStart=/usr/bin/dms run --session
ExecReload=/bin/kill -USR1 $MAINPID ExecReload=/bin/kill -USR1 $MAINPID
LimitNOFILE=16384:infinity LimitNOFILE=16384:infinity
+10 -10
View File
@@ -6,13 +6,13 @@ template: testify
template-data: template-data:
unroll-variadic: true unroll-variadic: true
packages: packages:
github.com/duxlabs-dev/dux-shell/core/internal/geolocation: github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config: config:
dir: internal/mocks/geolocation dir: internal/mocks/geolocation
pkgname: mocks_geolocation pkgname: mocks_geolocation
interfaces: interfaces:
Client: {} Client: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/cups: github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
config: config:
dir: internal/mocks/cups dir: internal/mocks/cups
pkgname: mocks_cups pkgname: mocks_cups
@@ -22,46 +22,46 @@ packages:
config: config:
dir: internal/mocks/cups_pkhelper dir: internal/mocks/cups_pkhelper
pkgname: mocks_cups_pkhelper pkgname: mocks_cups_pkhelper
github.com/duxlabs-dev/dux-shell/core/internal/server/evdev: github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
config: config:
dir: internal/mocks/evdev dir: internal/mocks/evdev
pkgname: mocks_evdev pkgname: mocks_evdev
interfaces: interfaces:
EvdevDevice: {} EvdevDevice: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/network: github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config: config:
dir: internal/mocks/network dir: internal/mocks/network
pkgname: mocks_network pkgname: mocks_network
interfaces: interfaces:
Backend: {} Backend: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/wlcontext: github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
config: config:
dir: internal/mocks/wlcontext dir: internal/mocks/wlcontext
pkgname: mocks_wlcontext pkgname: mocks_wlcontext
interfaces: interfaces:
WaylandContext: {} WaylandContext: {}
github.com/duxlabs-dev/dux-shell/core/internal/utils: github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config: config:
dir: internal/mocks/utils dir: internal/mocks/utils
pkgname: mocks_utils pkgname: mocks_utils
interfaces: interfaces:
AppChecker: {} AppChecker: {}
github.com/duxlabs-dev/dux-shell/core/internal/version: github.com/AvengeMedia/DankMaterialShell/core/internal/version:
config: config:
dir: internal/mocks/version dir: internal/mocks/version
pkgname: mocks_version pkgname: mocks_version
interfaces: interfaces:
VersionFetcher: {} VersionFetcher: {}
github.com/duxlabs-dev/dux-go/wayland/client: github.com/AvengeMedia/dankgo/wayland/client:
config: config:
dir: internal/mocks/wlclient dir: internal/mocks/wlclient
pkgname: mocks_wlclient pkgname: mocks_wlclient
interfaces: interfaces:
WaylandDisplay: {} WaylandDisplay: {}
github.com/duxlabs-dev/dux-shell/core/internal/plugins: github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
interfaces: interfaces:
GitClient: {} GitClient: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/brightness: github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
config: config:
dir: internal/mocks/brightness dir: internal/mocks/brightness
pkgname: mocks_brightness pkgname: mocks_brightness
+4 -17
View File
@@ -1,26 +1,13 @@
repos: repos:
- repo: local - repo: https://github.com/golangci/golangci-lint
rev: v2.13.1
hooks: hooks:
- id: golangci-lint-fmt - id: golangci-lint-fmt
name: golangci-lint-fmt
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] fmt
language: system
types: [go]
require_serial: true require_serial: true
pass_filenames: false
- id: golangci-lint-full - id: golangci-lint-full
name: golangci-lint-full
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --fix
language: system
types: [go]
require_serial: true
pass_filenames: false
- id: golangci-lint-config-verify - id: golangci-lint-config-verify
name: golangci-lint-config-verify - repo: local
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] config verify hooks:
language: system
files: '\.golangci\.(?:yml|yaml|toml|json)'
pass_filenames: false
- id: go-test - id: go-test
name: go test name: go test
entry: go test ./... entry: go test ./...
+10 -3
View File
@@ -37,13 +37,20 @@ endif
# Default target # Default target
all: build all: build
# Copy the quickshell UI into the embed dir (gitignored) so tagged builds
# can bake it into the binary. Dev-only files are stripped; scripts/ is kept
# minus its dev entries, since Theme.qml and BluetoothService.qml run
# gtk.sh/qt.sh/bluez-card-profile.lua out of the resolved shell dir.
# tar -h dereferences the DankCommon submodule symlink; go:embed rejects
# symlinks. .qmlls.ini is excluded at copy time: it's a symlink into the
# quickshell runtime VFS and dereferencing it fails whenever the shell isn't
# running.
sync-shell: sync-shell:
@test -e $(SHELL_SRC)/DCommon/Widgets/DIcon.qml || { echo "DCommon missing: run git submodule update --init"; exit 1; } @test -e $(SHELL_SRC)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
@rm -rf $(EMBED_DIR) @rm -rf $(EMBED_DIR)
@mkdir -p $(EMBED_DIR) @mkdir -p $(EMBED_DIR)
@tar -C $(SHELL_SRC) --exclude=.qmlls.ini --exclude-from=../scripts/shell-test-excludes.txt -chf - . | tar -C $(EMBED_DIR) -xf - @tar -C $(SHELL_SRC) --exclude=.qmlls.ini -chf - . | tar -C $(EMBED_DIR) -xf -
@rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github @rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github
@rm -rf $(EMBED_DIR)/tests
@find $(EMBED_DIR) -type d \( -name .claude -o -name .vscode \) -prune -exec rm -rf {} + @find $(EMBED_DIR) -type d \( -name .claude -o -name .vscode \) -prune -exec rm -rf {} +
@rm -f $(EMBED_DIR)/AGENTS.md $(EMBED_DIR)/qmlformat-all.sh @rm -f $(EMBED_DIR)/AGENTS.md $(EMBED_DIR)/qmlformat-all.sh
@rm -f $(EMBED_DIR)/scripts/i18nsync.py $(EMBED_DIR)/scripts/build-vscode-vsix.sh $(EMBED_DIR)/scripts/qmllint-entrypoints.sh @rm -f $(EMBED_DIR)/scripts/i18nsync.py $(EMBED_DIR)/scripts/build-vscode-vsix.sh $(EMBED_DIR)/scripts/qmllint-entrypoints.sh
+9 -9
View File
@@ -1,6 +1,6 @@
# DMS Backend & CLI # DMS Backend & CLI
Go-based backend for Dux Shell providing system integration, IPC, and installation tools. Go-based backend for DankMaterialShell providing system integration, IPC, and installation tools.
**See [root README](../README.md) for project overview and installation.** **See [root README](../README.md) for project overview and installation.**
@@ -78,7 +78,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
- `dms brightness [list|set]` - Control display/monitor brightness - `dms brightness [list|set]` - Control display/monitor brightness
- `dms color pick` - Native color picker (see below) - `dms color pick` - Native color picker (see below)
- `dms update` - Update DMS and dependencies (disabled in distro packages) - `dms update` - Update DMS and dependencies (disabled in distro packages)
- `dms greeter` - Deprecated; forwards to the standalone [dms-greeter](https://github.com/duxlabs-dev/dux-greeter) binary - `dms greeter` - Deprecated; forwards to the standalone [dms-greeter](https://github.com/AvengeMedia/dank-greeter) binary
### Color Picker ### Color Picker
@@ -132,10 +132,10 @@ This runs gofmt, golangci-lint, tests, and builds before each commit when `core/
**Regenerating Wayland Protocol Bindings:** **Regenerating Wayland Protocol Bindings:**
Protocol XML files live in `internal/proto/xml/`. Bindings are generated with the [dankgo](https://github.com/duxlabs-dev/dux-go) `go-wayland-scanner`: Protocol XML files live in `internal/proto/xml/`. Bindings are generated with the [dankgo](https://github.com/AvengeMedia/dankgo) `go-wayland-scanner`:
```bash ```bash
go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \ go run github.com/AvengeMedia/dankgo/cmd/go-wayland-scanner@latest \
-i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \ -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
-pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go -pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go
``` ```
@@ -152,7 +152,7 @@ go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \
**Interactive (TUI):** **Interactive (TUI):**
```bash ```bash
curl -fsSL https://install.duxlabs.dev | sh curl -fsSL https://install.danklinux.com | sh
``` ```
**Headless (unattended):** **Headless (unattended):**
@@ -160,9 +160,9 @@ curl -fsSL https://install.duxlabs.dev | sh
Headless mode requires cached credentials or a passwordless rule for your privilege escalation tool (sudo, doas, or run0). With sudo, run `sudo -v` first: Headless mode requires cached credentials or a passwordless rule for your privilege escalation tool (sudo, doas, or run0). With sudo, run `sudo -v` first:
```bash ```bash
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c niri -t ghostty -y sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c hyprland -t kitty --dms-greeter -y sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --dms-greeter -y
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c niri -t ghostty --git-deps niri,quickshell --all-features -y sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty --git-deps niri,quickshell --all-features -y
``` ```
| Flag | Short | Description | | Flag | Short | Description |
@@ -225,6 +225,6 @@ Most packages available in standard repos. Minimal building required.
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration. Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
**Void Linux** **Void Linux**
Uses XBPS with the DMS and Dux Labs self-hosted repositories. Uses XBPS with the DMS and DankLinux self-hosted repositories.
See installer output for distribution-specific details during installation. See installer output for distribution-specific details during installation.
+5 -5
View File
@@ -5,10 +5,10 @@ import (
"os" "os"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/duxlabs-dev/dux-shell/core/internal/headless"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -33,8 +33,8 @@ var (
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "dankinstall", Use: "dankinstall",
Short: "Install Dux Shell and its dependencies", Short: "Install DankMaterialShell and its dependencies",
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal. Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
Without flags, it launches an interactive TUI. Providing either --compositor Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags. or --term activates headless (unattended) mode, which requires both flags.
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/duxlabs-dev/dux-shell/core/internal/pam" sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+2 -2
View File
@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/duxlabs-dev/dux-shell/core/internal/backup" "github.com/AvengeMedia/DankMaterialShell/core/internal/backup"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -17,7 +17,7 @@ var backupCmd = &cobra.Command{
var backupCreateCmd = &cobra.Command{ var backupCreateCmd = &cobra.Command{
Use: "create", Use: "create",
Short: "Create a tar.gz backup of ~/.config/dms (settings, plugins, themes)", Short: "Create a tar.gz backup of ~/.config/DankMaterialShell (settings, plugins, themes)",
Args: cobra.NoArgs, Args: cobra.NoArgs,
Run: runBackupCreate, Run: runBackupCreate,
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/duxlabs-dev/dux-shell/core/internal/blur" "github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server/brightness" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+3 -3
View File
@@ -8,11 +8,11 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight"
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight/lexers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight/styles"
"github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight/lexers"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight/styles"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
+20 -26
View File
@@ -28,10 +28,10 @@ import (
_ "golang.org/x/image/tiff" _ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-go/wlclipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/dankgo/wlclipboard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -430,7 +430,7 @@ func runCommand(args []string, stdin []byte) {
} }
func runClipHistory(cmd *cobra.Command, args []string) { func runClipHistory(cmd *cobra.Command, args []string) {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.getHistory", Method: "clipboard.getHistory",
} }
@@ -501,7 +501,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
} }
if clipGetCopy { if clipGetCopy {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.copyEntry", Method: "clipboard.copyEntry",
Params: map[string]any{"id": id}, Params: map[string]any{"id": id},
@@ -519,7 +519,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
return return
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.getEntry", Method: "clipboard.getEntry",
Params: map[string]any{ Params: map[string]any{
@@ -565,7 +565,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
log.Fatalf("Invalid ID: %v", err) log.Fatalf("Invalid ID: %v", err)
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.deleteEntry", Method: "clipboard.deleteEntry",
Params: map[string]any{ Params: map[string]any{
@@ -586,7 +586,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
} }
func runClipClear(cmd *cobra.Command, args []string) { func runClipClear(cmd *cobra.Command, args []string) {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.clearHistory", Method: "clipboard.clearHistory",
} }
@@ -621,7 +621,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
params["isImage"] = false params["isImage"] = false
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.search", Method: "clipboard.search",
Params: params, Params: params,
@@ -688,7 +688,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
} }
func runClipConfigGet(cmd *cobra.Command, args []string) { func runClipConfigGet(cmd *cobra.Command, args []string) {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.getConfig", Method: "clipboard.getConfig",
} }
@@ -742,7 +742,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
return return
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.setConfig", Method: "clipboard.setConfig",
Params: params, Params: params,
@@ -761,7 +761,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
} }
func runClipExport(cmd *cobra.Command, args []string) { func runClipExport(cmd *cobra.Command, args []string) {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.getHistory", Method: "clipboard.getHistory",
} }
@@ -1004,25 +1004,19 @@ func downloadToTempFile(rawURL string) (string, error) {
return filePath, nil return filePath, nil
} }
// copyFileToClipboard copies through the server so the entry lands in
// history, and serves the same offers itself when the server is down or
// refuses (e.g. file over the history size limit).
func copyFileToClipboard(filePath string) error { func copyFileToClipboard(filePath string) error {
if abs, err := filepath.Abs(filePath); err == nil { req := models.Request{
filePath = abs
}
resp, ok := tryServerRequest(ipc.Request{
ID: 1, ID: 1,
Method: "clipboard.copyFile", Method: "clipboard.copyFile",
Params: map[string]any{"filePath": filePath}, Params: map[string]any{"filePath": filePath},
})
if ok && resp.Error == "" {
return nil
} }
data, err := os.ReadFile(filePath) resp, err := sendServerRequest(req)
if err != nil { if err != nil {
return fmt.Errorf("read file: %w", err) return fmt.Errorf("server request: %w", err)
} }
return clipboard.CopyMulti(clipboard.FileOffers(filePath, filePath, data), false, false) if resp.Error != "" {
return fmt.Errorf("server error: %s", resp.Error)
}
return nil
} }
+3 -4
View File
@@ -5,9 +5,8 @@ import (
"os" "os"
"runtime/debug" "runtime/debug"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/colorpicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -27,7 +26,7 @@ var colorCmd = &cobra.Command{
This is the screen eyedropper CLI. To open the in-shell color modal, use: This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle dms ipc call color-picker toggle
See: ` + site.Docs + "/dms/keybinds-ipc", See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
} }
var colorPickCmd = &cobra.Command{ var colorPickCmd = &cobra.Command{
+17 -19
View File
@@ -6,12 +6,10 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/duxlabs-dev/dux-shell/core/internal/server" "github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate" "github.com/AvengeMedia/DankMaterialShell/core/internal/shellembed"
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -29,7 +27,7 @@ var ipcCmd = &cobra.Command{
dms ipc call <target> <function> [args...] invoke a command dms ipc call <target> <function> [args...] invoke a command
dms ipc list list all targets and functions dms ipc list list all targets and functions
Full reference: ` + site.Docs + "/dms/keybinds-ipc", Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
}, },
@@ -219,17 +217,21 @@ func runVersion(cmd *cobra.Command, args []string) {
// Git builds: dms (git) v0.6.2-XXXX // Git builds: dms (git) v0.6.2-XXXX
// Stable releases: dms v0.6.2 // Stable releases: dms v0.6.2
func formatVersion(version string) string { func formatVersion(version string) string {
if n := sysupdate.GitBuildCount(version); n > 0 { // Arch/Debian/Ubuntu/OpenSUSE git format: 0.6.2+git2264.c5c5ce84
// Fedora COPR (0.0.git.2267.d430cae9) carries no real base version. re := regexp.MustCompile(`^([\d.]+)\+git(\d+)\.`)
base, _, ok := strings.Cut(version, "+git") if matches := re.FindStringSubmatch(version); matches != nil {
if !ok { return fmt.Sprintf("dms (git) v%s-%s", matches[1], matches[2])
base = getBaseVersion() }
}
return fmt.Sprintf("dms (git) v%s-%d", strings.TrimPrefix(base, "v"), n) // Fedora COPR git format: 0.0.git.2267.d430cae9
re = regexp.MustCompile(`^[\d.]+\.git\.(\d+)\.`)
if matches := re.FindStringSubmatch(version); matches != nil {
baseVersion := getBaseVersion()
return fmt.Sprintf("dms (git) v%s-%s", baseVersion, matches[1])
} }
// Stable release format: 0.6.2 // Stable release format: 0.6.2
re := regexp.MustCompile(`^([\d.]+)$`) re = regexp.MustCompile(`^([\d.]+)$`)
if matches := re.FindStringSubmatch(version); matches != nil { if matches := re.FindStringSubmatch(version); matches != nil {
return fmt.Sprintf("dms v%s", matches[1]) return fmt.Sprintf("dms v%s", matches[1])
} }
@@ -771,7 +773,6 @@ func getCommonCommands() []*cobra.Command {
registryCmd, registryCmd,
dank16Cmd, dank16Cmd,
brightnessCmd, brightnessCmd,
iccCmd,
dpmsCmd, dpmsCmd,
keybindsCmd, keybindsCmd,
greeterCmd, greeterCmd,
@@ -792,9 +793,6 @@ func getCommonCommands() []*cobra.Command {
blurCmd, blurCmd,
trashCmd, trashCmd,
systemCmd, systemCmd,
systemdCmd,
switchUserCmd, switchUserCmd,
switchCmd,
dmenuCmd,
}...) }...)
} }
+69 -69
View File
@@ -7,10 +7,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/luaconfig" "github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -36,7 +35,6 @@ var resolveIncludeCmd = &cobra.Command{
"layout.lua", "layout.lua",
"outputs.lua", "outputs.lua",
"cursor.lua", "cursor.lua",
"input.lua",
"windowrules.lua", "windowrules.lua",
"cursor.kdl", "cursor.kdl",
"layout.kdl", "layout.kdl",
@@ -44,7 +42,6 @@ var resolveIncludeCmd = &cobra.Command{
"binds.kdl", "binds.kdl",
"input.kdl", "input.kdl",
"cursor.conf", "cursor.conf",
"input.conf",
"layout.conf", "layout.conf",
"outputs.conf", "outputs.conf",
"binds.conf", "binds.conf",
@@ -55,32 +52,8 @@ var resolveIncludeCmd = &cobra.Command{
Run: runResolveInclude, Run: runResolveInclude,
} }
var mangoMigrateMain bool
var mangoMigrateCmd = &cobra.Command{
Use: "mango-migrate",
Short: "Respell Mango config keys for the installed Mango",
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dialect := mangoconf.Detect()
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
for _, path := range changed {
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
}
// Printed before any error: the shell needs the dialect even when a fragment failed.
fmt.Println(dialect)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() { func init() {
configCmd.AddCommand(resolveIncludeCmd) configCmd.AddCommand(resolveIncludeCmd)
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
configCmd.AddCommand(mangoMigrateCmd)
} }
type IncludeResult struct { type IncludeResult struct {
@@ -131,21 +104,32 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
return result, err return result, err
} }
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
mainLua := filepath.Join(configDir, "hyprland.lua") mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil { if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua" result.ConfigFormat = "lua"
result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool)) result.ReadOnly = false
return result, nil processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
return result, nil
}
} }
// 0.55/0.56 installs still on hyprland.conf: read-only until migrated.
mainConf := filepath.Join(configDir, "hyprland.conf") mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err != nil { if _, err := os.Stat(mainConf); err == nil {
return result, nil if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
return result, nil
}
} }
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
result.Included = hyprlandFindIncludeHyprlang(mainConf, targetAbs, make(map[string]bool))
return result, nil return result, nil
} }
@@ -166,8 +150,14 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
} }
baseDir := filepath.Dir(absPath) baseDir := filepath.Dir(absPath)
for line := range strings.SplitSeq(string(data), "\n") { lines := strings.SplitSeq(string(data), "\n")
for line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") { if !strings.HasPrefix(trimmed, "source") {
continue continue
} }
@@ -177,12 +167,22 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
continue continue
} }
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1])) sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil { if err != nil {
continue continue
} }
if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) { if hyprlandFindIncludeHyprlang(expanded, target, processed) {
return true return true
} }
} }
@@ -205,13 +205,8 @@ func checkNiriInclude(filename string) (IncludeResult, error) {
return result, nil return result, nil
} }
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
processed := make(map[string]bool) processed := make(map[string]bool)
result.Included = niriFindInclude(mainConfig, targetAbs, processed) result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil return result, nil
} }
@@ -253,12 +248,17 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
continue continue
} }
fullPath, err := resolveSourcePath(baseDir, trimmed[startQuote+1:endQuote]) includePath := trimmed[startQuote+1 : endQuote]
if err != nil { if matchesTarget(includePath, target) {
continue return true
} }
if fullPath == target || niriFindInclude(fullPath, target, processed) { fullPath := includePath
if !filepath.IsAbs(includePath) {
fullPath = filepath.Join(baseDir, includePath)
}
if niriFindInclude(fullPath, target, processed) {
return true return true
} }
} }
@@ -267,7 +267,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
} }
func checkMangoWCInclude(filename string) (IncludeResult, error) { func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir := mangoconf.Dir() configDir := filepath.Join(utils.XDGConfigHome(), "mango")
targetPath := filepath.Join(configDir, "dms", filename) targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{} result := IncludeResult{}
@@ -284,13 +284,8 @@ func checkMangoWCInclude(filename string) (IncludeResult, error) {
return result, nil return result, nil
} }
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
processed := make(map[string]bool) processed := make(map[string]bool)
result.Included = mangowcFindInclude(mainConfig, targetAbs, processed) result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil return result, nil
} }
@@ -328,12 +323,22 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
continue continue
} }
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1])) sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil { if err != nil {
continue continue
} }
if fullPath == target || mangowcFindInclude(fullPath, target, processed) { if mangowcFindInclude(expanded, target, processed) {
return true return true
} }
} }
@@ -341,13 +346,8 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
return false return false
} }
func resolveSourcePath(baseDir, raw string) (string, error) { func matchesTarget(path, target string) bool {
expanded, err := utils.ExpandPath(raw) path = strings.TrimPrefix(path, "./")
if err != nil { target = strings.TrimPrefix(target, "./")
return "", err return path == target || strings.HasSuffix(path, "/"+target)
}
if !filepath.IsAbs(expanded) {
expanded = filepath.Join(baseDir, expanded)
}
return filepath.Abs(expanded)
} }
-74
View File
@@ -1,74 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func writeConfigFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestNiriFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
dir := t.TempDir()
writeConfigFile(t, filepath.Join(dir, "config.kdl"), "include \"dms/dms.kdl\"\n")
writeConfigFile(t, filepath.Join(dir, "dms", "dms.kdl"), "include \"binds.kdl\"\ninclude \"./outputs.kdl\"\n")
for _, name := range []string{"binds.kdl", "outputs.kdl"} {
target := filepath.Join(dir, "dms", name)
if !niriFindInclude(filepath.Join(dir, "config.kdl"), target, map[string]bool{}) {
t.Errorf("%s included through dms/dms.kdl not detected", name)
}
}
if niriFindInclude(filepath.Join(dir, "config.kdl"), filepath.Join(dir, "dms", "layout.kdl"), map[string]bool{}) {
t.Error("layout.kdl reported included without an include line")
}
}
func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
dir := t.TempDir()
writeConfigFile(t, filepath.Join(dir, "config.conf"), "source=./dms/dms.conf\n")
writeConfigFile(t, filepath.Join(dir, "dms", "dms.conf"), "source = binds.conf\n")
if !mangowcFindInclude(filepath.Join(dir, "config.conf"), filepath.Join(dir, "dms", "binds.conf"), map[string]bool{}) {
t.Error("binds.conf sourced through dms/dms.conf not detected")
}
}
func TestCheckHyprlandIncludeFormat(t *testing.T) {
tests := []struct {
name string
files map[string]string
wantFormat string
wantReadOnly bool
wantIncluded bool
}{
{"hyprlang sourced", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n"}, "hyprlang", true, true},
{"hyprlang not sourced", map[string]string{"hyprland.conf": "bind = SUPER, Q, killactive\n"}, "hyprlang", true, false},
{"lua wins over leftover conf", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n", "hyprland.lua": "\n"}, "lua", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
for name, content := range tt.files {
writeConfigFile(t, filepath.Join(xdg, "hypr", name), content)
}
got, err := checkHyprlandInclude("colors.conf")
if err != nil {
t.Fatal(err)
}
if got.ConfigFormat != tt.wantFormat || got.ReadOnly != tt.wantReadOnly || got.Included != tt.wantIncluded {
t.Fatalf("got %+v, want format %q readOnly %v included %v", got, tt.wantFormat, tt.wantReadOnly, tt.wantIncluded)
}
})
}
}
+7 -14
View File
@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/dank16" "github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -28,13 +28,9 @@ func init() {
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format") dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
dank16Cmd.Flags().String("background", "", "Custom background color") dank16Cmd.Flags().String("background", "", "Custom background color")
dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag") dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag")
dank16Cmd.Flags().String("profile", "default", "Palette: default, low, medium or high")
dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON") dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON")
dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)") dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)")
dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)") dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)")
_ = dank16Cmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"default", "low", "medium", "high"}, cobra.ShellCompDirectiveNoFileComp
})
_ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { _ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp
}) })
@@ -51,7 +47,6 @@ func runDank16(cmd *cobra.Command, args []string) {
isWezterm, _ := cmd.Flags().GetBool("wezterm") isWezterm, _ := cmd.Flags().GetBool("wezterm")
background, _ := cmd.Flags().GetString("background") background, _ := cmd.Flags().GetString("background")
contrastAlgo, _ := cmd.Flags().GetString("contrast") contrastAlgo, _ := cmd.Flags().GetString("contrast")
profile, _ := cmd.Flags().GetString("profile")
useVariants, _ := cmd.Flags().GetBool("variants") useVariants, _ := cmd.Flags().GetBool("variants")
primaryDark, _ := cmd.Flags().GetString("primary-dark") primaryDark, _ := cmd.Flags().GetString("primary-dark")
primaryLight, _ := cmd.Flags().GetString("primary-light") primaryLight, _ := cmd.Flags().GetString("primary-light")
@@ -84,12 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
primaryLight = primaryColor primaryLight = primaryColor
} }
variantOpts := dank16.VariantOptions{ variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark, PrimaryDark: primaryDark,
PrimaryLight: primaryLight, PrimaryLight: primaryLight,
BackgroundDark: background, Background: background,
UseDPS: contrastAlgo == "dps", UseDPS: contrastAlgo == "dps",
IsLightMode: isLight, IsLightMode: isLight,
Profile: profile,
} }
variantColors := dank16.GenerateVariantPalette(variantOpts) variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors)) fmt.Print(dank16.GenerateVariantJSON(variantColors))
@@ -108,7 +102,6 @@ func runDank16(cmd *cobra.Command, args []string) {
IsLight: isLight, IsLight: isLight,
Background: background, Background: background,
UseDPS: contrastAlgo == "dps", UseDPS: contrastAlgo == "dps",
Profile: profile,
} }
colors := dank16.GeneratePalette(primaryColor, opts) colors := dank16.GeneratePalette(primaryColor, opts)
-643
View File
@@ -1,643 +0,0 @@
package main
import (
"bufio"
"bytes"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/dmenuipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const dmenuConnectTimeout = 5 * time.Second
var (
dmenuPrompt string
dmenuLines int
dmenuPlaceholder string
dmenuPromptOnly bool
dmenuSep string
dmenuActiveSpec string
dmenuUrgentSpec string
dmenuOnlyMatch bool
dmenuNoCustom bool
dmenuSelect string
dmenuMesg string
dmenuInput string
dmenuPassword bool
dmenuMarkupRows bool
dmenuSync bool
dmenuNoRunIfEmpty bool
dmenuFormat string
dmenuMultiSelect bool
dmenuDump bool
dmenuKeybindSpecs []string
dmenuPrintInfo bool
dmenuView string
dmenuSize string
dmenuIcon string
dmenuCaseInsens bool
dmenuDisableHist bool
)
var dmenuCmd = &cobra.Command{
Use: "dmenu",
Short: "dmenu-compatible picker backed by the DMS app launcher",
Long: `Read newline-separated items from stdin, show them in the DMS launcher,
and print the selected item to stdout
Examples:
printf '%s\n' opt1 opt2 opt3 | dms dmenu -p "Pick one:"
dms dmenu --prompt-only -p "Rename to:"
Matching is always case-insensitive; -i/--case-insensitive is accepted for
dmenu script compatibility but has no effect.
Most, but not all, rofi flags are implemented.
Long-form flags also accept rofi's single-dash form
(e.g. -only-match as well as --only-match).
Exit codes: 0 on accept, 1 if the user cancelled (or stdin was empty),
2 if the shell was unreachable.`,
Run: runDmenu,
}
func init() {
dmenuCmd.Flags().BoolVarP(&dmenuCaseInsens, "case-insensitive", "i", false, "No effect: matching is always case-insensitive. Accepted for dmenu/rofi script compatibility")
dmenuCmd.Flags().StringVarP(&dmenuPrompt, "prompt", "p", "dmenu", "Prompt label")
dmenuCmd.Flags().IntVarP(&dmenuLines, "lines", "l", 0, "Max visible result rows (0 = shell default)")
dmenuCmd.Flags().StringVar(&dmenuPlaceholder, "placeholder", "", "Input placeholder text")
dmenuCmd.Flags().BoolVar(&dmenuPromptOnly, "prompt-only", false, "No piped item list — pure free-text prompt")
dmenuCmd.Flags().StringVar(&dmenuSep, "sep", "\n", "Input item separator")
dmenuCmd.Flags().StringVarP(&dmenuActiveSpec, "active", "a", "", "Mark rows active by index (e.g. \"1,3,7:11,-3:\")")
dmenuCmd.Flags().StringVarP(&dmenuUrgentSpec, "urgent", "u", "", "Mark rows urgent by index (same spec syntax as --active)")
dmenuCmd.Flags().BoolVar(&dmenuOnlyMatch, "only-match", false, "Reject free text; only a listed item may be returned; also blocks Escape/click-outside cancel, forcing a real pick")
dmenuCmd.Flags().BoolVar(&dmenuNoCustom, "no-custom", false, "Reject free text; only a listed item may be returned (unlike --only-match, Escape/cancel still works normally)")
dmenuCmd.Flags().StringVar(&dmenuSelect, "select", "", "Pre-select the first row matching this string (or, with --dump, filter to matching rows)")
dmenuCmd.Flags().StringVar(&dmenuMesg, "mesg", "", "Extra message line shown alongside the list")
dmenuCmd.Flags().StringVar(&dmenuInput, "input", "", "Read items from this file instead of stdin")
dmenuCmd.Flags().BoolVar(&dmenuPassword, "password", false, "Mask typed input (cosmetic only, not a security boundary)")
dmenuCmd.Flags().BoolVar(&dmenuMarkupRows, "markup-rows", false, "Render each row's text as rich markup")
dmenuCmd.Flags().BoolVar(&dmenuSync, "sync", false, "Read all input to EOF before showing the picker (default: stream as items arrive)")
dmenuCmd.Flags().BoolVar(&dmenuNoRunIfEmpty, "no-run-if-empty", false, "If stdin is empty, exit 1 immediately with no UI shown")
dmenuCmd.Flags().StringVar(&dmenuFormat, "format", "s", "What to print on accept: s=text (default), i=0-based index, d=1-based index, q=shell-quoted, p=markup-stripped, f=typed filter text, F=quoted filter text")
dmenuCmd.Flags().BoolVar(&dmenuMultiSelect, "multi-select", false, "Allow selecting multiple rows; each is printed on its own line on accept")
dmenuCmd.Flags().BoolVar(&dmenuDump, "dump", false, "Apply --select against the item list and print the result immediately; no UI is ever shown")
dmenuCmd.Flags().StringArrayVar(&dmenuKeybindSpecs, "keybind", nil, "Bind a custom accept key (repeatable): N=keyspec, e.g. --keybind 1=ctrl+e (N is 1-19); exits 9+N (10-28) instead of 0 when accepted that way")
dmenuCmd.Flags().BoolVar(&dmenuPrintInfo, "print-info", false, "Also print an accepted row's info metadata (the per-row \\0info\\x1f... field, see the per-row metadata protocol) on a second line")
dmenuCmd.Flags().BoolVar(&dmenuDisableHist, "disable-history", false, "Don't add this session's search query to the launcher's persistent history (default: recorded, like other launcher modes)")
dmenuCmd.Flags().StringVar(&dmenuView, "view", "", "Override the result view mode for this invocation: list, grid, or tile (default: list)")
dmenuCmd.Flags().StringVar(&dmenuSize, "size", "", "Override the popup size preset for this invocation: 1-4, matching the Appearance settings size buttons (default: the shell's own launcher size setting)")
dmenuCmd.Flags().StringVar(&dmenuIcon, "icon", "", "Show a Material Symbols icon next to the prompt badge (default: no icon)")
}
var dmenuLongFlagToken = regexp.MustCompile(`^-([a-zA-Z][a-zA-Z0-9-]+)(=.*)?$`)
func dmenuAliasableLongFlags() map[string]bool {
set := map[string]bool{}
dmenuCmd.Flags().VisitAll(func(f *pflag.Flag) {
if len(f.Name) > 1 {
set[f.Name] = true
}
})
return set
}
func normalizeDmenuArgv(args []string) []string {
if len(args) < 2 || args[1] != "dmenu" {
return args
}
aliasable := dmenuAliasableLongFlags()
out := make([]string, len(args))
copy(out, args)
for i := 2; i < len(out); i++ {
tok := out[i]
if tok == "--" {
break
}
m := dmenuLongFlagToken.FindStringSubmatch(tok)
if m == nil {
continue
}
name, valueSuffix := m[1], m[2]
if aliasable[name] {
out[i] = "--" + name + valueSuffix
}
}
return out
}
func dmenuSplitFunc(sep string) bufio.SplitFunc {
if sep == "\n" {
return bufio.ScanLines
}
sepBytes := []byte(sep)
return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.Index(data, sepBytes); i >= 0 {
return i + len(sepBytes), data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
}
}
func parseIndexSpec(spec string) ([]dmenuipc.Range, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, nil
}
var ranges []dmenuipc.Range
for _, tok := range strings.Split(spec, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
if idx := strings.Index(tok, ":"); idx >= 0 {
startStr, endStr := strings.TrimSpace(tok[:idx]), strings.TrimSpace(tok[idx+1:])
start, end := 0, -1
if startStr != "" {
n, err := strconv.Atoi(startStr)
if err != nil {
return nil, fmt.Errorf("invalid range start %q in spec %q", startStr, spec)
}
start = n
}
if endStr != "" {
n, err := strconv.Atoi(endStr)
if err != nil {
return nil, fmt.Errorf("invalid range end %q in spec %q", endStr, spec)
}
end = n
}
ranges = append(ranges, dmenuipc.Range{Start: start, End: end})
continue
}
n, err := strconv.Atoi(tok)
if err != nil {
return nil, fmt.Errorf("invalid index %q in spec %q", tok, spec)
}
ranges = append(ranges, dmenuipc.Range{Start: n, End: n + 1})
}
return ranges, nil
}
func parseDmenuKeybinds(specs []string) ([]dmenuipc.Keybind, error) {
var out []dmenuipc.Keybind
seen := map[int]bool{}
for _, s := range specs {
eq := strings.IndexByte(s, '=')
if eq <= 0 {
return nil, fmt.Errorf("invalid --keybind %q (expected N=keyspec, e.g. 1=ctrl+e)", s)
}
n, err := strconv.Atoi(strings.TrimSpace(s[:eq]))
if err != nil || n < 1 || n > 19 {
return nil, fmt.Errorf("invalid --keybind index in %q (expected 1-19)", s)
}
key := strings.TrimSpace(s[eq+1:])
if key == "" {
return nil, fmt.Errorf("invalid --keybind %q: empty key spec", s)
}
if seen[n] {
return nil, fmt.Errorf("duplicate --keybind index %d", n)
}
seen[n] = true
out = append(out, dmenuipc.Keybind{N: n, Key: key})
}
return out, nil
}
func dmenuParseBool(val string) bool {
switch strings.ToLower(strings.TrimSpace(val)) {
case "", "0", "false", "no":
return false
default:
return true
}
}
func parseDmenuRow(line string) (text string, meta *dmenuipc.ItemMeta) {
idx := strings.IndexByte(line, 0)
if idx < 0 {
return line, nil
}
text = line[:idx]
fields := strings.Split(line[idx+1:], "\x1f")
m := dmenuipc.ItemMeta{}
any := false
for i := 0; i+1 < len(fields); i += 2 {
key, val := fields[i], fields[i+1]
switch key {
case "icon":
m.Icon = val
case "display":
m.Display = val
case "meta":
m.Meta = val
case "info":
m.Info = val
case "nonselectable":
m.NonSelectable = dmenuParseBool(val)
case "urgent":
m.Urgent = dmenuParseBool(val)
case "active":
m.Active = dmenuParseBool(val)
case "permanent":
m.Permanent = dmenuParseBool(val)
case "header":
m.Header = dmenuParseBool(val)
default:
continue
}
any = true
}
if !any {
return text, nil
}
return text, &m
}
func validateDmenuFormat(format string) error {
switch format {
case "", "s", "i", "d", "q", "p", "f", "F":
return nil
default:
return fmt.Errorf("invalid --format %q (expected one of s,i,d,q,p,f,F)", format)
}
}
func validateDmenuView(view string) error {
switch view {
case "", "list", "grid", "tile":
return nil
default:
return fmt.Errorf("invalid --view %q (expected one of list,grid,tile)", view)
}
}
func dmenuSizePreset(size string) (string, error) {
switch size {
case "":
return "", nil
case "1":
return "micro", nil
case "2":
return "compact", nil
case "3":
return "medium", nil
case "4":
return "large", nil
default:
return "", fmt.Errorf("invalid --size %q (expected one of 1,2,3,4)", size)
}
}
var dmenuMarkupTagRe = regexp.MustCompile(`<[^>]*>`)
func dmenuShellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func dmenuFormatOutput(format string, index int, text, filterText string) (string, error) {
switch format {
case "", "s":
return text, nil
case "i":
return strconv.Itoa(index), nil
case "d":
if index < 0 {
return strconv.Itoa(index), nil
}
return strconv.Itoa(index + 1), nil
case "q":
return dmenuShellQuote(text), nil
case "p":
return dmenuMarkupTagRe.ReplaceAllString(text, ""), nil
case "f":
return filterText, nil
case "F":
return dmenuShellQuote(filterText), nil
default:
return "", fmt.Errorf("invalid --format %q (expected one of s,i,d,q,p,f,F)", format)
}
}
func dmenuOpenInput() *os.File {
if dmenuPromptOnly {
return nil
}
if dmenuInput != "" {
f, err := os.Open(dmenuInput)
if err != nil {
log.Fatalf("Error opening --input file: %v", err)
}
return f
}
return os.Stdin
}
type dmenuRow struct {
Text string
Meta *dmenuipc.ItemMeta
}
func dmenuReadAll(input *os.File) []dmenuRow {
if input == nil {
return nil
}
scanner := bufio.NewScanner(input)
scanner.Buffer(make([]byte, 64*1024), 1<<20)
scanner.Split(dmenuSplitFunc(dmenuSep))
var rows []dmenuRow
for scanner.Scan() {
text, meta := parseDmenuRow(scanner.Text())
rows = append(rows, dmenuRow{Text: text, Meta: meta})
}
if err := scanner.Err(); err != nil {
log.Fatalf("Error reading items: %v", err)
}
return rows
}
func dmenuDumpLines(rows []dmenuRow, select_, format string, printInfo bool) ([]string, error) {
var lower string
filtering := select_ != ""
if filtering {
lower = strings.ToLower(select_)
}
var lines []string
for i, row := range rows {
if row.Meta != nil && (row.Meta.NonSelectable || row.Meta.Header) {
continue
}
if filtering && !strings.Contains(strings.ToLower(row.Text), lower) {
continue
}
out, err := dmenuFormatOutput(format, i, row.Text, select_)
if err != nil {
return nil, err
}
lines = append(lines, out)
if printInfo && row.Meta != nil && row.Meta.Info != "" {
lines = append(lines, row.Meta.Info)
}
}
return lines, nil
}
func runDmenuDump(rows []dmenuRow, format string) {
lines, err := dmenuDumpLines(rows, dmenuSelect, format, dmenuPrintInfo)
if err != nil {
log.Fatalf("Error formatting output: %v", err)
}
for _, l := range lines {
fmt.Println(l)
}
os.Exit(0)
}
func runDmenu(cmd *cobra.Command, args []string) {
if cmd.Flags().Changed("case-insensitive") {
fmt.Fprintln(os.Stderr, "dms dmenu: warning: -i/--case-insensitive has no effect (matching is always case-insensitive)")
}
os.Exit(dmenuSession())
}
func dmenuSession() int {
format := dmenuFormat
if format == "" {
format = "s"
}
if err := validateDmenuFormat(format); err != nil {
log.Fatalf("Error in --format: %v", err)
}
if dmenuDump {
input := dmenuOpenInput()
if input != nil && input != os.Stdin {
defer input.Close()
}
runDmenuDump(dmenuReadAll(input), format)
return 0
}
activeRanges, err := parseIndexSpec(dmenuActiveSpec)
if err != nil {
log.Fatalf("Error parsing --active: %v", err)
}
urgentRanges, err := parseIndexSpec(dmenuUrgentSpec)
if err != nil {
log.Fatalf("Error parsing --urgent: %v", err)
}
keybinds, err := parseDmenuKeybinds(dmenuKeybindSpecs)
if err != nil {
log.Fatalf("Error parsing --keybind: %v", err)
}
if err := validateDmenuView(dmenuView); err != nil {
log.Fatalf("Error in --view: %v", err)
}
dmenuSizePresetName, err := dmenuSizePreset(dmenuSize)
if err != nil {
log.Fatalf("Error in --size: %v", err)
}
input := dmenuOpenInput()
if input != nil && input != os.Stdin {
defer input.Close()
}
var scanner *bufio.Scanner
if input != nil {
scanner = bufio.NewScanner(input)
scanner.Buffer(make([]byte, 64*1024), 1<<20)
scanner.Split(dmenuSplitFunc(dmenuSep))
}
var bufferedItems []string
streaming := scanner != nil && !dmenuSync
if scanner != nil && !streaming {
for scanner.Scan() {
bufferedItems = append(bufferedItems, scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Errorf("Error reading items: %v", err)
return 1
}
}
var pendingFirstItem string
havePendingFirstItem := false
if dmenuNoRunIfEmpty {
if streaming {
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
log.Errorf("Error reading items: %v", err)
return 1
}
return 1
}
pendingFirstItem = scanner.Text()
havePendingFirstItem = true
} else if len(bufferedItems) == 0 {
return 1
}
}
release, err := dmenuipc.AcquireQueueSlot(func() {
fmt.Fprintln(os.Stderr, "dms dmenu: waiting for another dmenu session to finish...")
})
if err != nil {
log.Fatalf("Error acquiring dmenu queue slot: %v", err)
}
defer release()
sess, err := dmenuipc.Listen()
if err != nil {
log.Fatalf("Error creating dmenu socket: %v", err)
}
defer sess.Close()
pid, ok := shellApp.SessionPID()
if !ok {
log.Errorf("DMS shell is not running")
return 1
}
mode := "dmenu:" + sess.Path()
if _, _, err := qsipc.Call(qsipc.SocketPathForPID(pid), "launcher", "openWith", []string{mode}); err != nil {
log.Errorf("Error opening launcher: %v", err)
return 1
}
conn, err := sess.Accept(dmenuConnectTimeout)
if err != nil {
log.Debugf("dmenu: shell never connected: %v", err)
return 2
}
defer conn.Close()
header := dmenuipc.Header{
Prompt: dmenuPrompt,
Placeholder: dmenuPlaceholder,
Lines: dmenuLines,
PromptOnly: dmenuPromptOnly,
Select: dmenuSelect,
Mesg: dmenuMesg,
Password: dmenuPassword,
MarkupRows: dmenuMarkupRows,
OnlyMatch: dmenuOnlyMatch,
NoCustom: dmenuNoCustom,
MultiSelect: dmenuMultiSelect,
Active: activeRanges,
Urgent: urgentRanges,
Keybinds: keybinds,
View: dmenuView,
Size: dmenuSizePresetName,
Icon: dmenuIcon,
DisableHistory: dmenuDisableHist,
}
if err := conn.SendHeader(header); err != nil {
log.Debugf("dmenu: failed to send header: %v", err)
return 2
}
var sendErr error
sendItem := func(line string) {
text, meta := parseDmenuRow(line)
if err := conn.SendItem(text, meta); err != nil {
log.Debugf("dmenu: failed to send item: %v", err)
sendErr = err
}
}
if streaming {
if havePendingFirstItem && sendErr == nil {
sendItem(pendingFirstItem)
}
for sendErr == nil && scanner.Scan() {
sendItem(scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Debugf("dmenu: error reading items mid-stream: %v", err)
return 2
}
} else {
for _, item := range bufferedItems {
if sendErr != nil {
break
}
sendItem(item)
}
}
if sendErr != nil {
return 2
}
if err := conn.SendEnd(); err != nil {
log.Debugf("dmenu: failed to send end marker: %v", err)
return 2
}
sel, selected, err := conn.ReadSelection()
if err != nil {
log.Debugf("dmenu: failed to read result: %v", err)
return 2
}
if !selected {
return 1
}
switch sel.Kind {
case "multi":
for _, item := range sel.Items {
out, err := dmenuFormatOutput(format, item.Index, item.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
if dmenuPrintInfo && item.Info != "" {
fmt.Println(item.Info)
}
}
case "freetext":
out, err := dmenuFormatOutput(format, -1, sel.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
default: // "row"
out, err := dmenuFormatOutput(format, sel.Index, sel.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
if dmenuPrintInfo && sel.Info != "" {
fmt.Println(sel.Info)
}
}
return dmenuKeybindExitCode(sel.KeybindN)
}
func dmenuKeybindExitCode(keybindN int) int {
if keybindN <= 0 {
return 0
}
return 9 + keybindN
}
-350
View File
@@ -1,350 +0,0 @@
package main
import (
"reflect"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/dmenuipc"
)
func TestParseIndexSpec(t *testing.T) {
for _, tc := range []struct {
spec string
want []dmenuipc.Range
wantErr bool
}{
{"", nil, false},
{"1,3", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}}, false},
{"7:11", []dmenuipc.Range{{Start: 7, End: 11}}, false},
{"-3:", []dmenuipc.Range{{Start: -3, End: -1}}, false},
{":5", []dmenuipc.Range{{Start: 0, End: 5}}, false},
{"1, 3 , 7:11", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}, {Start: 7, End: 11}}, false},
{"1,,3", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}}, false},
{"x", nil, true},
{"1:y", nil, true},
{"x:1", nil, true},
} {
got, err := parseIndexSpec(tc.spec)
if tc.wantErr {
if err == nil {
t.Errorf("parseIndexSpec(%q): expected error, got %v", tc.spec, got)
}
continue
}
if err != nil {
t.Errorf("parseIndexSpec(%q): unexpected error: %v", tc.spec, err)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("parseIndexSpec(%q) = %+v, want %+v", tc.spec, got, tc.want)
}
}
}
func TestParseDmenuKeybinds(t *testing.T) {
for _, tc := range []struct {
name string
specs []string
want []dmenuipc.Keybind
wantErr bool
}{
{"empty", nil, nil, false},
{"multiple", []string{"1=ctrl+e", "2=f5"}, []dmenuipc.Keybind{{N: 1, Key: "ctrl+e"}, {N: 2, Key: "f5"}}, false},
{"missing equals", []string{"bad"}, nil, true},
{"zero index", []string{"0=x"}, nil, true},
{"out of range index", []string{"20=x"}, nil, true},
{"empty key spec", []string{"1="}, nil, true},
{"duplicate index", []string{"1=ctrl+e", "1=f5"}, nil, true},
{"non numeric index", []string{"a=x"}, nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := parseDmenuKeybinds(tc.specs)
if tc.wantErr {
if err == nil {
t.Fatalf("parseDmenuKeybinds(%v): expected error, got %v", tc.specs, got)
}
return
}
if err != nil {
t.Fatalf("parseDmenuKeybinds(%v): unexpected error: %v", tc.specs, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("parseDmenuKeybinds(%v) = %+v, want %+v", tc.specs, got, tc.want)
}
})
}
}
func TestDmenuParseBool(t *testing.T) {
for _, tc := range []struct {
val string
want bool
}{
{"", false},
{"0", false},
{"false", false},
{"False", false},
{"no", false},
{"anything-else", true},
} {
if got := dmenuParseBool(tc.val); got != tc.want {
t.Errorf("dmenuParseBool(%q) = %v, want %v", tc.val, got, tc.want)
}
}
}
func TestParseDmenuRow(t *testing.T) {
t.Run("plain text, no metadata", func(t *testing.T) {
text, meta := parseDmenuRow("apple")
if text != "apple" || meta != nil {
t.Fatalf("parseDmenuRow(apple) = %q, %+v, want apple, nil", text, meta)
}
})
t.Run("single field", func(t *testing.T) {
text, meta := parseDmenuRow("Firefox\x00icon\x1ffirefox")
if text != "Firefox" {
t.Fatalf("text = %q, want Firefox", text)
}
if meta == nil || meta.Icon != "firefox" {
t.Fatalf("meta = %+v, want Icon=firefox", meta)
}
})
t.Run("all recognized fields", func(t *testing.T) {
line := "Firefox\x00icon\x1ffirefox\x1fdisplay\x1fMozilla Firefox\x1fmeta\x1fbrowser\x1finfo\x1ffirefox.desktop" +
"\x1fnonselectable\x1ftrue\x1furgent\x1ftrue\x1factive\x1ftrue\x1fpermanent\x1ftrue\x1fheader\x1ftrue"
text, meta := parseDmenuRow(line)
want := dmenuipc.ItemMeta{
Icon: "firefox", Display: "Mozilla Firefox", Meta: "browser", Info: "firefox.desktop",
NonSelectable: true, Urgent: true, Active: true, Permanent: true, Header: true,
}
if text != "Firefox" {
t.Fatalf("text = %q, want Firefox", text)
}
if meta == nil || *meta != want {
t.Fatalf("meta = %+v, want %+v", meta, want)
}
})
t.Run("unknown field ignored but still produces meta", func(t *testing.T) {
text, meta := parseDmenuRow("apple\x00bogus\x1fvalue")
if text != "apple" {
t.Fatalf("text = %q, want apple", text)
}
if meta != nil {
t.Fatalf("meta = %+v, want nil (no recognized fields)", meta)
}
})
t.Run("dangling unpaired field ignored", func(t *testing.T) {
text, meta := parseDmenuRow("apple\x00icon\x1ffirefox\x1fdangling")
if text != "apple" {
t.Fatalf("text = %q, want apple", text)
}
if meta == nil || meta.Icon != "firefox" {
t.Fatalf("meta = %+v, want Icon=firefox", meta)
}
})
}
func TestValidateDmenuFormat(t *testing.T) {
for _, f := range []string{"", "s", "i", "d", "q", "p", "f", "F"} {
if err := validateDmenuFormat(f); err != nil {
t.Errorf("validateDmenuFormat(%q): unexpected error: %v", f, err)
}
}
for _, f := range []string{"x", "S", "ff"} {
if err := validateDmenuFormat(f); err == nil {
t.Errorf("validateDmenuFormat(%q): expected error, got nil", f)
}
}
}
func TestValidateDmenuView(t *testing.T) {
for _, v := range []string{"", "list", "grid", "tile"} {
if err := validateDmenuView(v); err != nil {
t.Errorf("validateDmenuView(%q): unexpected error: %v", v, err)
}
}
for _, v := range []string{"bogus", "List"} {
if err := validateDmenuView(v); err == nil {
t.Errorf("validateDmenuView(%q): expected error, got nil", v)
}
}
}
func TestDmenuShellQuote(t *testing.T) {
for _, tc := range []struct {
in string
want string
}{
{"banana", "'banana'"},
{"it's", `'it'\''s'`},
{"", "''"},
} {
if got := dmenuShellQuote(tc.in); got != tc.want {
t.Errorf("dmenuShellQuote(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestDmenuFormatOutput(t *testing.T) {
for _, tc := range []struct {
format string
index int
text string
filterText string
want string
wantErr bool
}{
{"", 1, "banana", "ban", "banana", false},
{"s", 1, "banana", "ban", "banana", false},
{"i", 1, "banana", "ban", "1", false},
{"d", 1, "banana", "ban", "2", false},
{"d", -1, "banana", "xyz", "-1", false},
{"q", 0, "it's", "", "'it'\\''s'", false},
{"p", 0, "<b>Bold</b> item", "", "Bold item", false},
{"f", 0, "banana", "ban", "ban", false},
{"F", 0, "banana", "it's", "'it'\\''s'", false},
{"bogus", 0, "banana", "", "", true},
} {
got, err := dmenuFormatOutput(tc.format, tc.index, tc.text, tc.filterText)
if tc.wantErr {
if err == nil {
t.Errorf("dmenuFormatOutput(%q, ...): expected error, got %q", tc.format, got)
}
continue
}
if err != nil {
t.Errorf("dmenuFormatOutput(%q, ...): unexpected error: %v", tc.format, err)
continue
}
if got != tc.want {
t.Errorf("dmenuFormatOutput(%q, %d, %q, %q) = %q, want %q", tc.format, tc.index, tc.text, tc.filterText, got, tc.want)
}
}
}
func TestDmenuDumpLines(t *testing.T) {
rows := []dmenuRow{
{Text: "Header", Meta: &dmenuipc.ItemMeta{Header: true}},
{Text: "Firefox", Meta: &dmenuipc.ItemMeta{Info: "firefox.desktop"}},
{Text: "apple"},
{Text: "skip", Meta: &dmenuipc.ItemMeta{NonSelectable: true}},
{Text: "banana"},
}
t.Run("no filter, default format", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "", "s", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"Firefox", "apple", "banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("with select filter", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "an", "s", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("print-info appends info lines", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "", "s", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"Firefox", "firefox.desktop", "apple", "banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("invalid format propagates error", func(t *testing.T) {
if _, err := dmenuDumpLines(rows, "", "bogus", false); err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestDmenuKeybindExitCode(t *testing.T) {
for _, tc := range []struct {
n int
want int
}{
{0, 0},
{1, 10},
{19, 28},
} {
if got := dmenuKeybindExitCode(tc.n); got != tc.want {
t.Errorf("dmenuKeybindExitCode(%d) = %d, want %d", tc.n, got, tc.want)
}
}
}
func TestNormalizeDmenuArgv(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want []string
}{
{
"not the dmenu subcommand: untouched",
[]string{"dms", "ipc", "-password"},
[]string{"dms", "ipc", "-password"},
},
{
"rofi-style single-dash long flags get a second dash",
[]string{"dms", "dmenu", "-only-match", "-password", "-sep", ","},
[]string{"dms", "dmenu", "--only-match", "--password", "--sep", ","},
},
{
"= form is preserved",
[]string{"dms", "dmenu", "-format=i"},
[]string{"dms", "dmenu", "--format=i"},
},
{
"already-double-dash flags are untouched",
[]string{"dms", "dmenu", "--password", "-p", "hi"},
[]string{"dms", "dmenu", "--password", "-p", "hi"},
},
{
"single-char shorthands are untouched",
[]string{"dms", "dmenu", "-p", "hi", "-l", "5", "-i"},
[]string{"dms", "dmenu", "-p", "hi", "-l", "5", "-i"},
},
{
"negative --active/--urgent spec values are not mistaken for flag names",
[]string{"dms", "dmenu", "--active", "-3:"},
[]string{"dms", "dmenu", "--active", "-3:"},
},
{
"DMS-native long flags also get the single-dash alias",
[]string{"dms", "dmenu", "-icon", "star"},
[]string{"dms", "dmenu", "--icon", "star"},
},
{
"rofi's -a/-u abbreviations are real shorthands, untouched by the rewrite",
[]string{"dms", "dmenu", "-a", "1,3", "-u", "2"},
[]string{"dms", "dmenu", "-a", "1,3", "-u", "2"},
},
{
"stops rewriting after a bare --",
[]string{"dms", "dmenu", "--", "-password"},
[]string{"dms", "dmenu", "--", "-password"},
},
} {
got := normalizeDmenuArgv(tc.args)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%s: normalizeDmenuArgv(%v) = %v, want %v", tc.name, tc.args, got, tc.want)
}
}
}
+140 -367
View File
@@ -1,11 +1,10 @@
package main package main
import ( import (
"bytes"
"debug/elf"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -14,20 +13,17 @@ import (
"slices" "slices"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/duxlabs-dev/dux-shell/core/internal/blur"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/matugen"
"github.com/duxlabs-dev/dux-shell/core/internal/server/brightness"
"github.com/duxlabs-dev/dux-shell/core/internal/server/network"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/duxlabs-dev/dux-shell/core/internal/tui"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/version"
"github.com/godbus/dbus/v5"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -91,6 +87,7 @@ func (ds *DoctorStatus) OKCount() int {
var ( var (
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`) quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`) niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`) swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`) riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
@@ -98,9 +95,6 @@ var (
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`) labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`) mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`) miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
scrollVersionRegex = regexp.MustCompile(`scroll version (\d+\.\d+)`)
aqueousVersionRegex = regexp.MustCompile(`(?i)aqueous v?(\d+\.\d+(?:\.\d+)?)`)
umbrielVersionRegex = regexp.MustCompile(`umbriel (\d+\.\d+\.\d+)`)
) )
var doctorCmd = &cobra.Command{ var doctorCmd = &cobra.Command{
@@ -166,7 +160,7 @@ func (c category) String() string {
const ( const (
checkNameMaxLength = 21 checkNameMaxLength = 21
doctorDocsURL = site.Docs + "/dms/cli-doctor" doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor"
) )
type checkResult struct { type checkResult struct {
@@ -224,7 +218,6 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkOptionalDependencies(), checkOptionalDependencies(),
checkConfigurationFiles(), checkConfigurationFiles(),
checkSystemdServices(), checkSystemdServices(),
checkSettingsPortal(),
checkEnvironmentVars(), checkEnvironmentVars(),
checkFonts(), checkFonts(),
) )
@@ -330,24 +323,15 @@ func checkEnvironmentVars() []checkResult {
var results []checkResult var results []checkResult
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...) results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")...) results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")...)
switch { results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME_QT6")...)
case os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != "": if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != os.Getenv("QT_QPA_PLATFORMTHEME") {
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME_QT6")...) results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")...)
if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != os.Getenv("QT_QPA_PLATFORMTHEME") {
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")...)
}
case os.Getenv("QT_QPA_PLATFORMTHEME") != "":
if doctorVerbose {
results = append(results, checkResult{catEnvironment, "QT_QPA_PLATFORMTHEME_QT6", statusInfo, "Not set, Qt6 uses QT_QPA_PLATFORMTHEME", "", doctorDocsURL + "#environment-variables"})
}
default:
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME_QT6")...)
}
if matugen.QtengineActive() {
results = append(results, checkQtengineConfig()...)
} }
results = append(results, checkEnvVar("QS_ICON_THEME")...) results = append(results, checkEnvVar("QS_ICON_THEME")...)
results = append(results, checkXDGMenuPrefix()...) results = append(results, checkXDGMenuPrefix()...)
if matugen.QtengineActive() {
results = append(results, checkQtenginePlugin()...)
}
return results return results
} }
@@ -368,7 +352,7 @@ func checkXDGMenuPrefix() []checkResult {
if checkXDGMenuFile(menuPrefix) { if checkXDGMenuFile(menuPrefix) {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}} return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}}
} }
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure %sapplications.menu exists in ~/.config/menus or /etc/xdg/menus.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}} return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/%sapplications.menu exists.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
} }
if _, err := exec.LookPath("keditfiletype"); err == nil { if _, err := exec.LookPath("keditfiletype"); err == nil {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}} return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}}
@@ -394,41 +378,55 @@ var qtQueryBinaries = []struct{ bin, flag string }{
{"qtpaths", "--query"}, {"qtpaths", "--query"},
} }
// checkQtengineConfig reports whether qtengine's config points Qt apps at the // checkQtenginePlugin verifies the qtengine platform theme plugin is installed
// DMS color scheme, which DMS writes there when it generates a theme. // where Qt actually searches, for every Qt major that can be interrogated.
func checkQtengineConfig() []checkResult { //
// Qt is asked where it looks rather than the filesystem searched, because a
// package can install the Qt5 plugin under a prefix Qt5 does not search, after
// which Qt5 apps silently fall back while Qt6 apps look correct. A search would
// find the plugin and report OK on exactly that bug.
func checkQtenginePlugin() []checkResult {
url := doctorDocsURL + "#environment-variables" url := doctorDocsURL + "#environment-variables"
path := matugen.QtengineConfigPath()
data, err := os.ReadFile(path) pluginRoots := qtPluginRoots()
if os.IsNotExist(err) || (err == nil && len(bytes.TrimSpace(data)) == 0) {
return []checkResult{{catEnvironment, "qtengine config", statusInfo, "Not found", "Qt apps use qtengine's default palette until DMS generates a theme.", url}} // qmake/qtpaths ship in dev packages most users lack — never warn on a Qt
} // that could not be interrogated, and never claim it is fine either.
var cfg struct { if len(pluginRoots) == 0 {
Theme struct { return []checkResult{{
ColorScheme string `json:"colorScheme"` catEnvironment, "qtengine plugin", statusInfo, "Cannot verify (no qmake/qtpaths)",
} `json:"theme"` "Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.",
} url,
if err == nil { }}
err = json.Unmarshal(data, &cfg)
}
if err != nil {
return []checkResult{{catEnvironment, "qtengine config", statusWarn, "Unreadable", fmt.Sprintf("DMS won't overwrite %s while it can't parse it, so Qt apps stop following DMS colors. Fix or remove the file.", path), url}}
} }
scheme := matugen.QtengineColorSchemePath() // Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable.
if cfg.Theme.ColorScheme == scheme { envRoots := filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))
return []checkResult{{catEnvironment, "qtengine config", statusOK, "Uses DMS colors", path, url}}
var results []checkResult
for _, major := range slices.Sorted(maps.Keys(pluginRoots)) {
// qtengine only ships Qt5 and Qt6 plugins.
if major != "5" && major != "6" {
continue
}
root := pluginRoots[major]
name := fmt.Sprintf("qtengine plugin (Qt%s)", major)
plugin := qtenginePluginPath(append([]string{root}, envRoots...), major)
if plugin == "" {
expected := qtenginePluginFile(root, major)
results = append(results, checkResult{
catEnvironment, name, statusWarn, fmt.Sprintf("Not found in Qt%s plugin path", major),
fmt.Sprintf("Expected %s — Qt%s apps will silently fall back to an unthemed palette. The plugin is likely installed under a prefix Qt%s does not search.", expected, major, major),
url,
})
continue
}
results = append(results, checkResult{catEnvironment, name, statusOK, "Installed", plugin, url})
} }
current := "No color scheme is set"
if cfg.Theme.ColorScheme != "" { return results
current = "The color scheme is " + cfg.Theme.ColorScheme
}
return []checkResult{{
catEnvironment, "qtengine config", statusInfo, "Not using DMS colors",
fmt.Sprintf("%s. DMS sets %s when it generates a theme with the qtengine and KColorScheme templates enabled.", current, scheme),
url,
}}
} }
// checkQtPlatformThemePlugin validates one of the QT_QPA_PLATFORMTHEME // checkQtPlatformThemePlugin validates one of the QT_QPA_PLATFORMTHEME
@@ -438,8 +436,8 @@ func checkQtengineConfig() []checkResult {
// Quickshell tray menus draw a placeholder for every icon an app publishes. // Quickshell tray menus draw a placeholder for every icon an app publishes.
func checkQtPlatformThemePlugin(varName string) []checkResult { func checkQtPlatformThemePlugin(varName string) []checkResult {
value := os.Getenv(varName) value := os.Getenv(varName)
// Unset is reported by checkEnvVar. // Unset is reported by checkEnvVar; qtengine has its own plugin check.
if value == "" { if value == "" || value == "qtengine" {
return nil return nil
} }
@@ -455,111 +453,46 @@ func checkQtPlatformThemePlugin(varName string) []checkResult {
}} }}
} }
roots := qtPluginRoots() // DMS themes Qt6 through these variables; qt5ct is the Qt5 counterpart.
var results []checkResult major := "6"
for _, major := range qtPlatformThemeMajors(varName, value, roots) { if value == "qt5ct" {
results = append(results, checkQtPlatformThemePluginFor(varName, value, major, roots[major])) major = "5"
} }
return results files := qtPlatformThemePluginFiles(value)
}
// qtPlatformThemeMajors lists the Qt majors whose plugin a variable's value
// must provide. DMS themes Qt6 through these variables and qt5ct is the Qt5
// counterpart. qtengine ships both, and Qt5 reads only QT_QPA_PLATFORMTHEME, so
// its Qt5 plugin is checked there when a Qt5 build tool can be asked.
func qtPlatformThemeMajors(varName, value string, roots map[string]qtInstall) []string {
switch {
case value == "qt5ct":
return []string{"5"}
case value == "qtengine" && varName == "QT_QPA_PLATFORMTHEME" && roots["5"].plugins != "":
return []string{"5", "6"}
}
return []string{"6"}
}
func checkQtPlatformThemePluginFor(varName, value, major string, qt qtInstall) checkResult {
url := doctorDocsURL + "#environment-variables"
files := qtPlatformThemePluginFiles(value, major)
name := fmt.Sprintf("%s plugin (Qt%s)", value, major) name := fmt.Sprintf("%s plugin (Qt%s)", value, major)
root := qtPluginRoots()[major]
// qmake/qtpaths ship in dev packages most users lack — never warn on a Qt // qmake/qtpaths ship in dev packages most users lack — never warn on a Qt
// that could not be interrogated, and never claim it is fine either. // that could not be interrogated, and never claim it is fine either.
if qt.plugins == "" { if root == "" {
return checkResult{ return []checkResult{{
catEnvironment, name, statusInfo, "Cannot verify (no qmake/qtpaths)", catEnvironment, name, statusInfo, "Cannot verify (no qmake/qtpaths)",
"Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.", "Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.",
url, url,
} }}
} }
// Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable. // Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable.
plugin := qtPluginPath(append([]string{qt.plugins}, filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))...), files...) plugin := qtPluginPath(append([]string{root}, filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))...), files...)
if plugin == "" { if plugin == "" {
// plasma-integration ships the kde theme under a file name that has // plasma-integration ships the kde theme under a file name that has
// changed across versions; a miss says nothing reliable about the // changed across versions; a miss says nothing reliable about the
// install, so degrade to info instead of warning at a working setup. // install, so degrade to info instead of warning at a working setup.
if value == "kde" { if value == "kde" {
return checkResult{ return []checkResult{{
catEnvironment, name, statusInfo, "Cannot verify (plasma-integration)", catEnvironment, name, statusInfo, "Cannot verify (plasma-integration)",
"The kde theme comes from plasma-integration, whose plugin file name varies across versions. Verify visually that Qt apps follow your theme.", "The kde theme comes from plasma-integration, whose plugin file name varies across versions. Verify visually that Qt apps follow your theme.",
url, url,
} }}
} }
expected := filepath.Join(qt.plugins, "platformthemes", files[0]) expected := filepath.Join(root, "platformthemes", files[0])
return checkResult{ return []checkResult{{
catEnvironment, name, statusWarn, fmt.Sprintf("Not found in Qt%s plugin path", major), catEnvironment, name, statusWarn, fmt.Sprintf("Not found in Qt%s plugin path", major),
fmt.Sprintf("Expected %s — Qt apps silently fall back to a built-in theme, breaking icon lookups and palettes. Install the package providing it, or correct %s.", expected, varName), fmt.Sprintf("Expected %s — Qt apps silently fall back to a built-in theme, breaking icon lookups and palettes. Install the package providing it, or correct %s.", expected, varName),
url, url,
} }}
} }
// Qt skips QPA plugins built against another Qt major.minor without a return []checkResult{{catEnvironment, name, statusOK, "Installed", plugin, url}}
// warning, so an out-of-tree plugin silently stops loading after a Qt update.
if built, ok := qtPluginBuildVersion(plugin); ok && built != qtMinorVersion(qt.version) {
return checkResult{
catEnvironment, name, statusWarn, fmt.Sprintf("Built for Qt %s, Qt is %s", built, qt.version),
fmt.Sprintf("Qt ignores platform theme plugins built for another Qt minor version, so Qt apps silently fall back to a built-in theme, breaking icon lookups and palettes. Rebuild or reinstall the package providing %s against the installed Qt, then restart the shell.", plugin),
url,
}
}
return checkResult{catEnvironment, name, statusOK, "Installed", plugin, url}
}
// qtPluginBuildVersion reads the Qt major.minor a plugin was built against
// from the ELF note Qt 6 embeds in every plugin. Qt 5 plugins carry no such
// note and report false.
func qtPluginBuildVersion(path string) (string, bool) {
f, err := elf.Open(path)
if err != nil {
return "", false
}
defer f.Close()
section := f.Section(".note.qt.metadata")
if section == nil {
return "", false
}
data, err := section.Data()
if err != nil || len(data) < 12 {
return "", false
}
nameSize := f.ByteOrder.Uint32(data[0:4])
descSize := f.ByteOrder.Uint32(data[4:8])
if f.ByteOrder.Uint32(data[8:12]) != 0x74510001 || descSize < 3 {
return "", false
}
// The descriptor starts with the metadata format version, then Qt's major and minor.
desc := 12 + (uint64(nameSize)+3)&^3
if uint64(len(data)) < desc+uint64(descSize) {
return "", false
}
return fmt.Sprintf("%d.%d", data[desc+1], data[desc+2]), true
}
func qtMinorVersion(version string) string {
parts := strings.SplitN(version, ".", 3)
if len(parts) < 2 {
return version
}
return parts[0] + "." + parts[1]
} }
// qtPlatformThemePluginFiles lists the plugin file names that can provide a // qtPlatformThemePluginFiles lists the plugin file names that can provide a
@@ -567,10 +500,7 @@ func qtMinorVersion(version string) string {
// through plugin metadata keys rather than file names, so these are the names // through plugin metadata keys rather than file names, so these are the names
// each integration is known to ship; unmapped values fall back to Qt's // each integration is known to ship; unmapped values fall back to Qt's
// libq<value> naming convention. // libq<value> naming convention.
func qtPlatformThemePluginFiles(value, major string) []string { func qtPlatformThemePluginFiles(value string) []string {
if value == "qtengine" {
return []string{fmt.Sprintf("libqt%sengine-plugin.so", major)}
}
if files, ok := map[string][]string{ if files, ok := map[string][]string{
"qt6ct": {"libqt6ct.so"}, "qt6ct": {"libqt6ct.so"},
"qt5ct": {"libqt5ct.so"}, "qt5ct": {"libqt5ct.so"},
@@ -582,28 +512,22 @@ func qtPlatformThemePluginFiles(value, major string) []string {
return []string{"libq" + value + ".so", "lib" + value + ".so"} return []string{"libq" + value + ".so", "lib" + value + ".so"}
} }
type qtInstall struct {
plugins string
version string
}
// qtPluginRoots asks every available Qt build tool where its Qt looks for // qtPluginRoots asks every available Qt build tool where its Qt looks for
// plugins, keyed by Qt major. A binary's name does not reliably say which Qt // plugins, keyed by Qt major. A binary's name does not reliably say which Qt
// it belongs to — plain "qmake" is Qt5 on Arch and Qt6 elsewhere — so each one // it belongs to — plain "qmake" is Qt5 on Arch and Qt6 elsewhere — so each one
// is asked for QT_VERSION and the major is taken from the answer. // is asked for QT_VERSION and the major is taken from the answer.
func qtPluginRoots() map[string]qtInstall { func qtPluginRoots() map[string]string {
pluginRoots := make(map[string]qtInstall) pluginRoots := make(map[string]string)
for _, q := range qtQueryBinaries { for _, q := range qtQueryBinaries {
if _, err := exec.LookPath(q.bin); err != nil { if _, err := exec.LookPath(q.bin); err != nil {
continue continue
} }
version := qtQuery(q.bin, q.flag, "QT_VERSION") major, _, _ := strings.Cut(qtQuery(q.bin, q.flag, "QT_VERSION"), ".")
major, _, _ := strings.Cut(version, ".") if major == "" || pluginRoots[major] != "" {
if major == "" || pluginRoots[major].plugins != "" {
continue continue
} }
if root := qtQuery(q.bin, q.flag, "QT_INSTALL_PLUGINS"); root != "" { if root := qtQuery(q.bin, q.flag, "QT_INSTALL_PLUGINS"); root != "" {
pluginRoots[major] = qtInstall{plugins: root, version: version} pluginRoots[major] = root
} }
} }
return pluginRoots return pluginRoots
@@ -626,6 +550,16 @@ func qtPluginPath(roots []string, files ...string) string {
return "" return ""
} }
// qtenginePluginPath returns the first of roots that holds the qtengine platform
// theme plugin for the given Qt major, or "" if none does.
func qtenginePluginPath(roots []string, major string) string {
return qtPluginPath(roots, fmt.Sprintf("libqt%sengine-plugin.so", major))
}
func qtenginePluginFile(root, major string) string {
return filepath.Join(root, "platformthemes", fmt.Sprintf("libqt%sengine-plugin.so", major))
}
// qtQuery asks a Qt build tool for a single QLibraryInfo property. // qtQuery asks a Qt build tool for a single QLibraryInfo property.
func qtQuery(bin, flag, key string) string { func qtQuery(bin, flag, key string) string {
output, err := exec.Command(bin, flag, key).Output() output, err := exec.Command(bin, flag, key).Output()
@@ -651,23 +585,10 @@ func qtQueryValue(output, key string) string {
return "" return ""
} }
// checkXDGMenuFile looks for the menu file where the XDG menu spec does: the
// user's config dir first, then each system config dir.
func checkXDGMenuFile(prefix string) bool { func checkXDGMenuFile(prefix string) bool {
configDirs := os.Getenv("XDG_CONFIG_DIRS") menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
if configDirs == "" { _, err := os.Stat(menuPath)
configDirs = "/etc/xdg" return err == nil
}
dirs := append([]string{utils.XDGConfigHome()}, filepath.SplitList(configDirs)...)
for _, dir := range dirs {
if dir == "" {
continue
}
if _, err := os.Stat(filepath.Join(dir, "menus", prefix+"applications.menu")); err == nil {
return true
}
}
return false
} }
func readOSRelease() map[string]string { func readOSRelease() map[string]string {
@@ -807,20 +728,17 @@ func checkDMSInstallation() []checkResult {
func checkWindowManagers() []checkResult { func checkWindowManagers() []checkResult {
compositors := []struct { compositors := []struct {
name, versionCmd, versionArg string name, versionCmd, versionArg string
parseVersion func(string) string versionRegex *regexp.Regexp
commands []string commands []string
}{ }{
{"Hyprland", "Hyprland", "--version", distros.ParseHyprlandVersion, []string{"hyprland", "Hyprland"}}, {"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
{"niri", "niri", "--version", firstSubmatch(niriVersionRegex), []string{"niri"}}, {"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
{"Sway", "sway", "--version", firstSubmatch(swayVersionRegex), []string{"sway"}}, {"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
{"River", "river", "-version", firstSubmatch(riverVersionRegex), []string{"river"}}, {"River", "river", "-version", riverVersionRegex, []string{"river"}},
{"Wayfire", "wayfire", "--version", firstSubmatch(wayfireVersionRegex), []string{"wayfire"}}, {"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", firstSubmatch(labwcVersionRegex), []string{"labwc"}}, {"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", firstSubmatch(mangowcVersionRegex), []string{"mango"}}, {"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", firstSubmatch(miracleVersionRegex), []string{"miracle-wm"}}, {"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
{"Scroll", "scroll", "--version", firstSubmatch(scrollVersionRegex), []string{"scroll"}},
{"Aqueous", "aqueous", "-version", firstSubmatch(aqueousVersionRegex), []string{"aqueous"}},
{"Umbriel", "umbriel", "--version", firstSubmatch(umbrielVersionRegex), []string{"umbriel"}},
} }
var results []checkResult var results []checkResult
@@ -842,29 +760,18 @@ func checkWindowManagers() []checkResult {
if doctorVerbose && compositorPath != "" { if doctorVerbose && compositorPath != "" {
details = compositorPath details = compositorPath
} }
display, ver := getVersionFromCommand(c.versionCmd, c.versionArg, c.parseVersion)
results = append(results, checkResult{ results = append(results, checkResult{
catCompositor, c.name, statusOK, display, details, catCompositor, c.name, statusOK,
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
doctorDocsURL + "#compositor-checks", doctorDocsURL + "#compositor-checks",
}) })
if c.name == "Hyprland" && hyprlandLacksLua(ver) {
results = append(results, checkResult{
catCompositor, "Hyprland Lua config", statusWarn,
fmt.Sprintf("Hyprland %s is older than 0.55", ver),
"DMS writes Hyprland config in Lua only, which needs Hyprland 0.55 or newer",
doctorDocsURL + "#compositor-checks",
})
}
if c.name == "Hyprland" {
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
}
} }
if !foundAny { if !foundAny {
results = append(results, checkResult{ results = append(results, checkResult{
catCompositor, "Compositor", statusError, catCompositor, "Compositor", statusError,
"No supported Wayland compositor found", "No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, Aqueous, or Umbriel", "Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
doctorDocsURL + "#compositor-checks", doctorDocsURL + "#compositor-checks",
}) })
} }
@@ -873,67 +780,11 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"}) results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
} }
if os.Getenv("AQUEOUS_SOCKET") != "" {
results = append(results, checkAqueousConfigHelper())
}
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
results = append(results, checkMangoConfig(mangoconf.Dir(), mangoconf.Detect())...)
}
results = append(results, checkCompositorBlurSupport()) results = append(results, checkCompositorBlurSupport())
return results return results
} }
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult {
url := doctorDocsURL + "#compositor-checks"
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
paths = append([]string{filepath.Join(mangoDir, "config.conf")}, paths...)
var stale, overviewBinds []string
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
if dialect.Translate(string(data)) != string(data) {
stale = append(stale, filepath.Base(path))
}
if mangoLegacyOverviewBinds.Match(data) {
overviewBinds = append(overviewBinds, filepath.Base(path))
}
}
var results []checkResult
if len(stale) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
fmt.Sprintf("Not %s keys: %s", dialect, strings.Join(stale, ", ")),
"This Mango rejects those lines as unknown keywords. Run `dms config mango-migrate --main` (config.conf is backed up).", url})
}
if len(overviewBinds) > 0 {
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
"mousebind=NONE,btn_left,toggleoverview,1 and mousebind=NONE,btn_right,killclient,0 swallow normal clicks; remove them.", url})
}
return results
}
func checkAqueousConfigHelper() checkResult {
url := doctorDocsURL + "#compositor-checks"
path, err := exec.LookPath("aqueous-config")
if err != nil {
return checkResult{catCompositor, "aqueous-config", statusWarn, "Not found", "Keybind and display settings edits from the shell need the aqueous-config helper", url}
}
details := "Keybind and display settings edits from the shell"
if doctorVerbose {
details = path
}
return checkResult{catCompositor, "aqueous-config", statusOK, "Installed", details, url}
}
func checkCompositorBlurSupport() checkResult { func checkCompositorBlurSupport() checkResult {
supported, err := blur.ProbeSupport() supported, err := blur.ProbeSupport()
if err != nil { if err != nil {
@@ -947,56 +798,25 @@ func checkCompositorBlurSupport() checkResult {
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"} return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
} }
// Same detection as the keybinds parser: a regular hyprland.lua wins, else hyprland.conf is read-only. func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
func checkHyprlandConfigFormat(hyprDir string) []checkResult {
if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.lua")); err == nil && st.Mode().IsRegular() {
return nil
}
if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.conf")); err != nil || !st.Mode().IsRegular() {
return nil
}
return []checkResult{{
catCompositor, "Hyprland Lua config", statusWarn,
"Hyprland config is hyprland.conf",
"DMS keybinds, window rules and displays are read-only until you run `dms setup` to migrate to Lua. Hyprland 0.57 drops .conf support.",
doctorDocsURL + "#compositor-checks",
}}
}
func hyprlandLacksLua(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
}
func firstSubmatch(re *regexp.Regexp) func(string) string {
return func(s string) string {
if m := re.FindStringSubmatch(s); len(m) > 1 {
return m[1]
}
return ""
}
}
func getVersionFromCommand(cmd, arg string, parse func(string) string) (display, ver string) {
output, err := exec.Command(cmd, arg).CombinedOutput() output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 { if err != nil && len(output) == 0 {
return "installed", "" return "installed"
} }
outStr := string(output) outStr := string(output)
ver = parse(outStr) if matches := regex.FindStringSubmatch(outStr); len(matches) > 1 {
if ver == "" { ver := matches[1]
return strings.TrimSpace(outStr), "" if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
return ver + " (git)"
}
return ver
} }
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { return strings.TrimSpace(outStr)
return ver + " (git)", ver
}
return ver, ver
} }
func detectRunningWM() string { func detectRunningWM() string {
switch { switch {
case os.Getenv("AQUEOUS_SOCKET") != "":
return "Aqueous"
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "": case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
return "Hyprland" return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "": case os.Getenv("NIRI_SOCKET") != "":
@@ -1005,8 +825,6 @@ func detectRunningWM() string {
return "MangoWC" return "MangoWC"
case os.Getenv("MIRACLESOCK") != "": case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM" return "Miracle WM"
case os.Getenv("UMBRIEL_SOCKET") != "":
return "Umbriel"
case os.Getenv("XDG_CURRENT_DESKTOP") != "": case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP") return os.Getenv("XDG_CURRENT_DESKTOP")
} }
@@ -1093,7 +911,7 @@ ShellRoot {
{"Polkit", "Authentication prompts"}, {"Polkit", "Authentication prompts"},
{"IdleMonitor", "Idle detection"}, {"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"}, {"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri, Aqueous)"}, {"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"BackgroundBlur", "Background blur API support in Quickshell"}, {"BackgroundBlur", "Background blur API support in Quickshell"},
} }
@@ -1407,7 +1225,7 @@ func checkAdwGtk3() checkResult {
func checkConfigurationFiles() []checkResult { func checkConfigurationFiles() []checkResult {
configDir, _ := os.UserConfigDir() configDir, _ := os.UserConfigDir()
cacheDir, _ := os.UserCacheDir() cacheDir, _ := os.UserCacheDir()
dmsDir := "dms" dmsDir := "DankMaterialShell"
configFiles := []struct{ name, path string }{ configFiles := []struct{ name, path string }{
{"settings.json", filepath.Join(configDir, dmsDir, "settings.json")}, {"settings.json", filepath.Join(configDir, dmsDir, "settings.json")},
@@ -1449,7 +1267,6 @@ func checkSystemdServices() []checkResult {
var results []checkResult var results []checkResult
dmsState := getServiceState("dms", true) dmsState := getServiceState("dms", true)
wanted := config.SessionWanted()
if !dmsState.exists { if !dmsState.exists {
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"})
} else { } else {
@@ -1461,28 +1278,12 @@ func checkSystemdServices() []checkResult {
case dmsState.active == "failed": case dmsState.active == "failed":
status = statusError status = statusError
case dmsState.active == "active": case dmsState.active == "active":
case dmsState.enabled == "disabled" && len(wanted) > 0:
message = "Started with " + strings.Join(wanted, ", ")
if dmsState.active != "" {
message += ", " + dmsState.active
}
case dmsState.enabled == "disabled" && os.Getenv("MANGO_INSTANCE_SIGNATURE") != "":
status, message = statusInfo, "Disabled (Mango starts DMS from its config)"
case dmsState.enabled == "disabled": case dmsState.enabled == "disabled":
status, message = statusWarn, "Disabled" status, message = statusWarn, "Disabled"
case dmsState.active == "inactive": case dmsState.active == "inactive":
status = statusError status = statusError
} }
details := "" results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
if len(wanted) > 0 {
details = "Wanted by " + strings.Join(wanted, ", ")
}
if wm, ok := config.RunningCompositor(); ok && status != statusOK {
if unit := config.SessionUnitName(wm); !slices.Contains(wanted, unit) {
details = fmt.Sprintf("Nothing starts dms.service with %s. Run 'dms systemd enable'.", unit)
}
}
results = append(results, checkResult{catServices, "dms.service", status, message, details, doctorDocsURL + "#services"})
} }
if dmsState.exists && dmsState.enabled == "enabled" { if dmsState.exists && dmsState.enabled == "enabled" {
@@ -1507,24 +1308,6 @@ func checkSystemdServices() []checkResult {
return results return results
} }
// Chromium, Electron and libadwaita follow light/dark through the settings
// portal; without it they fall back to GTK theme colours that never update.
func checkSettingsPortal() []checkResult {
conn, err := dbus.SessionBus()
if err != nil {
return nil
}
var value dbus.Variant
err = conn.Object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop").
Call("org.freedesktop.portal.Settings.ReadOne", 0, "org.freedesktop.appearance", "color-scheme").
Store(&value)
if err == nil {
return []checkResult{{catServices, "xdg-desktop-portal", statusOK, "Settings portal reachable", "", doctorDocsURL + "#services"}}
}
hint := "Apps cannot follow light/dark mode. The portal is D-Bus activated and needs graphical-session.target; without systemd, or a compositor that never starts that target (Mango before 0.17.1), exec the portal backend and xdg-desktop-portal at session start."
return []checkResult{{catServices, "xdg-desktop-portal", statusWarn, "Settings portal unreachable", hint, doctorDocsURL + "#services"}}
}
type serviceState struct { type serviceState struct {
exists bool exists bool
enabled string enabled string
@@ -1612,7 +1395,7 @@ func printResultLine(r checkResult, styles tui.Styles) {
fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message) fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message)
if r.details != "" && (doctorVerbose || r.status != statusOK) { if doctorVerbose && r.details != "" {
fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details)) fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details))
} }
@@ -1671,7 +1454,7 @@ func formatResultsPlain(results []checkResult) string {
fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message) fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message)
if r.details != "" && (doctorVerbose || r.status != statusOK) { if doctorVerbose && r.details != "" {
fmt.Fprintf(&sb, " - %s\n", r.details) fmt.Fprintf(&sb, " - %s\n", r.details)
} }
} }
@@ -1689,29 +1472,25 @@ func formatResultsPlain(results []checkResult) string {
} }
const ( const (
defaultDoctorFontFamily = "Google Sans Flex" defaultDoctorFontFamily = "Inter Variable"
defaultDoctorMonoFontFamily = "Fira Code" defaultDoctorMonoFontFamily = "Fira Code"
defaultDoctorDisplayFamily = "DM Serif Display"
) )
// bundledFontRelPaths maps settings/default family names to font files shipped with
// the shell and loaded via Qt FontLoader (not registered with fontconfig).
var bundledFontRelPaths = map[string][]string{ var bundledFontRelPaths = map[string][]string{
"google sans flex": { "inter variable": {
"DCommon/assets/fonts/google-sans-flex/GoogleSansFlex.ttf", "DankCommon/assets/fonts/inter/InterVariable.ttf",
"assets/fonts/inter/InterVariable.ttf",
}, },
"fira code": { "fira code": {
"DCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf", "DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf", "assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
}, },
"firacode nerd font": { "firacode nerd font": {
"DCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf", "DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf", "assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
}, },
"dm serif display": {
"DCommon/assets/fonts/dm-serif-display/DMSerifDisplay-Regular.ttf",
},
"notable": {
"DCommon/assets/fonts/notable/Notable-Regular.ttf",
},
} }
func resolveDoctorShellPath() string { func resolveDoctorShellPath() string {
@@ -1782,7 +1561,7 @@ func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailab
return checkResult{ return checkResult{
catFonts, label, statusWarn, catFonts, label, statusWarn,
fmt.Sprintf("'%s' bundled file missing", family), fmt.Sprintf("'%s' bundled file missing", family),
"Expected font file missing from shell install. Reinstall DMS or check DCommon assets.", "Expected font file missing from shell install. Reinstall DMS or check DankCommon assets.",
url, url,
} }
} }
@@ -1823,15 +1602,13 @@ func checkFonts() []checkResult {
fontFamily := defaultDoctorFontFamily fontFamily := defaultDoctorFontFamily
monoFontFamily := defaultDoctorMonoFontFamily monoFontFamily := defaultDoctorMonoFontFamily
displayFontFamily := defaultDoctorDisplayFamily
if configDir, err := os.UserConfigDir(); err == nil { if configDir, err := os.UserConfigDir(); err == nil {
settingsPath := filepath.Join(configDir, "dms", "settings.json") settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
if data, err := os.ReadFile(settingsPath); err == nil { if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct { var settings struct {
FontFamily string `json:"fontFamily"` FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"` MonoFontFamily string `json:"monoFontFamily"`
DisplayFontFamily string `json:"displayFontFamily"`
} }
if err := json.Unmarshal(data, &settings); err == nil { if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" { if settings.FontFamily != "" {
@@ -1840,15 +1617,12 @@ func checkFonts() []checkResult {
if settings.MonoFontFamily != "" { if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily monoFontFamily = settings.MonoFontFamily
} }
if settings.DisplayFontFamily != "" {
displayFontFamily = settings.DisplayFontFamily
}
} }
} }
} }
shellPath := resolveDoctorShellPath() shellPath := resolveDoctorShellPath()
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily) || !isBundledDefaultFont(displayFontFamily) needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily)
fcListAvailable := utils.CommandExists("fc-list") fcListAvailable := utils.CommandExists("fc-list")
fcCache := "" fcCache := ""
@@ -1872,7 +1646,6 @@ func checkFonts() []checkResult {
results = append(results, results = append(results,
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url), checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url), checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
checkConfiguredFont("Display Font", displayFontFamily, shellPath, fcCache, fcListAvailable, url),
) )
return results return results
@@ -1,28 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCheckBundledGoogleSans(t *testing.T) {
shellPath := t.TempDir()
fontPath := filepath.Join(shellPath, "DCommon", "assets", "fonts", "google-sans-flex", "GoogleSansFlex.ttf")
missing := checkConfiguredFont("UI font", "Google Sans Flex", shellPath, "", false, "")
if missing.status != statusWarn {
t.Fatalf("missing bundled font status = %v, want %v", missing.status, statusWarn)
}
if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
t.Fatal(err)
}
for _, family := range []string{"Google Sans Flex", " google sans flex "} {
result := checkConfiguredFont("UI font", family, shellPath, "", false, "")
if result.status != statusOK {
t.Fatalf("bundled font %q status = %v, want %v", family, result.status, statusOK)
}
}
}
+28 -228
View File
@@ -1,15 +1,10 @@
package main package main
import ( import (
"bytes"
"debug/elf"
"encoding/binary"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
) )
func TestQuickshellVersionFailureDetails(t *testing.T) { func TestQuickshellVersionFailureDetails(t *testing.T) {
@@ -64,12 +59,24 @@ QT_VERSION:5.15.19`
key: "QT_INSTALL_PLUGINS", key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt/plugins", want: "/usr/lib/qt/plugins",
}, },
{
name: "a different key from the same dump",
output: dumpAll,
key: "QT_VERSION",
want: "5.15.19",
},
{ {
name: "key absent from a multi line dump yields nothing", name: "key absent from a multi line dump yields nothing",
output: dumpAll, output: dumpAll,
key: "QT_INSTALL_LIBEXECS", key: "QT_INSTALL_LIBEXECS",
want: "", want: "",
}, },
{
name: "empty output yields nothing",
output: "",
key: "QT_INSTALL_PLUGINS",
want: "",
},
{ {
name: "surrounding whitespace is trimmed", name: "surrounding whitespace is trimmed",
output: " QT_INSTALL_PLUGINS: /usr/lib/qt/plugins \n", output: " QT_INSTALL_PLUGINS: /usr/lib/qt/plugins \n",
@@ -112,43 +119,24 @@ func writePlugin(t *testing.T, pluginsDir, file string) {
} }
} }
// writeQtPluginELF writes a minimal ELF holding only the .note.qt.metadata // qtenginePluginFile("", major) is a path with a directory component, and
// note Qt 6 embeds in plugins, recording the Qt version it was built against. // qtPluginPath joins platformthemes/ again — a regression here silently warns
func writeQtPluginELF(t *testing.T, pluginsDir, file string, major, minor byte) { // at every correctly installed system, so pin the full path down.
t.Helper() func TestQtenginePluginPath(t *testing.T) {
var note bytes.Buffer root := t.TempDir()
binary.Write(&note, binary.LittleEndian, []uint32{12, 4, 0x74510001}) if got := qtenginePluginPath([]string{root}, "6"); got != "" {
note.WriteString("qt-project!\x00") t.Fatalf("missing plugin should yield an empty path, got %q", got)
note.Write([]byte{1, major, minor, 1})
shstrtab := []byte("\x00.note.qt.metadata\x00.shstrtab\x00")
noteOff := uint64(64)
strOff := noteOff + uint64(note.Len())
shOff := strOff + uint64(len(shstrtab))
var out bytes.Buffer
header := elf.Header64{
Type: uint16(elf.ET_DYN), Machine: uint16(elf.EM_X86_64), Version: uint32(elf.EV_CURRENT),
Shoff: shOff, Ehsize: 64, Shentsize: 64, Shnum: 3, Shstrndx: 2,
} }
copy(header.Ident[:], elf.ELFMAG) want := filepath.Join(root, "platformthemes", "libqt6engine-plugin.so")
header.Ident[elf.EI_CLASS] = byte(elf.ELFCLASS64) if err := os.MkdirAll(filepath.Join(root, "platformthemes"), 0o755); err != nil {
header.Ident[elf.EI_DATA] = byte(elf.ELFDATA2LSB)
header.Ident[elf.EI_VERSION] = byte(elf.EV_CURRENT)
binary.Write(&out, binary.LittleEndian, header)
out.Write(note.Bytes())
out.Write(shstrtab)
binary.Write(&out, binary.LittleEndian, []elf.Section64{
{},
{Name: 1, Type: uint32(elf.SHT_NOTE), Off: noteOff, Size: uint64(note.Len()), Addralign: 4},
{Name: 20, Type: uint32(elf.SHT_STRTAB), Off: strOff, Size: uint64(len(shstrtab)), Addralign: 1},
})
if err := os.MkdirAll(filepath.Join(pluginsDir, "platformthemes"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(pluginsDir, "platformthemes", file), out.Bytes(), 0o644); err != nil { if err := os.WriteFile(want, nil, 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := qtenginePluginPath([]string{root}, "6"); got != want {
t.Fatalf("qtenginePluginPath = %q, want %q", got, want)
}
} }
func TestCheckQtPlatformThemePlugin(t *testing.T) { func TestCheckQtPlatformThemePlugin(t *testing.T) {
@@ -181,34 +169,6 @@ func TestCheckQtPlatformThemePlugin(t *testing.T) {
} }
}) })
t.Run("plugin built for the installed Qt minor reports OK", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
writeQtPluginELF(t, plugins, "libqt6ct.so", 6, 12)
stubQtQueryTool(t, "qmake6", "6.12.3", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK result", results)
}
})
t.Run("plugin built for another Qt minor warns", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
writeQtPluginELF(t, plugins, "libqt6ct.so", 6, 11)
stubQtQueryTool(t, "qmake6", "6.12.0", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusWarn {
t.Fatalf("got %+v, want one warn result", results)
}
if results[0].message != "Built for Qt 6.11, Qt is 6.12.0" {
t.Fatalf("message = %q, want both Qt versions", results[0].message)
}
if !strings.Contains(results[0].details, filepath.Join(plugins, "platformthemes", "libqt6ct.so")) {
t.Fatalf("details should name the stale plugin, got %q", results[0].details)
}
})
t.Run("missing plugin warns with the expected path", func(t *testing.T) { t.Run("missing plugin warns with the expected path", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct") t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir() plugins := t.TempDir()
@@ -232,55 +192,15 @@ func TestCheckQtPlatformThemePlugin(t *testing.T) {
} }
}) })
t.Run("unset is left to checkEnvVar", func(t *testing.T) { t.Run("unset and qtengine are left to their own checks", func(t *testing.T) {
t.Setenv("PATH", t.TempDir()) t.Setenv("PATH", t.TempDir())
t.Setenv("QT_QPA_PLATFORMTHEME", "") t.Setenv("QT_QPA_PLATFORMTHEME", "")
if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil { if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
t.Fatalf("unset should yield no results, got %+v", results) t.Fatalf("unset should yield no results, got %+v", results)
} }
})
t.Run("qtengine checks the Qt5 and Qt6 plugins", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qtengine") t.Setenv("QT_QPA_PLATFORMTHEME", "qtengine")
qt5, qt6 := t.TempDir(), t.TempDir() if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
writePlugin(t, qt6, "libqt6engine-plugin.so") t.Fatalf("qtengine should yield no results, got %+v", results)
dir5 := stubQtQueryTool(t, "qmake-qt5", "5.15.19", qt5)
dir6 := stubQtQueryTool(t, "qmake6", "6.11.2", qt6)
t.Setenv("PATH", dir6+string(os.PathListSeparator)+dir5)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 2 {
t.Fatalf("got %d results, want 2: %+v", len(results), results)
}
if results[0].name != "qtengine plugin (Qt5)" || results[0].status != statusWarn {
t.Fatalf("Qt5 result = %+v, want a warning for the missing plugin", results[0])
}
if !strings.Contains(results[0].details, filepath.Join(qt5, "platformthemes", "libqt5engine-plugin.so")) {
t.Fatalf("Qt5 details should contain the expected path, got %q", results[0].details)
}
if results[1].name != "qtengine plugin (Qt6)" || results[1].status != statusOK {
t.Fatalf("Qt6 result = %+v, want OK", results[1])
}
})
t.Run("qtengine in the qt6 variable skips Qt5", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME_QT6", "qtengine")
qt5, qt6 := t.TempDir(), t.TempDir()
writePlugin(t, qt6, "libqt6engine-plugin.so")
dir5 := stubQtQueryTool(t, "qmake-qt5", "5.15.19", qt5)
dir6 := stubQtQueryTool(t, "qmake6", "6.11.2", qt6)
t.Setenv("PATH", dir6+string(os.PathListSeparator)+dir5)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")
if len(results) != 1 || results[0].name != "qtengine plugin (Qt6)" || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK Qt6 result", results)
}
})
t.Run("qtengine without Qt tools degrades to one info", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qtengine")
t.Setenv("PATH", t.TempDir())
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusInfo || results[0].name != "qtengine plugin (Qt6)" {
t.Fatalf("got %+v, want one Qt6 info result", results)
} }
}) })
@@ -315,123 +235,3 @@ func TestCheckQtPlatformThemePlugin(t *testing.T) {
} }
}) })
} }
func TestCheckQtengineConfig(t *testing.T) {
configHome, dataHome := t.TempDir(), t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("XDG_DATA_HOME", dataHome)
path := filepath.Join(configHome, "qtengine", "config.json")
scheme := filepath.Join(dataHome, "color-schemes", "DankMatugen.colors")
write := func(t *testing.T, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
tests := []struct {
name string
content string // "" leaves the file absent
status status
message string
}{
{"missing", "", statusInfo, "Not found"},
{"empty", "\n", statusInfo, "Not found"},
{"unparseable", "{", statusWarn, "Unreadable"},
{"theme is not an object", `{"theme": 1}`, statusWarn, "Unreadable"},
{"dms scheme", `{"theme": {"colorScheme": "` + scheme + `"}}`, statusOK, "Uses DMS colors"},
{"other scheme", `{"theme": {"colorScheme": "/usr/share/color-schemes/BreezeDark.colors"}}`, statusInfo, "Not using DMS colors"},
{"no scheme", `{"theme": {}}`, statusInfo, "Not using DMS colors"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
os.Remove(path)
if tc.content != "" {
write(t, tc.content)
}
results := checkQtengineConfig()
if len(results) != 1 || results[0].status != tc.status || results[0].message != tc.message {
t.Fatalf("got %+v, want %s %q", results, tc.status, tc.message)
}
})
}
}
func TestCheckXDGMenuFile(t *testing.T) {
configHome, systemDir := t.TempDir(), t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("XDG_CONFIG_DIRS", systemDir)
if checkXDGMenuFile("hyprland-") {
t.Fatal("no menu file anywhere should not be found")
}
for _, dir := range []string{configHome, systemDir} {
menus := filepath.Join(dir, "menus")
if err := os.MkdirAll(menus, 0o755); err != nil {
t.Fatal(err)
}
file := filepath.Join(menus, "hyprland-applications.menu")
if err := os.WriteFile(file, nil, 0o644); err != nil {
t.Fatal(err)
}
if !checkXDGMenuFile("hyprland-") {
t.Fatalf("menu file in %s should be found", menus)
}
os.Remove(file)
}
}
func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "dms", "layout.conf"), []byte("border_px=2\n"), 0o644)
if got := checkMangoConfig(dir, mangoconf.Legacy); len(got) != 2 || got[0].message != "Not legacy keys: layout.conf" {
t.Fatalf("legacy: %+v", got)
}
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" {
t.Fatalf("snake: %+v", got)
}
}
func TestHyprlandLacksLua(t *testing.T) {
for ver, want := range map[string]bool{
"0.54.9": true,
"0.55.0": false,
"": false,
} {
if got := hyprlandLacksLua(ver); got != want {
t.Errorf("hyprlandLacksLua(%q) = %v, want %v", ver, got, want)
}
}
}
func TestCheckHyprlandConfigFormat(t *testing.T) {
for name, tc := range map[string]struct {
files []string
warn bool
}{
"lua only": {[]string{"hyprland.lua"}, false},
"lua wins": {[]string{"hyprland.lua", "hyprland.conf"}, false},
"conf only": {[]string{"hyprland.conf"}, true},
"neither": {nil, false},
} {
dir := t.TempDir()
for _, f := range tc.files {
if err := os.WriteFile(filepath.Join(dir, f), nil, 0o644); err != nil {
t.Fatal(err)
}
}
got := checkHyprlandConfigFormat(dir)
if warned := len(got) == 1 && got[0].status == statusWarn; warned != tc.warn || (!tc.warn && len(got) != 0) {
t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn)
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch" "github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+22 -45
View File
@@ -13,21 +13,20 @@ import (
"strings" "strings"
"time" "time"
"github.com/duxlabs-dev/dux-shell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch" "github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/duxlabs-dev/dux-shell/core/internal/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var updateCmd = &cobra.Command{ var updateCmd = &cobra.Command{
Use: "update", Use: "update",
Short: "Update Dux Shell to the latest version", Short: "Update DankMaterialShell to the latest version",
Long: "Update Dux Shell to the latest version using the appropriate package manager for your distribution", Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution",
PreRunE: shellApp.ResolveConfig, PreRunE: shellApp.ResolveConfig,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
runUpdate() runUpdate()
@@ -36,7 +35,7 @@ var updateCmd = &cobra.Command{
var updateCheckCmd = &cobra.Command{ var updateCheckCmd = &cobra.Command{
Use: "check", Use: "check",
Short: "Check if updates are available for Dux Shell", Short: "Check if updates are available for DankMaterialShell",
Long: "Check for available updates without performing the actual update", Long: "Check for available updates without performing the actual update",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
runUpdateCheck() runUpdateCheck()
@@ -44,19 +43,9 @@ var updateCheckCmd = &cobra.Command{
} }
func runUpdateCheck() { func runUpdateCheck() {
fmt.Println("Checking for Dux Shell updates...") fmt.Println("Checking for DankMaterialShell updates...")
fmt.Println() fmt.Println()
shell := sysupdate.ProbeShell(context.Background(), Version)
switch shell.InstallMethod {
case sysupdate.InstallPacman, sysupdate.InstallRPM, sysupdate.InstallDpkg, sysupdate.InstallXbps:
fmt.Printf("Installed as package %s (%s); update it with your package manager.\n", shell.PackageName, shell.InstallMethod)
os.Exit(0)
case sysupdate.InstallNix:
fmt.Println("Managed by Nix; update through your flake or channel.")
os.Exit(0)
}
versionInfo, err := version.GetDMSVersionInfo() versionInfo, err := version.GetDMSVersionInfo()
if err != nil { if err != nil {
log.Fatalf("Error checking for updates: %v", err) log.Fatalf("Error checking for updates: %v", err)
@@ -169,7 +158,7 @@ func updateArchLinux() error {
return updateOtherDistros() return updateOtherDistros()
} }
fmt.Printf("This will update Dux Shell using %s.\n", helper) fmt.Printf("This will update DankMaterialShell using %s.\n", helper)
if !confirmUpdate() { if !confirmUpdate() {
return errdefs.ErrUpdateCancelled return errdefs.ErrUpdateCancelled
} }
@@ -193,8 +182,12 @@ func updateOtherDistros() error {
} }
dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
_, statErr := os.Stat(dmsPath)
hasClone := statErr == nil if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
return fmt.Errorf("DMS configuration directory not found at %s", dmsPath)
}
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
versionInfo, err := version.GetDMSVersionInfo() versionInfo, err := version.GetDMSVersionInfo()
if err == nil && !versionInfo.HasUpdate { if err == nil && !versionInfo.HasUpdate {
@@ -206,25 +199,9 @@ func updateOtherDistros() error {
return errdefs.ErrNoUpdateNeeded return errdefs.ErrNoUpdateNeeded
} }
// Release binaries embed the shell; without a clone the binary is the whole install.
if !hasClone {
fmt.Println("\nThis will update the dms binary from GitHub releases.")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Println("\n=== Updating dms binary ===")
if err := updateDMSBinary(); err != nil {
return fmt.Errorf("failed to update dms binary: %w", err)
}
fmt.Println("dms binary successfully updated")
return nil
}
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
fmt.Println("\nThis will update:") fmt.Println("\nThis will update:")
fmt.Println(" 1. The dms binary from GitHub releases") fmt.Println(" 1. The dms binary from GitHub releases")
fmt.Println(" 2. Dux Shell configuration using git") fmt.Println(" 2. DankMaterialShell configuration using git")
if !confirmUpdate() { if !confirmUpdate() {
return errdefs.ErrUpdateCancelled return errdefs.ErrUpdateCancelled
} }
@@ -385,7 +362,7 @@ func offerReclone(dmsPath string) bool {
} }
fmt.Println("Cloning fresh copy...") fmt.Println("Cloning fresh copy...")
cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/duxlabs-dev/dux-shell.git", dmsPath) cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
cloneCmd.Stdout = os.Stdout cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr cloneCmd.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil { if err := cloneCmd.Run(); err != nil {
@@ -449,8 +426,8 @@ func updateDMSBinary() error {
} }
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
binaryURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/releases/download/%s/dms-cli-%s.gz", version, arch) binaryURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch)
checksumURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch) checksumURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch)
binaryPath := filepath.Join(tempDir, "dms.gz") binaryPath := filepath.Join(tempDir, "dms.gz")
checksumPath := filepath.Join(tempDir, "dms.gz.sha256") checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
+4 -9
View File
@@ -6,8 +6,7 @@ import (
"os/exec" "os/exec"
"syscall" "syscall"
"github.com/duxlabs-dev/dux-shell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -53,15 +52,11 @@ func greeterPackageInstallHint() string {
return "Install package: dms-greeter" return "Install package: dms-greeter"
} }
if config.ID == "pika" {
return "Install with 'sudo apt install dms-greeter'"
}
switch config.Family { switch config.Family {
case distros.FamilyDebian: case distros.FamilyDebian:
return "Install with 'sudo apt install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#debian)" return "Install with 'sudo apt install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#debian)"
case distros.FamilySUSE: case distros.FamilySUSE:
return "Install with 'sudo zypper install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#opensuse--derivatives)" return "Install with 'sudo zypper install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#opensuse)"
case distros.FamilyUbuntu: case distros.FamilyUbuntu:
return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)" return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)"
case distros.FamilyFedora: case distros.FamilyFedora:
@@ -69,7 +64,7 @@ func greeterPackageInstallHint() string {
case distros.FamilyArch: case distros.FamilyArch:
return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'" return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'"
case distros.FamilyVoid: case distros.FamilyVoid:
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)" return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf)"
default: default:
return "Install the dms-greeter package for your distribution" return "Install the dms-greeter package for your distribution"
} }
-485
View File
@@ -1,485 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/icc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server/wayland"
"github.com/spf13/cobra"
)
var iccCmd = &cobra.Command{
Use: "icc",
Short: "Manage ICC color profiles",
Long: "Manage ICC color profile assignments for displays",
}
var iccListCmd = &cobra.Command{
Use: "list",
Short: "List available ICC profiles and current assignments",
Args: cobra.NoArgs,
Run: runICCList,
}
var iccInfoCmd = &cobra.Command{
Use: "info <file>",
Short: "Show details of an ICC profile",
Args: cobra.ExactArgs(1),
Run: runICCInfo,
}
var iccApplyCmd = &cobra.Command{
Use: "apply <output> <file>",
Short: "Apply ICC profile to a specific output",
Args: cobra.ExactArgs(2),
Run: runICCApply,
}
var iccRemoveCmd = &cobra.Command{
Use: "remove <output>",
Short: "Remove ICC profile from a specific output",
Args: cobra.ExactArgs(1),
Run: runICCRemove,
}
var iccStatusCmd = &cobra.Command{
Use: "status",
Short: "Show ICC profile status for all outputs",
Args: cobra.NoArgs,
Run: runICCStatus,
}
var iccSetTempCmd = &cobra.Command{
Use: "set-temp <output> <kelvin>",
Aliases: []string{"setTemp"},
Short: "Set the color temperature for a specific output",
Long: "Set the color temperature for a specific output, independently of the\n" +
"night light schedule: every display keeps its own value, which also makes\n" +
"the range wider than the night light's.\n\n" +
"For an output with an ICC profile the temperature is composed on top of\n" +
"it, treating the profile as measured at 6500K.\n\n" +
"Accepts 1000-10000K; 0 removes the override, so the output follows the\n" +
"night light schedule again (6500K while it is disabled).",
Args: cobra.ExactArgs(2),
Run: runICCSetTemp,
}
func init() {
iccCmd.AddCommand(iccListCmd, iccInfoCmd, iccApplyCmd, iccRemoveCmd, iccStatusCmd, iccSetTempCmd)
}
func getICCConfigDir() string {
return wayland.ICCProfilesDir()
}
func listAvailableProfiles() []string {
dir := getICCConfigDir()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var profiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext == ".icc" || ext == ".icm" {
profiles = append(profiles, filepath.Join(dir, name))
}
}
return profiles
}
func runICCList(cmd *cobra.Command, args []string) {
// Get current assignments from running server
req := ipc.Request{
Method: "wayland.icc.getStatus",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get ICC status: %v", err)
}
statusMap := make(map[string]map[string]any)
outputs := []string{}
if resp.Result != nil {
if resultBytes, err := json.Marshal(*resp.Result); err == nil {
var result map[string]any
if err := json.Unmarshal(resultBytes, &result); err == nil {
if outputsRaw, ok := result["outputs"].([]any); ok {
for _, o := range outputsRaw {
if s, ok := o.(string); ok {
outputs = append(outputs, s)
}
}
}
if profilesRaw, ok := result["profiles"].(map[string]any); ok {
for k, v := range profilesRaw {
if m, ok := v.(map[string]any); ok {
statusMap[k] = m
}
}
}
}
}
}
// Show existing assignments
fmt.Println("Current ICC Assignments:")
fmt.Println(strings.Repeat("─", 80))
if len(outputs) == 0 && len(statusMap) == 0 {
fmt.Println(" (no outputs found)")
} else {
shown := make(map[string]bool)
for _, output := range outputs {
shown[output] = true
if status, ok := statusMap[output]; ok {
desc := ""
if d, ok := status["description"].(string); ok {
desc = d
}
path := ""
if p, ok := status["path"].(string); ok {
path = p
}
fmt.Printf(" %-20s %-30s %s\n", output, desc, path)
} else {
fmt.Printf(" %-20s %-30s %s\n", output, "(none)", "")
}
}
// Show any outputs that are in statusMap but not in outputs list
for output, status := range statusMap {
if shown[output] {
continue
}
desc := ""
if d, ok := status["description"].(string); ok {
desc = d
}
path := ""
if p, ok := status["path"].(string); ok {
path = p
}
fmt.Printf(" %-20s %-30s %s\n", output, desc, path)
}
}
// Show available profiles in config dir
fmt.Println()
fmt.Println("Available Profiles in", getICCConfigDir()+":")
fmt.Println(strings.Repeat("─", 80))
profiles := listAvailableProfiles()
if len(profiles) == 0 {
fmt.Println(" (no .icc/.icm files found)")
} else {
for _, path := range profiles {
profile, err := icc.ParseFile(path)
if err != nil {
fmt.Printf(" %-40s (parse error: %v)\n", filepath.Base(path), err)
continue
}
// A profile without a vcgt table has no video card gamma ramp, so the
// daemon refuses to apply it: say so here instead of letting the user
// find out from a rejected `dms icc apply`.
note := ""
if !profile.HasVCGT {
note = " (no vcgt table: cannot be applied)"
}
fmt.Printf(" %-40s %s%s\n", filepath.Base(path), profile.Description, note)
}
}
}
func runICCInfo(cmd *cobra.Command, args []string) {
path := args[0]
profile, err := icc.ParseFile(path)
if err != nil {
log.Fatalf("Failed to parse ICC profile: %v", err)
}
fmt.Printf("ICC Profile: %s\n", filepath.Base(path))
fmt.Println(strings.Repeat("─", 60))
fmt.Printf(" Description: %s\n", profile.Description)
fmt.Printf(" Version: %s\n", profile.Version)
fmt.Printf(" Class: %s\n", profile.Class)
fmt.Printf(" Color Space: %s\n", profile.ColorSpace)
fmt.Println()
if profile.HasMatrix {
fmt.Println("Matrix:")
for i := 0; i < 3; i++ {
fmt.Printf(" %8.4f %8.4f %8.4f\n",
profile.Matrix[i][0], profile.Matrix[i][1], profile.Matrix[i][2])
}
fmt.Println()
}
if profile.HasTRC {
fmt.Println("TRC (Tone Reproduction Curves):")
trcNames := []string{" Red", " Green", " Blue"}
for i, name := range trcNames {
curve := profile.TRC[i]
switch curve.Type {
case icc.CurveIdentity:
fmt.Printf("%-8s identity\n", name)
case icc.CurveParametric:
fmt.Printf("%-8s gamma=%.2f\n", name, curve.Gamma)
case icc.CurveTable:
fmt.Printf("%-8s table (%d entries)\n", name, len(curve.Entries))
}
}
fmt.Println()
}
if profile.HasVCGT && profile.VCGT != nil {
fmt.Printf("VCGT: %d channels, %d entries per channel\n", profile.VCGT.Channels, profile.VCGT.Entries)
fmt.Println()
}
if x, y, ok := profile.WhitePointXY(); ok {
cct := profile.WhitePointCCT()
name := profile.WhitePointName()
switch {
case name != "" && cct > 0:
fmt.Printf("White Point: %s (%.4f, %.4f, %dK)\n", name, x, y, cct)
case cct > 0:
fmt.Printf("White Point: (%.4f, %.4f, %dK)\n", x, y, cct)
default:
fmt.Printf("White Point: (%.4f, %.4f)\n", x, y)
}
} else {
fmt.Printf("White Point: %.4f %.4f %.4f\n",
profile.WhitePoint[0], profile.WhitePoint[1], profile.WhitePoint[2])
}
}
func runICCApply(cmd *cobra.Command, args []string) {
outputName := args[0]
filePath, err := filepath.Abs(args[1])
if err != nil {
log.Fatalf("Failed to resolve path: %v", err)
}
profile, err := icc.ParseFile(filePath)
if err != nil {
log.Fatalf("Invalid ICC profile: %v", err)
}
req := ipc.Request{
Method: "wayland.icc.apply",
Params: map[string]any{
"output": outputName,
"path": filePath,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to apply ICC profile: %v", err)
}
if resp.Error != "" {
log.Fatalf("Server error: %s", resp.Error)
}
fmt.Printf("Applied ICC profile '%s' to output '%s'\n", profile.Description, outputName)
}
func runICCRemove(cmd *cobra.Command, args []string) {
outputName := args[0]
req := ipc.Request{
Method: "wayland.icc.remove",
Params: map[string]any{
"output": outputName,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to remove ICC profile: %v", err)
}
if resp.Error != "" {
log.Fatalf("Server error: %s", resp.Error)
}
fmt.Printf("Removed ICC profile from output '%s'\n", outputName)
}
func runICCSetTemp(cmd *cobra.Command, args []string) {
outputName := args[0]
temp, err := strconv.Atoi(args[1])
if err != nil {
log.Fatalf("Invalid temperature %q: expected a number in kelvin", args[1])
}
if temp != 0 && (temp < 1000 || temp > 10000) {
log.Fatalf("Temperature %d out of range (1000-10000, or 0 to follow the schedule)", temp)
}
req := ipc.Request{
Method: "wayland.icc.setTemp",
Params: map[string]any{
"output": outputName,
"temp": temp,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to set output temperature: %v", err)
}
if resp.Error != "" {
log.Fatalf("Server error: %s", resp.Error)
}
if temp == 0 {
fmt.Printf("Output '%s' follows the night light schedule again\n", outputName)
return
}
fmt.Printf("Set output '%s' color temperature to %dK\n", outputName, temp)
}
// fetchICCOutputTemps asks the running server for the per-output overrides.
// Outputs without an override report 0.
func fetchICCOutputTemps() map[string]int {
temps := make(map[string]int)
resp, err := sendServerRequest(ipc.Request{Method: "wayland.icc.getTemps"})
if err != nil || resp.Result == nil {
return temps
}
resultBytes, err := json.Marshal(*resp.Result)
if err != nil {
return temps
}
var raw map[string]any
if err := json.Unmarshal(resultBytes, &raw); err != nil {
return temps
}
for output, value := range raw {
if f, ok := value.(float64); ok {
temps[output] = int(f)
}
}
return temps
}
// formatOutputTemp renders a per-output override, or the schedule marker.
func formatOutputTemp(temps map[string]int, output string) string {
temp, ok := temps[output]
if !ok || temp == 0 {
return "schedule"
}
return strconv.Itoa(temp) + "K"
}
func runICCStatus(cmd *cobra.Command, args []string) {
req := ipc.Request{
Method: "wayland.icc.getStatus",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get ICC status: %v", err)
}
statusMap := make(map[string]map[string]any)
outputs := []string{}
if resp.Result != nil {
if resultBytes, err := json.Marshal(*resp.Result); err == nil {
var result map[string]any
if err := json.Unmarshal(resultBytes, &result); err == nil {
if outputsRaw, ok := result["outputs"].([]any); ok {
for _, o := range outputsRaw {
if s, ok := o.(string); ok {
outputs = append(outputs, s)
}
}
}
if profilesRaw, ok := result["profiles"].(map[string]any); ok {
for k, v := range profilesRaw {
if m, ok := v.(map[string]any); ok {
statusMap[k] = m
}
}
}
}
}
}
temps := fetchICCOutputTemps()
fmt.Println("ICC Profile Status:")
fmt.Println(strings.Repeat("─", 100))
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", "Output", "Profile", "Version", "ColorSpace", "Active", "Temp")
fmt.Println(" " + strings.Repeat("─", 96))
shown := make(map[string]bool)
for _, output := range outputs {
shown[output] = true
if status, ok := statusMap[output]; ok {
active := false
if a, ok := status["active"].(bool); ok {
active = a
}
desc := "(none)"
if d, ok := status["description"].(string); ok && d != "" {
desc = d
}
version := ""
if v, ok := status["version"].(string); ok {
version = v
}
colorSpace := ""
if c, ok := status["colorSpace"].(string); ok {
colorSpace = c
}
activeStr := "no"
if active {
activeStr = "yes"
}
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, desc, version, colorSpace, activeStr, formatOutputTemp(temps, output))
} else {
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, "(none)", "", "", "no", formatOutputTemp(temps, output))
}
}
// Show any outputs that are in statusMap but not in outputs list
for output, status := range statusMap {
if shown[output] {
continue
}
active := false
if a, ok := status["active"].(bool); ok {
active = a
}
desc := "(none)"
if d, ok := status["description"].(string); ok && d != "" {
desc = d
}
version := ""
if v, ok := status["version"].(string); ok {
version = v
}
colorSpace := ""
if c, ok := status["colorSpace"].(string); ok {
colorSpace = c
}
activeStr := "no"
if active {
activeStr = "yes"
}
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, desc, version, colorSpace, activeStr, formatOutputTemp(temps, output))
}
}
+4 -45
View File
@@ -4,16 +4,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strconv"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -70,15 +66,6 @@ var keybindsResetCmd = &cobra.Command{
Run: runKeybindsEdit, Run: runKeybindsEdit,
} }
var keybindsKeymapCmd = &cobra.Command{
Use: "keymap",
Short: "Report which keysym each physical key carries on the first level",
Long: "Print the keysyms the active keyboard layout puts on the first level of every physical key, keyed by xkb keycode.\n\n" +
"Compositors resolve bind keysyms at that level, so a keysym missing from this list cannot be bound however many modifiers are pressed. " +
"The keybind editor uses it to warn instead of storing a bind that never fires.",
Run: runKeybindsKeymap,
}
func init() { func init() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings") keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} { for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
@@ -92,14 +79,13 @@ func init() {
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat") keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)") keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)")
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)") keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags as letters: e repeat, l locked, r release, o long-press, n non-consuming, m mouse, t transparent, i ignore-mods, u submap-universal, a auto-consuming, x allow-input-capture") keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
keybindsCmd.AddCommand(keybindsListCmd) keybindsCmd.AddCommand(keybindsListCmd)
keybindsCmd.AddCommand(keybindsShowCmd) keybindsCmd.AddCommand(keybindsShowCmd)
keybindsCmd.AddCommand(keybindsSetCmd) keybindsCmd.AddCommand(keybindsSetCmd)
keybindsCmd.AddCommand(keybindsRemoveCmd) keybindsCmd.AddCommand(keybindsRemoveCmd)
keybindsCmd.AddCommand(keybindsResetCmd) keybindsCmd.AddCommand(keybindsResetCmd)
keybindsCmd.AddCommand(keybindsKeymapCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) { keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath) return providers.NewJSONFileProvider(filePath)
@@ -177,33 +163,6 @@ func runKeybindsList(cmd *cobra.Command, _ []string) {
} }
} }
// runKeybindsKeymap answers with empty fields rather than an error when there
// is no Wayland seat to ask, so the caller can simply skip the check instead
// of having to tell "unknown layout" apart from "nothing wrong".
func runKeybindsKeymap(_ *cobra.Command, _ []string) {
keysyms := map[string][]string{}
named := []string{}
km, err := keymap.FromSeat()
if err != nil {
log.Warnf("Failed to read the seat keymap: %v", err)
} else {
for code, syms := range km.Level1ByXkbKeycode() {
keysyms[strconv.FormatUint(uint64(code), 10)] = syms
}
// The vocabulary the keysyms above are drawn from, so a caller can
// tell a keysym we could not name from one the layout is missing.
named = slices.Compact(slices.Sorted(maps.Values(keymap.KeysymNames)))
}
output, err := json.Marshal(map[string]any{"keysyms": keysyms, "named": named})
if err != nil {
log.Warnf("Failed to encode keymap: %v", err)
return
}
fmt.Fprintln(os.Stdout, string(output))
}
func makeProviderWithPath(name, path string) keybinds.Provider { func makeProviderWithPath(name, path string) keybinds.Provider {
switch name { switch name {
case "hyprland": case "hyprland":
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
) )
func TestKeybindEditFailure(t *testing.T) { func TestKeybindEditFailure(t *testing.T) {
+5 -23
View File
@@ -8,9 +8,9 @@ import (
"os" "os"
"time" "time"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/duxlabs-dev/dux-shell/core/internal/matugen" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -70,13 +70,9 @@ func init() {
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON") cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal") cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant") cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
cmd.Flags().Int("terminal-opacity", 0, "Terminal background opacity percent, 1-99 (0 or 100 leaves the terminal's own setting)")
cmd.Flags().String("terminal-palette", "", "Terminal palette: default, low, medium or high")
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip") cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)") cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
cmd.Flags().String("source-mode", "", "Source color selection: dominant, colorful, darkness, lightness, saturation, less-saturation, value") cmd.Flags().String("source-mode", "", "Source color selection: dominant, colorful, darkness, lightness, saturation, less-saturation, value")
cmd.Flags().String("seed-color", "", "Hex color to build the palette from instead of extracting one from the wallpaper")
cmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default), 2025 (Material 3 Expressive) or dms (2025 accents on neutral surfaces)")
} }
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion") matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
@@ -84,7 +80,6 @@ func init() {
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews") matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
matugenPreviewCmd.Flags().String("image", "", "Wallpaper image used to resolve the scheme-smart preview") matugenPreviewCmd.Flags().String("image", "", "Wallpaper image used to resolve the scheme-smart preview")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)") matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
matugenPreviewCmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default), 2025 (Material 3 Expressive) or dms (2025 accents on neutral surfaces)")
matugenQtengineCmd.Flags().String("config-dir", "", "User config directory") matugenQtengineCmd.Flags().String("config-dir", "", "User config directory")
matugenQtengineCmd.Flags().String("icon-theme", "", "Icon theme name") matugenQtengineCmd.Flags().String("icon-theme", "", "Icon theme name")
} }
@@ -102,13 +97,9 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
stockColors, _ := cmd.Flags().GetString("stock-colors") stockColors, _ := cmd.Flags().GetString("stock-colors")
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal") syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark") terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
terminalOpacity, _ := cmd.Flags().GetInt("terminal-opacity")
terminalPalette, _ := cmd.Flags().GetString("terminal-palette")
skipTemplates, _ := cmd.Flags().GetString("skip-templates") skipTemplates, _ := cmd.Flags().GetString("skip-templates")
contrast, _ := cmd.Flags().GetFloat64("contrast") contrast, _ := cmd.Flags().GetFloat64("contrast")
sourceMode, _ := cmd.Flags().GetString("source-mode") sourceMode, _ := cmd.Flags().GetString("source-mode")
seedColor, _ := cmd.Flags().GetString("seed-color")
spec, _ := cmd.Flags().GetString("spec")
return matugen.Options{ return matugen.Options{
StateDir: stateDir, StateDir: stateDir,
@@ -124,12 +115,8 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
StockColors: stockColors, StockColors: stockColors,
SyncModeWithPortal: syncModeWithPortal, SyncModeWithPortal: syncModeWithPortal,
TerminalsAlwaysDark: terminalsAlwaysDark, TerminalsAlwaysDark: terminalsAlwaysDark,
TerminalOpacity: terminalOpacity,
TerminalPalette: terminalPalette,
SkipTemplates: skipTemplates, SkipTemplates: skipTemplates,
SourceMode: sourceMode, SourceMode: sourceMode,
SeedColor: seedColor,
Spec: spec,
} }
} }
@@ -149,7 +136,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
wait, _ := cmd.Flags().GetBool("wait") wait, _ := cmd.Flags().GetBool("wait")
timeout, _ := cmd.Flags().GetDuration("timeout") timeout, _ := cmd.Flags().GetDuration("timeout")
request := ipc.Request{ request := models.Request{
ID: 1, ID: 1,
Method: "matugen.queue", Method: "matugen.queue",
Params: map[string]any{ Params: map[string]any{
@@ -165,13 +152,9 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"stockColors": opts.StockColors, "stockColors": opts.StockColors,
"syncModeWithPortal": opts.SyncModeWithPortal, "syncModeWithPortal": opts.SyncModeWithPortal,
"terminalsAlwaysDark": opts.TerminalsAlwaysDark, "terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"terminalOpacity": opts.TerminalOpacity,
"terminalPalette": opts.TerminalPalette,
"skipTemplates": opts.SkipTemplates, "skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast, "contrast": opts.Contrast,
"sourceMode": opts.SourceMode, "sourceMode": opts.SourceMode,
"seedColor": opts.SeedColor,
"spec": opts.Spec,
"wait": wait, "wait": wait,
}, },
} }
@@ -245,8 +228,7 @@ func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color") sourceColor, _ := cmd.Flags().GetString("source-color")
imagePath, _ := cmd.Flags().GetString("image") imagePath, _ := cmd.Flags().GetString("image")
contrast, _ := cmd.Flags().GetFloat64("contrast") contrast, _ := cmd.Flags().GetFloat64("contrast")
spec, _ := cmd.Flags().GetString("spec") previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath)
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath, spec)
if err != nil { if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err) log.Fatalf("Failed to generate Matugen previews: %v", err)
} }
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMatugenQtengineCommandAcceptsConfigDir(t *testing.T) {
flag := matugenQtengineCmd.Flags().Lookup("config-dir")
require.NotNil(t, flag)
assert.Equal(t, "", flag.DefValue)
}
+30 -241
View File
@@ -3,50 +3,30 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings"
"time"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/duxlabs-dev/dux-shell/core/internal/notify" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/godbus/dbus/v5"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
var ( var (
notifyAppName string notifyAppName string
notifyAppIcon string notifyIcon string
notifyIcon string notifyFile string
notifyFile string notifyTimeout int
notifyTimeout int
notifyUrgency string
notifyCategory string
notifyTransient bool
notifyHints []string
notifyActions []string
notifyPrintID bool
notifyReplaceID uint32
notifyWait bool
notifyIDFd int
notifyActionFd int
) )
var notifyCmd = &cobra.Command{ var notifyCmd = &cobra.Command{
Use: "notify <summary> [body]", Use: "notify <summary> [body]",
Short: "Send a desktop notification", Short: "Send a desktop notification",
Long: `Send a desktop notification. Flags follow notify-send. Long: `Send a desktop notification with optional actions.
If --file is provided, the notification gets "Open" and "Open Folder" actions If --file is provided, the notification will have "Open" and "Open Folder" actions.
that open the file or its directory when clicked.
Examples: Examples:
dms notify "Hello" "World" dms notify "Hello" "World"
dms notify -u critical -t 0 "Disk almost full" dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
dms notify -a "Backup" -i drive-harddisk -c transfer.complete "Backup finished" dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg
dms notify -A retry=Retry -A "Dismiss" "Build failed"
dms notify -h string:x-dms-tag:build -h int:value:42 "Hinted"`,
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
Run: runNotify, Run: runNotify,
} }
@@ -59,236 +39,45 @@ var genericNotifyActionCmd = &cobra.Command{
}, },
} }
var notifyFlagAliases = map[string]string{
"app": "app-name",
"timeout": "expire-time",
}
func init() { func init() {
f := notifyCmd.Flags() notifyCmd.Flags().StringVar(&notifyAppName, "app", "DMS", "Application name")
f.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName { notifyCmd.Flags().StringVar(&notifyIcon, "icon", "", "Icon name or path")
if alias, ok := notifyFlagAliases[name]; ok { notifyCmd.Flags().StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
return pflag.NormalizedName(alias) notifyCmd.Flags().IntVar(&notifyTimeout, "timeout", 5000, "Timeout in milliseconds")
}
return pflag.NormalizedName(name)
})
// Frees -h for --hint like notify-send; cobra skips its own help flag when one exists.
f.Bool("help", false, "Show help")
f.StringVarP(&notifyUrgency, "urgency", "u", "", "Urgency level: low, normal, critical")
f.IntVarP(&notifyTimeout, "expire-time", "t", -1, "Timeout in milliseconds (0 never expires, -1 server default)")
f.StringVarP(&notifyAppName, "app-name", "a", "DMS", "Application name")
f.StringVarP(&notifyIcon, "icon", "i", "", "Icon name or path")
f.StringVarP(&notifyAppIcon, "app-icon", "n", "", "Application icon name or path; --icon then becomes the image")
f.StringVar(&notifyCategory, "category", "", "Notification category, comma separated (-c is taken by the global --config)")
f.BoolVarP(&notifyTransient, "transient", "e", false, "Do not keep the notification in history")
f.StringArrayVarP(&notifyHints, "hint", "h", nil, "Extra hint as TYPE:NAME:VALUE (boolean, int, double, string, byte)")
f.BoolVarP(&notifyPrintID, "print-id", "p", false, "Print the notification id")
f.IntVar(&notifyIDFd, "id-fd", -1, "File descriptor to write the notification id to")
f.Uint32VarP(&notifyReplaceID, "replace-id", "r", 0, "Id of the notification to replace")
f.BoolVarP(&notifyWait, "wait", "w", false, "Wait until the notification closes")
f.StringArrayVarP(&notifyActions, "action", "A", nil, "Action as [NAME=]Text; implies --wait, prints the chosen NAME (repeatable)")
f.IntVar(&notifyActionFd, "selected-action-fd", -1, "File descriptor to write the chosen action to")
f.StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
} }
func runNotify(cmd *cobra.Command, args []string) { func runNotify(cmd *cobra.Command, args []string) {
n, err := buildNotification(args) summary := args[0]
if err != nil { body := ""
fail(err) if len(args) > 1 {
body = args[1]
} }
wait := notifyWait || len(n.Actions) > 0 n := notify.Notification{
var waiter *notify.Waiter AppName: notifyAppName,
if wait { Icon: notifyIcon,
waiter, err = notify.NewWaiter() Summary: summary,
if err != nil { Body: body,
fail(err) FilePath: notifyFile,
} Timeout: int32(notifyTimeout),
defer waiter.Close()
} }
id, err := notify.Send(n) id, err := notify.Send(n)
if err != nil { if err != nil {
fail(err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
} }
reportID(id) if n.FilePath == "" {
if n.FilePath != "" {
watchNotificationAction(id, n.FilePath)
}
if !wait {
return return
} }
if id == 0 { watchNotificationAction(id, n.FilePath)
return
}
res := waiter.Wait(id, waitTimeout())
if res.TimedOut {
fmt.Fprintln(os.Stderr, "Wait timeout expired")
return
}
if !res.Invoked {
return
}
fmt.Println(res.Action)
writeFd(notifyActionFd, res.Action)
}
func buildNotification(args []string) (notify.Notification, error) {
n := notify.Notification{
AppName: notifyAppName,
AppIcon: notifyAppIcon,
Icon: notifyIcon,
Summary: args[0],
FilePath: notifyFile,
Timeout: int32(notifyTimeout),
ReplaceID: notifyReplaceID,
Hints: map[string]dbus.Variant{},
}
if len(args) > 1 {
n.Body = args[1]
}
if notifyFile != "" && len(notifyActions) > 0 {
return n, fmt.Errorf("--file and --action cannot be combined")
}
if notifyUrgency != "" {
level, err := parseUrgency(notifyUrgency)
if err != nil {
return n, err
}
n.Hints["urgency"] = dbus.MakeVariant(level)
}
if notifyCategory != "" {
n.Hints["category"] = dbus.MakeVariant(notifyCategory)
}
if notifyTransient {
n.Hints["transient"] = dbus.MakeVariant(true)
}
for _, spec := range notifyHints {
name, value, err := parseHint(spec)
if err != nil {
return n, err
}
n.Hints[name] = value
}
n.Actions = parseActions(notifyActions)
return n, nil
}
func parseUrgency(s string) (byte, error) {
switch strings.ToLower(s) {
case "low", "0":
return 0, nil
case "normal", "1":
return 1, nil
case "critical", "2":
return 2, nil
}
return 0, fmt.Errorf("invalid urgency %q: use low, normal or critical", s)
}
func parseHint(spec string) (string, dbus.Variant, error) {
parts := strings.SplitN(spec, ":", 3)
if len(parts) != 3 || parts[1] == "" {
return "", dbus.Variant{}, fmt.Errorf("invalid hint %q: expected TYPE:NAME:VALUE", spec)
}
typ, name, value := strings.ToLower(parts[0]), parts[1], parts[2]
switch typ {
case "string":
return name, dbus.MakeVariant(value), nil
case "int":
v, err := strconv.ParseInt(value, 10, 32)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid int %q", name, value)
}
return name, dbus.MakeVariant(int32(v)), nil
case "double":
v, err := strconv.ParseFloat(value, 64)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid double %q", name, value)
}
return name, dbus.MakeVariant(v), nil
case "byte":
v, err := strconv.ParseUint(value, 0, 8)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid byte %q", name, value)
}
return name, dbus.MakeVariant(byte(v)), nil
case "boolean":
v, err := parseBoolean(value)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid boolean %q", name, value)
}
return name, dbus.MakeVariant(v), nil
}
return "", dbus.Variant{}, fmt.Errorf("invalid hint type %q: use boolean, int, double, string or byte", parts[0])
}
func parseBoolean(s string) (bool, error) {
switch strings.ToLower(s) {
case "true":
return true, nil
case "false":
return false, nil
}
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return false, err
}
return v != 0, nil
}
func parseActions(specs []string) []string {
if len(specs) == 0 {
return nil
}
actions := make([]string, 0, len(specs)*2)
for i, spec := range specs {
name, label, hasName := strings.Cut(spec, "=")
if !hasName {
name, label = strconv.Itoa(i), spec
}
actions = append(actions, name, label)
}
return actions
}
func waitTimeout() time.Duration {
if notifyTimeout <= 0 {
return 0
}
return time.Duration(notifyTimeout) * time.Millisecond
}
func reportID(id uint32) {
if notifyPrintID {
fmt.Println(id)
}
writeFd(notifyIDFd, strconv.FormatUint(uint64(id), 10))
}
func writeFd(fd int, line string) {
if fd < 0 {
return
}
f := os.NewFile(uintptr(fd), "")
if f == nil {
return
}
fmt.Fprintln(f, line)
_ = f.Sync()
}
func fail(err error) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
} }
func watchNotificationAction(id uint32, path string) { func watchNotificationAction(id uint32, path string) {
if id == 0 { if id == 0 {
return return
} }
resp, ok := tryServerRequest(ipc.Request{ resp, ok := tryServerRequest(models.Request{
Method: "notify.watchAction", Method: "notify.watchAction",
Params: map[string]any{"id": id, "path": path}, Params: map[string]any{"id": id, "path": path},
}) })
+3 -3
View File
@@ -8,8 +8,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -123,7 +123,7 @@ func runOpen(target string) {
params["url"] = target params["url"] = target
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: method, Method: method,
Params: params, Params: params,
+4 -4
View File
@@ -6,9 +6,9 @@ import (
"os" "os"
"strings" "strings"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
"github.com/duxlabs-dev/dux-shell/core/internal/qrcode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -243,7 +243,7 @@ func fatalf(format string, args ...any) {
} }
func fetchWifiQRContent(ssid string) (string, error) { func fetchWifiQRContent(ssid string) (string, error) {
resp, err := sendServerRequest(ipc.Request{ resp, err := sendServerRequest(models.Request{
ID: 1, ID: 1,
Method: "network.qrcode-content", Method: "network.qrcode-content",
Params: map[string]any{"ssid": ssid}, Params: map[string]any{"ssid": ssid},
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+3 -3
View File
@@ -3,8 +3,8 @@ package main
import ( import (
"fmt" "fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/registries" "github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -12,7 +12,7 @@ import (
var registryCmd = &cobra.Command{ var registryCmd = &cobra.Command{
Use: "registry", Use: "registry",
Short: "Manage plugin and theme registries", Short: "Manage plugin and theme registries",
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active and additional registries can be added by name and git URL, unless the CLI policy sets disable_registries.", Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
} }
var registryListCmd = &cobra.Command{ var registryListCmd = &cobra.Command{
+1 -1
View File
@@ -7,7 +7,7 @@ import (
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "dms", Use: "dms",
Short: "dms CLI", Short: "dms CLI",
Long: "dms is the Dux Shell management CLI and backend server.", Long: "dms is the DankMaterialShell management CLI and backend server.",
} }
func init() { func init() {
+75 -115
View File
@@ -9,12 +9,12 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime/debug" "runtime/debug"
"strconv"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/notify" "github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -35,7 +35,6 @@ var (
ssJSON bool ssJSON bool
ssGeometry bool ssGeometry bool
ssAllowMulti bool ssAllowMulti bool
ssHUD string
) )
type screenshotMetadata struct { type screenshotMetadata struct {
@@ -182,7 +181,6 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON") screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image") screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open") screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
screenshotCmd.PersistentFlags().StringVar(&ssHUD, "hud", "auto", "HUD overlay scale in region selector (auto, off/0, or integer scale 1-4)")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)") ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
@@ -214,7 +212,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.Stdout = ssStdout config.Stdout = ssStdout
config.Geometry = ssGeometry config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti config.AllowMultiple = ssAllowMulti
config.HUD = ssHUD
if ssGeometry { if ssGeometry {
config.Clipboard = false config.Clipboard = false
@@ -246,29 +243,9 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
} }
config.Quality = ssQuality config.Quality = ssQuality
if mode == screenshot.ModeRegion || mode == screenshot.ModeScroll {
config.SnapTargets = fetchShellSnapTargets()
}
return config return config
} }
func fetchShellSnapTargets() []screenshot.SnapTarget {
pid, ok := shellApp.SessionPID()
if !ok {
return nil
}
res, isVoid, err := qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", "getSurfaces", nil)
if err != nil || isVoid || res == "" {
return nil
}
var targets []screenshot.SnapTarget
if err := json.Unmarshal([]byte(res), &targets); err != nil {
return nil
}
return targets
}
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. // setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select.
// Best-effort and not awaited: qs takes ~20ms to come up, longer than the selector needs to show. // Best-effort and not awaited: qs takes ~20ms to come up, longer than the selector needs to show.
func setPopoutScreenshotMode(begin bool) { func setPopoutScreenshotMode(begin bool) {
@@ -276,18 +253,18 @@ func setPopoutScreenshotMode(begin bool) {
if begin { if begin {
fn = "begin" fn = "begin"
} }
if pid, ok := shellApp.SessionPID(); ok {
_, _, _ = qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", fn, nil)
return
}
cmdArgs := []string{"ipc"} cmdArgs := []string{"ipc"}
if err := shellApp.ResolveConfig(nil, nil); err != nil { if pid, ok := shellApp.SessionPID(); ok {
return cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
} else {
if err := shellApp.ResolveConfig(nil, nil); err != nil {
return
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
} }
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
cmdArgs = append(cmdArgs, "call", "screenshot", fn) cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Start() _ = exec.Command("qs", cmdArgs...).Start()
} }
@@ -378,12 +355,6 @@ func runScreenshot(config screenshot.Config) {
} }
} }
if result.CopyOnly {
config.SaveFile = false
config.Clipboard = true
config.Stdout = false
}
if config.Stdout { if config.Stdout {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil { if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
exitScreenshotError(" writing to stdout", err) exitScreenshotError(" writing to stdout", err)
@@ -414,48 +385,31 @@ func runScreenshot(config screenshot.Config) {
} }
if config.Clipboard { if config.Clipboard {
var err error if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
if filePath != "" && config.Format != screenshot.FormatPPM {
err = copyFileToClipboard(filePath)
} else {
err = copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format, result.CICP)
}
if err != nil {
exitScreenshotError(" copying to clipboard", err) exitScreenshotError(" copying to clipboard", err)
} }
if !ssJSON && !config.SaveFile { if !ssJSON && !config.SaveFile {
if ssStdout { fmt.Println("Copied to clipboard")
fmt.Fprintln(os.Stderr, "Copied to clipboard")
} else {
fmt.Println("Copied to clipboard")
}
} }
} }
if ssJSON { if ssJSON {
if result.CopyOnly { scale := result.Scale
writeScreenshotJSON(screenshotMetadata{ if scale <= 0 {
Status: "aborted", scale = 1.0
Error: "User copied to clipboard",
})
} else {
scale := result.Scale
if scale <= 0 {
scale = 1.0
}
writeScreenshotJSON(screenshotMetadata{
Status: "success",
Path: filePath,
Width: result.Buffer.Width,
Height: result.Buffer.Height,
Scale: scale,
Mime: formatMime(config.Format),
})
} }
writeScreenshotJSON(screenshotMetadata{
Status: "success",
Path: filePath,
Width: result.Buffer.Width,
Height: result.Buffer.Height,
Scale: scale,
Mime: formatMime(config.Format),
})
} }
if config.Notify { if config.Notify {
thumbData, thumbW, thumbH := bufferToRGBAThumbnail(result.Buffer, 640, result.Format) thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
id := screenshot.SendNotification(screenshot.NotifyResult{ id := screenshot.SendNotification(screenshot.NotifyResult{
FilePath: filePath, FilePath: filePath,
Clipboard: config.Clipboard, Clipboard: config.Clipboard,
@@ -496,21 +450,28 @@ func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, qua
} }
} }
func bufferToRGBAThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) { func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) {
if buf == nil || buf.Width <= 0 || buf.Height <= 0 || maxSize <= 0 { srcW, srcH := buf.Width, buf.Height
return nil, 0, 0 scale := 1.0
if srcW > maxSize || srcH > maxSize {
if srcW > srcH {
scale = float64(maxSize) / float64(srcW)
} else {
scale = float64(maxSize) / float64(srcH)
}
} }
srcW, srcH := buf.Width, buf.Height dstW := int(float64(srcW) * scale)
longest := max(srcW, srcH) dstH := int(float64(srcH) * scale)
dstW, dstH := srcW, srcH if dstW < 1 {
if longest > maxSize { dstW = 1
dstW = max(1, srcW*maxSize/longest) }
dstH = max(1, srcH*maxSize/longest) if dstH < 1 {
dstH = 1
} }
data := buf.Data() data := buf.Data()
rgba := make([]byte, dstW*dstH*4) rgb := make([]byte, dstW*dstH*3)
is10Bit := screenshot.PixelFormat(pixelFormat).Is10Bit() is10Bit := screenshot.PixelFormat(pixelFormat).Is10Bit()
@@ -523,42 +484,41 @@ func bufferToRGBAThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat u
swapRB = true swapRB = true
} }
for y := range dstH { for y := 0; y < dstH; y++ {
y0, y1 := y*srcH/dstH, (y+1)*srcH/dstH srcY := int(float64(y) / scale)
for x := range dstW { if srcY >= srcH {
x0, x1 := x*srcW/dstW, (x+1)*srcW/dstW srcY = srcH - 1
var r, g, b, samples uint64 }
for srcY := y0; srcY < y1; srcY++ { for x := 0; x < dstW; x++ {
for srcX := x0; srcX < x1; srcX++ { srcX := int(float64(x) / scale)
si := srcY*buf.Stride + srcX*4 if srcX >= srcW {
if si+3 >= len(data) { srcX = srcW - 1
continue
}
c0, c1, c2 := data[si], data[si+1], data[si+2]
if is10Bit {
v := binary.LittleEndian.Uint32(data[si:])
c0, c1, c2 = uint8(v>>2), uint8(v>>12), uint8(v>>22)
}
if swapRB {
c0, c2 = c2, c0
}
r += uint64(c0)
g += uint64(c1)
b += uint64(c2)
samples++
}
} }
if samples == 0 { si := srcY*buf.Stride + srcX*4
di := (y*dstW + x) * 3
if si+3 >= len(data) {
continue continue
} }
di := (y*dstW + x) * 4 switch {
rgba[di+0] = byte((r + samples/2) / samples) case is10Bit:
rgba[di+1] = byte((g + samples/2) / samples) v := binary.LittleEndian.Uint32(data[si:])
rgba[di+2] = byte((b + samples/2) / samples) c0, c1, c2 := uint8(v>>2), uint8(v>>12), uint8(v>>22)
rgba[di+3] = 255 if swapRB {
c0, c2 = c2, c0
}
rgb[di+0], rgb[di+1], rgb[di+2] = c0, c1, c2
case swapRB:
rgb[di+0] = data[si+2]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+0]
default:
rgb[di+0] = data[si+0]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+2]
}
} }
} }
return rgba, dstW, dstH return rgb, dstW, dstH
} }
func runScreenshotRegion(cmd *cobra.Command, args []string) { func runScreenshotRegion(cmd *cobra.Command, args []string) {
-147
View File
@@ -1,147 +0,0 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
)
func thumbnailTestBuffer(t testing.TB, width, height int) *screenshot.ShmBuffer {
t.Helper()
buf, err := screenshot.CreateShmBuffer(width, height, width*4+16)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { buf.Close() })
return buf
}
func TestBufferToRGBAThumbnailPixels(t *testing.T) {
formats := []struct {
name string
format screenshot.PixelFormat
swapRB bool
tenBit bool
}{
{"argb", screenshot.FormatARGB8888, true, false},
{"xrgb", screenshot.FormatXRGB8888, true, false},
{"abgr", screenshot.FormatABGR8888, false, false},
{"xbgr", screenshot.FormatXBGR8888, false, false},
{"argb10", screenshot.FormatARGB2101010, true, true},
{"xrgb10", screenshot.FormatXRGB2101010, true, true},
{"abgr10", screenshot.FormatABGR2101010, false, true},
{"xbgr10", screenshot.FormatXBGR2101010, false, true},
}
for _, tc := range formats {
t.Run(tc.name, func(t *testing.T) {
buf := thumbnailTestBuffer(t, 4, 2)
data := buf.Data()
var originalRGBA []byte
for y := range buf.Height {
for x := range buf.Width {
r, g, b := byte(20), byte(40), byte(60)
if (x+y)%2 != 0 {
r, g, b = 100, 160, 220
}
originalRGBA = append(originalRGBA, r, g, b, 255)
if tc.swapRB {
r, b = b, r
}
i := y*buf.Stride + x*4
if tc.tenBit {
binary.LittleEndian.PutUint32(data[i:], uint32(r)<<2|uint32(g)<<12|uint32(b)<<22)
continue
}
data[i], data[i+1], data[i+2] = r, g, b
}
}
original := bytes.Clone(data)
got, width, height := bufferToRGBAThumbnail(buf, 2, uint32(tc.format))
want := []byte{60, 100, 140, 255, 60, 100, 140, 255}
if width != 2 || height != 1 || !bytes.Equal(got, want) {
t.Fatalf("averaged thumbnail = %dx%d %v, want 2x1 %v", width, height, got, want)
}
got, width, height = bufferToRGBAThumbnail(buf, 640, uint32(tc.format))
if width != 4 || height != 2 || !bytes.Equal(got, originalRGBA) {
t.Fatalf("unscaled thumbnail = %dx%d %v, want 4x2 %v", width, height, got, originalRGBA)
}
if !bytes.Equal(data, original) {
t.Fatal("source pixels changed")
}
})
}
}
func TestBufferToRGBAThumbnailDimensions(t *testing.T) {
for _, tc := range []struct {
width, height int
wantWidth, wantHeight int
}{
{1920, 1080, 640, 360},
{1080, 1920, 360, 640},
{1365, 767, 640, 359},
{1, 10000, 1, 640},
{10000, 1, 640, 1},
{1, 1, 1, 1},
} {
t.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func(t *testing.T) {
buf := thumbnailTestBuffer(t, tc.width, tc.height)
got, width, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
if width != tc.wantWidth || height != tc.wantHeight || len(got) != width*height*4 {
t.Fatalf("thumbnail = %dx%d, %d bytes; want %dx%d RGBA", width, height, len(got), tc.wantWidth, tc.wantHeight)
}
})
}
}
func TestBufferToRGBAThumbnailOddWidthRows(t *testing.T) {
for _, width := range []int{319, 321, 361} {
t.Run(fmt.Sprint(width), func(t *testing.T) {
buf := thumbnailTestBuffer(t, width, 64)
data := buf.Data()
for y := range buf.Height {
for x := range width {
i := y*buf.Stride + x*4
data[i], data[i+1], data[i+2] = byte(y), byte(x>>8), byte(x)
}
}
got, gotWidth, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
if gotWidth != width || height != buf.Height || len(got) != width*height*4 {
t.Fatalf("thumbnail = %dx%d, %d bytes; want %dx%d RGBA", gotWidth, height, len(got), width, buf.Height)
}
for y := range height {
for x := range width {
i := y*width*4 + x*4
want := []byte{byte(x), byte(x >> 8), byte(y), 255}
if !bytes.Equal(got[i:i+4], want) {
t.Fatalf("pixel (%d, %d) = %v, want %v", x, y, got[i:i+4], want)
}
}
}
})
}
}
func TestBufferToRGBAThumbnailEmpty(t *testing.T) {
for _, buf := range []*screenshot.ShmBuffer{nil, {}} {
data, width, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
if data != nil || width != 0 || height != 0 {
t.Fatalf("empty thumbnail = %dx%d %v", width, height, data)
}
}
}
func BenchmarkBufferToRGBAThumbnail(b *testing.B) {
for _, size := range [][2]int{{3840, 2160}, {3840, 3840}, {1920, 10000}} {
b.Run(fmt.Sprintf("%dx%d", size[0], size[1]), func(b *testing.B) {
buf := thumbnailTestBuffer(b, size[0], size[1])
b.ReportAllocs()
for b.Loop() {
bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
}
})
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+16 -76
View File
@@ -8,13 +8,12 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -130,7 +129,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "layout.conf", mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig }, hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) }, mangoContent: func(_ string) string { return config.MangoLayoutConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
@@ -138,19 +137,15 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "colors.conf", mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig }, hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) }, mangoContent: func(_ string) string { return config.MangoColorsConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig }, niriContent: func(_ string) string { return config.NiriAlttabConfig },
}, },
"input": { "input": {
niriFile: "input.kdl", niriFile: "input.kdl",
hyprFile: "input.lua", niriContent: func(_ string) string { return config.NiriInputConfig },
mangoFile: "input.conf",
niriContent: func(_ string) string { return config.NiriInputConfig },
hyprContent: func(_ string) string { return config.DMSInputLuaConfig },
mangoContent: func(_ string) string { return "" },
}, },
"outputs": { "outputs": {
niriFile: "outputs.kdl", niriFile: "outputs.kdl",
@@ -178,43 +173,15 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
}, },
} }
var knownTerminals = []string{"ghostty", "foot", "kitty", "alacritty"} func detectTerminal() (string, error) {
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
func terminalFromEnv() string {
return strings.TrimSpace(os.Getenv("TERMINAL"))
}
func installedTerminals() []string {
var found []string var found []string
for _, t := range knownTerminals { for _, t := range terminals {
if utils.CommandExists(t) { if utils.CommandExists(t) {
found = append(found, t) found = append(found, t)
} }
} }
return found
}
func installedHyprlandVersion() string {
_, ver := getVersionFromCommand("Hyprland", "--version", distros.ParseHyprlandVersion)
return ver
}
func defaultTerminalCommand() string {
if env := terminalFromEnv(); env != "" {
return env
}
if found := installedTerminals(); len(found) > 0 {
return found[0]
}
return "ghostty"
}
func detectTerminal() (string, error) {
if env := terminalFromEnv(); env != "" {
return env, nil
}
found := installedTerminals()
switch len(found) { switch len(found) {
case 0: case 0:
return "ghostty", nil return "ghostty", nil
@@ -309,7 +276,7 @@ func runSetupDmsConfig(name string) error {
case "hyprland": case "hyprland":
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms") dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc": case "mango", "mangowc":
dmsDir = filepath.Join(mangoconf.Dir(), "dms") dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
} }
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -347,9 +314,8 @@ func runSetup() error {
terminal, terminalSelected := promptTerminal() terminal, terminalSelected := promptTerminal()
useSystemd := true useSystemd := true
if wmSelected { if wmSelected {
if wm == deps.WindowManagerMango && !mangoconf.SessionTargetInstalled() { if wm == deps.WindowManagerMango {
useSystemd = false useSystemd = false
fmt.Println("\nThis Mango has no mango-session.target (added in 0.17.1); DMS will start from the Mango config.")
} else if isVoidSetup() { } else if isVoidSetup() {
useSystemd = false useSystemd = false
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.") fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
@@ -363,13 +329,6 @@ func runSetup() error {
return nil return nil
} }
replaceBinds := false
if wmSelected {
if path, ok := config.ExistingBinds(wm); ok {
replaceBinds = promptBinds(path)
}
}
if wmSelected || terminalSelected { if wmSelected || terminalSelected {
willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected) willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected)
if willBackup { if willBackup {
@@ -390,7 +349,6 @@ func runSetup() error {
fmt.Println("\nDeploying configurations...") fmt.Println("\nDeploying configurations...")
logChan := make(chan string, 100) logChan := make(chan string, 100)
deployer := config.NewConfigDeployer(logChan) deployer := config.NewConfigDeployer(logChan)
deployer.SetReplaceBinds(replaceBinds)
go func() { go func() {
for msg := range logChan { for msg := range logChan {
@@ -402,15 +360,8 @@ func runSetup() error {
var err error var err error
if wmSelected { if wmSelected {
terminalCommand := defaultTerminalCommand()
if terminalSelected {
terminalCommand = terminal.Command()
}
if wm == deps.WindowManagerHyprland {
deployer.SetHyprlandVersion(installedHyprlandVersion())
}
var result config.DeploymentResult var result config.DeploymentResult
result, err = deployer.DeployCompositor(wm, terminalCommand, useSystemd) result, err = deployer.DeployCompositor(wm, terminal, useSystemd)
results = append(results, result) results = append(results, result)
} }
if err == nil && terminalSelected { if err == nil && terminalSelected {
@@ -526,17 +477,6 @@ func promptTerminal() (deps.Terminal, bool) {
} }
} }
func promptBinds(path string) bool {
fmt.Printf("\nFound your keybinds in %s.\n", path)
fmt.Println(" 1) Keep my binds as they are")
fmt.Println(" 2) Start from the DMS stock binds")
fmt.Println("Either way a timestamped backup is kept.")
fmt.Print("Choice (1-2) [1]: ")
var response string
fmt.Scanln(&response)
return strings.TrimSpace(response) == "2"
}
func promptSystemd() bool { func promptSystemd() bool {
fmt.Println("\nUse systemd for session management?") fmt.Println("\nUse systemd for session management?")
fmt.Println("1) Yes (recommended for most distros)") fmt.Println("1) Yes (recommended for most distros)")
+7 -17
View File
@@ -3,8 +3,8 @@ package main
import ( import (
"fmt" "fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -19,24 +19,22 @@ var setupHeadlessCmd = &cobra.Command{
noSystemd, _ := cmd.Flags().GetBool("no-systemd") noSystemd, _ := cmd.Flags().GetBool("no-systemd")
force, _ := cmd.Flags().GetBool("force") force, _ := cmd.Flags().GetBool("force")
skipExisting, _ := cmd.Flags().GetBool("skip-existing") skipExisting, _ := cmd.Flags().GetBool("skip-existing")
stockBinds, _ := cmd.Flags().GetBool("stock-binds")
cmd.SilenceUsage = true cmd.SilenceUsage = true
return runSetupHeadless(compositor, terminal, noSystemd, force, skipExisting, stockBinds) return runSetupHeadless(compositor, terminal, noSystemd, force, skipExisting)
}, },
} }
func init() { func init() {
setupHeadlessCmd.Flags().String("compositor", "", "Compositor to configure: niri, hyprland, or mango") setupHeadlessCmd.Flags().String("compositor", "", "Compositor to configure: niri, hyprland, or mango")
setupHeadlessCmd.Flags().String("terminal", "", "Also deploy a terminal config: ghostty, kitty, or alacritty (binds otherwise use $TERMINAL or the first installed terminal)") setupHeadlessCmd.Flags().String("terminal", "", "Also deploy a terminal config: ghostty, kitty, or alacritty")
setupHeadlessCmd.Flags().Bool("no-systemd", false, "Deploy session config without systemd integration") setupHeadlessCmd.Flags().Bool("no-systemd", false, "Deploy session config without systemd integration")
setupHeadlessCmd.Flags().Bool("force", false, "Overwrite existing configs (timestamped backups are created)") setupHeadlessCmd.Flags().Bool("force", false, "Overwrite existing configs (timestamped backups are created)")
setupHeadlessCmd.Flags().Bool("skip-existing", false, "Warn and skip instead of failing when a config already exists") setupHeadlessCmd.Flags().Bool("skip-existing", false, "Warn and skip instead of failing when a config already exists")
setupHeadlessCmd.Flags().Bool("stock-binds", false, "Replace existing keybinds with the DMS stock set (a backup is kept)")
_ = setupHeadlessCmd.MarkFlagRequired("compositor") _ = setupHeadlessCmd.MarkFlagRequired("compositor")
setupHeadlessCmd.MarkFlagsMutuallyExclusive("force", "skip-existing") setupHeadlessCmd.MarkFlagsMutuallyExclusive("force", "skip-existing")
} }
func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExisting, stockBinds bool) error { func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExisting bool) error {
wm, err := parseHeadlessCompositor(compositor) wm, err := parseHeadlessCompositor(compositor)
if err != nil { if err != nil {
return err return err
@@ -81,19 +79,11 @@ func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExistin
close(logDone) close(logDone)
}() }()
deployer := config.NewConfigDeployer(logChan) deployer := config.NewConfigDeployer(logChan)
deployer.SetReplaceBinds(stockBinds)
var results []config.DeploymentResult var results []config.DeploymentResult
var deployErr error var deployErr error
if deployCompositor { if deployCompositor {
terminalCommand := defaultTerminalCommand() result, err := deployer.DeployCompositor(wm, term, headlessUseSystemd(wm, noSystemd))
if termSelected {
terminalCommand = term.Command()
}
if wm == deps.WindowManagerHyprland {
deployer.SetHyprlandVersion(installedHyprlandVersion())
}
result, err := deployer.DeployCompositor(wm, terminalCommand, headlessUseSystemd(wm, noSystemd))
results = append(results, result) results = append(results, result)
deployErr = err deployErr = err
} }
@@ -151,7 +141,7 @@ func parseHeadlessTerminal(name string) (deps.Terminal, bool, error) {
} }
func headlessUseSystemd(wm deps.WindowManager, noSystemd bool) bool { func headlessUseSystemd(wm deps.WindowManager, noSystemd bool) bool {
if noSystemd { if noSystemd || wm == deps.WindowManagerMango {
return false return false
} }
return !isVoidSetup() return !isVoidSetup()
-282
View File
@@ -1,282 +0,0 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/backup"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/spf13/cobra"
)
const switchDMSDep = "dms (Dux Shell)"
// The greeter runs the shell's QML, so it moves with dms or the login screen can break.
var switchFamily = []string{switchDMSDep, "quickshell", "dms-greeter"}
var switchAliases = map[string]string{
"dms": switchDMSDep,
"quickshell": "quickshell",
"dms-greeter": "dms-greeter",
"greeter": "dms-greeter",
"dankcalendar": "dankcalendar",
"danksearch": "danksearch",
}
var switchAssumeYes bool
var switchCmd = &cobra.Command{
Use: "switch <stable|beta> [package...]",
Short: "Switch DMS packages between stable and beta builds",
Long: `Switch installed DMS packages between stable releases and beta builds.
With no packages, dms, quickshell and dms-greeter switch together. Name packages
(dms, quickshell, greeter, dankcalendar, danksearch) to switch only those.
Settings, plugins, themes and session state are backed up before anything changes.
Settings written by beta can lose options on stable, so switching back to stable
offers to restore the backup taken the last time you left stable.`,
Args: cobra.MinimumNArgs(1),
ValidArgs: []string{"stable", "beta"},
Run: func(cmd *cobra.Command, args []string) {
if err := runSwitch(args[0], args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
switchCmd.Flags().BoolVarP(&switchAssumeYes, "yes", "y", false, "Do not ask for confirmation; keeps current settings when switching to stable")
}
func runSwitch(channel string, packages []string) error {
var target deps.PackageVariant
switch channel {
case "stable":
target = deps.VariantStable
case "beta":
target = deps.VariantGit
default:
return fmt.Errorf("unknown channel %q: use stable or beta", channel)
}
explicit := len(packages) > 0
names := switchFamily
if explicit {
names = nil
for _, pkg := range packages {
name, ok := switchAliases[strings.ToLower(pkg)]
if !ok {
return fmt.Errorf("unknown package %q", pkg)
}
names = append(names, name)
}
}
osInfo, err := distros.GetOSInfo()
if err != nil {
return fmt.Errorf("detecting distribution: %w", err)
}
distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil)
if err != nil {
return err
}
// The package names are compositor independent, so any window manager works here.
var wm deps.WindowManager
var swaps []distros.VariantSwap
for _, name := range names {
swap, status := distros.PlanVariantSwap(distro, wm, name, target)
switch status {
case distros.SwapNeeded:
swaps = append(swaps, swap)
case distros.SwapUnsupported:
return fmt.Errorf("switching packages is not supported on %s", osInfo.PrettyName)
case distros.SwapAlreadyOnTarget:
fmt.Printf("%s is already on %s\n", name, channel)
case distros.SwapNoVariant:
if explicit {
fmt.Printf("%s has no separate beta build on %s\n", name, osInfo.PrettyName)
}
case distros.SwapNotInstalled:
if explicit {
fmt.Printf("%s is not installed from a package\n", name)
}
}
}
if len(swaps) == 0 {
fmt.Println("Nothing to switch.")
return nil
}
shellChanges := false
snapshotLabel := ""
fmt.Printf("\nSwitching to %s:\n", channel)
for _, swap := range swaps {
fmt.Printf(" %s → %s\n", switchLabel(swap.From), switchLabel(swap.To))
switch swap.Dep {
case switchDMSDep:
shellChanges = true
snapshotLabel = "stable"
if target == deps.VariantStable {
snapshotLabel = "beta"
}
case "quickshell":
shellChanges = true
}
}
if snapshotLabel != "" {
dir, _ := backup.SnapshotDir()
fmt.Printf("\nYour DMS settings will be backed up to %s first.\n", dir)
}
if shellChanges {
fmt.Println("The shell restarts when the switch is done.")
}
if !switchAssumeYes && !askSwitch("\nContinue? [y/N] ") {
return errors.New("cancelled")
}
if snapshotLabel != "" {
path, err := backup.Snapshot(snapshotLabel)
if err != nil {
return fmt.Errorf("backing up settings, nothing was changed: %w", err)
}
if path != "" {
fmt.Printf("Backed up settings to %s\n", path)
}
}
ctx := context.Background()
if privesc.CheckCached(ctx) != nil {
if err := privesc.Run(ctx, "", "true"); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
}
if err := runSwaps(ctx, distro.(distros.VariantSwapper), swaps); err != nil {
return err
}
restore := ""
if snapshotLabel == "beta" && !switchAssumeYes {
if path, ok := backup.LatestSnapshot("stable"); ok {
fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path)
if askSwitch("") {
restore = path
}
}
}
if shellChanges || restore != "" {
return restartShellAfterSwitch(restore)
}
fmt.Println("Done.")
return nil
}
func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error {
progressChan := make(chan distros.InstallProgressMsg, 100)
swapErr := make(chan error, 1)
go func() {
defer close(progressChan)
swapErr <- swapper.SwapVariants(ctx, swaps, "", progressChan)
}()
// runWithProgress repeats the step on every output line, so only print it when it changes.
lastStep := ""
for msg := range progressChan {
switch {
case msg.Error != nil:
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
case msg.Step != "" && msg.Step != lastStep:
fmt.Println(msg.Step)
lastStep = msg.Step
}
if msg.LogOutput != "" {
fmt.Printf(" %s\n", msg.LogOutput)
}
}
return <-swapErr
}
// restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the
// restored settings and save them back. A plain restart would also reload the old binary's QML.
func restartShellAfterSwitch(restore string) error {
managed := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit) == nil
if managed {
if err := runSystemctl("--user", "stop", dmsSystemdUnit); err != nil {
return fmt.Errorf("stopping the shell: %w", err)
}
} else {
stopShellForSwitch()
}
if restore != "" {
if err := backup.RestoreSnapshot(restore); err != nil {
fmt.Fprintf(os.Stderr, "Restoring settings failed: %v\n", err)
} else {
fmt.Printf("Restored settings from %s\n", restore)
}
}
if managed {
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return fmt.Errorf("starting the shell: %w", err)
}
} else {
dmsPath, err := exec.LookPath("dms")
if err != nil {
return fmt.Errorf("switch done, but dms was not found to start the shell: %w", err)
}
if err := exec.Command(dmsPath, "run", "-d").Run(); err != nil {
return fmt.Errorf("switch done, but starting the shell failed (run 'dms run -d'): %w", err)
}
}
fmt.Println("Done. The shell has restarted.")
return nil
}
// stopShellForSwitch terminates the supervisors and waits for them: Kill() would SIGKILL them,
// orphaning quickshell in its own session, which then keeps saving settings over the restore.
func stopShellForSwitch() {
for _, pid := range shellApp.PIDs() {
if proc, err := os.FindProcess(pid); err == nil {
proc.Signal(syscall.SIGTERM)
}
}
deadline := time.Now().Add(5 * time.Second)
for len(shellApp.PIDs()) > 0 && time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
}
shellApp.Kill()
}
func switchLabel(m distros.PackageMapping) string {
if m.RepoURL == "" {
return m.Name
}
return m.RepoURL + "/" + m.Name
}
func askSwitch(prompt string) bool {
if !stdinIsTTY() {
return false
}
fmt.Print(prompt)
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return false
}
answer := strings.ToLower(strings.TrimSpace(line))
return answer == "y" || answer == "yes"
}
+25 -32
View File
@@ -6,15 +6,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"runtime"
"strings" "strings"
"time" "time"
"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/charmbracelet/lipgloss"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/lowprio"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -36,7 +34,6 @@ Examples:
dms system update --check # list available updates dms system update --check # list available updates
dms system update # apply updates (interactive prompt) dms system update # apply updates (interactive prompt)
dms system update --noconfirm # apply updates without prompting dms system update --noconfirm # apply updates without prompting
dms system update --interactive # let the package manager ask its own questions
dms system update --dry # simulate without changing anything dms system update --dry # simulate without changing anything
dms system update --no-flatpak --noconfirm # apply system updates only dms system update --no-flatpak --noconfirm # apply system updates only
dms system update --interval 3600 # set the server poll interval to 1h`, dms system update --interval 3600 # set the server poll interval to 1h`,
@@ -44,26 +41,24 @@ Examples:
} }
var ( var (
sysUpdateCheck bool sysUpdateCheck bool
sysUpdateNoConfirm bool sysUpdateNoConfirm bool
sysUpdateInteractive bool sysUpdateDry bool
sysUpdateDry bool sysUpdateJSON bool
sysUpdateJSON bool sysUpdateNoFlatpak bool
sysUpdateNoFlatpak bool sysUpdateNoAUR bool
sysUpdateNoAUR bool sysUpdateIgnore []string
sysUpdateIgnore []string sysUpdateIntervalS int
sysUpdateIntervalS int sysUpdateListPmTime = 5 * time.Minute
sysUpdateListPmTime = 5 * time.Minute
) )
func init() { func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying") systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting") systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
systemUpdateCmd.Flags().BoolVar(&sysUpdateInteractive, "interactive", false, "Let the package manager ask its own questions instead of answering yes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes") systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)") systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)") systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)") systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
@@ -71,19 +66,14 @@ func init() {
} }
func runSystemUpdate(cmd *cobra.Command, args []string) { func runSystemUpdate(cmd *cobra.Command, args []string) {
if sysUpdateIntervalS >= 0 { switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS) runSystemUpdateSetInterval(sysUpdateIntervalS)
return case sysUpdateCheck:
}
// Package managers started from this thread inherit its priority.
runtime.LockOSThread()
lowprio.LowerThreadPriority()
if sysUpdateCheck {
runSystemUpdateCheck() runSystemUpdateCheck()
return default:
runSystemUpdateApply()
} }
runSystemUpdateApply()
} }
func selectBackends(ctx context.Context) []sysupdate.Backend { func selectBackends(ctx context.Context) []sysupdate.Backend {
@@ -190,7 +180,7 @@ func runSystemUpdateApply() {
} }
fmt.Println() fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateInteractive && !sysUpdateDry { if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [Y/n]: ") { if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
fmt.Println("Aborted.") fmt.Println("Aborted.")
return return
@@ -209,7 +199,6 @@ func runSystemUpdateApply() {
UseSudo: true, UseSudo: true,
} }
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts) opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
opts.Interactive = sysUpdateInteractive && opts.AttachStdio
onLine := func(line string) { fmt.Println(line) } onLine := func(line string) { fmt.Println(line) }
ran := false ran := false
@@ -251,12 +240,16 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 { if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
return pkgs return pkgs
} }
ignored := make(map[string]bool, len(sysUpdateIgnore))
for _, name := range sysUpdateIgnore {
ignored[name] = true
}
out := pkgs[:0] out := pkgs[:0]
for _, p := range pkgs { for _, p := range pkgs {
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR { if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
continue continue
} }
if sysupdate.IsPackageIgnored(p.Name, sysUpdateIgnore) { if ignored[p.Name] {
continue continue
} }
out = append(out, p) out = append(out, p)
@@ -265,7 +258,7 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
} }
func runSystemUpdateSetInterval(seconds int) { func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(ipc.Request{ resp, err := sendServerRequest(models.Request{
ID: 1, ID: 1,
Method: "sysupdate.setInterval", Method: "sysupdate.setInterval",
Params: map[string]any{"seconds": float64(seconds)}, Params: map[string]any{"seconds": float64(seconds)},
-134
View File
@@ -1,134 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/spf13/cobra"
)
var systemdCompositorFlag string
var systemdCmd = &cobra.Command{
Use: "systemd",
Short: "Start DMS as a systemd user service with the compositor session",
Long: `Manage dms.service for the compositor session.
enable makes the compositor's own session unit want dms.service, so DMS starts
with that compositor only: hyprland-session.target, niri.service or
mango-session.target. The compositor is the running one, or --compositor.`,
}
var systemdEnableCmd = &cobra.Command{
Use: "enable",
Short: "Start dms.service with the compositor session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
wm, err := systemdCompositor()
if err != nil {
return err
}
unit, err := config.EnableSession(wm)
if err != nil {
return err
}
fmt.Printf("dms.service starts with %s\n", unit)
now, _ := cmd.Flags().GetBool("now")
if !now {
return nil
}
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return err
}
fmt.Println("dms.service started")
return nil
},
}
var systemdDisableCmd = &cobra.Command{
Use: "disable",
Short: "Stop starting dms.service with any compositor session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
removed, err := config.DisableSession()
if err != nil {
return err
}
if len(removed) == 0 {
fmt.Println("dms.service was not wanted by any compositor session")
} else {
fmt.Printf("dms.service no longer starts with %s\n", strings.Join(removed, ", "))
}
now, _ := cmd.Flags().GetBool("now")
if !now {
return nil
}
return runSystemctl("--user", "stop", dmsSystemdUnit)
},
}
var systemdStatusCmd = &cobra.Command{
Use: "status",
Short: "Show which compositor sessions start dms.service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
state := getServiceState("dms", true)
if !state.exists {
fmt.Println("dms.service: not installed")
return
}
fmt.Printf("dms.service: %s", state.enabled)
if state.active != "" {
fmt.Printf(", %s", state.active)
}
fmt.Println()
wanted := config.SessionWanted()
if len(wanted) == 0 {
fmt.Println("Starts with: nothing (run 'dms systemd enable')")
return
}
fmt.Printf("Starts with: %s\n", strings.Join(wanted, ", "))
},
}
func systemdLifecycleCmd(verb string) *cobra.Command {
return &cobra.Command{
Use: verb,
Short: strings.ToUpper(verb[:1]) + verb[1:] + " dms.service",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runSystemctl("--user", verb, dmsSystemdUnit)
},
}
}
func systemdCompositor() (deps.WindowManager, error) {
if systemdCompositorFlag == "" {
if wm, ok := config.RunningCompositor(); ok {
return wm, nil
}
name, err := detectCompositorForSetup()
if err != nil {
return 0, err
}
systemdCompositorFlag = name
}
switch strings.ToLower(systemdCompositorFlag) {
case "hyprland":
return deps.WindowManagerHyprland, nil
case "niri":
return deps.WindowManagerNiri, nil
case "mango", "mangowc":
return deps.WindowManagerMango, nil
}
return 0, fmt.Errorf("unknown compositor %q (hyprland, niri or mango)", systemdCompositorFlag)
}
func init() {
systemdEnableCmd.Flags().StringVar(&systemdCompositorFlag, "compositor", "", "hyprland, niri or mango (default: the running one)")
systemdEnableCmd.Flags().Bool("now", false, "Also start dms.service")
systemdDisableCmd.Flags().Bool("now", false, "Also stop dms.service")
systemdCmd.AddCommand(systemdEnableCmd, systemdDisableCmd, systemdStatusCmd, systemdLifecycleCmd("start"), systemdLifecycleCmd("stop"), systemdLifecycleCmd("restart"))
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/duxlabs-dev/dux-go/trash" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+6 -7
View File
@@ -8,11 +8,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules" "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers"
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules/providers"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -217,7 +216,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus result.DMSStatus = parseResult.DMSStatus
case "mango", "mangowc": case "mango", "mangowc":
configDir := mangoconf.Dir() configDir := filepath.Join(utils.XDGConfigHome(), "mango")
parseResult, err := providers.ParseMangoWindowRules(configDir) parseResult, err := providers.ParseMangoWindowRules(configDir)
if err != nil { if err != nil {
@@ -352,7 +351,7 @@ func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
configDir := filepath.Join(utils.XDGConfigHome(), "hypr") configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
return providers.NewHyprlandWritableProvider(configDir) return providers.NewHyprlandWritableProvider(configDir)
case "mango", "mangowc": case "mango", "mangowc":
configDir := mangoconf.Dir() configDir := filepath.Join(utils.XDGConfigHome(), "mango")
return providers.NewMangoWritableProvider(configDir) return providers.NewMangoWritableProvider(configDir)
default: default:
return nil return nil
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"sync" "sync"
"time" "time"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_power"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_power" wlclient "github.com/AvengeMedia/dankgo/wayland/client"
) )
type cmd struct { type cmd struct {
+35 -8
View File
@@ -9,12 +9,15 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
const (
cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json"
cliPolicyAdminPath = "/etc/dms/cli-policy.json"
)
var ( var (
immutablePolicyOnce sync.Once immutablePolicyOnce sync.Once
immutablePolicy immutableCommandPolicy immutablePolicy immutableCommandPolicy
@@ -31,6 +34,13 @@ type immutableCommandPolicy struct {
Message string Message string
} }
type cliPolicyFile struct {
PolicyVersion int `json:"policy_version"`
ImmutableSystem *bool `json:"immutable_system"`
BlockedCommands *[]string `json:"blocked_commands"`
Message *string `json:"message"`
}
func normalizeCommandSpec(raw string) string { func normalizeCommandSpec(raw string) string {
normalized := strings.ToLower(strings.TrimSpace(raw)) normalized := strings.ToLower(strings.TrimSpace(raw))
normalized = strings.TrimPrefix(normalized, "dms ") normalized = strings.TrimPrefix(normalized, "dms ")
@@ -72,8 +82,25 @@ func commandBlockedByPolicy(commandPath string, blocked []string) bool {
return false return false
} }
func loadPolicyFile(path string) (*cliPolicyFile, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read %s: %w", path, err)
}
var policy cliPolicyFile
if err := json.Unmarshal(data, &policy); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
}
return &policy, nil
}
func mergePolicyFile(base *immutableCommandPolicy, path string) error { func mergePolicyFile(base *immutableCommandPolicy, path string) error {
policyFile, err := clipolicy.LoadFile(afero.NewOsFs(), path) policyFile, err := loadPolicyFile(path)
if err != nil { if err != nil {
return err return err
} }
@@ -191,7 +218,7 @@ func getImmutablePolicy() (*immutableCommandPolicy, error) {
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.", Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
} }
var defaultPolicy clipolicy.File var defaultPolicy cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil { if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil {
immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err) immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err)
return return
@@ -206,11 +233,11 @@ func getImmutablePolicy() (*immutableCommandPolicy, error) {
} }
} }
if err := mergePolicyFile(&immutablePolicy, clipolicy.PackagedPath); err != nil { if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil {
immutablePolicyErr = err immutablePolicyErr = err
return return
} }
if err := mergePolicyFile(&immutablePolicy, clipolicy.AdminPath); err != nil { if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil {
immutablePolicyErr = err immutablePolicyErr = err
return return
} }
@@ -241,7 +268,7 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
reason = "Detected immutable system: " + policy.ImmutableReason + "\n" reason = "Detected immutable system: " + policy.ImmutableReason + "\n"
} }
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, clipolicy.PackagedPath, clipolicy.AdminPath) return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
} }
// preRunPrivileged combines the immutable-system check with a privesc tool // preRunPrivileged combines the immutable-system check with a privesc tool
+1 -3
View File
@@ -3,12 +3,10 @@ package main
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
) )
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) { func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
var policyFile clipolicy.File var policyFile cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil { if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
t.Fatalf("failed to parse embedded CLI policy: %v", err) t.Fatalf("failed to parse embedded CLI policy: %v", err)
} }
+2 -10
View File
@@ -5,9 +5,8 @@ package main
import ( import (
"os" "os"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
) )
var Version = "dev" var Version = "dev"
@@ -34,13 +33,6 @@ func main() {
log.Fatal("This program should not be run as root. Exiting.") log.Fatal("This program should not be run as root. Exiting.")
} }
if os.Geteuid() != 0 {
for _, err := range utils.MigrateLegacyDirs() {
log.Warnf("dms dir migration: %v", err)
}
}
os.Args = normalizeDmenuArgv(os.Args)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
+2 -10
View File
@@ -5,9 +5,8 @@ package main
import ( import (
"os" "os"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
) )
var Version = "dev" var Version = "dev"
@@ -30,13 +29,6 @@ func main() {
log.Fatal("This program should not be run as root. Exiting.") log.Fatal("This program should not be run as root. Exiting.")
} }
if os.Geteuid() != 0 {
for _, err := range utils.MigrateLegacyDirs() {
log.Warnf("dms dir migration: %v", err)
}
}
os.Args = normalizeDmenuArgv(os.Args)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
+2 -2
View File
@@ -3,8 +3,8 @@ package main
import ( import (
"fmt" "fmt"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management" wlclient "github.com/AvengeMedia/dankgo/wayland/client"
) )
type randrOutput struct { type randrOutput struct {

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