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
1994 changed files with 271578 additions and 303142 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs,
| 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
@@ -28,7 +28,7 @@ ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
**Personal** (all your projects):
```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.
+30 -110
View File
@@ -1,71 +1,65 @@
---
name: dms-plugin-dev
description: >
Develop plugins for Dux Shell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports widget (bar, dock + Control Center), daemon (background service),
launcher (search + actions), desktop (draggable desktop widgets), dash (dash tab),
dashCard (dash overview card), and composite (multi-surface) plugins. Covers manifest
creation, QML component development, startup checks,
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports five plugin types: widget (bar + Control Center), daemon (background
service), launcher (search + actions), desktop (draggable desktop widgets), and composite
(multi-surface). Covers manifest creation, QML component development, startup checks,
settings UI, data persistence, theme integration, PopoutService usage, IPC runtime
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.
compatibility: Designed for Claude Code (or similar products)
metadata:
author: Dux Shell
version: "1.2"
author: DankMaterialShell
version: "1.1"
domain: qml-desktop-development
framework: Dux Shell
framework: DankMaterialShell
languages: qml, javascript
allowed-tools: Bash Read Write Edit
---
# Dux Shell Plugin Development
# DankMaterialShell Plugin Development
## Overview
DMS plugins extend the desktop shell with bar and dock widgets, background services, launcher
integrations, desktop widgets, and dash tabs and cards. Plugins are QML components discovered from
`~/.config/dms/plugins/`.
DMS plugins extend the desktop shell with custom widgets, background services, launcher
integrations, and desktop widgets. Plugins are QML components discovered from
`~/.config/DankMaterialShell/plugins/`.
**Minimum plugin structure:**
```
~/.config/dms/plugins/YourPlugin/
~/.config/DankMaterialShell/plugins/YourPlugin/
plugin.json # Required: manifest with metadata
YourComponent.qml # Required: main QML component
YourSettings.qml # Optional: settings UI
*.js # Optional: JavaScript utilities
```
**Plugin registry:** Community plugins are available at https://plugins.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 |
|-------------|--------------------------------|----------------------------|----------------|----------------|
| `widget` | Bar or dock widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
| `dash` | Tab in the dash popout | `DashTabComponent` | No | No |
| `dashCard` | Card in the dash overview grid | `DashCardComponent` | No | No |
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
| Type | Purpose | Base Component | Bar pills | CC integration |
|-------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
## Step 1: Determine Plugin Type
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
component), optionally opens a popout or an attached dock panel, optionally integrates with
Control Center.
- **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
optionally integrates with Control Center.
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
changes, notifications, battery level, etc.).
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
desktop layer.
- **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
of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own
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:**
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
- `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.
### 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
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
```
`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)
@@ -480,7 +414,7 @@ Always use `Theme.*` properties from `qs.Common` - never hardcode colors or size
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
- 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.
@@ -497,6 +431,7 @@ PluginComponent {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
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`
@@ -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)
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
3. Open Settings > Plugins > Scan for Plugins
4. Enable your plugin and add it to the DankBar layout, 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:**
@@ -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`
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
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
@@ -680,16 +611,6 @@ import QtQuick
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 processes:** `import Quickshell.Io`
**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:
- [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
- [dash-plugin-guide.md](references/dash-plugin-guide.md) - DashTabComponent, DashCardComponent, DashWidgetGrid, dash options, card sizing
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
@@ -1,8 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://duxlabs.dev/schemas/plugin.json",
"title": "Dux Shell Plugin Manifest",
"description": "Schema for Dux Shell plugin.json manifest files",
"$id": "https://danklinux.com/schemas/plugin.json",
"title": "DankMaterialShell Plugin Manifest",
"description": "Schema for DankMaterialShell plugin.json manifest files",
"type": "object",
"required": [
"id",
@@ -42,7 +42,7 @@
"type": {
"type": "string",
"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": {
"type": "array",
@@ -80,83 +80,11 @@
"type": "string",
"description": "Launcher provider component (requires 'trigger')",
"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,
"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": {
"type": "string",
"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 {
// PopoutComponent {
// headerText: "My Widget"
// showCloseButton: true
//
// Column {
// width: parent.width
@@ -43,7 +43,7 @@ PluginSettings {
pluginId: "exampleVariants"
// Variant creation UI
DButton {
DankButton {
text: "Add New Instance"
onClicked: {
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)
```
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
@@ -9,7 +9,7 @@
| `description` | string | Short description (shown in UI) | Non-empty |
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
| `author` | string | Creator name or email | Non-empty |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `dash`, `dashCard`, `composite` |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
| `capabilities` | array | Plugin capabilities | At least 1 string item |
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 |
|-------|------|-------------|------------|
| `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
@@ -38,7 +38,6 @@ One of `component` or `components` is required (not both):
| `requires` | array | Deprecated alias for `dependencies` |
| `permissions` | array | Required permissions |
| `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
@@ -60,8 +59,6 @@ Capabilities are free-form strings that describe what the plugin does. Common va
- `monitoring` - system/service monitoring
- `launcher` - launcher search provider
- `desktop-widget` - desktop background widget
- `dash-tab` - dash tab
- `dash-card` - dash overview card
- `ai` - AI/LLM integration
- `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.
## 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"`.
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
## Complete Example
@@ -83,12 +83,11 @@ Theme.popupBackground() // Popup background with proper opacity
```qml
import qs.Widgets
import qs.DCommon.Widgets
Row {
spacing: Theme.spacingS
DIcon {
DankIcon {
name: "icon_name"
color: Theme.onSurface
font.pixelSize: Theme.iconSize
@@ -152,7 +151,7 @@ StyledRect {
anchors.centerIn: parent
spacing: Theme.spacingS
DIcon {
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
@@ -192,17 +191,17 @@ font.pixelSize: 14 // Use Theme.fontSizeMedium
|--------|-------------|
| `StyledText` | Themed text with proper color defaults |
| `StyledRect` | Themed rectangle |
| `DIcon` | Material Symbols icon renderer |
| `DankIcon` | Material Symbols icon renderer |
| `DankNFIcon` | Nerd Font icon renderer |
| `DButton` | Themed button |
| `DToggle` | Toggle switch |
| `DTextField` | Text input field |
| `DSlider` | Slider control |
| `DDropdown` | Dropdown menu |
| `DGridView` | Grid layout view |
| `DListView` | List layout view |
| `DFlickable` | Scrollable container |
| `DTabBar` | Tab bar navigation |
| `DankButton` | Themed button |
| `DankToggle` | Toggle switch |
| `DankTextField` | Text input field |
| `DankSlider` | Slider control |
| `DankDropdown` | Dropdown menu |
| `DankGridView` | Grid layout view |
| `DankListView` | List layout view |
| `DankFlickable` | Scrollable container |
| `DankTabBar` | Tab bar navigation |
| `DankCollapsibleSection` | Collapsible content section |
| `DankTooltip` | Hover tooltip |
| `DankNumberStepper` | Number +/- control |
@@ -1,6 +1,6 @@
# 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
@@ -37,8 +37,6 @@ These are automatically set by the plugin host:
| `pluginId` | string | This plugin's ID |
| `pluginService` | object | PluginService reference |
| `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
@@ -59,7 +57,7 @@ horizontalBarPill: Component {
anchors.centerIn: parent
spacing: Theme.spacingS
DIcon {
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSize
@@ -92,7 +90,7 @@ verticalBarPill: Component {
anchors.centerIn: parent
spacing: Theme.spacingS
DIcon {
DankIcon {
name: "star"
color: Theme.surfaceText
font.pixelSize: Theme.iconSizeSmall
@@ -117,6 +115,7 @@ PluginComponent {
PopoutComponent {
headerText: "My Plugin"
detailsText: "Optional subtitle"
showCloseButton: true
Column {
width: parent.width
@@ -139,6 +138,7 @@ PluginComponent {
|----------|------|---------|-------------|
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
| `closePopout` | function | (injected) | Call to close the popout programmatically. |
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
@@ -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.
## 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
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
```qml
PluginComponent {
layerNamespacePlugin: "my-plugin"
layerNamespacePlugin: true
}
```
The shell prefixes it, so the popout's namespace becomes `dms:plugins:my-plugin`.
## Reading Plugin Data
Access saved settings reactively via the injected `pluginData`:
@@ -311,7 +278,6 @@ import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.DCommon.Widgets
import qs.Services
import qs.Modules.Plugins
@@ -367,8 +333,9 @@ PluginComponent {
popoutContent: Component {
PopoutComponent {
headerText: "Emoji Picker"
showCloseButton: true
DGridView {
DankGridView {
width: parent.width
height: 300
cellWidth: 50
+1 -1
View File
@@ -3,7 +3,7 @@
github: [avengemedia]
patreon: # Replace with a single Patreon 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
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
## Dux Shell Bug Report
## DankMaterialShell Bug Report
Limit your report to one issue per submission unless similarly related
- type: dropdown
id: compositor
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
## Dux Shell Feature Request
## DankMaterialShell Feature Request
- type: textarea
id: feature_description
attributes:
+2 -2
View File
@@ -1,12 +1,12 @@
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:
- support
body:
- type: markdown
attributes:
value: |
## Dux Shell Support Request
## DankMaterialShell Support Request
- type: dropdown
id: compositor
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
- [ ] 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
- [ ] 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
submodules: recursive
- name: Resolve base branch
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
- uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
@@ -82,13 +75,12 @@ jobs:
---
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.
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.
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.
Steps:
1. Read CONTRIBUTING.md.
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.
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:
@@ -96,43 +88,26 @@ jobs:
## Claude review
<one line verdict>
- <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.
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.
- 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).
- 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.
- 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.
- 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: |
--model claude-opus-5-5
--max-turns 120
--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:*)"
--model claude-opus-5
--max-turns 80
--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"
- 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()
env:
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:
workflow_dispatch:
push:
paths:
- "dux-qml-common"
- "dank-qml-common"
branches:
- master
- "stable-*"
permissions:
contents: write
@@ -18,7 +17,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -35,10 +34,10 @@ jobs:
- name: Point flake input at the submodule commit
run: |
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'])")
[ "$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
env:
@@ -49,9 +48,9 @@ jobs:
echo "No changes to flake.lock"
exit 0
fi
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "nix: sync flake.lock to dux-qml-common submodule"
git commit -m "nix: sync flake.lock to dank-qml-common submodule"
git pull --rebase origin ${{ github.ref_name }}
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
cache-dependency-path: ./core/go.sum
- name: Test
run: go test -race -v ./...
run: go test -v ./...
- name: Build 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
echo "::error::tag ${TAG} already exists"; exit 1
fi
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
echo "${TAG}" > quickshell/VERSION
git add quickshell/VERSION
+1 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
inputs:
target:
description: "Release branch to audit (default: newest stable-*)"
description: "Release branch to audit (default: newest release/*)"
required: false
type: string
+4 -4
View File
@@ -48,8 +48,8 @@ jobs:
COMMITS: ${{ toJSON(github.event.commits) }}
run: |
set -euo pipefail
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
git cat-file -e "$sha" 2>/dev/null || continue
@@ -86,8 +86,8 @@ jobs:
PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
sed -E 's,^stable-,,' | sort -u)
-2
View File
@@ -29,5 +29,3 @@ jobs:
- name: run pre-commit hooks
uses: j178/prek-action@v2
env:
SKIP: qml-tests
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
prune:
name: Prune archive snapshots
runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell'
if: github.repository == 'AvengeMedia/DankMaterialShell'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
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 }}
# run: |
# set -euo pipefail
# git config user.name "dux-ci[bot]"
# git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
# git config user.name "dms-ci[bot]"
# git config user.email "dms-ci[bot]@users.noreply.github.com"
# version="${GITHUB_REF#refs/tags/}"
# echo "Updating to version: $version"
@@ -280,7 +280,7 @@ jobs:
## Installation
```bash
curl -fsSL https://install.duxlabs.dev | sh
curl -fsSL https://install.danklinux.com | sh
```
## Assets
@@ -304,7 +304,7 @@ jobs:
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
- **`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
- **`*.sha256`** - SHA256 checksums for verifying download integrity
@@ -352,15 +352,15 @@ jobs:
# Copy completions
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
# Replace the DCommon symlink with real submodule content for packaging
rm quickshell/DCommon
cp -r dux-qml-common/DCommon quickshell/DCommon
# Replace the DankCommon symlink with real submodule content for packaging
rm quickshell/DankCommon
cp -r dank-qml-common/DankCommon quickshell/DankCommon
# Create full source tarball (GitHub tag archives never contain submodule content)
VERSION_NUM=${TAG#v}
tar --exclude='.git' \
--exclude='.github' \
--exclude='dux-qml-common' \
--exclude='dank-qml-common' \
--exclude='_release_assets' \
--exclude='_core_assets' \
--exclude='RELEASE_BODY.md' \
@@ -378,9 +378,7 @@ jobs:
# Tar the CONTENTS of quickshell/, not the directory itself
(cd quickshell && tar --exclude='.git' \
--exclude='.github' \
--exclude='./tests' \
--exclude='*.tar.gz' \
--exclude-from=../scripts/shell-test-excludes.txt \
-czf ../_release_assets/dms-qml.tar.gz .)
# Generate checksum for QML package
@@ -420,7 +418,7 @@ jobs:
# Create installation guide
cat > _temp_full/INSTALL.md << 'EOFINSTALL'
# Dux Shell Installation
# DankMaterialShell Installation
## Requirements
@@ -458,8 +456,8 @@ jobs:
## Configuration
- Settings are stored in `~/.config/dms/settings.json`
- Plugins go in `~/.config/dms/plugins/`
- Settings are stored in `~/.config/DankMaterialShell/settings.json`
- Plugins go in `~/.config/DankMaterialShell/plugins/`
- See the documentation in the `dms/` directory for more details
## Troubleshooting
@@ -520,8 +518,8 @@ jobs:
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
# 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
# stable-X.Y, X.Y.Z tags stable-X.Y and skips when it has nothing new.
# decides for itself whether to tag: X.Y.0 always tags, X.Y.Z skips when the
# repo has no commits of its own since its last tag.
fanout:
name: Pin ${{ matrix.repo }}
runs-on: ubuntu-latest
@@ -531,13 +529,12 @@ jobs:
fail-fast: false
matrix:
repo:
- dux-gop
- dux-go
- dux-qml-common
- dux-calendar
- dux-search
- dux-greeter
- dux-files
- dgop
- dankgo
- dank-qml-common
- dankcalendar
- danksearch
- dank-greeter
steps:
- name: Create GitHub App token
id: app_token
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
echo "📦 Downloading DMS QML source for v${VERSION}..."
# 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}"
exit 1
}
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
check-updates:
name: Check for updates
runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell'
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
@@ -61,7 +61,7 @@ jobs:
# Helper function to check dms stable tag
# Sets LATEST_TAG variable in parent scope if update needed
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_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
@@ -192,7 +192,7 @@ jobs:
# Determine version for dms stable using the API
# 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
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
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Using latest release from API: $LATEST_TAG"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
check-updates:
name: Check package/series updates
runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell'
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
name: Build & Deploy XBPS packages
runs-on: ubuntu-latest
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:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+17 -4
View File
@@ -8,7 +8,6 @@ on:
- "core/go.sum"
branches:
- master
- "stable-*"
permissions:
contents: write
@@ -34,7 +33,21 @@ jobs:
uses: cachix/install-nix-action@v31
- 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
env:
@@ -42,8 +55,8 @@ jobs:
run: |
set -euo pipefail
if ! git diff --quiet flake.nix; then
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.nix
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
git pull --rebase origin ${{ github.ref_name }}
+1 -2
View File
@@ -64,7 +64,7 @@ AGENTS-temp.md
niri-colors.generated.kdl
ghostty-colors.generated.conf
# Notepad files (should be in ~/.local/state/dms/)
# Notepad files (should be in ~/.local/state/DankMaterialShell/)
untitled-*.txt
file:*
notepad-files/
@@ -130,6 +130,5 @@ distro/void/masterdir*/
# Often gets built
core/dms
core/dankinstall
core/internal/shellembed/dist
+2 -2
View File
@@ -1,4 +1,4 @@
[submodule "dank-qml-common"]
path = dux-qml-common
url = https://github.com/duxlabs-dev/dux-qml-common.git
path = dank-qml-common
url = https://github.com/AvengeMedia/dank-qml-common.git
branch = master
+1 -17
View File
@@ -5,16 +5,6 @@ repos:
- id: trailing-whitespace
- id: check-yaml
- 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
hooks:
- id: shellcheck
@@ -30,12 +20,6 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
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
hooks:
- 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' --
language: system
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?
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.
- 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
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.
## Rules
There is a GO module (dankgo) that is used by core for certain common go functionality.
- Keep it simple.
- 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.
### 1- core:
## Tests
Contains 2 apps:
- A test must fail on the broken code. Otherwise do not write it.
- No useless tests: "main.go contains func main()", defaults, "it instantiates", behavior the change didn't touch.
- 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.
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.
2. cmd/dankinstall - An entirely separate app, a TUI for quickly installing DMS
## 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.
- 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.
- 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.
- Singletons under Services/ and Common/ keep their `Connections`.
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.
Layout:
- Common/ - app-wide singletons and helpers (Theme, SettingsData, SessionData, I18n, PopoutManager)
- 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
`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.
# 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)
- 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
+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
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
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:
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 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`.
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.
1. Edit files under `dank-qml-common/` (or through the `quickshell/DankCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dank-qml-common`.
2. Commit and PR those changes in the `dank-qml-common` repo: `cd dank-qml-common && git switch -c my-change`, push, open the PR there.
3. Once merged, bump the pointer here: `make update-common` (updates the submodule and the nix flake input together), then commit alongside any DMS-side changes. If you only bump the submodule, CI syncs `flake.lock` to it automatically on master.
The submodule URL in `.gitmodules` is HTTPS so CI and anonymous clones keep working. To push over SSH instead of being prompted for credentials, add a push rewrite to your git config — fetches stay HTTPS, pushes use SSH:
```bash
git config --global url."[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
@@ -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`.
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.
7. Make your changes, test, and open a pull request.
6. Make your changes, test, and open a pull request.
### 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.)
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)
@@ -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.
## 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
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
# 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
SHELL_DIR=quickshell
QMLTESTRUNNER ?=
SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications
@@ -42,14 +41,10 @@ clean:
lint-qml:
@./quickshell/scripts/qmllint-entrypoints.sh
.PHONY: test-qml
test-qml:
QMLTESTRUNNER="$(QMLTESTRUNNER)" python3 quickshell/tests/run-qml.py
# Pull the latest dux-qml-common and pin it everywhere it is consumed
# Pull the latest dank-qml-common and pin it everywhere it is consumed
# (submodule pointer + nix flake input). Commit both in one change.
update-common:
git submodule update --remote --merge dux-qml-common
git submodule update --remote --merge dank-qml-common
nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common
# Installation targets
@@ -83,17 +78,15 @@ endif
install-icon:
@echo "Installing icon..."
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.svg $(ICON_DIR)/dev.duxlabs.shell.svg
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
@install -D -m 644 $(ASSETS_DIR)/danklogo.svg $(ICON_DIR)/danklogo.svg
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
@echo "Icon installed"
install-desktop:
@echo "Installing desktop entries..."
@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)/dev.duxlabs.shell.notepad.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed"
@@ -129,8 +122,6 @@ uninstall-systemd:
uninstall-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
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
@echo "Icon removed"
@@ -138,9 +129,8 @@ uninstall-icon:
uninstall-desktop:
@echo "Removing desktop entries..."
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries removed"
@@ -157,7 +147,6 @@ help:
@echo " build - Same as 'all'"
@echo " clean - Clean build artifacts"
@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 "Install:"
@echo " install - Build and install everything (requires sudo)"
+34 -37
View File
@@ -1,32 +1,32 @@
# Dux Shell
# DankMaterialShell
<div align="center">
<a href="https://duxlabs.dev">
<img src="assets/danklogo.svg" alt="Dux Shell" width="200">
<a href="https://danklinux.com">
<img src="assets/danklogo.svg" alt="DankMaterialShell" width="200">
</a>
### A modern desktop shell for Wayland
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)
[![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 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 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)
[![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/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![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/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/)
[![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>
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
This is a monorepo containing both the shell interface and the core backend services:
```
dux-shell/
DankMaterialShell/
├── quickshell/ # QML-based shell interface
│ ├── Modules/ # UI components (panels, widgets, overlays)
│ ├── Services/ # System integration (audio, network, bluetooth)
@@ -70,14 +70,12 @@ https://github.com/user-attachments/assets/d310418b-cc44-4ace-ac0d-2fc0c851291d
## Installation
```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.
**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://duxlabs.dev/docs/dms/installation)**
**[Manual installation guide](https://danklinux.com/docs/dankmaterialshell/installation)**
## 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.
**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**
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**
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.
**Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dux-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**
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps
`~/.config/dms/plugins.lock.json` synchronized with managed plugin installs and
Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
`~/.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.
## 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.
[Compositor configuration guide](https://duxlabs.dev/docs/dms/compositors)
[Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
## Command Line Interface
@@ -128,26 +126,26 @@ dms plugins lock # Refresh the portable plugin lockfile
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
- **Website:** [duxlabs.dev](https://duxlabs.dev)
- **Docs:** [duxlabs.dev/docs](https://duxlabs.dev/docs/)
- **Theming:** [Application themes](https://duxlabs.dev/docs/dms/application-themes) | [Custom themes](https://duxlabs.dev/docs/dms/custom-themes)
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs)
- **Website:** [danklinux.com](https://danklinux.com)
- **Docs:** [danklinux.com/docs](https://danklinux.com/docs/)
- **Theming:** [Application themes](https://danklinux.com/docs/dankmaterialshell/application-themes) | [Custom themes](https://danklinux.com/docs/dankmaterialshell/custom-themes)
- **Plugins:** [Development guide](https://danklinux.com/docs/dankmaterialshell/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/avengemediallc)
## Dux Projects
## Dank Projects
DMS is one piece of the suite. The rest lives in its own repos:
- [dux-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.
- [dux-calendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux 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.
- [dsearch](https://github.com/duxlabs-dev/dux-search) - 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, dux-greeter, and dux-calendar.
- [dux-go](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps.
- [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/AvengeMedia/dankcalendar) - Local, Google, Microsoft, and CalDAV calendars for the dank desktop.
- [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/AvengeMedia/danksearch) - Fast file search that powers file results in the launcher.
- [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/AvengeMedia/dankgo) - Common Go modules behind the single binary apps.
## Development
@@ -177,7 +175,7 @@ quickshell -p quickshell/
```nix
{
inputs.dms.url = "github:duxlabs-dev/dux-shell";
inputs.dms.url = "github:AvengeMedia/DankMaterialShell";
# Use in home-manager or NixOS configuration
imports = [ inputs.dms.homeModules.dank-material-shell ];
@@ -193,7 +191,7 @@ Contributions welcome. Bug fixes, widgets, features, documentation, and plugins
3. Test thoroughly
4. Open a pull request
For documentation contributions, see [dux-docs](https://github.com/duxlabs-dev/dux-docs).
For documentation contributions, see [DankLinux-Docs](https://github.com/AvengeMedia/DankLinux-Docs).
## Credits
@@ -202,11 +200,10 @@ For documentation contributions, see [dux-docs](https://github.com/duxlabs-dev/d
- [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
- [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 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
@@ -1,10 +1,10 @@
[Desktop Entry]
Type=Application
Name=Dux Shell
Name=DankMaterialShell
GenericName=Desktop Shell
Comment=Material 3 inspired shell for Wayland compositors
Exec=dms run
Icon=dev.duxlabs.shell
Icon=danklogo
Terminal=false
NoDisplay=true
StartupNotify=false
@@ -2,9 +2,9 @@
Type=Application
Name=DMS Notepad
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
Icon=dev.duxlabs.shell
Icon=danklogo
Terminal=false
NoDisplay=true
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
Comment=Select an application to open links and files
Exec=dms open %u
Icon=dev.duxlabs.shell
Icon=danklogo
Terminal=false
NoDisplay=true
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]
Description=Dux Shell (DMS)
Description=Dank Material Shell (DMS)
PartOf=graphical-session.target
After=graphical-session.target
Requisite=graphical-session.target
[Service]
Type=notify
NotifyAccess=main
Environment=QS_DISABLE_CRASH_HANDLER=1
Type=dbus
BusName=org.freedesktop.Notifications
ExecStart=/usr/bin/dms run --session
ExecReload=/bin/kill -USR1 $MAINPID
LimitNOFILE=16384:infinity
+10 -10
View File
@@ -6,13 +6,13 @@ template: testify
template-data:
unroll-variadic: true
packages:
github.com/duxlabs-dev/dux-shell/core/internal/geolocation:
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config:
dir: internal/mocks/geolocation
pkgname: mocks_geolocation
interfaces:
Client: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/cups:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
config:
dir: internal/mocks/cups
pkgname: mocks_cups
@@ -22,46 +22,46 @@ packages:
config:
dir: internal/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:
dir: internal/mocks/evdev
pkgname: mocks_evdev
interfaces:
EvdevDevice: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/network:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config:
dir: internal/mocks/network
pkgname: mocks_network
interfaces:
Backend: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/wlcontext:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
config:
dir: internal/mocks/wlcontext
pkgname: mocks_wlcontext
interfaces:
WaylandContext: {}
github.com/duxlabs-dev/dux-shell/core/internal/utils:
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config:
dir: internal/mocks/utils
pkgname: mocks_utils
interfaces:
AppChecker: {}
github.com/duxlabs-dev/dux-shell/core/internal/version:
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
config:
dir: internal/mocks/version
pkgname: mocks_version
interfaces:
VersionFetcher: {}
github.com/duxlabs-dev/dux-go/wayland/client:
github.com/AvengeMedia/dankgo/wayland/client:
config:
dir: internal/mocks/wlclient
pkgname: mocks_wlclient
interfaces:
WaylandDisplay: {}
github.com/duxlabs-dev/dux-shell/core/internal/plugins:
github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
interfaces:
GitClient: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/brightness:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
config:
dir: internal/mocks/brightness
pkgname: mocks_brightness
+4 -17
View File
@@ -1,26 +1,13 @@
repos:
- repo: local
- repo: https://github.com/golangci/golangci-lint
rev: v2.13.1
hooks:
- 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
pass_filenames: false
- 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
name: golangci-lint-config-verify
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] config verify
language: system
files: '\.golangci\.(?:yml|yaml|toml|json)'
pass_filenames: false
- repo: local
hooks:
- id: go-test
name: go test
entry: go test ./...
+10 -3
View File
@@ -37,13 +37,20 @@ endif
# Default target
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:
@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)
@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)/tests
@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)/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
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.**
@@ -78,7 +78,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
- `dms brightness [list|set]` - Control display/monitor brightness
- `dms color pick` - Native color picker (see below)
- `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
@@ -132,10 +132,10 @@ This runs gofmt, golangci-lint, tests, and builds before each commit when `core/
**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
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 \
-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):**
```bash
curl -fsSL https://install.duxlabs.dev | sh
curl -fsSL https://install.danklinux.com | sh
```
**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:
```bash
sudo -v && curl -fsSL https://install.duxlabs.dev | 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.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 -y
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --dms-greeter -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 |
@@ -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.
**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.
+5 -5
View File
@@ -5,10 +5,10 @@ import (
"os"
"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"
"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"
)
@@ -33,8 +33,8 @@ var (
var rootCmd = &cobra.Command{
Use: "dankinstall",
Short: "Install Dux Shell and its dependencies",
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal.
Short: "Install DankMaterialShell and its dependencies",
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags.
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"path/filepath"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
sharedpam "github.com/duxlabs-dev/dux-shell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra"
)
+2 -2
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"os"
"github.com/duxlabs-dev/dux-shell/core/internal/backup"
"github.com/AvengeMedia/DankMaterialShell/core/internal/backup"
"github.com/spf13/cobra"
)
@@ -17,7 +17,7 @@ var backupCmd = &cobra.Command{
var backupCreateCmd = &cobra.Command{
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,
Run: runBackupCreate,
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"os"
"github.com/duxlabs-dev/dux-shell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/spf13/cobra"
)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/spf13/cobra"
)
+3 -3
View File
@@ -8,11 +8,11 @@ import (
"strings"
"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/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/yuin/goldmark"
"github.com/yuin/goldmark/extension"
+20 -26
View File
@@ -28,10 +28,10 @@ import (
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-go/wlclipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/dankgo/wlclipboard"
"github.com/spf13/cobra"
)
@@ -430,7 +430,7 @@ func runCommand(args []string, stdin []byte) {
}
func runClipHistory(cmd *cobra.Command, args []string) {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.getHistory",
}
@@ -501,7 +501,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
}
if clipGetCopy {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.copyEntry",
Params: map[string]any{"id": id},
@@ -519,7 +519,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
return
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.getEntry",
Params: map[string]any{
@@ -565,7 +565,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
log.Fatalf("Invalid ID: %v", err)
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.deleteEntry",
Params: map[string]any{
@@ -586,7 +586,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
}
func runClipClear(cmd *cobra.Command, args []string) {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.clearHistory",
}
@@ -621,7 +621,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
params["isImage"] = false
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.search",
Params: params,
@@ -688,7 +688,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
}
func runClipConfigGet(cmd *cobra.Command, args []string) {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.getConfig",
}
@@ -742,7 +742,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
return
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.setConfig",
Params: params,
@@ -761,7 +761,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
}
func runClipExport(cmd *cobra.Command, args []string) {
req := ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.getHistory",
}
@@ -1004,25 +1004,19 @@ func downloadToTempFile(rawURL string) (string, error) {
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 {
if abs, err := filepath.Abs(filePath); err == nil {
filePath = abs
}
resp, ok := tryServerRequest(ipc.Request{
req := models.Request{
ID: 1,
Method: "clipboard.copyFile",
Params: map[string]any{"filePath": filePath},
})
if ok && resp.Error == "" {
return nil
}
data, err := os.ReadFile(filePath)
resp, err := sendServerRequest(req)
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"
"runtime/debug"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/colorpicker"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker"
"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:
dms ipc call color-picker toggle
See: ` + site.Docs + "/dms/keybinds-ipc",
See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
}
var colorPickCmd = &cobra.Command{
+17 -19
View File
@@ -6,12 +6,10 @@ import (
"regexp"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/plugins"
"github.com/duxlabs-dev/dux-shell/core/internal/server"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/AvengeMedia/DankMaterialShell/core/internal/shellembed"
"github.com/spf13/cobra"
)
@@ -29,7 +27,7 @@ var ipcCmd = &cobra.Command{
dms ipc call <target> <function> [args...] invoke a command
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) {
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
// Stable releases: dms v0.6.2
func formatVersion(version string) string {
if n := sysupdate.GitBuildCount(version); n > 0 {
// Fedora COPR (0.0.git.2267.d430cae9) carries no real base version.
base, _, ok := strings.Cut(version, "+git")
if !ok {
base = getBaseVersion()
}
return fmt.Sprintf("dms (git) v%s-%d", strings.TrimPrefix(base, "v"), n)
// Arch/Debian/Ubuntu/OpenSUSE git format: 0.6.2+git2264.c5c5ce84
re := regexp.MustCompile(`^([\d.]+)\+git(\d+)\.`)
if matches := re.FindStringSubmatch(version); matches != nil {
return fmt.Sprintf("dms (git) v%s-%s", matches[1], matches[2])
}
// 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
re := regexp.MustCompile(`^([\d.]+)$`)
re = regexp.MustCompile(`^([\d.]+)$`)
if matches := re.FindStringSubmatch(version); matches != nil {
return fmt.Sprintf("dms v%s", matches[1])
}
@@ -771,7 +773,6 @@ func getCommonCommands() []*cobra.Command {
registryCmd,
dank16Cmd,
brightnessCmd,
iccCmd,
dpmsCmd,
keybindsCmd,
greeterCmd,
@@ -792,9 +793,6 @@ func getCommonCommands() []*cobra.Command {
blurCmd,
trashCmd,
systemCmd,
systemdCmd,
switchUserCmd,
switchCmd,
dmenuCmd,
}...)
}
+69 -69
View File
@@ -7,10 +7,9 @@ import (
"path/filepath"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/luaconfig"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -36,7 +35,6 @@ var resolveIncludeCmd = &cobra.Command{
"layout.lua",
"outputs.lua",
"cursor.lua",
"input.lua",
"windowrules.lua",
"cursor.kdl",
"layout.kdl",
@@ -44,7 +42,6 @@ var resolveIncludeCmd = &cobra.Command{
"binds.kdl",
"input.kdl",
"cursor.conf",
"input.conf",
"layout.conf",
"outputs.conf",
"binds.conf",
@@ -55,32 +52,8 @@ var resolveIncludeCmd = &cobra.Command{
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() {
configCmd.AddCommand(resolveIncludeCmd)
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
configCmd.AddCommand(mangoMigrateCmd)
}
type IncludeResult struct {
@@ -131,21 +104,32 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
return result, err
}
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua"
result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool))
return result, nil
result.ReadOnly = false
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")
if _, err := os.Stat(mainConf); err != nil {
return result, nil
if _, err := os.Stat(mainConf); err == 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
}
@@ -166,8 +150,14 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
}
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)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") {
continue
}
@@ -177,12 +167,22 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
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 {
continue
}
if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) {
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
return true
}
}
@@ -205,13 +205,8 @@ func checkNiriInclude(filename string) (IncludeResult, error) {
return result, nil
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
processed := make(map[string]bool)
result.Included = niriFindInclude(mainConfig, targetAbs, processed)
result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
@@ -253,12 +248,17 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
continue
}
fullPath, err := resolveSourcePath(baseDir, trimmed[startQuote+1:endQuote])
if err != nil {
continue
includePath := trimmed[startQuote+1 : endQuote]
if matchesTarget(includePath, target) {
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
}
}
@@ -267,7 +267,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
}
func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir := mangoconf.Dir()
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
@@ -284,13 +284,8 @@ func checkMangoWCInclude(filename string) (IncludeResult, error) {
return result, nil
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
processed := make(map[string]bool)
result.Included = mangowcFindInclude(mainConfig, targetAbs, processed)
result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
@@ -328,12 +323,22 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
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 {
continue
}
if fullPath == target || mangowcFindInclude(fullPath, target, processed) {
if mangowcFindInclude(expanded, target, processed) {
return true
}
}
@@ -341,13 +346,8 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
return false
}
func resolveSourcePath(baseDir, raw string) (string, error) {
expanded, err := utils.ExpandPath(raw)
if err != nil {
return "", err
}
if !filepath.IsAbs(expanded) {
expanded = filepath.Join(baseDir, expanded)
}
return filepath.Abs(expanded)
func matchesTarget(path, target string) bool {
path = strings.TrimPrefix(path, "./")
target = strings.TrimPrefix(target, "./")
return path == target || strings.HasSuffix(path, "/"+target)
}
-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"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/dank16"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
@@ -28,13 +28,9 @@ func init() {
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
dank16Cmd.Flags().String("background", "", "Custom background color")
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().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.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) {
return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -51,7 +47,6 @@ func runDank16(cmd *cobra.Command, args []string) {
isWezterm, _ := cmd.Flags().GetBool("wezterm")
background, _ := cmd.Flags().GetString("background")
contrastAlgo, _ := cmd.Flags().GetString("contrast")
profile, _ := cmd.Flags().GetString("profile")
useVariants, _ := cmd.Flags().GetBool("variants")
primaryDark, _ := cmd.Flags().GetString("primary-dark")
primaryLight, _ := cmd.Flags().GetString("primary-light")
@@ -84,12 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
primaryLight = primaryColor
}
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
BackgroundDark: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
Profile: profile,
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors))
@@ -108,7 +102,6 @@ func runDank16(cmd *cobra.Command, args []string) {
IsLight: isLight,
Background: background,
UseDPS: contrastAlgo == "dps",
Profile: profile,
}
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
import (
"bytes"
"debug/elf"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"os/exec"
"path/filepath"
@@ -14,20 +13,17 @@ import (
"slices"
"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/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"
)
@@ -91,6 +87,7 @@ func (ds *DoctorStatus) OKCount() int {
var (
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
@@ -98,9 +95,6 @@ var (
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\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{
@@ -166,7 +160,7 @@ func (c category) String() string {
const (
checkNameMaxLength = 21
doctorDocsURL = site.Docs + "/dms/cli-doctor"
doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor"
)
type checkResult struct {
@@ -224,7 +218,6 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkOptionalDependencies(),
checkConfigurationFiles(),
checkSystemdServices(),
checkSettingsPortal(),
checkEnvironmentVars(),
checkFonts(),
)
@@ -330,24 +323,15 @@ func checkEnvironmentVars() []checkResult {
var results []checkResult
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")...)
switch {
case os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != "":
results = append(results, checkEnvVar("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("QT_QPA_PLATFORMTHEME_QT6")...)
if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != os.Getenv("QT_QPA_PLATFORMTHEME") {
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")...)
}
results = append(results, checkEnvVar("QS_ICON_THEME")...)
results = append(results, checkXDGMenuPrefix()...)
if matugen.QtengineActive() {
results = append(results, checkQtenginePlugin()...)
}
return results
}
@@ -368,7 +352,7 @@ func checkXDGMenuPrefix() []checkResult {
if checkXDGMenuFile(menuPrefix) {
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 {
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"},
}
// checkQtengineConfig reports whether qtengine's config points Qt apps at the
// DMS color scheme, which DMS writes there when it generates a theme.
func checkQtengineConfig() []checkResult {
// checkQtenginePlugin verifies the qtengine platform theme plugin is installed
// where Qt actually searches, for every Qt major that can be interrogated.
//
// 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"
path := matugen.QtengineConfigPath()
data, err := os.ReadFile(path)
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}}
}
var cfg struct {
Theme struct {
ColorScheme string `json:"colorScheme"`
} `json:"theme"`
}
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}}
pluginRoots := qtPluginRoots()
// 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.
if len(pluginRoots) == 0 {
return []checkResult{{
catEnvironment, "qtengine plugin", statusInfo, "Cannot verify (no qmake/qtpaths)",
"Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.",
url,
}}
}
scheme := matugen.QtengineColorSchemePath()
if cfg.Theme.ColorScheme == scheme {
return []checkResult{{catEnvironment, "qtengine config", statusOK, "Uses DMS colors", path, url}}
// Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable.
envRoots := filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))
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 != "" {
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,
}}
return results
}
// 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.
func checkQtPlatformThemePlugin(varName string) []checkResult {
value := os.Getenv(varName)
// Unset is reported by checkEnvVar.
if value == "" {
// Unset is reported by checkEnvVar; qtengine has its own plugin check.
if value == "" || value == "qtengine" {
return nil
}
@@ -455,111 +453,46 @@ func checkQtPlatformThemePlugin(varName string) []checkResult {
}}
}
roots := qtPluginRoots()
var results []checkResult
for _, major := range qtPlatformThemeMajors(varName, value, roots) {
results = append(results, checkQtPlatformThemePluginFor(varName, value, major, roots[major]))
// DMS themes Qt6 through these variables; qt5ct is the Qt5 counterpart.
major := "6"
if value == "qt5ct" {
major = "5"
}
return results
}
// 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)
files := qtPlatformThemePluginFiles(value)
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
// that could not be interrogated, and never claim it is fine either.
if qt.plugins == "" {
return checkResult{
if root == "" {
return []checkResult{{
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.",
url,
}
}}
}
// 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 == "" {
// plasma-integration ships the kde theme under a file name that has
// changed across versions; a miss says nothing reliable about the
// install, so degrade to info instead of warning at a working setup.
if value == "kde" {
return checkResult{
return []checkResult{{
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.",
url,
}
}}
}
expected := filepath.Join(qt.plugins, "platformthemes", files[0])
return checkResult{
expected := filepath.Join(root, "platformthemes", files[0])
return []checkResult{{
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),
url,
}
}}
}
// Qt skips QPA plugins built against another Qt major.minor without a
// 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]
return []checkResult{{catEnvironment, name, statusOK, "Installed", plugin, url}}
}
// 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
// each integration is known to ship; unmapped values fall back to Qt's
// libq<value> naming convention.
func qtPlatformThemePluginFiles(value, major string) []string {
if value == "qtengine" {
return []string{fmt.Sprintf("libqt%sengine-plugin.so", major)}
}
func qtPlatformThemePluginFiles(value string) []string {
if files, ok := map[string][]string{
"qt6ct": {"libqt6ct.so"},
"qt5ct": {"libqt5ct.so"},
@@ -582,28 +512,22 @@ func qtPlatformThemePluginFiles(value, major string) []string {
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
// 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
// is asked for QT_VERSION and the major is taken from the answer.
func qtPluginRoots() map[string]qtInstall {
pluginRoots := make(map[string]qtInstall)
func qtPluginRoots() map[string]string {
pluginRoots := make(map[string]string)
for _, q := range qtQueryBinaries {
if _, err := exec.LookPath(q.bin); err != nil {
continue
}
version := qtQuery(q.bin, q.flag, "QT_VERSION")
major, _, _ := strings.Cut(version, ".")
if major == "" || pluginRoots[major].plugins != "" {
major, _, _ := strings.Cut(qtQuery(q.bin, q.flag, "QT_VERSION"), ".")
if major == "" || pluginRoots[major] != "" {
continue
}
if root := qtQuery(q.bin, q.flag, "QT_INSTALL_PLUGINS"); root != "" {
pluginRoots[major] = qtInstall{plugins: root, version: version}
pluginRoots[major] = root
}
}
return pluginRoots
@@ -626,6 +550,16 @@ func qtPluginPath(roots []string, files ...string) string {
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.
func qtQuery(bin, flag, key string) string {
output, err := exec.Command(bin, flag, key).Output()
@@ -651,23 +585,10 @@ func qtQueryValue(output, key string) string {
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 {
configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs == "" {
configDirs = "/etc/xdg"
}
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
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
_, err := os.Stat(menuPath)
return err == nil
}
func readOSRelease() map[string]string {
@@ -807,20 +728,17 @@ func checkDMSInstallation() []checkResult {
func checkWindowManagers() []checkResult {
compositors := []struct {
name, versionCmd, versionArg string
parseVersion func(string) string
versionRegex *regexp.Regexp
commands []string
}{
{"Hyprland", "Hyprland", "--version", distros.ParseHyprlandVersion, []string{"hyprland", "Hyprland"}},
{"niri", "niri", "--version", firstSubmatch(niriVersionRegex), []string{"niri"}},
{"Sway", "sway", "--version", firstSubmatch(swayVersionRegex), []string{"sway"}},
{"River", "river", "-version", firstSubmatch(riverVersionRegex), []string{"river"}},
{"Wayfire", "wayfire", "--version", firstSubmatch(wayfireVersionRegex), []string{"wayfire"}},
{"labwc", "labwc", "--version", firstSubmatch(labwcVersionRegex), []string{"labwc"}},
{"mangowc", "mango", "-v", firstSubmatch(mangowcVersionRegex), []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", firstSubmatch(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"}},
{"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
{"River", "river", "-version", riverVersionRegex, []string{"river"}},
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
}
var results []checkResult
@@ -842,29 +760,18 @@ func checkWindowManagers() []checkResult {
if doctorVerbose && compositorPath != "" {
details = compositorPath
}
display, ver := getVersionFromCommand(c.versionCmd, c.versionArg, c.parseVersion)
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",
})
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 {
results = append(results, checkResult{
catCompositor, "Compositor", statusError,
"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",
})
}
@@ -873,67 +780,11 @@ func checkWindowManagers() []checkResult {
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())
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 {
supported, err := blur.ProbeSupport()
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"}
}
// Same detection as the keybinds parser: a regular hyprland.lua wins, else hyprland.conf is read-only.
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) {
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 {
return "installed", ""
return "installed"
}
outStr := string(output)
ver = parse(outStr)
if ver == "" {
return strings.TrimSpace(outStr), ""
if matches := regex.FindStringSubmatch(outStr); len(matches) > 1 {
ver := matches[1]
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
return ver + " (git)"
}
return ver
}
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
return ver + " (git)", ver
}
return ver, ver
return strings.TrimSpace(outStr)
}
func detectRunningWM() string {
switch {
case os.Getenv("AQUEOUS_SOCKET") != "":
return "Aqueous"
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
@@ -1005,8 +825,6 @@ func detectRunningWM() string {
return "MangoWC"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("UMBRIEL_SOCKET") != "":
return "Umbriel"
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP")
}
@@ -1093,7 +911,7 @@ ShellRoot {
{"Polkit", "Authentication prompts"},
{"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri, Aqueous)"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
}
@@ -1407,7 +1225,7 @@ func checkAdwGtk3() checkResult {
func checkConfigurationFiles() []checkResult {
configDir, _ := os.UserConfigDir()
cacheDir, _ := os.UserCacheDir()
dmsDir := "dms"
dmsDir := "DankMaterialShell"
configFiles := []struct{ name, path string }{
{"settings.json", filepath.Join(configDir, dmsDir, "settings.json")},
@@ -1449,7 +1267,6 @@ func checkSystemdServices() []checkResult {
var results []checkResult
dmsState := getServiceState("dms", true)
wanted := config.SessionWanted()
if !dmsState.exists {
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"})
} else {
@@ -1461,28 +1278,12 @@ func checkSystemdServices() []checkResult {
case dmsState.active == "failed":
status = statusError
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":
status, message = statusWarn, "Disabled"
case dmsState.active == "inactive":
status = statusError
}
details := ""
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"})
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
}
if dmsState.exists && dmsState.enabled == "enabled" {
@@ -1507,24 +1308,6 @@ func checkSystemdServices() []checkResult {
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 {
exists bool
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)
if r.details != "" && (doctorVerbose || r.status != statusOK) {
if doctorVerbose && 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)
if r.details != "" && (doctorVerbose || r.status != statusOK) {
if doctorVerbose && r.details != "" {
fmt.Fprintf(&sb, " - %s\n", r.details)
}
}
@@ -1689,29 +1472,25 @@ func formatResultsPlain(results []checkResult) string {
}
const (
defaultDoctorFontFamily = "Google Sans Flex"
defaultDoctorFontFamily = "Inter Variable"
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{
"google sans flex": {
"DCommon/assets/fonts/google-sans-flex/GoogleSansFlex.ttf",
"inter variable": {
"DankCommon/assets/fonts/inter/InterVariable.ttf",
"assets/fonts/inter/InterVariable.ttf",
},
"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",
},
"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",
},
"dm serif display": {
"DCommon/assets/fonts/dm-serif-display/DMSerifDisplay-Regular.ttf",
},
"notable": {
"DCommon/assets/fonts/notable/Notable-Regular.ttf",
},
}
func resolveDoctorShellPath() string {
@@ -1782,7 +1561,7 @@ func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailab
return checkResult{
catFonts, label, statusWarn,
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,
}
}
@@ -1823,15 +1602,13 @@ func checkFonts() []checkResult {
fontFamily := defaultDoctorFontFamily
monoFontFamily := defaultDoctorMonoFontFamily
displayFontFamily := defaultDoctorDisplayFamily
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 {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
DisplayFontFamily string `json:"displayFontFamily"`
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
@@ -1840,15 +1617,12 @@ func checkFonts() []checkResult {
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
if settings.DisplayFontFamily != "" {
displayFontFamily = settings.DisplayFontFamily
}
}
}
}
shellPath := resolveDoctorShellPath()
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily) || !isBundledDefaultFont(displayFontFamily)
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily)
fcListAvailable := utils.CommandExists("fc-list")
fcCache := ""
@@ -1872,7 +1646,6 @@ func checkFonts() []checkResult {
results = append(results,
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
checkConfiguredFont("Display Font", displayFontFamily, shellPath, fcCache, fcListAvailable, url),
)
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
import (
"bytes"
"debug/elf"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
)
func TestQuickshellVersionFailureDetails(t *testing.T) {
@@ -64,12 +59,24 @@ QT_VERSION:5.15.19`
key: "QT_INSTALL_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",
output: dumpAll,
key: "QT_INSTALL_LIBEXECS",
want: "",
},
{
name: "empty output yields nothing",
output: "",
key: "QT_INSTALL_PLUGINS",
want: "",
},
{
name: "surrounding whitespace is trimmed",
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
// note Qt 6 embeds in plugins, recording the Qt version it was built against.
func writeQtPluginELF(t *testing.T, pluginsDir, file string, major, minor byte) {
t.Helper()
var note bytes.Buffer
binary.Write(&note, binary.LittleEndian, []uint32{12, 4, 0x74510001})
note.WriteString("qt-project!\x00")
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,
// qtenginePluginFile("", major) is a path with a directory component, and
// qtPluginPath joins platformthemes/ again — a regression here silently warns
// at every correctly installed system, so pin the full path down.
func TestQtenginePluginPath(t *testing.T) {
root := t.TempDir()
if got := qtenginePluginPath([]string{root}, "6"); got != "" {
t.Fatalf("missing plugin should yield an empty path, got %q", got)
}
copy(header.Ident[:], elf.ELFMAG)
header.Ident[elf.EI_CLASS] = byte(elf.ELFCLASS64)
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 {
want := filepath.Join(root, "platformthemes", "libqt6engine-plugin.so")
if err := os.MkdirAll(filepath.Join(root, "platformthemes"), 0o755); err != nil {
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)
}
if got := qtenginePluginPath([]string{root}, "6"); got != want {
t.Fatalf("qtenginePluginPath = %q, want %q", got, want)
}
}
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.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
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("QT_QPA_PLATFORMTHEME", "")
if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
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")
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")
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)
if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
t.Fatalf("qtengine should yield no results, got %+v", 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"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
+22 -45
View File
@@ -13,21 +13,20 @@ import (
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/version"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update Dux Shell to the latest version",
Long: "Update Dux Shell to the latest version using the appropriate package manager for your distribution",
Short: "Update DankMaterialShell to the latest version",
Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution",
PreRunE: shellApp.ResolveConfig,
Run: func(cmd *cobra.Command, args []string) {
runUpdate()
@@ -36,7 +35,7 @@ var updateCmd = &cobra.Command{
var updateCheckCmd = &cobra.Command{
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",
Run: func(cmd *cobra.Command, args []string) {
runUpdateCheck()
@@ -44,19 +43,9 @@ var updateCheckCmd = &cobra.Command{
}
func runUpdateCheck() {
fmt.Println("Checking for Dux Shell updates...")
fmt.Println("Checking for DankMaterialShell updates...")
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()
if err != nil {
log.Fatalf("Error checking for updates: %v", err)
@@ -169,7 +158,7 @@ func updateArchLinux() error {
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() {
return errdefs.ErrUpdateCancelled
}
@@ -193,8 +182,12 @@ func updateOtherDistros() error {
}
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()
if err == nil && !versionInfo.HasUpdate {
@@ -206,25 +199,9 @@ func updateOtherDistros() error {
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(" 1. The dms binary from GitHub releases")
fmt.Println(" 2. Dux Shell configuration using git")
fmt.Println(" 2. DankMaterialShell configuration using git")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
@@ -385,7 +362,7 @@ func offerReclone(dmsPath string) bool {
}
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.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil {
@@ -449,8 +426,8 @@ func updateDMSBinary() error {
}
defer os.RemoveAll(tempDir)
binaryURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/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)
binaryURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", 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")
checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
+4 -9
View File
@@ -6,8 +6,7 @@ import (
"os/exec"
"syscall"
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/spf13/cobra"
)
@@ -53,15 +52,11 @@ func greeterPackageInstallHint() string {
return "Install package: dms-greeter"
}
if config.ID == "pika" {
return "Install with 'sudo apt install dms-greeter'"
}
switch config.Family {
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:
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:
return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)"
case distros.FamilyFedora:
@@ -69,7 +64,7 @@ func greeterPackageInstallHint() string {
case distros.FamilyArch:
return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'"
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:
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"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
@@ -70,15 +66,6 @@ var keybindsResetCmd = &cobra.Command{
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() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
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-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("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(keybindsShowCmd)
keybindsCmd.AddCommand(keybindsSetCmd)
keybindsCmd.AddCommand(keybindsRemoveCmd)
keybindsCmd.AddCommand(keybindsResetCmd)
keybindsCmd.AddCommand(keybindsKeymapCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
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 {
switch name {
case "hyprland":
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
)
func TestKeybindEditFailure(t *testing.T) {
+5 -23
View File
@@ -8,9 +8,9 @@ import (
"os"
"time"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra"
)
@@ -70,13 +70,9 @@ func init() {
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("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().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("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")
@@ -84,7 +80,6 @@ func init() {
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().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("icon-theme", "", "Icon theme name")
}
@@ -102,13 +97,9 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
stockColors, _ := cmd.Flags().GetString("stock-colors")
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
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")
contrast, _ := cmd.Flags().GetFloat64("contrast")
sourceMode, _ := cmd.Flags().GetString("source-mode")
seedColor, _ := cmd.Flags().GetString("seed-color")
spec, _ := cmd.Flags().GetString("spec")
return matugen.Options{
StateDir: stateDir,
@@ -124,12 +115,8 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
StockColors: stockColors,
SyncModeWithPortal: syncModeWithPortal,
TerminalsAlwaysDark: terminalsAlwaysDark,
TerminalOpacity: terminalOpacity,
TerminalPalette: terminalPalette,
SkipTemplates: skipTemplates,
SourceMode: sourceMode,
SeedColor: seedColor,
Spec: spec,
}
}
@@ -149,7 +136,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
wait, _ := cmd.Flags().GetBool("wait")
timeout, _ := cmd.Flags().GetDuration("timeout")
request := ipc.Request{
request := models.Request{
ID: 1,
Method: "matugen.queue",
Params: map[string]any{
@@ -165,13 +152,9 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"stockColors": opts.StockColors,
"syncModeWithPortal": opts.SyncModeWithPortal,
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"terminalOpacity": opts.TerminalOpacity,
"terminalPalette": opts.TerminalPalette,
"skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast,
"sourceMode": opts.SourceMode,
"seedColor": opts.SeedColor,
"spec": opts.Spec,
"wait": wait,
},
}
@@ -245,8 +228,7 @@ func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color")
imagePath, _ := cmd.Flags().GetString("image")
contrast, _ := cmd.Flags().GetFloat64("contrast")
spec, _ := cmd.Flags().GetString("spec")
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath, spec)
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath)
if err != nil {
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 (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/notify"
"github.com/godbus/dbus/v5"
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var (
notifyAppName string
notifyAppIcon string
notifyIcon string
notifyFile string
notifyTimeout int
notifyUrgency string
notifyCategory string
notifyTransient bool
notifyHints []string
notifyActions []string
notifyPrintID bool
notifyReplaceID uint32
notifyWait bool
notifyIDFd int
notifyActionFd int
notifyAppName string
notifyIcon string
notifyFile string
notifyTimeout int
)
var notifyCmd = &cobra.Command{
Use: "notify <summary> [body]",
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
that open the file or its directory when clicked.
If --file is provided, the notification will have "Open" and "Open Folder" actions.
Examples:
dms notify "Hello" "World"
dms notify -u critical -t 0 "Disk almost full"
dms notify -a "Backup" -i drive-harddisk -c transfer.complete "Backup finished"
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"`,
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
Args: cobra.MinimumNArgs(1),
Run: runNotify,
}
@@ -59,236 +39,45 @@ var genericNotifyActionCmd = &cobra.Command{
},
}
var notifyFlagAliases = map[string]string{
"app": "app-name",
"timeout": "expire-time",
}
func init() {
f := notifyCmd.Flags()
f.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
if alias, ok := notifyFlagAliases[name]; ok {
return pflag.NormalizedName(alias)
}
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)")
notifyCmd.Flags().StringVar(&notifyAppName, "app", "DMS", "Application name")
notifyCmd.Flags().StringVar(&notifyIcon, "icon", "", "Icon name or path")
notifyCmd.Flags().StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
notifyCmd.Flags().IntVar(&notifyTimeout, "timeout", 5000, "Timeout in milliseconds")
}
func runNotify(cmd *cobra.Command, args []string) {
n, err := buildNotification(args)
if err != nil {
fail(err)
summary := args[0]
body := ""
if len(args) > 1 {
body = args[1]
}
wait := notifyWait || len(n.Actions) > 0
var waiter *notify.Waiter
if wait {
waiter, err = notify.NewWaiter()
if err != nil {
fail(err)
}
defer waiter.Close()
n := notify.Notification{
AppName: notifyAppName,
Icon: notifyIcon,
Summary: summary,
Body: body,
FilePath: notifyFile,
Timeout: int32(notifyTimeout),
}
id, err := notify.Send(n)
if err != nil {
fail(err)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
reportID(id)
if n.FilePath != "" {
watchNotificationAction(id, n.FilePath)
}
if !wait {
if n.FilePath == "" {
return
}
if id == 0 {
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)
watchNotificationAction(id, n.FilePath)
}
func watchNotificationAction(id uint32, path string) {
if id == 0 {
return
}
resp, ok := tryServerRequest(ipc.Request{
resp, ok := tryServerRequest(models.Request{
Method: "notify.watchAction",
Params: map[string]any{"id": id, "path": path},
})
+3 -3
View File
@@ -8,8 +8,8 @@ import (
"path/filepath"
"strings"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra"
)
@@ -123,7 +123,7 @@ func runOpen(target string) {
params["url"] = target
}
req := ipc.Request{
req := models.Request{
ID: 1,
Method: method,
Params: params,
+4 -4
View File
@@ -6,9 +6,9 @@ import (
"os"
"strings"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/qrcode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)
@@ -243,7 +243,7 @@ func fatalf(format string, args ...any) {
}
func fetchWifiQRContent(ssid string) (string, error) {
resp, err := sendServerRequest(ipc.Request{
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "network.qrcode-content",
Params: map[string]any{"ssid": ssid},
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
+3 -3
View File
@@ -3,8 +3,8 @@ package main
import (
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/registries"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
@@ -12,7 +12,7 @@ import (
var registryCmd = &cobra.Command{
Use: "registry",
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{
+1 -1
View File
@@ -7,7 +7,7 @@ import (
var rootCmd = &cobra.Command{
Use: "dms",
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() {
+75 -115
View File
@@ -9,12 +9,12 @@ import (
"os/exec"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/notify"
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc"
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
"github.com/spf13/cobra"
)
@@ -35,7 +35,6 @@ var (
ssJSON bool
ssGeometry bool
ssAllowMulti bool
ssHUD string
)
type screenshotMetadata struct {
@@ -182,7 +181,6 @@ func init() {
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().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)")
@@ -214,7 +212,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.Stdout = ssStdout
config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti
config.HUD = ssHUD
if ssGeometry {
config.Clipboard = false
@@ -246,29 +243,9 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
}
config.Quality = ssQuality
if mode == screenshot.ModeRegion || mode == screenshot.ModeScroll {
config.SnapTargets = fetchShellSnapTargets()
}
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.
// Best-effort and not awaited: qs takes ~20ms to come up, longer than the selector needs to show.
func setPopoutScreenshotMode(begin bool) {
@@ -276,18 +253,18 @@ func setPopoutScreenshotMode(begin bool) {
if begin {
fn = "begin"
}
if pid, ok := shellApp.SessionPID(); ok {
_, _, _ = qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", fn, nil)
return
}
cmdArgs := []string{"ipc"}
if err := shellApp.ResolveConfig(nil, nil); err != nil {
return
if pid, ok := shellApp.SessionPID(); ok {
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)
_ = 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 err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
exitScreenshotError(" writing to stdout", err)
@@ -414,48 +385,31 @@ func runScreenshot(config screenshot.Config) {
}
if config.Clipboard {
var err error
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 {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
exitScreenshotError(" copying to clipboard", err)
}
if !ssJSON && !config.SaveFile {
if ssStdout {
fmt.Fprintln(os.Stderr, "Copied to clipboard")
} else {
fmt.Println("Copied to clipboard")
}
fmt.Println("Copied to clipboard")
}
}
if ssJSON {
if result.CopyOnly {
writeScreenshotJSON(screenshotMetadata{
Status: "aborted",
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),
})
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),
})
}
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{
FilePath: filePath,
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) {
if buf == nil || buf.Width <= 0 || buf.Height <= 0 || maxSize <= 0 {
return nil, 0, 0
func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) {
srcW, srcH := buf.Width, buf.Height
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
longest := max(srcW, srcH)
dstW, dstH := srcW, srcH
if longest > maxSize {
dstW = max(1, srcW*maxSize/longest)
dstH = max(1, srcH*maxSize/longest)
dstW := int(float64(srcW) * scale)
dstH := int(float64(srcH) * scale)
if dstW < 1 {
dstW = 1
}
if dstH < 1 {
dstH = 1
}
data := buf.Data()
rgba := make([]byte, dstW*dstH*4)
rgb := make([]byte, dstW*dstH*3)
is10Bit := screenshot.PixelFormat(pixelFormat).Is10Bit()
@@ -523,42 +484,41 @@ func bufferToRGBAThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat u
swapRB = true
}
for y := range dstH {
y0, y1 := y*srcH/dstH, (y+1)*srcH/dstH
for x := range dstW {
x0, x1 := x*srcW/dstW, (x+1)*srcW/dstW
var r, g, b, samples uint64
for srcY := y0; srcY < y1; srcY++ {
for srcX := x0; srcX < x1; srcX++ {
si := srcY*buf.Stride + srcX*4
if si+3 >= len(data) {
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++
}
for y := 0; y < dstH; y++ {
srcY := int(float64(y) / scale)
if srcY >= srcH {
srcY = srcH - 1
}
for x := 0; x < dstW; x++ {
srcX := int(float64(x) / scale)
if srcX >= srcW {
srcX = srcW - 1
}
if samples == 0 {
si := srcY*buf.Stride + srcX*4
di := (y*dstW + x) * 3
if si+3 >= len(data) {
continue
}
di := (y*dstW + x) * 4
rgba[di+0] = byte((r + samples/2) / samples)
rgba[di+1] = byte((g + samples/2) / samples)
rgba[di+2] = byte((b + samples/2) / samples)
rgba[di+3] = 255
switch {
case 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
}
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) {
-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"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
+16 -76
View File
@@ -8,13 +8,12 @@ import (
"path/filepath"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"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/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -130,7 +129,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
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": {
niriFile: "colors.kdl",
@@ -138,19 +137,15 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig },
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": {
niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig },
},
"input": {
niriFile: "input.kdl",
hyprFile: "input.lua",
mangoFile: "input.conf",
niriContent: func(_ string) string { return config.NiriInputConfig },
hyprContent: func(_ string) string { return config.DMSInputLuaConfig },
mangoContent: func(_ string) string { return "" },
niriFile: "input.kdl",
niriContent: func(_ string) string { return config.NiriInputConfig },
},
"outputs": {
niriFile: "outputs.kdl",
@@ -178,43 +173,15 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
},
}
var knownTerminals = []string{"ghostty", "foot", "kitty", "alacritty"}
func terminalFromEnv() string {
return strings.TrimSpace(os.Getenv("TERMINAL"))
}
func installedTerminals() []string {
func detectTerminal() (string, error) {
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
var found []string
for _, t := range knownTerminals {
for _, t := range terminals {
if utils.CommandExists(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) {
case 0:
return "ghostty", nil
@@ -309,7 +276,7 @@ func runSetupDmsConfig(name string) error {
case "hyprland":
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc":
dmsDir = filepath.Join(mangoconf.Dir(), "dms")
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
}
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -347,9 +314,8 @@ func runSetup() error {
terminal, terminalSelected := promptTerminal()
useSystemd := true
if wmSelected {
if wm == deps.WindowManagerMango && !mangoconf.SessionTargetInstalled() {
if wm == deps.WindowManagerMango {
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() {
useSystemd = false
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
@@ -363,13 +329,6 @@ func runSetup() error {
return nil
}
replaceBinds := false
if wmSelected {
if path, ok := config.ExistingBinds(wm); ok {
replaceBinds = promptBinds(path)
}
}
if wmSelected || terminalSelected {
willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected)
if willBackup {
@@ -390,7 +349,6 @@ func runSetup() error {
fmt.Println("\nDeploying configurations...")
logChan := make(chan string, 100)
deployer := config.NewConfigDeployer(logChan)
deployer.SetReplaceBinds(replaceBinds)
go func() {
for msg := range logChan {
@@ -402,15 +360,8 @@ func runSetup() error {
var err error
if wmSelected {
terminalCommand := defaultTerminalCommand()
if terminalSelected {
terminalCommand = terminal.Command()
}
if wm == deps.WindowManagerHyprland {
deployer.SetHyprlandVersion(installedHyprlandVersion())
}
var result config.DeploymentResult
result, err = deployer.DeployCompositor(wm, terminalCommand, useSystemd)
result, err = deployer.DeployCompositor(wm, terminal, useSystemd)
results = append(results, result)
}
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 {
fmt.Println("\nUse systemd for session management?")
fmt.Println("1) Yes (recommended for most distros)")
+7 -17
View File
@@ -3,8 +3,8 @@ package main
import (
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/spf13/cobra"
)
@@ -19,24 +19,22 @@ var setupHeadlessCmd = &cobra.Command{
noSystemd, _ := cmd.Flags().GetBool("no-systemd")
force, _ := cmd.Flags().GetBool("force")
skipExisting, _ := cmd.Flags().GetBool("skip-existing")
stockBinds, _ := cmd.Flags().GetBool("stock-binds")
cmd.SilenceUsage = true
return runSetupHeadless(compositor, terminal, noSystemd, force, skipExisting, stockBinds)
return runSetupHeadless(compositor, terminal, noSystemd, force, skipExisting)
},
}
func init() {
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("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("stock-binds", false, "Replace existing keybinds with the DMS stock set (a backup is kept)")
_ = setupHeadlessCmd.MarkFlagRequired("compositor")
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)
if err != nil {
return err
@@ -81,19 +79,11 @@ func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExistin
close(logDone)
}()
deployer := config.NewConfigDeployer(logChan)
deployer.SetReplaceBinds(stockBinds)
var results []config.DeploymentResult
var deployErr error
if deployCompositor {
terminalCommand := defaultTerminalCommand()
if termSelected {
terminalCommand = term.Command()
}
if wm == deps.WindowManagerHyprland {
deployer.SetHyprlandVersion(installedHyprlandVersion())
}
result, err := deployer.DeployCompositor(wm, terminalCommand, headlessUseSystemd(wm, noSystemd))
result, err := deployer.DeployCompositor(wm, term, headlessUseSystemd(wm, noSystemd))
results = append(results, result)
deployErr = err
}
@@ -151,7 +141,7 @@ func parseHeadlessTerminal(name string) (deps.Terminal, bool, error) {
}
func headlessUseSystemd(wm deps.WindowManager, noSystemd bool) bool {
if noSystemd {
if noSystemd || wm == deps.WindowManagerMango {
return false
}
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"
"fmt"
"os"
"runtime"
"strings"
"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/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"
)
@@ -36,7 +34,6 @@ Examples:
dms system update --check # list available updates
dms system update # apply updates (interactive prompt)
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 --no-flatpak --noconfirm # apply system updates only
dms system update --interval 3600 # set the server poll interval to 1h`,
@@ -44,26 +41,24 @@ Examples:
}
var (
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateInteractive bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIgnore []string
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIgnore []string
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
)
func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
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(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
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().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) {
if sysUpdateIntervalS >= 0 {
switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS)
return
}
// Package managers started from this thread inherit its priority.
runtime.LockOSThread()
lowprio.LowerThreadPriority()
if sysUpdateCheck {
case sysUpdateCheck:
runSystemUpdateCheck()
return
default:
runSystemUpdateApply()
}
runSystemUpdateApply()
}
func selectBackends(ctx context.Context) []sysupdate.Backend {
@@ -190,7 +180,7 @@ func runSystemUpdateApply() {
}
fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateInteractive && !sysUpdateDry {
if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
fmt.Println("Aborted.")
return
@@ -209,7 +199,6 @@ func runSystemUpdateApply() {
UseSudo: true,
}
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
opts.Interactive = sysUpdateInteractive && opts.AttachStdio
onLine := func(line string) { fmt.Println(line) }
ran := false
@@ -251,12 +240,16 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
return pkgs
}
ignored := make(map[string]bool, len(sysUpdateIgnore))
for _, name := range sysUpdateIgnore {
ignored[name] = true
}
out := pkgs[:0]
for _, p := range pkgs {
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
continue
}
if sysupdate.IsPackageIgnored(p.Name, sysUpdateIgnore) {
if ignored[p.Name] {
continue
}
out = append(out, p)
@@ -265,7 +258,7 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
}
func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(ipc.Request{
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "sysupdate.setInterval",
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"
"os"
"github.com/duxlabs-dev/dux-go/trash"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra"
)
+6 -7
View File
@@ -8,11 +8,10 @@ import (
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules/providers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers"
"github.com/spf13/cobra"
)
@@ -217,7 +216,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus
case "mango", "mangowc":
configDir := mangoconf.Dir()
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
parseResult, err := providers.ParseMangoWindowRules(configDir)
if err != nil {
@@ -352,7 +351,7 @@ func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
return providers.NewHyprlandWritableProvider(configDir)
case "mango", "mangowc":
configDir := mangoconf.Dir()
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
return providers.NewMangoWritableProvider(configDir)
default:
return nil
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"sync"
"time"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_power"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_power"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
)
type cmd struct {
+35 -8
View File
@@ -9,12 +9,15 @@ import (
"strings"
"sync"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
"github.com/spf13/afero"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/spf13/cobra"
)
const (
cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json"
cliPolicyAdminPath = "/etc/dms/cli-policy.json"
)
var (
immutablePolicyOnce sync.Once
immutablePolicy immutableCommandPolicy
@@ -31,6 +34,13 @@ type immutableCommandPolicy struct {
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 {
normalized := strings.ToLower(strings.TrimSpace(raw))
normalized = strings.TrimPrefix(normalized, "dms ")
@@ -72,8 +82,25 @@ func commandBlockedByPolicy(commandPath string, blocked []string) bool {
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 {
policyFile, err := clipolicy.LoadFile(afero.NewOsFs(), path)
policyFile, err := loadPolicyFile(path)
if err != nil {
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.",
}
var defaultPolicy clipolicy.File
var defaultPolicy cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil {
immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err)
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
return
}
if err := mergePolicyFile(&immutablePolicy, clipolicy.AdminPath); err != nil {
if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil {
immutablePolicyErr = err
return
}
@@ -241,7 +268,7 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
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
+1 -3
View File
@@ -3,12 +3,10 @@ package main
import (
"encoding/json"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
)
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
var policyFile clipolicy.File
var policyFile cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
t.Fatalf("failed to parse embedded CLI policy: %v", err)
}
+2 -10
View File
@@ -5,9 +5,8 @@ package main
import (
"os"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
var Version = "dev"
@@ -34,13 +33,6 @@ func main() {
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 {
log.Fatal(err)
}
+2 -10
View File
@@ -5,9 +5,8 @@ package main
import (
"os"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
var Version = "dev"
@@ -30,13 +29,6 @@ func main() {
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 {
log.Fatal(err)
}
+2 -2
View File
@@ -3,8 +3,8 @@ package main
import (
"fmt"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
)
type randrOutput struct {

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